Files
Jyotisha/frontend/tests/home-sidebar-rerender.test.tsx
T
Jesse_ChenandClaude Opus 5.5 0f0fea325e
Independent Staging Quality Gate / validate (push) Failing after 15m9s
Independent Staging Quality Gate / publish (push) Skipped
Sidebar session menu and drag on every page (issue #116)
Rename, delete with confirmation, pin, share and reorder moved from Home's
session hook into the shared (app) shell, so /chart, /ephemeris, /reports
and /people rows get the same menu and drag as /. Home only adds its paging
cursor, what the open conversation does when deleted, and which local chats
are unsaved. BUG-1467.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014Ad9rqAB8pXXvyzh7f4X8v
2026-10-28 01:52:47 +08:00

330 lines
13 KiB
TypeScript

import "./install-test-document";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import { Profiler, useCallback, useEffect, useState, type ProfilerOnRenderCallback, type ReactNode } from "react";
import { AppSidebar } from "../src/components/app-sidebar.tsx";
import { SidebarInset, SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { useHomeShellRegistration } from "../src/hooks/use-home-shell-registration.ts";
import { resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
import { emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts";
import {
SessionListProvider,
sessionListSidebarModel,
useSessionList,
useShellRegistration,
type ShellRegistration,
} from "../src/lib/session-list-context.tsx";
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 trigger = { current: null };
const charts: never[] = [];
function syntheticSession(index: number, title = `Synthetic session ${index}`): ChatSession {
return {
id: `session-${index}`,
title,
theme: "general",
modelId: "test",
messages: [],
createdAt: 1,
updatedAt: 1_700_000_000_000 + index,
sessionType: "consultation",
rectificationCaseId: null,
chartProfileId: null,
chartProfileName: null,
chartProfileRole: null,
pinned: false,
archivedAt: null,
messagesHydrated: false,
};
}
function registrationData(registration: ShellRegistration | null) {
if (!registration) return null;
return {
activeSessionId: registration.activeSessionId,
openingSessionId: registration.openingSessionId,
escapeBlocked: registration.escapeBlocked,
insetClassName: registration.insetClassName,
insetInert: registration.insetInert,
account: registration.sidebarAccount ?? null,
sessions: registration.sidebarSessions ?? [],
newChatDisabled: registration.controls.newChatDisabled,
creatingSession: registration.controls.creatingSession,
accountMenuOpen: registration.controls.accountMenuOpen,
// 原值:hasMore / menuSessionId / disabled 都读 registration.controls.sessionControls
// 新值:只剩 hasMore,读 registration.sessionHooks;菜单开合与锁行改由外壳从 store 读
// 原因:issue #116 会话行的「…」与拖动搬进共用外壳,Home 不再发布这两项
hasMore: registration.sessionHooks.hasMore,
};
}
function payloadSha(data: ReturnType<typeof registrationData>) {
return createHash("sha256").update(JSON.stringify(data)).digest("hex");
}
function uncaught(errors: readonly unknown[]) {
return errors.map((error) => error instanceof Error ? error.stack ?? error.message : String(error)).join("\n");
}
async function runProbe(sessionCount: number) {
const harness = createClientLifecycleHarness();
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 originalFetch = globalThis.fetch;
const originalIntersection = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
class FakeIntersectionObserver {
observe() {}
disconnect() {}
unobserve() {}
}
Object.defineProperty(globalThis, "IntersectionObserver", {
configurable: true,
writable: true,
value: FakeIntersectionObserver,
});
globalThis.fetch = (async (input: unknown) => {
const url = String(input);
const payload = url === "/api/account" ? account
: url === "/api/chart-profiles" ? { profiles: [] }
: { sessions: [], nextCursor: null };
return new Response(JSON.stringify(payload), { status: 200 });
}) as typeof fetch;
const metrics = { registers: 0, commits: 0, actualDuration: 0, recording: false };
const selectRevisions: number[] = [];
// 原值:renameRevisions,经 sessionControls.onRename 记录
// 新值:loadMoreRevisions,经 sessionHooks.onLoadMore 记录
// 原因:重命名归外壳(issue #116);Home 仍发布的稳定包装换成翻页,断言同一件事:点下去跑的是最新一次渲染的回调
const loadMoreRevisions: number[] = [];
const driver: {
setFrame: (frame: number) => void;
setActive: (id: string) => void;
retitle: () => void;
switchPerson: () => void;
setRevision: (revision: number) => void;
} = {
setFrame: () => {},
setActive: () => {},
retitle: () => {},
switchPerson: () => {},
setRevision: () => {},
};
let latestShell: ShellRegistration | null = null;
const currentShell = (): ShellRegistration | null => latestShell;
const initialSessions = Array.from({ length: sessionCount }, (_, index) => syntheticSession(index));
const otherPerson = [syntheticSession(sessionCount + 1, "Other person session")];
function HomeProbe() {
const list = useSessionList();
const [frame, setFrame] = useState(0);
const [activeSessionId, setActive] = useState(initialSessions[0]?.id ?? null);
const [sessions, setSessions] = useState(initialSessions);
const [revision, setRevision] = useState(0);
// Production passes SessionListProvider's stable registrar. A fresh wrapper
// every render would rerun the unmount cleanup and publish twice per frame.
const registerShellControls = useCallback((registration: ShellRegistration | null) => {
if (metrics.recording) metrics.registers += 1;
list.registerShellControls(registration);
}, [list]);
useHomeShellRegistration({
account: list.account,
accountMenuOpen: false,
accountTrigger: trigger,
activeSessionId: activeSessionId ?? undefined,
cancellationPending: false,
chartLibrary: charts,
creatingSession: false,
hydrated: list.settled,
loadMoreSessions: () => { loadMoreRevisions.push(revision); },
modalOpen: false,
modelCatalog: null,
openAccountDialog: () => {},
pendingSessionId: null,
profile: { ...emptyProfile, name: "Synthetic" },
rectificationOpeningSessionId: null,
rectificationSurfaceOpen: false,
registerShellControls,
selectSession: () => { selectRevisions.push(revision); },
sessionsCursor: null,
setAccountMenuOpen: () => {},
onSessionDeleted: () => {},
isSessionSaved: () => true,
uiPreview: { current: false },
startNewChat: () => {},
// New array every render. Element identity stays until a real edit.
visibleSessions: sessions.filter(() => true),
});
void frame;
useEffect(() => {
driver.setFrame = setFrame;
driver.setActive = setActive;
driver.retitle = () => setSessions((current) => current.map((session, index) => (
index === 0 ? { ...session, title: "Renamed synthetic" } : session
)));
driver.switchPerson = () => setSessions(otherPerson);
driver.setRevision = setRevision;
}, []);
return null;
}
const onSidebarRender: ProfilerOnRenderCallback = (_id, phase, actualDuration) => {
if (!metrics.recording) return;
if (phase !== "mount" && phase !== "update") return;
metrics.commits += 1;
metrics.actualDuration += actualDuration;
};
function Shell({ children }: { children: ReactNode }) {
const list = useSessionList();
const registration = useShellRegistration();
const model = sessionListSidebarModel(list, registration);
useEffect(() => { latestShell = registration; });
return (
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
<Profiler id="sidebar" onRender={onSidebarRender}>
<AppSidebar
sessions={model.sessions}
activeSessionId={registration?.activeSessionId ?? null}
openingSessionId={registration?.openingSessionId ?? null}
account={model.account}
settled={list.settled}
signedOut={list.signedOut}
controls={registration?.controls}
/>
</Profiler>
<SidebarInset inert={registration?.insetInert}>{children}</SidebarInset>
</SidebarProvider>
);
}
const restore = () => {
globalThis.fetch = originalFetch;
resetSubjectCatalogForTests();
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
if (originalIntersection) Object.defineProperty(globalThis, "IntersectionObserver", originalIntersection);
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
};
try {
// The list and the account menu arrive as chunks after the first paint.
// A 30-row paint can finish before those chunks, so the swap landed inside
// the 60 frames and counted as one sidebar commit. Load them first.
await import("../src/components/ui/dropdown-menu.tsx");
await import("../src/components/sidebar-session-sortable.tsx");
await harness.render(
<SessionListProvider>
<Shell><HomeProbe /></Shell>
</SessionListProvider>,
);
await harness.idle();
assert.equal(harness.errors.length, 0, uncaught(harness.errors));
assert.ok(latestShell, "hydrated home registers once before the frame run");
metrics.recording = true;
metrics.registers = 0;
metrics.commits = 0;
metrics.actualDuration = 0;
let firstLoadQuiet = false;
for (let attempt = 0; attempt < 8; attempt += 1) {
const registersBefore = metrics.registers;
const commitsBefore = metrics.commits;
await harness.idle();
if (metrics.registers === registersBefore && metrics.commits === commitsBefore && harness.errors.length === 0) {
firstLoadQuiet = true;
break;
}
}
assert.equal(firstLoadQuiet, true, "sidebar was still painting its first load");
assert.equal(harness.errors.length, 0, uncaught(harness.errors));
metrics.registers = 0;
metrics.commits = 0;
metrics.actualDuration = 0;
for (let frame = 1; frame <= 60; frame += 1) {
await harness.update(() => driver.setFrame(frame));
}
const duringFrames = {
registers: metrics.registers,
commits: metrics.commits,
actualDuration: Number(metrics.actualDuration.toFixed(3)),
};
metrics.registers = 0;
metrics.commits = 0;
await harness.update(() => driver.setActive("session-active"));
const afterActiveRegisters = metrics.registers;
const afterActiveCommits = metrics.commits;
const afterActive = registrationData(currentShell());
metrics.registers = 0;
await harness.update(() => driver.retitle());
const afterTitleRegisters = metrics.registers;
const renamedTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null;
metrics.registers = 0;
await harness.update(() => driver.setRevision(4));
const afterRevisionRegisters = metrics.registers;
currentShell()?.controls.onSelectSession("session-0");
currentShell()?.sessionHooks.onLoadMore();
metrics.registers = 0;
await harness.update(() => driver.switchPerson());
const afterSwitchRegisters = metrics.registers;
const otherTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null;
const report = {
sessions: sessionCount,
duringFrames,
afterActiveRegisters,
afterActiveCommits,
afterActiveSha: payloadSha(afterActive),
afterActive,
afterTitleRegisters,
renamedTitle,
afterRevisionRegisters,
selectRevisions,
loadMoreRevisions,
afterSwitchRegisters,
otherTitle,
};
console.log(`SIDEBAR_AB ${JSON.stringify(report)}`);
assert.equal(duringFrames.registers, 0, "streaming frames republished the sidebar");
assert.equal(duringFrames.commits, 0, "streaming frames recommitted the sidebar");
assert.equal(afterActiveRegisters, 1);
assert.equal(afterActive?.activeSessionId, "session-active");
assert.equal(afterTitleRegisters, 1);
assert.equal(renamedTitle, "Renamed synthetic");
assert.equal(afterRevisionRegisters, 0, "a revision republished the sidebar");
assert.deepEqual(selectRevisions, [4]);
assert.deepEqual(loadMoreRevisions, [4]);
assert.equal(afterSwitchRegisters, 1);
assert.equal(otherTitle, "Other person session");
return report;
} finally {
await harness.close();
restore();
}
}
for (const sessionCount of [30, 100]) {
test(`streaming frames do not republish the sidebar (${sessionCount} sessions)`, async () => {
await runProbe(sessionCount);
});
}