import assert from "node:assert/strict"; import test from "node:test"; import { StrictMode, startTransition, useEffect, useState, type ReactNode } from "react"; import { SidebarInset, SidebarProvider } from "../src/components/ui/sidebar.tsx"; import { useHomeShellRegistration } from "../src/hooks/use-home-shell-registration.ts"; import * as sessionList from "../src/lib/session-list-context.tsx"; import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts"; import { resetSubjectCatalogForTests, setCurrentSubject } from "../src/lib/current-subject.ts"; import { emptyProfile, previewModelCatalog, type Account, type ChatSession } from "../src/lib/home-types.ts"; import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts"; const account: Account = { user: { id: "11111111-1111-4111-8111-111111111111", email: null }, profile: { ...emptyProfile, name: "Synthetic" }, avatar: null, credits: 10, isAdmin: false, adminUrl: null, rectificationPriceCredits: 0, activeSubscription: null, hasConfirmedBirthTime: false, hasUsableBirthTime: false, }; const row: ChatSession = { id: "22222222-2222-4222-8222-222222222222", title: "Synthetic session", theme: "general", modelId: "test", messages: [], createdAt: 1, updatedAt: 1, sessionType: "consultation", rectificationCaseId: null, chartProfileId: null, chartProfileName: null, chartProfileRole: null, pinned: false, archivedAt: null, messagesHydrated: false, }; const noop = () => {}; const trigger = { current: null }; const charts: never[] = []; async function fixture(options: { strict?: boolean; unauthorized?: boolean } = {}) { const harness = createClientLifecycleHarness(); // 原值:只模拟 account / sessions,跨用例未清理人物目录模块缓存。 // 新值:通过真实 account → chart-profiles → sessions 初始化,并隔离目录状态。 // 原因:Provider 等人物绑定完成再读历史;不预设 ready,不改外壳/StrictMode 行为断言。 resetSubjectCatalogForTests(); const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); const storageValues = new Map(); Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: (key: string) => storageValues.get(key) ?? null, setItem: (key: string, value: string) => { storageValues.set(key, String(value)); }, removeItem: (key: string) => { storageValues.delete(key); }, clear: () => { storageValues.clear(); }, key: (index: number) => Array.from(storageValues.keys())[index] ?? null, get length() { return storageValues.size; }, } satisfies Storage, }); const restoreStorage = () => { if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage); else Reflect.deleteProperty(globalThis, "localStorage"); }; const originalFetch = globalThis.fetch; const requests: string[] = []; globalThis.fetch = (async (input) => { const url = String(input); requests.push(url); assert.ok(url === "/api/account" || url === "/api/chart-profiles" || url.startsWith("/api/sessions?"), `unexpected fetch ${url}`); const payload = url === "/api/account" ? account : url === "/api/chart-profiles" ? { profiles: [] } : { sessions: [], nextCursor: null }; return new Response(JSON.stringify(payload), { status: options.unauthorized ? 401 : 200, }); }) as typeof fetch; let homeRenders = 0; let targetCommits = 0; let latestList: sessionList.SessionListContextValue | undefined; let latestShell: sessionList.ShellRegistration | null = null; let latestModel: ReturnType | undefined; let setPage: (home: boolean) => void = noop; let revise: (revision: number) => void = noop; const callbackRevisions: number[] = []; function HomeProbe() { const list = sessionList.useSessionList(); const [revision, setRevision] = useState(0); // Bound failure even if the old effect/context feedback is reintroduced. homeRenders += 1; if (homeRenders > 50) throw new Error("Home registration failed to settle within 50 renders"); const changed = () => { callbackRevisions.push(revision); }; useHomeShellRegistration({ account: list.account, accountMenuOpen: revision > 0, accountTrigger: trigger, activeSessionId: `revision-${revision}`, cancellationPending: false, chartLibrary: charts, creatingSession: false, hydrated: list.settled, loadMoreSessions: changed, modalOpen: revision > 0, modelCatalog: null, openAccountDialog: changed, pendingSessionId: null, profile: { ...emptyProfile, name: `Synthetic ${revision}` }, rectificationErrorMessage: "", rectificationErrorSessionId: null, rectificationOpeningSessionId: null, rectificationSurfaceOpen: false, registerShellControls: list.registerShellControls, renameSession: changed, selectSession: changed, sessionMenuId: null, sessions: list.sessions, sessionsCursor: null, setAccountMenuOpen: noop, setPendingSessionDeletion: noop, setSessionMenuId: noop, shareSession: changed, startNewChat: changed, togglePinnedSession: changed, // Like useSessionManagement, intentionally new array/callbacks each render. visibleSessions: list.sessions.filter(() => true), }); useEffect(() => { revise = setRevision; }, []); return null; } function Target() { useEffect(() => { targetCommits += 1; }, []); return null; } function Shell({ children }: { children: ReactNode }) { const list = sessionList.useSessionList(); const registration = sessionList.useShellRegistration(); useEffect(() => { latestList = list; latestShell = registration; latestModel = sessionList.sessionListSidebarModel(list, registration); }); // Exercise the actual second provider and unchanged children identity too: // shell context updates must not re-render the Home registration producer. return {children} ; } function Router() { const [home, showHome] = useState(true); useEffect(() => { setPage = showHome; }, []); return {home ? : }; } try { await harness.render(options.strict ? : ); } catch (error) { await harness.close(); globalThis.fetch = originalFetch; resetSubjectCatalogForTests(); restoreStorage(); throw error; } return { harness, requests, callbackRevisions, get renders() { return homeRenders; }, get commits() { return targetCommits; }, get list() { assert.ok(latestList); return latestList; }, get shell() { return latestShell; }, get model() { assert.ok(latestModel); return latestModel; }, async navigate(home: boolean) { await harness.update(() => startTransition(() => setPage(home))); }, async revise(value: number) { await harness.update(() => revise(value)); }, async close() { try { await harness.close(); } finally { globalThis.fetch = originalFetch; resetSubjectCatalogForTests(); restoreStorage(); } }, }; } for (const strict of [false, true]) { test(`home registration settles and transitions unregister/reenter (StrictMode=${strict})`, async () => { const app = await fixture({ strict }); try { assert.deepEqual(app.harness.errors, []); assert.ok(app.shell, "hydrated Home registers its controls"); const idleRenders = app.renders; await app.harness.idle(); assert.equal(app.renders, idleRenders, "idle shell does not feed updates back into Home"); assert.ok(idleRenders < 15, `unexpected render growth: ${idleRenders}`); const reads = app.requests.length; await app.navigate(false); assert.deepEqual(app.harness.errors, []); assert.ok(app.commits > 0, "startTransition commits the destination"); assert.equal(app.shell, null, "Home cleanup unregisters controls"); assert.equal(app.model.account?.name, "Synthetic", "secondary page uses account fallback"); await app.navigate(true); assert.ok(app.shell, "returning Home registers new controls"); assert.equal(app.requests.length, reads, "shared provider does not refetch on route changes"); await app.harness.idle(); assert.deepEqual(app.harness.errors, []); } finally { await app.close(); } }); } test("shell receives current callbacks, account and session state without freezing closures", async () => { const app = await fixture(); try { assert.deepEqual(app.harness.errors, []); const initialCallback = app.shell?.controls.onOpenProfile; await app.revise(1); assert.equal(app.shell?.activeSessionId, "revision-1"); assert.equal(app.shell?.insetInert, true); assert.equal(app.shell?.escapeBlocked, true); assert.notEqual(app.shell?.controls.onOpenProfile, initialCallback); app.shell?.controls.onOpenProfile(); assert.deepEqual(app.callbackRevisions, [1]); await app.harness.update(() => { app.list.setAccount({ ...account, credits: 27 }); app.list.setSessions([row]); }); assert.equal(app.model.account?.credits, 27); assert.equal(app.model.sessions[0]?.id, row.id); await app.navigate(false); assert.equal(app.shell, null); assert.equal(app.model.account?.credits, 27); assert.equal(app.model.sessions[0]?.id, row.id); assert.deepEqual(app.harness.errors, []); } finally { await app.close(); } }); test("401 settles signed-out fallback with no shell registration", async () => { const app = await fixture({ unauthorized: true }); try { assert.deepEqual(app.harness.errors, []); assert.equal(app.list.signedOut, true); assert.equal(app.list.settled, true); await app.list.ready; assert.equal(app.shell, null); assert.deepEqual(app.model.sessions, []); assert.equal(app.model.account, null); const renders = app.renders; await app.harness.idle(); assert.equal(app.renders, renders); } finally { await app.close(); } }); test("provider current-ready gate stays pending through reload and exposes catalog failure without signedOut", async () => { const app = await fixture(); const priorFetch = globalThis.fetch; try { await app.list.waitUntilReady(); let release!: (response: Response) => void; globalThis.fetch = (input) => String(input).startsWith("/api/sessions?") ? new Promise(resolve => { release = resolve; }) : priorFetch(input); let settled = false; await app.harness.update(() => { app.list.reload(); }); const pending = app.list.waitUntilReady().then(() => { settled = true; }); await app.harness.idle(); assert.equal(settled, false); assert.equal(app.list.boot(), null); await app.harness.update(() => release(Response.json({ sessions: [], nextCursor: null }))); await pending; assert.equal(app.list.boot()?.account?.user.id, account.user.id); resetSubjectCatalogForTests(); globalThis.fetch = async input => String(input) === "/api/chart-profiles" ? Response.json({}, { status: 503 }) : priorFetch(input); await app.harness.update(() => app.list.reload()); await app.list.waitUntilReady(); // 原值:人物目录 503 时 boot.error 为 session_list_unavailable,首页进入可恢复错误屏。 // 新值:目录失败不带 error,账户仍在,signedOut 为 false,历史按本人继续加载。 // 原因:产品决定人物接口故障降级为本人,不把聊天一起打进错误屏。 assert.equal(app.list.boot()?.signedOut, false); assert.equal(app.list.boot()?.error, undefined); assert.equal(app.list.boot()?.account?.user.id, account.user.id); } finally { globalThis.fetch = priorFetch; await app.close(); } }); test("Home connects to the real provider current generation during person-switch reload", async () => { const app = await fixture(); const priorFetch = globalThis.fetch; let release!: () => void; const gate = new Promise(resolve => { release = resolve; }); let redirects = 0, done = false, failure = "", phase = ""; try { await app.list.waitUntilReady(); globalThis.fetch = async input => { await gate; return priorFetch(input); }; await app.harness.update(() => setCurrentSubject("fictional-person")); assert.equal(app.list.boot(), null); const commit = new Proxy({ setAccountError: (value: string) => { failure = value; }, setBootstrapPhase: (value: string) => { phase = value; }, }, { get: (target, key) => Reflect.get(target, key) ?? noop }) as HomeBootstrapDeps["commit"]; const pending = runHomeBootstrap({ sessionListReady: app.list.ready, waitForSessionList: app.list.waitUntilReady, sessionListBoot: app.list.boot, uiPreview: { current: false }, uiPreviewMode: { current: null }, consultationStatusMissingCount: { current: 0 }, isDevelopment: false, readSearch: () => "", storage: { getItem: () => null, removeItem: noop }, restoreConsultationRecovery: noop, setComposerNotice: noop, commit, io: { fetchModelCatalog: async () => previewModelCatalog, fetchActiveConsultationStatus: async () => null, redirectToLogin: () => { redirects += 1; throw new Error("unexpected redirect"); }, resolveLookupBootstrap: async input => ({ selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: false }, sessions: input.sessions, notice: null, }), writeSessionUrl: noop, clearLoginSessionReturn: noop, clearStaleClientReload: noop, }, }, new AbortController().signal).then(() => { done = true; }); await app.harness.idle(); assert.equal(done, false); assert.equal(redirects, 0); await app.harness.update(release); await pending; assert.equal(done, true); assert.equal(redirects, 0); assert.equal(failure, ""); assert.equal(phase, "prepare"); } finally { globalThis.fetch = priorFetch; await app.close(); } });