import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; import { runInNewContext } from "node:vm"; import ts from "typescript"; import { createSession } from "../src/lib/home-cloud-sync.ts"; import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts"; import { pendingConsultationStorageKey, previewModelCatalog, emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts"; import { isUnsavedEmptyConsultation } from "../src/lib/session-list-filter.ts"; import type { SessionListBoot } from "../src/lib/session-list-context.tsx"; const run = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8"); function sourceBetween(source: string, start: string, end: string): string { const from = source.indexOf(start); const to = source.indexOf(end, from); assert.ok(from >= 0 && to > from, `missing source boundary: ${start}`); return source.slice(from, to); } function installBrowserHosts() { 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; }, }; } } function bindRestore(sandbox: Record) { const recovery = sourceBetween(run, " function restoreConsultationRecovery(", " consultationRecoveryCheck.current ="); const compiled = ts.transpileModule( `${recovery}\n__boundRestore = restoreConsultationRecovery;`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }, ).outputText; const box: Record = { __boundRestore: undefined, ...sandbox }; runInNewContext(compiled, box); return box.__boundRestore as (session: ChatSession, requestId: string, stored?: unknown) => void; } function account(): Account { return { user: { id: "account-1", email: "reader@example.com" }, avatar: null, credits: 3, isAdmin: false, adminUrl: null, rectificationPriceCredits: 1, activeSubscription: null, hasConfirmedBirthTime: false, hasUsableBirthTime: false, profile: emptyProfile, }; } async function bootstrapWithRecovery(urlAction: "new-chat" | "none" | "keep") { // 原值:把 page.tsx 激活段和 hook 里的 restore 一起塞进 vm。 // 新值:直接调用 runHomeBootstrap,restore 仍是 hook 里的真实函数。 // 原因:启动流程已搬出 page.tsx;调用整段函数还覆盖 new-chat 会新建空咨询。 installBrowserHosts(); const oldSession = createSession("test-model"); oldSession.messages = [{ role: "user", text: "测试问题" }]; const localSession = createSession("test-model"); const pending: { current: { sessionId: string; phase: string } | null } = { current: null }; const state = { active: "", pendingSession: "", pendingRequest: "", phase: "", notice: "", storedCleared: false, sessions: [] as ChatSession[], }; const writes: unknown[][] = []; const memory = new Map(); const sandbox = { AbortController, navigator: { onLine: true }, pendingConsultation: pending, pendingConsultationStorageKey, sessionStorage: { getItem: (key: string) => memory.get(key) ?? null, setItem: (key: string, value: string) => { memory.set(key, value); }, removeItem: (key: string) => { memory.delete(key); }, }, sessions: [localSession, oldSession], consultSendBlockedByRectificationSession: () => false, isGenericSessionTitle: () => false, resolveSessionTitle: () => "新对话", timestamp: () => 1, updateSession: () => { assert.fail("the existing user question must not be duplicated"); }, setActiveSessionId: (id: string) => { state.active = id; }, setPendingSessionId: (id: string) => { state.pendingSession = id; }, setPendingRequestId: (id: string) => { state.pendingRequest = id; }, setConsultationPhase: (phase: string) => { state.phase = phase; }, setStreamingReply: () => undefined, setComposerNotice: (notice: string) => { state.notice = notice; }, }; const restore = bindRestore(sandbox); const boot: SessionListBoot = { sessions: [localSession, oldSession], rawRows: [], cursor: null, account: account(), signedOut: false, }; const deps: HomeBootstrapDeps = { sessionListReady: Promise.resolve(), sessionListBoot: () => boot, uiPreview: { current: false }, uiPreviewMode: { current: null }, consultationStatusMissingCount: { current: 0 }, isDevelopment: false, readSearch: () => "", storage: { getItem: () => null, removeItem: () => undefined, }, restoreConsultationRecovery: restore, setComposerNotice: sandbox.setComposerNotice, commit: { setAccount: () => undefined, setAccountError: () => undefined, setActiveSessionId: sandbox.setActiveSessionId, setBirthTimeAssessmentPhase: () => undefined, setBirthTimeJourney: () => undefined, setBootstrapPhase: () => undefined, setGuidedJourneyPreview: () => undefined, setHydrated: () => undefined, setModelCatalog: () => undefined, setOnboardingStep: () => undefined, setProfile: () => undefined, setProfileDraft: () => undefined, setSessions: (sessions) => { state.sessions = sessions; }, setSessionsCursor: () => undefined, setStartGreeting: () => undefined, }, io: { fetchModelCatalog: async () => previewModelCatalog, fetchActiveConsultationStatus: async () => ({ status: "reserved", sessionId: oldSession.id, requestId: "33333333-3333-4333-8333-333333333333", }), resolveLookupBootstrap: async (input) => ({ selection: { sessionId: localSession.id, urlAction, missing: false, }, sessions: input.sessions, notice: null, }), writeSessionUrl: (...args: unknown[]) => { writes.push(args); }, clearLoginSessionReturn: () => { state.storedCleared = true; }, }, }; await runHomeBootstrap(deps, new AbortController().signal); return { state, pending, writes, localSession, oldSession }; } test("new chat keeps its local landing while a reserved older consultation recovers", async () => { const { state, pending, writes, oldSession } = await bootstrapWithRecovery("new-chat"); const landing = state.sessions[0]; assert.ok(landing); assert.equal(state.active, landing.id); assert.notEqual(landing.id, oldSession.id); assert.equal(isUnsavedEmptyConsultation(landing), true); assert.equal(state.notice, ""); assert.equal(state.storedCleared, true); assert.deepEqual(writes, [[null, "replace"]]); assert.equal(state.pendingSession, oldSession.id); assert.equal(state.pendingRequest, "33333333-3333-4333-8333-333333333333"); assert.equal(state.phase, "recovering"); assert.equal(pending.current?.sessionId, oldSession.id); assert.equal(pending.current?.phase, "recovering"); }); test("bare / keeps the blank starter home while a reserved older consultation recovers (BUG-1052)", async () => { // 原值: 标题 "ordinary home landing still activates and announces the reserved consultation"; // none → state.active === oldSession.id,notice「回答仍在后台生成,正在自动恢复。」,storedCleared === false。 // 新值: none → 停在空白首页(本地空对话 localSession),notice 清空,恢复照常在后台进行;storedCleared === true。 // 原因: BUG-1052 D1——裸 `/` 永远落空白首页、不落旧对话,与 BUG-1015 的 ?new=1 同一条「不抢落点」规则; // D3——启动一律清掉旧版本留下的存根值。「地址栏点名会话时恢复可以接管」改由下一条 keep 用例锁住。 const { state, pending, writes, localSession, oldSession } = await bootstrapWithRecovery("none"); assert.equal(state.active, localSession.id); assert.equal(isUnsavedEmptyConsultation(state.sessions.find((item) => item.id === state.active)!), true); assert.equal(state.notice, ""); assert.equal(state.storedCleared, true); assert.deepEqual(writes, []); assert.equal(state.pendingSession, oldSession.id); assert.equal(state.phase, "recovering"); assert.equal(pending.current?.sessionId, oldSession.id); }); test("a ?c= landing still activates and announces the reserved consultation", async () => { const { state, pending, writes, oldSession } = await bootstrapWithRecovery("keep"); assert.equal(state.active, oldSession.id); assert.equal(state.notice, "回答仍在后台生成,正在自动恢复。"); assert.deepEqual(writes, []); assert.equal(pending.current?.sessionId, oldSession.id); });