Files
Jyotisha/frontend/tests/chat-session-write.test.ts
T
Jesse_ChenandClaude Opus 5 66d9f59d84
Independent Staging Quality Gate / validate (push) Failing after 9m26s
Independent Staging Quality Gate / publish (push) Skipped
feat(ui): 标题不再落宋体,强调色分阶换 coral,删输入框常驻底栏(BUG-737/738)
BUG-737:--font-display 以两个从未加载的 Anthropic 授权字体开头
(Tiempos Headline / StyreneB:无 @font-face、public/ 无字体、layout.tsx
只 vendor 了 Inter),于是每个中文标题都掉到 Songti SC / SimSun——20 条
规则宽,含助手回答正文的 h2/h3。display 与 body 合并为同一条无衬线栈,
层级改由字重承担(33 条 display 规则 400→500)。

拉丁衬线方案实测否决:Newsreader 拉丁子集 132 KB(Inter 的 2.7 倍),
只为给一个品牌字上衬线,且会把「D10 事业盘怎么读」劈成两种字形。

BUG-738:亮色 #85432f 与暗色 #d78064 不同源;--color-ring 在
@theme inline 里硬编码不跟随 :root。产品拍板换 Claude coral #cc785c,
但实测它作文字色只有 3.14:1,而 62 个调用点里 53 个是 color:。
按角色拆两阶:--color-action #a9583e(文字 4.85:1)、
--color-action-strong #cc785c(填充/导轨/焦点环,3:1 非文字阈值)。
--report-accent 刻意保持 #85432f(报告是纸面,不跟应用强调色)。

T1.3/T1.4:模型选择器进 ChatComposer 的 toolbar 插槽,删掉常驻 44px 的
.composer-footer;顶栏 68→46px,「分析对象:」改为标题旁静默 chip。

防复发:新增 font-stack-loadable-contract,断言字体栈里每个 family 要么
vendored 要么是系统字体,且 --font-display 不得触达任何 CJK 衬线。

测试 3346→3350,fail 仍 31 且清单与基线逐条一致;/ 仍 Static;
CSS gzip 41,095→41,096(+0.002%);快速门 pytest 段 798 passed / 0 failed。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 04:53:14 +00:00

330 lines
14 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
chatSessionCreateInsertRow,
chatSessionCreateSchema,
chatSessionMetadataPatchSchema,
chatSessionWriteSchema,
writeChatSession,
type ChatSessionWrite,
} from "../src/lib/chat-session-write-contract.ts";
import { metadataUpdateValues } from "../src/lib/session-metadata-update.ts";
import { homeSurface } from "./home-surface.ts";
const sessionId = "11111111-1111-4111-8111-111111111111";
const values = {
title: "事业方向",
theme: "career",
model_id: "gpt-test",
messages: [{ role: "user", text: "你好" }],
session_type: "consultation",
rectification_case_id: null,
updated_at: "2026-07-22T00:00:00.000Z",
} satisfies ChatSessionWrite;
const createValues = {
title: values.title,
theme: values.theme,
model_id: values.model_id,
messages: [] as const,
session_type: values.session_type,
rectification_case_id: values.rectification_case_id,
};
const metadataPatch = {
title: values.title,
theme: values.theme,
model_id: values.model_id,
};
test("create schema keeps the client-generated session id after transcript limits", () => {
const parsed = chatSessionCreateSchema.parse({
id: sessionId,
...createValues,
});
const id: string = parsed.id;
assert.equal(id, sessionId);
assert.equal(parsed.title, values.title);
assert.equal(parsed.messages.length, 0);
});
test("create schema accepts continued_from_session_id and rejects client-supplied context_summary", () => {
const parsed = chatSessionCreateSchema.parse({
id: sessionId,
...createValues,
continued_from_session_id: "22222222-2222-4222-8222-222222222222",
});
assert.equal(parsed.continued_from_session_id, "22222222-2222-4222-8222-222222222222");
assert.equal("context_summary" in parsed, false);
assert.equal("context_summary" in chatSessionCreateSchema.shape, false);
const withSummary = chatSessionCreateSchema.safeParse({
id: sessionId,
...createValues,
context_summary: { version: 1, text: "injected" },
});
assert.equal(withSummary.success, false);
const badId = chatSessionCreateSchema.safeParse({
id: sessionId,
...createValues,
continued_from_session_id: "not-a-uuid",
});
assert.equal(badId.success, false);
});
test("create insert copies an owned summary and omits a missing one", () => {
const source = {
version: 1 as const,
text: "已问过的问题\n事业时机",
throughRequestId: "a1",
throughMessageIndex: 3,
messageCount: 4,
updatedAt: "2026-09-15T00:00:00.000Z",
};
const copied = chatSessionCreateInsertRow({
id: sessionId,
userId: "owner",
values: createValues,
inheritedSummary: source,
updatedAt: "2026-09-16T00:00:00.000Z",
});
assert.deepEqual(copied.context_summary, source);
assert.equal("continued_from_session_id" in copied, false);
const skipped = chatSessionCreateInsertRow({
id: sessionId,
userId: "owner",
values: createValues,
inheritedSummary: null,
updatedAt: "2026-09-16T00:00:00.000Z",
});
assert.equal("context_summary" in skipped, false);
assert.equal(skipped.user_id, "owner");
});
test("create schema rejects a non-empty transcript", () => {
// Former value: create accepted up to CHAT_SESSION_MAX_MESSAGES and was a
// history-import path. Create is now only an empty session.
const parsed = chatSessionCreateSchema.safeParse({
id: sessionId,
...values,
});
assert.equal(parsed.success, false);
});
test("chat session schema accepts chart profile snapshots and keeps legacy writes valid", () => {
const parsed = chatSessionWriteSchema.parse({
...values,
chart_profile_id: "other-profile-id",
chart_profile_name: "张三",
chart_profile_role: "other",
});
assert.equal(parsed.chart_profile_id, "other-profile-id");
assert.equal(parsed.chart_profile_name, "张三");
assert.equal(parsed.chart_profile_role, "other");
assert.equal(chatSessionWriteSchema.parse(values).chart_profile_id, undefined);
});
test("chat session schema preserves the safe agent execution receipt", () => {
const receipt = {
runId: "run-1",
runtime: "mastra-agentic" as const,
skill: { name: "jyotish-vedic-astrology" as const, loaded: true, referenceReads: 0, methodologySections: 0 },
steps: [{ sequence: 1, kind: "skill" as const, name: "jyotish-vedic-astrology", status: "completed" as const }],
workflow: { route: "multi-domain", status: "ready", preciseTiming: "allowed", missingLayers: [], domains: ["general", "timing"] },
techniqueTruth: "verified",
};
const workflowReceipt = {
route: "multi-domain",
status: "ready",
preciseTiming: "allowed",
missingLayers: [],
domains: ["general", "timing"] as const,
};
const parsed = chatSessionWriteSchema.parse({
...values,
messages: [{ role: "assistant", text: "回答", workflowReceipt, agentExecutionReceipt: receipt }],
});
assert.deepEqual(parsed.messages[0]?.workflowReceipt, workflowReceipt);
assert.deepEqual(parsed.messages[0]?.agentExecutionReceipt, receipt);
});
test("chat session schema keeps sanitized thinking text on assistant messages", () => {
const parsed = chatSessionWriteSchema.parse({
...values,
messages: [{ role: "assistant", text: "回答", thinkingText: "先看今日节奏。" }],
});
assert.equal(parsed.messages[0]?.thinkingText, "先看今日节奏。");
});
test("chat session schema keeps structured thinking sections on assistant messages", () => {
const parsed = chatSessionWriteSchema.parse({
...values,
messages: [{
role: "assistant",
text: "回答",
thinkingSections: [{
id: "foundation",
title: "先整理本盘的统一参数",
heading: "统一参数与原始结构",
steps: [{ id: "raw", label: "列出岁差、上升与宫位结构", status: "pending" }],
}],
}],
});
assert.equal(parsed.messages[0]?.thinkingSections?.[0]?.heading, "统一参数与原始结构");
});
test("metadata patch accepts pin and archive fields without a transcript", () => {
assert.deepEqual(chatSessionMetadataPatchSchema.parse({ pinned: true }), { pinned: true });
assert.deepEqual(
chatSessionMetadataPatchSchema.parse({ archived_at: "2026-09-01T00:00:00.000Z" }),
{ archived_at: "2026-09-01T00:00:00.000Z" },
);
assert.deepEqual(chatSessionMetadataPatchSchema.parse({ archived_at: null }), { archived_at: null });
});
test("create write may carry continued_from_session_id and never context_summary", async () => {
const calls: Array<Record<string, unknown>> = [];
await writeChatSession(sessionId, {
...createValues,
continued_from_session_id: "22222222-2222-4222-8222-222222222222",
}, "create", async (_url, init) => {
calls.push(JSON.parse(String(init?.body)));
return Response.json({ ok: true }, { status: 201 });
});
assert.equal(calls[0]?.continued_from_session_id, "22222222-2222-4222-8222-222222222222");
assert.equal("context_summary" in (calls[0] ?? {}), false);
});
test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => {
const calls: Array<{ url: string; init?: RequestInit }> = [];
await writeChatSession(sessionId, metadataPatch, "update", async (url, init) => {
calls.push({ url: String(url), init });
return Response.json({ ok: true });
});
assert.equal(calls.length, 1);
assert.equal(calls[0]?.url, `/api/sessions/${sessionId}`);
assert.equal(calls[0]?.init?.method, "PATCH");
assert.equal(calls[0]?.init?.credentials, "same-origin");
assert.equal(JSON.parse(String(calls[0]?.init?.body)).messages, undefined);
});
test("transient Load failed is retried and never exposed as raw browser copy", async () => {
let attempts = 0;
await assert.rejects(
writeChatSession(sessionId, metadataPatch, "update", async () => {
attempts += 1;
throw new TypeError("Load failed");
}),
(error: unknown) => error instanceof Error
&& error.message === "网络暂时不可用,云端记录尚未更新",
);
assert.equal(attempts, 2);
});
test("owner or validation failures are not retried", async () => {
let attempts = 0;
await assert.rejects(
writeChatSession(sessionId, metadataPatch, "update", async () => {
attempts += 1;
return Response.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
}),
/聊天记录不存在或已被删除/,
);
assert.equal(attempts, 1);
});
test("session writes reject oversized transcripts before they reach storage", () => {
const oversized = chatSessionWriteSchema.safeParse({
...values,
messages: [{ role: "user", text: "字".repeat(16_001) }],
});
const tooMany = chatSessionWriteSchema.safeParse({
...values,
messages: Array.from({ length: 201 }, () => ({ role: "user" as const, text: "你好" })),
});
const tooMuchText = chatSessionWriteSchema.safeParse({
...values,
messages: Array.from({ length: 20 }, () => ({ role: "user" as const, text: "字".repeat(12_000) })),
});
assert.equal(oversized.success, false);
assert.equal(tooMany.success, false);
assert.equal(tooMuchText.success, false);
});
test("session API owns create and update while answer UI keeps sync failures out of reply errors", () => {
const page = homeSurface;
const collectionRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
const observability = readFileSync(new URL("../src/lib/chat-session-observability.ts", import.meta.url), "utf8");
const contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8");
const migration = readFileSync(new URL("../supabase/migrations/20260830010000_chat_session_chart_profile_binding.sql", import.meta.url), "utf8");
assert.match(page, /writeChatSession\(session\.id, values, mode\)/);
// Former value: persistSession mapped thinkingText/thinkingSections into a
// client transcript PATCH. Messages are now server-appended; this client
// write is metadata only.
assert.doesNotMatch(page, /thinkingText: message\.thinkingText/);
assert.doesNotMatch(page, /thinkingSections: message\.thinkingSections/);
assert.match(page, /messages: \[\] as const/);
assert.doesNotMatch(page, /云端同步失败.*回答仍保留在当前页面/);
assert.match(collectionRoute, /export async function POST/);
assert.match(itemRoute, /export async function GET/);
assert.match(itemRoute, /export async function PATCH/);
assert.match(itemRoute, /logIgnoredSessionMessages/);
assert.match(observability, /compat_messages_ignored/);
assert.match(itemRoute, /\.eq\("user_id", user\.id\)/);
assert.match(collectionRoute, /readChatSessionJson/);
assert.match(itemRoute, /readChatSessionJson/);
assert.match(collectionRoute, /ChatSessionBodyTooLargeError/);
assert.match(itemRoute, /ChatSessionBodyTooLargeError/);
// Former value: `const { id, updated_at: _ignoredClientClock, ...values } = parsed.data`
// New value: also peel `continued_from_session_id` so it is never inserted as a column.
assert.match(
collectionRoute,
/const \{ id, updated_at: _ignoredClientClock, continued_from_session_id: continuedFromSessionId, \.\.\.values \} = parsed\.data/,
);
assert.match(collectionRoute, /resolveInheritedContextSummary/);
assert.match(collectionRoute, /chatSessionCreateInsertRow/);
assert.match(collectionRoute, /\.eq\("user_id", user\.id\)/);
assert.match(collectionRoute, /status: 201/);
assert.doesNotMatch(collectionRoute, /context_summary: parsed/);
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string; thinkingText\?: string; thinkingSections\?: unknown \}> \}>/);
assert.match(contract, /\): z\.ZodType<Output> \{/);
assert.match(page, /chartSnapshotForSession/);
assert.match(page, /chart_profile_name: session\.chartProfileName/);
// 原值 `分析对象:{...}` 副标题 / 新值 `.chat-header-chart` 静默 chip
// / 原因:顶栏从 68px 收到 46px 单行;「分析对象」是内部术语,标签去掉只留盘名。
// 会话与星盘资料的绑定关系本身没变,下面两条仍然锁住它。
assert.match(page, /className="chat-header-chart"/);
assert.match(page, /sessionChartLabel\(activeSession, chartLibrary\)/);
assert.match(migration, /add column if not exists chart_profile_id text/);
assert.match(migration, /grant insert \(chart_profile_id, chart_profile_name, chart_profile_role\)/);
assert.match(migration, /grant update \(chart_profile_id, chart_profile_name, chart_profile_role\)/);
});
test("self-hosted staging bootstrap reads profile and sessions through same-origin APIs", () => {
const page = homeSurface;
const accountRoute = readFileSync(new URL("../src/app/api/account/route.ts", import.meta.url), "utf8");
assert.doesNotMatch(page, /createBrowserSupabaseClient/);
assert.match(page, /fetch\("\/api\/account"/);
assert.match(page, /fetch\("\/api\/sessions"/);
assert.match(page, /writeChatSession\(initialSession\.id,[\s\S]*?"create"\)/);
assert.match(page, /fetch\(`\/api\/sessions\/\$\{encodeURIComponent\(sessionId\)\}`/);
assert.match(accountRoute, /AUTH_PROVIDER\?\.trim\(\) === "self-hosted"/);
assert.match(accountRoute, /profile,/);
assert.doesNotMatch(accountRoute, /rectificationCase/);
});
test("metadata PATCH no longer writes updated_at", () => {
const values = metadataUpdateValues({ title: "半年内换工作时机" });
assert.ok(values);
assert.equal("updated_at" in values, false);
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
assert.doesNotMatch(itemRoute, /updated_at: new Date\(\)\.toISOString\(\)/);
});