import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { runConsultationRecoveryPoll } from "../src/lib/consultation-recovery-poll.ts"; import { ConsultationStatusError, createSession } from "../src/lib/home-cloud-sync.ts"; import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts"; import { emptyProfile, pendingConsultationStorageKey, previewModelCatalog, type Account } from "../src/lib/home-types.ts"; import { homeSurface as source } from "./home-surface.ts"; const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8"); const sendSource = source.slice(source.indexOf(" async function send("), source.indexOf("\n\n consultationReplay.current")); const stopSource = source.slice(source.indexOf(" async function stopResponse("), source.indexOf("\n\n function completeConsultationInterface")); test("consultation does not persist the optimistic user message before generation starts", () => { // Former value: `await persistSession(userSession)` ran after the undo window and // before `/api/consult`, locking the client full-replace write. The server now // appends the question after a successful reserve. const undo = sendSource.indexOf("await waitForUndoWindow(controller.signal)"); const consult = sendSource.indexOf('fetch("/api/consult"'); assert.ok(undo >= 0 && undo < consult); assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1); assert.doesNotMatch(sendSource, /问题保存失败,未开始生成;问题已放回输入框。/); }); test("only explicit stop can request consultation cancellation", () => { const streamCatch = sendSource.indexOf(" } catch (caught) {", sendSource.indexOf('fetch("/api/consult"')); assert.equal(source.match(/confirmCancellation\(/g)?.length, 2); assert.match(stopSource, /await confirmCancellation\(/); assert.ok(streamCatch >= 0); assert.doesNotMatch(sendSource.slice(streamCatch), /confirmCancellation\(|\/api\/consult\/cancel/); }); test("durable partial stop cancels before preserving content and recovers completed conflicts", () => { const partialStop = stopSource.slice( stopSource.indexOf(" if (pending.partialReply)"), stopSource.indexOf("\n updateSession(pending.sessionId, () => pending.previousSession)"), ); const cancel = partialStop.indexOf("await requestCancellation(pending.requestId)"); const conflict = partialStop.indexOf("error.status === 409"); const recoveryPersist = partialStop.slice(conflict); assert.ok(cancel >= 0 && conflict > cancel); // Former value: persistSession(stoppedSession) after cancel. Refunded partial // answers stay in page memory only and must not overwrite the server transcript. assert.equal(partialStop.indexOf("await persistSession(stoppedSession)"), -1); assert.doesNotMatch(recoveryPersist, /persistSession\(stoppedSession\)/); assert.match(recoveryPersist, /fetchConsultationStatus\(pending.sessionId, pending.requestId\)/); assert.match(recoveryPersist, /status.status === "completed"[\s\S]*?fetchSessionDetail\(pending.sessionId, modelCatalog\)/); assert.match(partialStop, /已停止回答,现有内容已保留,本次点数已退回。/); assert.match(source, /停止回答,保留已生成内容并退回本次点数/); assert.match(source, /停止回答,保留现有内容并申请退回本次点数/); }); test("explicit consultation HTTP failures unlock instead of entering recovery", () => { assert.match(sendSource, /!response\.ok[\s\S]*throw new ConsultationResponseError\([\s\S]*response\.status/); const explicitFailure = sendSource.slice( sendSource.indexOf("caught instanceof ConsultationResponseError"), sendSource.indexOf("if (!cancelled && ownsInterface && pendingConsultation.current)"), ); assert.match(explicitFailure, /caught\.message === "request_conflict"[\s\S]*setConsultationPhase\("recovering"\)/); const genericFailure = explicitFailure.slice(explicitFailure.indexOf("setRequestError({ sessionId, message: caught.message })")); assert.match(genericFailure, /setRequestError\(\{ sessionId, message: caught\.message \}\)/); assert.match(genericFailure, /completeConsultationInterface\(requestId\)/); assert.doesNotMatch(genericFailure, /phase: "recovering"|setConsultationPhase\("recovering"\)/); }); test("reserved consultations recover through the status endpoint", () => { const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8"); const poll = readFileSync(new URL("../src/lib/consultation-recovery-poll.ts", import.meta.url), "utf8"); assert.match(source, /\/api\/consult\/status\?sessionId=\$\{encodeURIComponent\(sessionId\)\}&requestId=\$\{encodeURIComponent\(requestId\)\}/); assert.match(source, /fetch\("\/api\/consult\/status", \{ signal, cache: "no-store" \}\)/); // 原值: 预留判断、1750ms 轮询和完成后的 fetchSessionDetail 都在 page.tsx。 // 新值: 预留判断在 home-bootstrap-run;轮询间隔和详情读取在 consultation-recovery-poll。 // 原因: 两段流程搬出首页。状态端点、1750ms、完成后拉详情都还在。 assert.match(bootstrapRun, /reservedConsultation\?\.status === "reserved"[\s\S]*nextSessions\.find\(\(session\) => session\.id === reservedConsultation\.sessionId\)/); assert.match(source, /status\?\.status !== "reserved"[\s\S]*sessions\.find\(\(item\) => item\.id === status\.sessionId\)/); assert.match(source, /status: "reserved" \| "completed" \| "cancelled"/); assert.match(source, /readonly responseMessage\?: unknown/); assert.match(source, /phase: "recovering"/); assert.match(poll, /window\.setTimeout\(\(\) => void poll\(\), 1_750\)/); assert.match(poll, /status\.status === "completed"[\s\S]*?fetchDetail\(deps\.pendingSessionId, deps\.modelCatalog, signal\)/); assert.match(source, /window\.addEventListener\("online", onOnline\)/); assert.match(source, /window\.addEventListener\("pageshow", onPageShow\)/); assert.match(source, /网络已断开,回答仍在后台生成;联网后会自动恢复。/); }); test("a missing reservation is replayed once instead of being polled until the question is lost", async () => { // 原值: 按 page.tsx 恢复 effect 的源码切片匹配 404、重放和第 4 次放弃。 // 新值: 直接调用 runConsultationRecoveryPoll。逐步假计时器在 consultation-recovery-poll.test.ts。 // 原因: 轮询已搬出;这里确认第一次 404 只确认、不立刻丢掉问题。 assert.match(source, /class ConsultationStatusError extends Error[\s\S]*readonly status: number/); assert.match(source, /throw new ConsultationStatusError\([\s\S]*response\.status/); assert.match(source, /const consultationStatusMissingCount = useRef\(0\)/); assert.match(source, /const consultationReplayStarted = useRef\(null\)/); const host = globalThis as { window?: unknown }; if (host.window === undefined) host.window = globalThis; const missing = { current: 0 }; let notice = ""; let replayed = 0; const controller = new AbortController(); runConsultationRecoveryPoll({ pendingSessionId: "11111111-1111-4111-8111-111111111111", pendingRequestId: "22222222-2222-4222-8222-222222222222", modelCatalog: previewModelCatalog, consultationStatusMissingCount: missing, consultationReplayStarted: { current: null }, consultationReplay: { current: () => { replayed += 1; } }, pendingConsultation: { current: { phase: "recovering" } }, setComposerNotice: (value) => { notice = value; }, setSessions: () => undefined, setActiveSessionId: () => undefined, setPendingSessionId: () => undefined, setPendingRequestId: () => undefined, setConsultationPhase: () => undefined, setStreamingReply: () => undefined, setRequestError: () => undefined, refreshAccount: () => undefined, online: () => true, fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); }, }, controller.signal); try { for (let step = 0; step < 8; step += 1) await Promise.resolve(); assert.equal(missing.current, 1); assert.equal(replayed, 0); assert.equal(notice, "正在确认本次咨询请求是否已开始…"); } finally { controller.abort(); } }); test("tab-local pending ids drive strict bootstrap recovery before the global fallback", async () => { // 原值:从 page.tsx 切 storedPending 分支,并用源码顺序断言它先于全局状态。 // 新值:调用 runHomeBootstrap。pending 仍覆盖全局 reserved;无 pending 时清除 storage key。 // 原因:启动流程搬出后,源码切片不再落在 page.tsx;优先级改由结果锁定。 const host = globalThis as { window?: unknown; localStorage?: Storage }; if (host.window === undefined) host.window = globalThis; if (!host.localStorage) { const data = new Map(); host.localStorage = { getItem: (key) => data.get(key) ?? null, setItem: (key, value) => { data.set(key, String(value)); }, removeItem: (key) => { data.delete(key); }, clear: () => data.clear(), key: (index) => Array.from(data.keys())[index] ?? null, get length() { return data.size; }, }; } const older = createSession(previewModelCatalog.defaultModelId); const pendingSession = createSession(previewModelCatalog.defaultModelId); const storedRequestId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; const removed: string[] = []; let restored = ""; const reader: Account = { user: { id: "account-1", email: "reader@example.com" }, avatar: null, credits: 1, isAdmin: false, adminUrl: null, rectificationPriceCredits: 1, activeSubscription: null, hasConfirmedBirthTime: false, hasUsableBirthTime: false, profile: emptyProfile, }; const deps: HomeBootstrapDeps = { sessionListReady: Promise.resolve(), sessionListBoot: () => ({ sessions: [older, pendingSession], rawRows: [], cursor: null, account: reader, signedOut: false, }), uiPreview: { current: false }, uiPreviewMode: { current: null }, consultationStatusMissingCount: { current: 0 }, isDevelopment: false, readSearch: () => "", storage: { getItem: (key) => key === pendingConsultationStorageKey ? JSON.stringify({ sessionId: pendingSession.id, requestId: storedRequestId, question: "继续" }) : null, removeItem: (key) => { removed.push(key); }, }, restoreConsultationRecovery: (session, requestId) => { restored = `${session.id}:${requestId}`; }, setComposerNotice: () => undefined, commit: { setAccount: () => undefined, setAccountError: () => undefined, setActiveSessionId: () => undefined, setBirthTimeAssessmentPhase: () => undefined, setBirthTimeJourney: () => undefined, setBootstrapPhase: () => undefined, setGuidedJourneyPreview: () => undefined, setHydrated: () => undefined, setModelCatalog: () => undefined, setOnboardingStep: () => undefined, setProfile: () => undefined, setProfileDraft: () => undefined, setSessions: () => undefined, setSessionsCursor: () => undefined, setStartGreeting: () => undefined, }, io: { fetchModelCatalog: async () => previewModelCatalog, fetchActiveConsultationStatus: async () => ({ sessionId: older.id, requestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "reserved", }), fetchConsultationStatus: async () => ({ sessionId: pendingSession.id, requestId: storedRequestId, status: "reserved", }), resolveLookupBootstrap: async (input) => ({ selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false }, sessions: input.sessions, notice: null, }), }, }; await runHomeBootstrap(deps, new AbortController().signal); assert.equal(restored, `${pendingSession.id}:${storedRequestId}`); assert.equal(removed.includes(pendingConsultationStorageKey), false); // 原值:从拼接后的 `homeSurface` 中用两个跨文件 indexOf 边界切片。 // 新值:直接读取 page.tsx 的 pending storage effect。 // 原因:写入 sessionId/requestId/question 仍留在首页,不随启动流程搬走。 const pendingStorageMarker = " if (pendingSessionId && pendingRequestId) {"; const storageEffectStart = pageSource.lastIndexOf(" useEffect(() => {", pageSource.indexOf(pendingStorageMarker)); const storageSync = pageSource.slice( storageEffectStart, pageSource.indexOf("\n useEffect(() => {", pageSource.indexOf(pendingStorageMarker)), ); assert.match(storageSync, /pendingSessionId && pendingRequestId[\s\S]*sessionStorage\.setItem\(pendingConsultationStorageKey,[\s\S]*sessionId: pendingSessionId,[\s\S]*requestId: pendingRequestId,[\s\S]*question:/); assert.match(storageSync, /else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/); }); test("pending storage keeps the question so a refresh during the undo window can replay", () => { assert.match(source, /function readStoredPendingConsultation\(/); assert.match(source, /typeof parsedPending\.question === "string" \? parsedPending\.question : ""/); assert.match(sendSource, /sessionStorage\.setItem\(pendingConsultationStorageKey, JSON\.stringify\(\{[\s\S]*sessionId,[\s\S]*requestId,[\s\S]*question: originalQuestion/); // 原值: homeSurface 里 `restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending)`。 // 新值: 同一调用在 home-bootstrap-run.ts。 // 原因: 启动恢复搬出首页,仍把 stored pending 交给真实 restore。 const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8"); assert.match(bootstrapRun, /restoreConsultationRecovery\(recoverySession, reservedConsultation\.requestId, storedPending\)/); assert.match(source, /stored\?\.question\?\.trim\(\)/); assert.match(sendSource, /resumeRequestId/); assert.match(sendSource, /questionAlreadyPresent/); assert.match(source, /consultationReplay\.current = \(\) => \{[\s\S]*resumeRequestId: pending\.requestId/); }); test("the first default consultation title is persisted with the user question", () => { const userSessionBlock = sendSource.slice( sendSource.indexOf("const userSession: ChatSession"), sendSource.indexOf("const requestId = resumeRequestId ?? globalThis.crypto.randomUUID()"), ); assert.match(userSessionBlock, /currentSession\.messages\.length === 0 && isGenericSessionTitle\(currentSession\.title\)[\s\S]*resolveSessionTitle\(question/); // Former value: persistSession(userSession) before consult wrote the title by // replacing the whole messages array. Title now comes from SQL append, and the // completed metadata patch only updates title/theme/model/chart binding. assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1); assert.match(sendSource, /await persistSession\(completedSession\)/); assert.match(sendSource, /const modelTitle = streamedTitle \?\? reply\.title/); assert.match(sendSource, /const completedTitle = modelTitle && !isGenericSessionTitle\(modelTitle\)/); assert.match(sendSource, /resolveSessionTitle\(question, modelTitle/); }); test("a truncated generation keeps the partial answer and does not wait for a successful run", () => { const stream = sendSource.slice(sendSource.indexOf('fetch("/api/consult"')); assert.match(stream, /event\.code === "answer_truncated"/); assert.match(stream, /truncatedFailure = event/); assert.match(stream, /const truncatedSession: ChatSession = \{[\s\S]*role: "assistant"[\s\S]*text: reply\.text/); // Former value: persistSession(truncatedSession) saved a refunded partial answer. assert.doesNotMatch(stream, /await persistSession\(truncatedSession\)/); assert.match(stream, /setComposerNotice\(truncatedFailure\.message\)/); assert.match(stream, /if \(!runCompleted && !truncatedFailure\) \{\s*throw new ConsultationResponseError/); assert.doesNotMatch(stream.slice(stream.indexOf("if (truncatedFailure)")), /runCompleted = true/); });