import assert from "node:assert/strict"; import test from "node:test"; import { ConsultationStatusError, createSession, LoginRedirectError } from "../src/lib/home-cloud-sync.ts"; import { HOME_BOOTSTRAP_TIMEOUT_MESSAGE, runHomeBootstrap, type HomeBootstrapDeps, type HomeBootstrapIo, } from "../src/lib/home-bootstrap-run.ts"; import { HOME_BOOTSTRAP_SLOW_MS } from "../src/lib/home-bootstrap.ts"; import { emptyProfile, pendingConsultationStorageKey, previewModelCatalog, type Account, type ChatSession, type ConsultationStatus, } from "../src/lib/home-types.ts"; import type { SessionListBoot } from "../src/lib/session-list-context.tsx"; import { bindCurrentSubjectAccount, readCurrentSubjectId, resetSubjectCatalogForTests, setCurrentSubject } from "../src/lib/current-subject.ts"; import { installWindow, withFakeTimers } from "./home-bootstrap-fake-timers.ts"; function installBrowserHosts() { installWindow(); const host = globalThis as { localStorage?: Storage }; 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 memoryStorage() { const data = new Map(); return { getItem: (key: string) => data.get(key) ?? null, removeItem: (key: string) => { data.delete(key); }, setItem: (key: string, value: string) => { data.set(key, value); }, }; } 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, }; } function createHarness(options: { sessions: ChatSession[]; signedOut?: boolean; io?: Partial; restoreConsultationRecovery?: HomeBootstrapIo["restoreConsultationRecovery"]; } ) { installBrowserHosts(); const state: { account: Account | null; error: string; hydrated: boolean; phase: string; sessions: ChatSession[]; active: string; notice: string; profileSet: boolean; catalogSet: boolean; } = { account: null, error: "", hydrated: false, phase: "", sessions: [] as ChatSession[], active: "", notice: "", profileSet: false, catalogSet: false, }; const commits: string[] = []; const storage = memoryStorage(); const boot: SessionListBoot = { sessions: options.sessions, rawRows: [], cursor: "cursor-1", account: options.signedOut ? null : account(), signedOut: options.signedOut ?? false, }; const note = (name: string) => { commits.push(name); }; const deps: HomeBootstrapDeps = { sessionListReady: Promise.resolve(), sessionListBoot: () => boot, uiPreview: { current: false }, uiPreviewMode: { current: null }, consultationStatusMissingCount: { current: 0 }, isDevelopment: false, readSearch: () => "", storage, restoreConsultationRecovery: options.restoreConsultationRecovery ?? (() => undefined), setComposerNotice: (notice) => { state.notice = notice; note("notice"); }, commit: { setAccount: (value) => { state.account = value; note("account"); }, setAccountError: (value) => { state.error = value; note("error"); }, setActiveSessionId: (id) => { state.active = id; note("active"); }, setBirthTimeAssessmentPhase: () => note("assessment"), setBirthTimeJourney: () => note("journey"), setBootstrapPhase: (phase) => { state.phase = phase; note("phase"); }, setGuidedJourneyPreview: () => note("preview"), setHydrated: (value) => { state.hydrated = value; note("hydrated"); }, setModelCatalog: () => { state.catalogSet = true; note("catalog"); }, setOnboardingStep: () => note("onboarding"), setProfile: () => { state.profileSet = true; note("profile"); }, setProfileDraft: () => note("draft"), setSessions: (sessions) => { state.sessions = sessions; note("sessions"); }, setSessionsCursor: () => note("cursor"), setStartGreeting: () => note("greeting"), }, io: { fetchModelCatalog: async () => previewModelCatalog, fetchActiveConsultationStatus: async () => null, fetchConsultationStatus: async () => { throw new Error("unexpected status read"); }, resolveLookupBootstrap: async (input) => ({ selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: false, }, sessions: input.sessions, notice: null, }), ...options.io, }, }; return { deps, state, commits, storage }; } async function waitUntil(predicate: () => boolean) { for (let attempt = 0; attempt < 30; attempt += 1) { if (predicate()) return; await Promise.resolve(); } assert.equal(predicate(), true); } test("a successful bootstrap fills the home once, after every read has settled", async () => { const session = createSession(previewModelCatalog.defaultModelId); let lookupStarted = false; let releaseLookup: () => void = () => undefined; const lookupGate = new Promise((resolve) => { releaseLookup = resolve; }); const { deps, state, commits } = createHarness({ sessions: [session], io: { resolveLookupBootstrap: async (input) => { lookupStarted = true; await lookupGate; return { selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: false, }, sessions: input.sessions, notice: null, }; }, }, }); const pending = runHomeBootstrap(deps, new AbortController().signal); await waitUntil(() => lookupStarted); assert.equal(state.account === null, true); assert.equal(state.sessions.length, 0); assert.equal(state.phase, ""); assert.equal(state.hydrated, false); releaseLookup(); await pending; assert.equal(state.account?.user.id, "account-1"); assert.equal(state.sessions[0]?.id, session.id); assert.equal(state.active, session.id); assert.equal(state.profileSet, true); assert.equal(state.catalogSet, true); assert.equal(state.phase, "prepare"); assert.equal(state.hydrated, false); assert.equal(state.error, ""); assert.ok(commits.indexOf("account") < commits.indexOf("phase")); }); test("a model catalog failure stays on the fatal screen and does not half-reveal", async () => { const { deps, state } = createHarness({ sessions: [createSession(previewModelCatalog.defaultModelId)], io: { fetchModelCatalog: async () => { throw new Error("模型列表读取失败"); }, }, }); await runHomeBootstrap(deps, new AbortController().signal); assert.equal(state.account, null); assert.equal(state.sessions.length, 0); assert.equal(state.catalogSet, false); assert.equal(state.phase, ""); assert.equal(state.hydrated, true); assert.equal(state.error, "模型列表读取失败"); }); test("a stored pending reservation wins over the global active status", async () => { const older = createSession(previewModelCatalog.defaultModelId); const pendingSession = createSession(previewModelCatalog.defaultModelId); const globalStatus: ConsultationStatus = { sessionId: older.id, requestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", status: "reserved", }; const storedRequestId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; let restored: { sessionId: string; requestId: string } | null = null; const { deps, storage } = createHarness({ sessions: [older, pendingSession], restoreConsultationRecovery: (session, requestId) => { restored = { sessionId: session.id, requestId }; }, io: { fetchActiveConsultationStatus: async () => globalStatus, fetchConsultationStatus: async (sessionId, requestId) => ({ sessionId, requestId, status: "reserved", }), }, }); storage.setItem(pendingConsultationStorageKey, JSON.stringify({ sessionId: pendingSession.id, requestId: storedRequestId, question: "稍后继续", })); await runHomeBootstrap(deps, new AbortController().signal); assert.deepEqual(restored, { sessionId: pendingSession.id, requestId: storedRequestId }); assert.equal(deps.consultationStatusMissingCount.current, 0); }); test("a stored 404 still reserves that request and counts the first miss", async () => { const session = createSession(previewModelCatalog.defaultModelId); const requestId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; let restored: string | null = null; const { deps, storage } = createHarness({ sessions: [session], restoreConsultationRecovery: (_session, restoredRequestId) => { restored = restoredRequestId; }, io: { fetchActiveConsultationStatus: async () => null, fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); }, }, }); storage.setItem(pendingConsultationStorageKey, JSON.stringify({ sessionId: session.id, requestId, question: "", })); await runHomeBootstrap(deps, new AbortController().signal); assert.equal(deps.consultationStatusMissingCount.current, 1); assert.equal(restored, requestId); }); test("the slow-network timeout aborts the run and does not enter prepare", async () => { installBrowserHosts(); await withFakeTimers(async (tick) => { const { deps, state } = createHarness({ sessions: [createSession(previewModelCatalog.defaultModelId)], io: { fetchModelCatalog: (signal) => new Promise((_resolve, reject) => { const fail = () => { const error = new Error("Aborted"); error.name = "AbortError"; reject(error); }; if (signal?.aborted) fail(); else signal?.addEventListener("abort", fail, { once: true }); }), }, }); const pending = runHomeBootstrap(deps, new AbortController().signal); await Promise.resolve(); tick(HOME_BOOTSTRAP_SLOW_MS); await pending; assert.equal(state.error, HOME_BOOTSTRAP_TIMEOUT_MESSAGE); assert.equal(state.hydrated, true); assert.equal(state.phase, ""); assert.equal(state.account, null); }); }); test("a login redirect leaves the bootstrap timeout running", async () => { installBrowserHosts(); await withFakeTimers(async (tick) => { const { deps, state } = createHarness({ sessions: [], signedOut: true, io: { redirectToLogin: () => { throw new LoginRedirectError(); }, }, }); await runHomeBootstrap(deps, new AbortController().signal); assert.equal(state.error, ""); assert.equal(state.hydrated, false); tick(HOME_BOOTSTRAP_SLOW_MS); await Promise.resolve(); assert.equal(state.error, HOME_BOOTSTRAP_TIMEOUT_MESSAGE); assert.equal(state.hydrated, true); assert.equal(state.phase, ""); }); }); test("bootstrap waits for the current provider generation rather than an already settled initial gate", async () => { const { deps, state } = createHarness({ sessions: [createSession(previewModelCatalog.defaultModelId)] }); let release!: () => void; deps.waitForSessionList = () => new Promise(resolve => { release = resolve; }); const pending = runHomeBootstrap(deps, new AbortController().signal); await waitUntil(() => Boolean(release)); assert.equal(state.hydrated, false); release(); await pending; assert.equal(state.phase, "prepare"); assert.equal(state.account?.user.id, "account-1"); assert.equal(state.error, ""); }); test("a catalog outage is a recoverable bootstrap error, never a login redirect", async () => { let redirects = 0; const { deps, state } = createHarness({ sessions: [], io: { redirectToLogin: () => { redirects += 1; throw new Error("unexpected login redirect"); } } }); deps.sessionListBoot = () => ({ sessions: [], rawRows: [], cursor: null, account: null, signedOut: false, error: "session_list_unavailable" }); await runHomeBootstrap(deps, new AbortController().signal); assert.equal(redirects, 0); assert.equal(state.hydrated, true); assert.notEqual(state.error, ""); }); test("standard new=1 bootstrap creates a conversation bound to the selected person", async () => { const { deps, state } = createHarness({ sessions: [], io: { resolveLookupBootstrap: async input => ({ selection: { sessionId: input.defaultSessionId, urlAction: "new-chat", missing: false, clearStoredReturn: false }, sessions: input.sessions, notice: null, }), writeSessionUrl: () => {}, clearLoginSessionReturn: () => {}, } }); resetSubjectCatalogForTests(); bindCurrentSubjectAccount("fictional-account", ["fictional-person"]); setCurrentSubject("fictional-person"); deps.readSearch = () => "?new=1"; try { await runHomeBootstrap(deps, new AbortController().signal); assert.equal(state.sessions.find(session => session.id === state.active)?.chartProfileId, "fictional-person"); assert.equal(readCurrentSubjectId(), "fictional-person"); assert.equal(state.error, ""); } finally { resetSubjectCatalogForTests(); } });