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.
196 lines
7.7 KiB
TypeScript
196 lines
7.7 KiB
TypeScript
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);
|
||
});
|