Files
Jyotisha/frontend/tests/session-list-lifecycle.test.tsx
T
jesse-ux 5a4b87383f
Independent Staging Quality Gate / validate (push) Failing after 6m35s
Independent Staging Quality Gate / publish (push) Skipped
fix(web): retire session archive UI and API, restore archived rows
Remove archive/restore from the session menu and drop archived=1 listing. PATCH still accepts archived_at from old bundles then ignores it. A forward migration clears archived_at without bumping updated_at. Delete stays unchanged.
2026-09-21 18:49:40 +08:00

187 lines
8.5 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 { emptyProfile, 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();
const originalFetch = globalThis.fetch;
const requests: string[] = [];
globalThis.fetch = (async (input) => {
const url = String(input);
requests.push(url);
assert.ok(url === "/api/account" || url.startsWith("/api/sessions?"), `unexpected fetch ${url}`);
return new Response(JSON.stringify(url === "/api/account" ? account : { sessions: [], nextCursor: null }), {
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;
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; } },
};
}
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(); }
});