外壳只在会话、当前项或账户这些数据变了才更新。60 帧计数从注册 60 次、提交 60 次降到 0 次,点下去仍是最新一次操作。BUG-1329。 Co-Authored-By: Grok (agent loop)
305 lines
12 KiB
TypeScript
305 lines
12 KiB
TypeScript
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: registration.controls.sessionControls.hasMore ?? false,
|
|
menuSessionId: registration.controls.sessionControls.menuSessionId,
|
|
disabled: registration.controls.sessionControls.disabled,
|
|
};
|
|
}
|
|
|
|
function payloadSha(data: ReturnType<typeof registrationData>) {
|
|
return createHash("sha256").update(JSON.stringify(data)).digest("hex");
|
|
}
|
|
|
|
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[] = [];
|
|
const renameRevisions: 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: () => {},
|
|
modalOpen: false,
|
|
modelCatalog: null,
|
|
openAccountDialog: () => {},
|
|
pendingSessionId: null,
|
|
profile: { ...emptyProfile, name: "Synthetic" },
|
|
rectificationOpeningSessionId: null,
|
|
rectificationSurfaceOpen: false,
|
|
registerShellControls,
|
|
renameSession: () => { renameRevisions.push(revision); },
|
|
selectSession: () => { selectRevisions.push(revision); },
|
|
sessionMenuId: null,
|
|
sessions,
|
|
sessionsCursor: null,
|
|
setAccountMenuOpen: () => {},
|
|
setPendingSessionDeletion: () => {},
|
|
setSessionMenuId: () => {},
|
|
shareSession: () => {},
|
|
startNewChat: () => {},
|
|
togglePinnedSession: () => {},
|
|
// 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 {
|
|
await harness.render(
|
|
<SessionListProvider>
|
|
<Shell><HomeProbe /></Shell>
|
|
</SessionListProvider>,
|
|
);
|
|
await harness.idle();
|
|
assert.equal(harness.errors.length, 0);
|
|
assert.ok(latestShell, "hydrated home registers once before the frame run");
|
|
metrics.recording = true;
|
|
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()?.controls.sessionControls.onRename("session-0");
|
|
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,
|
|
renameRevisions,
|
|
afterSwitchRegisters,
|
|
otherTitle,
|
|
};
|
|
console.log(`SIDEBAR_AB ${JSON.stringify(report)}`);
|
|
assert.equal(duringFrames.registers, 0);
|
|
assert.equal(duringFrames.commits, 0);
|
|
assert.equal(afterActiveRegisters, 1);
|
|
assert.equal(afterActive?.activeSessionId, "session-active");
|
|
assert.equal(afterTitleRegisters, 1);
|
|
assert.equal(renamedTitle, "Renamed synthetic");
|
|
assert.equal(afterRevisionRegisters, 0);
|
|
assert.deepEqual(selectRevisions, [4]);
|
|
assert.deepEqual(renameRevisions, [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);
|
|
});
|
|
}
|