Files
Jyotisha/frontend/tests/session-list-lifecycle.test.tsx
T
jesse-ux d08ffdc0a6
Independent Staging Quality Gate / validate (push) Failing after 6m46s
Independent Staging Quality Gate / publish (push) Skipped
fix: keep western labels near their planets and chats on the current person
Cap planet captions at 30 degrees with a third ring, fall back only within the current person's sessions, and load self when the people catalog fails.
2026-09-25 23:02:17 +08:00

290 lines
14 KiB
TypeScript

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<string, string>();
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<typeof sessionList.sessionListSidebarModel> | 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 <SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
<SidebarInset inert={registration?.insetInert}>{children}</SidebarInset>
</SidebarProvider>;
}
function Router() {
const [home, showHome] = useState(true);
useEffect(() => { setPage = showHome; }, []);
return <sessionList.SessionListProvider><Shell>{home ? <HomeProbe /> : <Target />}</Shell></sessionList.SessionListProvider>;
}
try {
await harness.render(options.strict ? <StrictMode><Router /></StrictMode> : <Router />);
} 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<void>(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(); }
});