Files
Jyotisha/frontend/tests/new-chat-recovery.test.ts
T
jesse-ux da35b3e4af refactor(home): split bootstrap, recovery polling, and shells
Move the home startup sequence and consultation recovery poll into
plain functions, and the error and onboarding screens into components.
Landing rules, the 20s fatal screen, retry, and the chart settings
deep link stay the same.
2026-09-25 02:59:11 +08:00

196 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { createSession } from "../src/lib/home-cloud-sync.ts";
import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts";
import { pendingConsultationStorageKey, previewModelCatalog, emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts";
import { isUnsavedEmptyConsultation } from "../src/lib/session-list-filter.ts";
import type { SessionListBoot } from "../src/lib/session-list-context.tsx";
const run = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8");
function sourceBetween(source: string, start: string, end: string): string {
const from = source.indexOf(start);
const to = source.indexOf(end, from);
assert.ok(from >= 0 && to > from, `missing source boundary: ${start}`);
return source.slice(from, to);
}
function installBrowserHosts() {
const host = globalThis as { window?: unknown; localStorage?: Storage };
if (host.window === undefined) host.window = globalThis;
if (!host.localStorage) {
const data = new Map<string, string>();
host.localStorage = {
getItem: (key) => data.get(key) ?? null,
setItem: (key, value) => { data.set(key, String(value)); },
removeItem: (key) => { data.delete(key); },
clear: () => data.clear(),
key: (index) => Array.from(data.keys())[index] ?? null,
get length() { return data.size; },
};
}
}
function bindRestore(sandbox: Record<string, unknown>) {
const recovery = sourceBetween(run, " function restoreConsultationRecovery(", " consultationRecoveryCheck.current =");
const compiled = ts.transpileModule(
`${recovery}\n__boundRestore = restoreConsultationRecovery;`,
{ compilerOptions: { target: ts.ScriptTarget.ES2022 } },
).outputText;
const box: Record<string, unknown> = { __boundRestore: undefined, ...sandbox };
runInNewContext(compiled, box);
return box.__boundRestore as (session: ChatSession, requestId: string, stored?: unknown) => void;
}
function account(): Account {
return {
user: { id: "account-1", email: "reader@example.com" },
avatar: null,
credits: 3,
isAdmin: false,
adminUrl: null,
rectificationPriceCredits: 1,
activeSubscription: null,
hasConfirmedBirthTime: false,
hasUsableBirthTime: false,
profile: emptyProfile,
};
}
async function bootstrapWithRecovery(urlAction: "new-chat" | "none") {
// 原值:把 page.tsx 激活段和 hook 里的 restore 一起塞进 vm。
// 新值:直接调用 runHomeBootstrap,restore 仍是 hook 里的真实函数。
// 原因:启动流程已搬出 page.tsx;调用整段函数还覆盖 new-chat 会新建空咨询。
installBrowserHosts();
const oldSession = createSession("test-model");
oldSession.messages = [{ role: "user", text: "测试问题" }];
const localSession = createSession("test-model");
const pending: { current: { sessionId: string; phase: string } | null } = { current: null };
const state = {
active: "",
pendingSession: "",
pendingRequest: "",
phase: "",
notice: "",
storedCleared: false,
sessions: [] as ChatSession[],
};
const writes: unknown[][] = [];
const memory = new Map<string, string>();
const sandbox = {
AbortController,
navigator: { onLine: true },
pendingConsultation: pending,
pendingConsultationStorageKey,
sessionStorage: {
getItem: (key: string) => memory.get(key) ?? null,
setItem: (key: string, value: string) => { memory.set(key, value); },
removeItem: (key: string) => { memory.delete(key); },
},
sessions: [localSession, oldSession],
consultSendBlockedByRectificationSession: () => false,
isGenericSessionTitle: () => false,
resolveSessionTitle: () => "新对话",
timestamp: () => 1,
updateSession: () => { assert.fail("the existing user question must not be duplicated"); },
setActiveSessionId: (id: string) => { state.active = id; },
setPendingSessionId: (id: string) => { state.pendingSession = id; },
setPendingRequestId: (id: string) => { state.pendingRequest = id; },
setConsultationPhase: (phase: string) => { state.phase = phase; },
setStreamingReply: () => undefined,
setComposerNotice: (notice: string) => { state.notice = notice; },
};
const restore = bindRestore(sandbox);
const boot: SessionListBoot = {
sessions: [localSession, oldSession],
rawRows: [],
cursor: null,
account: account(),
signedOut: false,
};
const deps: HomeBootstrapDeps = {
sessionListReady: Promise.resolve(),
sessionListBoot: () => boot,
uiPreview: { current: false },
uiPreviewMode: { current: null },
consultationStatusMissingCount: { current: 0 },
isDevelopment: false,
readSearch: () => "",
storage: {
getItem: () => null,
removeItem: () => undefined,
},
restoreConsultationRecovery: restore,
setComposerNotice: sandbox.setComposerNotice,
commit: {
setAccount: () => undefined,
setAccountError: () => undefined,
setActiveSessionId: sandbox.setActiveSessionId,
setBirthTimeAssessmentPhase: () => undefined,
setBirthTimeJourney: () => undefined,
setBootstrapPhase: () => undefined,
setGuidedJourneyPreview: () => undefined,
setHydrated: () => undefined,
setModelCatalog: () => undefined,
setOnboardingStep: () => undefined,
setProfile: () => undefined,
setProfileDraft: () => undefined,
setSessions: (sessions) => { state.sessions = sessions; },
setSessionsCursor: () => undefined,
setStartGreeting: () => undefined,
},
io: {
fetchModelCatalog: async () => previewModelCatalog,
fetchActiveConsultationStatus: async () => ({
status: "reserved",
sessionId: oldSession.id,
requestId: "33333333-3333-4333-8333-333333333333",
}),
resolveLookupBootstrap: async (input) => ({
selection: {
sessionId: localSession.id,
urlAction,
missing: false,
clearStoredReturn: urlAction === "new-chat",
},
sessions: input.sessions,
notice: null,
}),
writeSessionUrl: (...args: unknown[]) => { writes.push(args); },
clearLoginSessionReturn: () => { state.storedCleared = true; },
},
};
await runHomeBootstrap(deps, new AbortController().signal);
return { state, pending, writes, localSession, oldSession };
}
test("new chat keeps its local landing while a reserved older consultation recovers", async () => {
const { state, pending, writes, oldSession } = await bootstrapWithRecovery("new-chat");
const landing = state.sessions[0];
assert.ok(landing);
assert.equal(state.active, landing.id);
assert.notEqual(landing.id, oldSession.id);
assert.equal(isUnsavedEmptyConsultation(landing), true);
assert.equal(state.notice, "");
assert.equal(state.storedCleared, true);
assert.deepEqual(writes, [[null, "replace"]]);
assert.equal(state.pendingSession, oldSession.id);
assert.equal(state.pendingRequest, "33333333-3333-4333-8333-333333333333");
assert.equal(state.phase, "recovering");
assert.equal(pending.current?.sessionId, oldSession.id);
assert.equal(pending.current?.phase, "recovering");
});
test("ordinary home landing still activates and announces the reserved consultation", async () => {
const { state, pending, writes, oldSession } = await bootstrapWithRecovery("none");
assert.equal(state.active, oldSession.id);
assert.equal(state.notice, "回答仍在后台生成,正在自动恢复。");
assert.equal(state.storedCleared, false);
assert.deepEqual(writes, []);
assert.equal(pending.current?.sessionId, oldSession.id);
});