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.
This commit is contained in:
jesse-ux
2026-09-25 02:59:11 +08:00
parent 9167d75e74
commit da35b3e4af
19 changed files with 1972 additions and 632 deletions
@@ -197,13 +197,23 @@ test("toast-owned and alert-owned events are deliberately silent in the status r
});
test("the recovered background reply tells the reader where it landed", () => {
assert.equal(pageSource.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 2);
// 原值: homeSurface 里两处「回答已恢复,已显示在对话区末尾。」
// 新值: use-consultation-run 与 consultation-recovery-poll 各一处。
// 原因: 恢复轮询搬出 page.tsx,两处文案都还在,没有改成更短的「回答已恢复。」
const poll = readFileSync(new URL("../src/lib/consultation-recovery-poll.ts", import.meta.url), "utf8");
const hook = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8");
assert.equal(poll.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 1);
assert.equal(hook.match(/回答已恢复,已显示在对话区末尾。/g)?.length, 1);
assert.doesNotMatch(pageSource, /setComposerNotice\("回答已恢复。"\)/);
assert.doesNotMatch(poll, /setComposerNotice\("回答已恢复。"\)/);
});
test("assertive announcement is reserved for genuine errors", () => {
// Given: the bootstrap failure screen is the only assertive surface on the page.
assert.match(pageSource, /<main className="app-loading app-loading-error">\n\s*<div className="app-loading-content" role="alert">/);
// 原值: page.tsx / homeSurface 内联 `<main className="app-loading app-loading-error">`。
// 新值: 同一段在 home-bootstrap-error.tsx。
// 原因: 错误屏抽成组件后仍是唯一的 alert,页面没有 assertive 直播区。
const bootstrapError = readFileSync(new URL("../src/components/home-bootstrap-error.tsx", import.meta.url), "utf8");
assert.match(bootstrapError, /<main className="app-loading app-loading-error">\n\s*<div className="app-loading-content" role="alert">/);
assert.doesNotMatch(pageSource, /aria-live="assertive"/);
// And: the still-loading screen stays polite and marks itself busy.
+21 -8
View File
@@ -19,6 +19,7 @@ import {
} from "../src/lib/chat-session-url.ts";
import { homeSurface as page } from "./home-surface.ts";
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
const login = readFileSync(new URL("../src/components/email-otp-login.tsx", import.meta.url), "utf8");
const loginPage = readFileSync(new URL("../src/app/login/page.tsx", import.meta.url), "utf8");
const sessionA = "11111111-1111-4111-8111-111111111111";
@@ -71,7 +72,10 @@ test("persisting a new chat replaces the intent with the saved session id", () =
});
test("new chat bootstrap consumes the URL before activation and reuses local-only creation", () => {
const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();");
// 原值: 从 homeSurface 里切 `async function loadCloudData()`。
// 新值: 切 home-bootstrap-run.ts 的 resolveLanding。
// 原因: 落点代码原样搬家,清 URL 仍在激活会话之前,且只本地创建一条咨询。
const bootstrap = sourceBetween(bootstrapRun, "export async function resolveLanding(", "export async function runHomeBootstrap(");
const clearIntent = 'if (bootstrapSelection.urlAction === "new-chat") writeSessionUrl(null, "replace");';
assert.ok(bootstrap.includes(clearIntent));
assert.ok(bootstrap.indexOf(clearIntent) < bootstrap.indexOf("setActiveSessionId(landingSessionId)"));
@@ -173,8 +177,11 @@ test("default bootstrap selection does not write a session URL", () => {
},
);
const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();");
assert.match(bootstrap, /defaultSessionId: nextSessions\[0\]\.id/);
// 原值: 从 homeSurface 里切 `async function loadCloudData()`。
// 新值: 切 resolveLanding。
// 原因: 默认落点不再写 push URL,缺失会话仍提示 SESSION_MISSING_NOTICE。
const bootstrap = sourceBetween(bootstrapRun, "export async function resolveLanding(", "export async function runHomeBootstrap(");
assert.match(bootstrap, /defaultSessionId: ready\.nextSessions\[0\]\.id/);
assert.match(bootstrap, /setActiveSessionId\(landingSessionId\)/);
assert.match(bootstrap, /urlAction === "replace-clear"/);
assert.match(bootstrap, /urlAction === "replace-selected"/);
@@ -281,16 +288,22 @@ test("home stays a client-read query on a static route", () => {
// 新值:`await sessionListReady` 后读 `sessionListBoot()`,page.tsx 不再 `fetchSessions(`
// 原因:T2 列表由 layout provider 拉一次;本条仍守「`/` 是静态路由上的客户端读」
const pageFile = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
// 原值: 从 page.tsx 切 `if (previewMode)` 到 `await sessionListReady`。
// 新值: 预览分支在 home-bootstrap-run.ts;首页把 sessionListReady / sessionListBoot 交给它。
// 原因: 预览仍不写会话 URL,`/` 仍是静态路由上的客户端读,不在首页里 fetchSessions。
const preview = sourceBetween(
pageFile,
bootstrapRun,
"if (previewMode) {",
"await sessionListReady;",
"await deps.sessionListReady;",
);
assert.doesNotMatch(preview, /writeSessionUrl|persistLoginSessionReturn|readLoginSessionReturn/);
assert.match(pageFile, /await sessionListReady;/);
assert.match(pageFile, /sessionListBoot\(\)/);
assert.match(bootstrapRun, /await deps\.sessionListReady;/);
assert.match(bootstrapRun, /deps\.sessionListBoot\(\)/);
assert.match(pageFile, /sessionListReady,/);
assert.match(pageFile, /sessionListBoot,/);
assert.doesNotMatch(pageFile, /fetchSessions\(/);
assert.match(page, /SESSION_MISSING_NOTICE/);
assert.doesNotMatch(bootstrapRun, /fetchSessions\(/);
assert.match(bootstrapRun, /SESSION_MISSING_NOTICE/);
assert.equal(SESSION_MISSING_NOTICE, "该对话不存在或已被删除");
});
@@ -0,0 +1,128 @@
import assert from "node:assert/strict";
import test from "node:test";
import { runConsultationRecoveryPoll, type ConsultationRecoveryPollDeps } from "../src/lib/consultation-recovery-poll.ts";
import { ConsultationStatusError } from "../src/lib/home-cloud-sync.ts";
import { previewModelCatalog } from "../src/lib/home-types.ts";
import { withFakeTimers } from "./home-bootstrap-fake-timers.ts";
const sessionId = "11111111-1111-4111-8111-111111111111";
const requestId = "22222222-2222-4222-8222-222222222222";
async function flush() {
for (let step = 0; step < 8; step += 1) await Promise.resolve();
}
function createPoll(overrides: {
fetchConsultationStatus: ConsultationRecoveryPollDeps["fetchConsultationStatus"];
missing?: number;
}) {
const state = {
notice: "",
phase: "recovering" as "recovering" | null,
pendingSession: sessionId as string | null,
pendingRequest: requestId as string | null,
replay: 0,
error: "",
refreshed: 0,
};
const deps: ConsultationRecoveryPollDeps = {
pendingSessionId: sessionId,
pendingRequestId: requestId,
modelCatalog: previewModelCatalog,
consultationStatusMissingCount: { current: overrides.missing ?? 0 },
consultationReplayStarted: { current: null },
consultationReplay: {
current: () => {
state.replay += 1;
deps.consultationReplayStarted.current = requestId;
},
},
pendingConsultation: { current: { phase: "recovering" } },
setComposerNotice: (notice) => { state.notice = notice; },
setSessions: () => undefined,
setActiveSessionId: () => undefined,
setPendingSessionId: (id) => { state.pendingSession = id; },
setPendingRequestId: (id) => { state.pendingRequest = id; },
setConsultationPhase: (phase) => { state.phase = phase === "recovering" ? "recovering" : null; },
setStreamingReply: () => undefined,
setRequestError: (value) => { state.error = value?.message ?? ""; },
refreshAccount: () => { state.refreshed += 1; },
online: () => true,
fetchConsultationStatus: overrides.fetchConsultationStatus,
fetchSessionDetail: async () => null,
};
return { deps, state };
}
test("the first 404 confirms the request and the next 404 replays it once", async () => {
await withFakeTimers(async (tick) => {
const { deps, state } = createPoll({
fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); },
});
runConsultationRecoveryPoll(deps, new AbortController().signal);
await flush();
assert.equal(deps.consultationStatusMissingCount.current, 1);
assert.equal(state.notice, "正在确认本次咨询请求是否已开始…");
assert.equal(state.replay, 0);
assert.equal(state.phase, "recovering");
tick(1_750);
await flush();
assert.equal(deps.consultationStatusMissingCount.current, 2);
assert.equal(state.replay, 1);
assert.equal(state.phase, "recovering");
});
});
test("the fourth missing status gives up and stops polling", async () => {
await withFakeTimers(async (tick) => {
const { deps, state } = createPoll({
fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); },
});
runConsultationRecoveryPoll(deps, new AbortController().signal);
await flush();
tick(1_750);
await flush();
tick(1_750);
await flush();
assert.equal(deps.consultationStatusMissingCount.current, 3);
assert.equal(state.notice, "正在重新发起本次咨询…");
assert.equal(state.replay, 1);
tick(1_750);
await flush();
assert.equal(deps.consultationStatusMissingCount.current, 4);
assert.equal(state.pendingSession, null);
assert.equal(state.pendingRequest, null);
assert.equal(state.phase, null);
assert.equal(state.error, "后台未找到本次咨询请求,请重新发送。");
assert.equal(state.notice, "后台未找到本次咨询请求,已停止恢复,请重新发送。");
const timersBefore = state.notice;
tick(1_750);
await flush();
assert.equal(state.notice, timersBefore);
assert.equal(deps.consultationStatusMissingCount.current, 4);
});
});
test("a reserved status clears the missing count", async () => {
await withFakeTimers(async (tick) => {
const { deps, state } = createPoll({
missing: 2,
fetchConsultationStatus: async () => ({
sessionId,
requestId,
status: "reserved",
}),
});
runConsultationRecoveryPoll(deps, new AbortController().signal);
await flush();
assert.equal(deps.consultationStatusMissingCount.current, 2);
tick(1_750);
await flush();
assert.equal(deps.consultationStatusMissingCount.current, 0);
assert.equal(state.notice, "回答仍在后台生成,正在自动恢复。");
assert.equal(state.phase, "recovering");
});
});
+154 -45
View File
@@ -2,6 +2,10 @@ import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runConsultationRecoveryPoll } from "../src/lib/consultation-recovery-poll.ts";
import { ConsultationStatusError, createSession } from "../src/lib/home-cloud-sync.ts";
import { runHomeBootstrap, type HomeBootstrapDeps } from "../src/lib/home-bootstrap-run.ts";
import { emptyProfile, pendingConsultationStorageKey, previewModelCatalog, type Account } from "../src/lib/home-types.ts";
import { homeSurface as source } from "./home-surface.ts";
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
@@ -64,73 +68,174 @@ test("explicit consultation HTTP failures unlock instead of entering recovery",
});
test("reserved consultations recover through the status endpoint", () => {
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
const poll = readFileSync(new URL("../src/lib/consultation-recovery-poll.ts", import.meta.url), "utf8");
assert.match(source, /\/api\/consult\/status\?sessionId=\$\{encodeURIComponent\(sessionId\)\}&requestId=\$\{encodeURIComponent\(requestId\)\}/);
assert.match(source, /fetch\("\/api\/consult\/status", \{ signal, cache: "no-store" \}\)/);
assert.match(source, /reservedConsultation\?\.status === "reserved"[\s\S]*nextSessions\.find\(\(session\) => session\.id === reservedConsultation\.sessionId\)/);
// 原值: 预留判断、1750ms 轮询和完成后的 fetchSessionDetail 都在 page.tsx。
// 新值: 预留判断在 home-bootstrap-run;轮询间隔和详情读取在 consultation-recovery-poll。
// 原因: 两段流程搬出首页。状态端点、1750ms、完成后拉详情都还在。
assert.match(bootstrapRun, /reservedConsultation\?\.status === "reserved"[\s\S]*nextSessions\.find\(\(session\) => session\.id === reservedConsultation\.sessionId\)/);
assert.match(source, /status\?\.status !== "reserved"[\s\S]*sessions\.find\(\(item\) => item\.id === status\.sessionId\)/);
assert.match(source, /status: "reserved" \| "completed" \| "cancelled"/);
assert.match(source, /readonly responseMessage\?: unknown/);
assert.match(source, /phase: "recovering"/);
assert.match(source, /window\.setTimeout\(\(\) => void poll\(\), 1_750\)/);
assert.match(source, /status\.status === "completed"[\s\S]*?fetchSessionDetail\(pendingSessionId, modelCatalog, controller\.signal\)/);
assert.match(poll, /window\.setTimeout\(\(\) => void poll\(\), 1_750\)/);
assert.match(poll, /status\.status === "completed"[\s\S]*?fetchDetail\(deps\.pendingSessionId, deps\.modelCatalog, signal\)/);
assert.match(source, /window\.addEventListener\("online", onOnline\)/);
assert.match(source, /window\.addEventListener\("pageshow", onPageShow\)/);
assert.match(source, /网络已断开,回答仍在后台生成;联网后会自动恢复。/);
});
test("a missing reservation is replayed once instead of being polled until the question is lost", () => {
test("a missing reservation is replayed once instead of being polled until the question is lost", async () => {
// 原值: 按 page.tsx 恢复 effect 的源码切片匹配 404、重放和第 4 次放弃。
// 新值: 直接调用 runConsultationRecoveryPoll。逐步假计时器在 consultation-recovery-poll.test.ts。
// 原因: 轮询已搬出;这里确认第一次 404 只确认、不立刻丢掉问题。
assert.match(source, /class ConsultationStatusError extends Error[\s\S]*readonly status: number/);
assert.match(source, /throw new ConsultationStatusError\([\s\S]*response\.status/);
const recoveryEffect = source.slice(
source.indexOf('if (consultationPhase !== "recovering"'),
source.indexOf("}, [consultationPhase, modelCatalog, pendingRequestId, pendingSessionId])"),
);
assert.match(source, /const consultationStatusMissingCount = useRef\(0\)/);
assert.match(source, /const consultationReplayStarted = useRef<string \| null>\(null\)/);
assert.match(recoveryEffect, /status\.status === "reserved"[\s\S]*consultationStatusMissingCount\.current = 0/);
assert.match(recoveryEffect, /caught instanceof ConsultationStatusError && caught\.status === 404[\s\S]*consultationStatusMissingCount\.current \+= 1/);
assert.match(recoveryEffect, /consultationStatusMissingCount\.current === 1[\s\S]*正在确认本次咨询请求是否已开始/);
assert.match(recoveryEffect, /consultationReplayStarted\.current !== pendingRequestId[\s\S]*consultationReplay\.current\(\)/);
assert.match(recoveryEffect, /consultationStatusMissingCount\.current >= 4[\s\S]*setPendingSessionId\(null\)[\s\S]*setPendingRequestId\(null\)[\s\S]*setConsultationPhase\(null\)/);
assert.match(recoveryEffect, /后台未找到本次咨询请求,已停止恢复,请重新发送。/);
assert.match(recoveryEffect, /consultationStatusMissingCount\.current = 0;[\s\S]*回答仍在后台生成,正在自动恢复。/);
assert.match(recoveryEffect, /consultationStatusMissingCount\.current > 0[\s\S]*window\.setTimeout\(\(\) => void poll\(\), 1_750\)[\s\S]*else \{[\s\S]*void poll\(\)/);
const host = globalThis as { window?: unknown };
if (host.window === undefined) host.window = globalThis;
const missing = { current: 0 };
let notice = "";
let replayed = 0;
const controller = new AbortController();
runConsultationRecoveryPoll({
pendingSessionId: "11111111-1111-4111-8111-111111111111",
pendingRequestId: "22222222-2222-4222-8222-222222222222",
modelCatalog: previewModelCatalog,
consultationStatusMissingCount: missing,
consultationReplayStarted: { current: null },
consultationReplay: { current: () => { replayed += 1; } },
pendingConsultation: { current: { phase: "recovering" } },
setComposerNotice: (value) => { notice = value; },
setSessions: () => undefined,
setActiveSessionId: () => undefined,
setPendingSessionId: () => undefined,
setPendingRequestId: () => undefined,
setConsultationPhase: () => undefined,
setStreamingReply: () => undefined,
setRequestError: () => undefined,
refreshAccount: () => undefined,
online: () => true,
fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); },
}, controller.signal);
try {
for (let step = 0; step < 8; step += 1) await Promise.resolve();
assert.equal(missing.current, 1);
assert.equal(replayed, 0);
assert.equal(notice, "正在确认本次咨询请求是否已开始…");
} finally {
controller.abort();
}
});
test("tab-local pending ids drive strict bootstrap recovery before the global fallback", () => {
// 原值:切片从 `let reservedConsultation = null` 开始,依赖初始赋值形状。
// 新值:以 recovery bootstrap 的稳定 pending-storage读取锚点切片。
// 原因:T1/D2 把全局状态预取并行化,保留行为但改变初始化右值。
const bootstrapStart = source.indexOf("const storedPending = dropRectificationStoredPending(");
const bootstrap = source.slice(
bootstrapStart,
source.indexOf("if (controller.signal.aborted) return;", bootstrapStart),
);
// 原值:从拼接后的 `homeSurface` 中用两个跨文件 indexOf 边界切片,
// 边界可能落在不同源码文件,得到空字符串并掩盖真实行为。
// 新值:直接读取 page.tsx,并以 pending storage effect 自身的稳定边界切片。
// 原因:该行为只属于 page.tsx;测试应锁定 pendingSessionId/pendingRequestId 写入
// sessionId/requestId/question 与无 pending 时清除,而不依赖文件拼接顺序。
test("tab-local pending ids drive strict bootstrap recovery before the global fallback", async () => {
// 原值:从 page.tsx 切 storedPending 分支,并用源码顺序断言它先于全局状态。
// 新值:调用 runHomeBootstrap。pending 仍覆盖全局 reserved;无 pending 时清除 storage key。
// 原因:启动流程搬出后,源码切片不再落在 page.tsx;优先级改由结果锁定。
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; },
};
}
const older = createSession(previewModelCatalog.defaultModelId);
const pendingSession = createSession(previewModelCatalog.defaultModelId);
const storedRequestId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const removed: string[] = [];
let restored = "";
const reader: Account = {
user: { id: "account-1", email: "reader@example.com" },
avatar: null,
credits: 1,
isAdmin: false,
adminUrl: null,
rectificationPriceCredits: 1,
activeSubscription: null,
hasConfirmedBirthTime: false,
hasUsableBirthTime: false,
profile: emptyProfile,
};
const deps: HomeBootstrapDeps = {
sessionListReady: Promise.resolve(),
sessionListBoot: () => ({
sessions: [older, pendingSession],
rawRows: [],
cursor: null,
account: reader,
signedOut: false,
}),
uiPreview: { current: false },
uiPreviewMode: { current: null },
consultationStatusMissingCount: { current: 0 },
isDevelopment: false,
readSearch: () => "",
storage: {
getItem: (key) => key === pendingConsultationStorageKey
? JSON.stringify({ sessionId: pendingSession.id, requestId: storedRequestId, question: "继续" })
: null,
removeItem: (key) => { removed.push(key); },
},
restoreConsultationRecovery: (session, requestId) => { restored = `${session.id}:${requestId}`; },
setComposerNotice: () => undefined,
commit: {
setAccount: () => undefined,
setAccountError: () => undefined,
setActiveSessionId: () => undefined,
setBirthTimeAssessmentPhase: () => undefined,
setBirthTimeJourney: () => undefined,
setBootstrapPhase: () => undefined,
setGuidedJourneyPreview: () => undefined,
setHydrated: () => undefined,
setModelCatalog: () => undefined,
setOnboardingStep: () => undefined,
setProfile: () => undefined,
setProfileDraft: () => undefined,
setSessions: () => undefined,
setSessionsCursor: () => undefined,
setStartGreeting: () => undefined,
},
io: {
fetchModelCatalog: async () => previewModelCatalog,
fetchActiveConsultationStatus: async () => ({
sessionId: older.id,
requestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
status: "reserved",
}),
fetchConsultationStatus: async () => ({
sessionId: pendingSession.id,
requestId: storedRequestId,
status: "reserved",
}),
resolveLookupBootstrap: async (input) => ({
selection: { sessionId: input.defaultSessionId, urlAction: "none", missing: false, clearStoredReturn: false },
sessions: input.sessions,
notice: null,
}),
},
};
await runHomeBootstrap(deps, new AbortController().signal);
assert.equal(restored, `${pendingSession.id}:${storedRequestId}`);
assert.equal(removed.includes(pendingConsultationStorageKey), false);
// 原值:从拼接后的 `homeSurface` 中用两个跨文件 indexOf 边界切片。
// 新值:直接读取 page.tsx 的 pending storage effect。
// 原因:写入 sessionId/requestId/question 仍留在首页,不随启动流程搬走。
const pendingStorageMarker = " if (pendingSessionId && pendingRequestId) {";
const storageEffectStart = pageSource.lastIndexOf(" useEffect(() => {", pageSource.indexOf(pendingStorageMarker));
const storageSync = pageSource.slice(
storageEffectStart,
pageSource.indexOf("\n useEffect(() => {", pageSource.indexOf(pendingStorageMarker)),
);
assert.match(bootstrap, /readStoredPendingConsultation\([\s\S]*pendingConsultationStorageKey[\s\S]*nextSessions\.map\(\(session\) => session\.id\)/);
// 原值:`if (!storedPending && sessionStorage.getItem(...)) { sessionStorage.removeItem(...) }`
// 新值:`if (!storedPending) sessionStorage.removeItem(...)`,读存储前先 `dropRectificationStoredPending`
// 原因:校正会话上的 pending 不得进普通咨询恢复;无 pending 时直接清 key,不必再 getItem。
assert.match(bootstrap, /dropRectificationStoredPending\([\s\S]*readStoredPendingConsultation/);
assert.match(bootstrap, /if \(!storedPending\) sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
assert.ok(bootstrap.indexOf("if (storedPending)") >= 0);
// 原值:`storedPending` 分支必须先于 `fetchActiveConsultationStatus`。
// 新值:两者现在并行预取;pending 仍优先覆盖全局结果,不能再用源码顺序断言。
// 原因:T1/D2 慢网下并行读取,减少首页串行等待,不改变恢复优先级。
assert.match(bootstrap, /if \(storedPending\) \{[\s\S]*fetchConsultationStatus\([\s\S]*storedPending\.sessionId,[\s\S]*storedPending\.requestId/);
assert.match(bootstrap, /status\.status === "reserved"[\s\S]*reservedConsultation = status;[\s\S]*else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
assert.match(bootstrap, /caught instanceof ConsultationStatusError && caught\.status === 404 \? 1 : 0/);
assert.match(storageSync, /pendingSessionId && pendingRequestId[\s\S]*sessionStorage\.setItem\(pendingConsultationStorageKey,[\s\S]*sessionId: pendingSessionId,[\s\S]*requestId: pendingRequestId,[\s\S]*question:/);
assert.match(storageSync, /else \{[\s\S]*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
});
@@ -139,7 +244,11 @@ test("pending storage keeps the question so a refresh during the undo window can
assert.match(source, /function readStoredPendingConsultation\(/);
assert.match(source, /typeof parsedPending\.question === "string" \? parsedPending\.question : ""/);
assert.match(sendSource, /sessionStorage\.setItem\(pendingConsultationStorageKey, JSON\.stringify\(\{[\s\S]*sessionId,[\s\S]*requestId,[\s\S]*question: originalQuestion/);
assert.match(source, /restoreConsultationRecovery\(recoverySession, reservedConsultation\.requestId, storedPending\)/);
// 原值: homeSurface 里 `restoreConsultationRecovery(recoverySession, reservedConsultation.requestId, storedPending)`。
// 新值: 同一调用在 home-bootstrap-run.ts。
// 原因: 启动恢复搬出首页,仍把 stored pending 交给真实 restore。
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
assert.match(bootstrapRun, /restoreConsultationRecovery\(recoverySession, reservedConsultation\.requestId, storedPending\)/);
assert.match(source, /stored\?\.question\?\.trim\(\)/);
assert.match(sendSource, /resumeRequestId/);
assert.match(sendSource, /questionAlreadyPresent/);
@@ -0,0 +1,46 @@
export function installWindow() {
const host = globalThis as { window?: unknown };
if (host.window === undefined) host.window = globalThis;
}
export async function withFakeTimers(run: (tick: (ms: number) => void) => Promise<void>): Promise<void> {
const originalSet = globalThis.setTimeout;
const originalClear = globalThis.clearTimeout;
const host = globalThis as { window?: unknown };
const originalWindow = host.window;
host.window = globalThis;
let now = 0;
let sequence = 1;
const queue = new Map<number, { at: number; fn: () => void }>();
globalThis.setTimeout = ((fn: TimerHandler, ms?: number) => {
const id = sequence;
sequence += 1;
queue.set(id, {
at: now + (typeof ms === "number" ? ms : 0),
fn: () => { if (typeof fn === "function") fn(); },
});
return id as unknown as ReturnType<typeof setTimeout>;
}) as unknown as typeof setTimeout;
globalThis.clearTimeout = ((id?: ReturnType<typeof setTimeout>) => {
if (typeof id === "number") queue.delete(id);
}) as unknown as typeof clearTimeout;
const tick = (ms: number) => {
now += ms;
const due = [...queue.entries()]
.filter((entry) => entry[1].at <= now)
.sort((left, right) => left[1].at - right[1].at);
for (const [id, item] of due) {
if (!queue.has(id)) continue;
queue.delete(id);
item.fn();
}
};
try {
await run(tick);
} finally {
globalThis.setTimeout = originalSet;
globalThis.clearTimeout = originalClear;
if (originalWindow === undefined) delete host.window;
else host.window = originalWindow;
}
}
+20 -5
View File
@@ -22,6 +22,8 @@ import { homeSurface } from "./home-surface.ts";
// being loaded.
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
const bootstrapError = readFileSync(new URL("../src/components/home-bootstrap-error.tsx", import.meta.url), "utf8");
const starterHome = readFileSync(new URL("../src/components/starter-home.tsx", import.meta.url), "utf8");
const globalsCss = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
const staleClientRecovery = readFileSync(new URL("../src/components/stale-client-recovery.tsx", import.meta.url), "utf8");
@@ -80,9 +82,12 @@ test("default landing replaces a latest rectification session with an empty cons
starterHomeLandingNeedsConsultation(sessions, rectificationId, "keep"),
false,
);
assert.match(page, /resolveStarterHomeLandingSessionId\(/);
// 原值: page.tsx 直接调用 resolveStarterHomeLandingSessionId / starterHomeLandingNeedsConsultation。
// 新值: 这两个调用在 home-bootstrap-run.ts;page.tsx 仍调用 shouldAutoOpenRectificationSession。
// 原因: 启动落点搬进 runHomeBootstrap,揭幕阶段的校正会话判断留在首页。
assert.match(bootstrapRun, /resolveStarterHomeLandingSessionId\(/);
assert.match(page, /shouldAutoOpenRectificationSession\(/);
assert.match(page, /starterHomeLandingNeedsConsultation\(/);
assert.match(bootstrapRun, /starterHomeLandingNeedsConsultation\(/);
});
test("new chat landing requests a fresh consultation even with an existing ordinary chat", () => {
@@ -124,8 +129,12 @@ test("home bootstrap keeps one slow-network budget before the fatal screen", ()
// 新值: 账户、模型与状态等待共享 20000ms,超时后只显示错误屏。
// 原因: 慢网串行链需要一次等待一次揭幕,不能半揭幕或提前放弃。
assert.equal(HOME_BOOTSTRAP_SLOW_MS, 20_000);
assert.match(page, /HOME_BOOTSTRAP_SLOW_MS/);
// 原值: page.tsx 引用 HOME_BOOTSTRAP_SLOW_MS。
// 新值: 20 秒预算在 runHomeBootstrap 里。
// 原因: 超时属于启动流程,首页只调用这个函数。
assert.match(bootstrapRun, /HOME_BOOTSTRAP_SLOW_MS/);
assert.doesNotMatch(page, /\}, 8000\);/);
assert.doesNotMatch(bootstrapRun, /\}, 8000\);/);
});
test("reveal delay is zero once settled and counts down from the phase start otherwise", () => {
@@ -146,7 +155,10 @@ test("loading copy changes per phase so the single loading screen narrates progr
});
test("bootstrap enters the prepare phase instead of revealing after the account fetch", () => {
assert.match(page, /if \(bootstrapFailed\) setHydrated\(true\);\n\s*else setBootstrapPhase\("prepare"\);/);
// 原值: `if (bootstrapFailed) setHydrated(true); else setBootstrapPhase("prepare");` 写在 page.tsx。
// 新值: 同一分支写在 runHomeBootstrap,setter 经 deps.commit 注入。
// 原因: 失败仍揭幕错误屏,成功仍进入 prepare,不半揭幕。
assert.match(bootstrapRun, /if \(bootstrapFailed\) deps\.commit\.setHydrated\(true\);\n\s*else deps\.commit\.setBootstrapPhase\("prepare"\);/);
// 原值: 3 个 prepare 守卫(onboarding / 今日星语 / 入口摘要)。
// 新值: 2 个(今日星语 / 入口摘要)。
// 原因: 建议问题请求已删除。
@@ -188,7 +200,10 @@ test("recent sessions are prefetched after reveal so switches do not wait", () =
test("the loading screen DOM contract stale-client recovery depends on is untouched", () => {
assert.match(page, /<main className="app-loading" aria-busy="true" aria-live="polite">/);
assert.match(page, /<main className="app-loading app-loading-error">/);
// 原值: 错误屏 main 写在 page.tsx。
// 新值: 同一段 main 在 home-bootstrap-error.tsx。
// 原因: 错误屏抽成组件,class 与 role 不变,僵死恢复脚本仍能找到它。
assert.match(bootstrapError, /<main className="app-loading app-loading-error">/);
assert.match(staleClientRecovery, /main\.app-loading\[aria-busy="true"\]/);
assert.match(staleClientRecovery, /main\.app-loading-error/);
assert.ok(homeSurface.includes("bootstrapPrepareSettled("));
+321
View File
@@ -0,0 +1,321 @@
import assert from "node:assert/strict";
import test from "node:test";
import { ConsultationStatusError, createSession, LoginRedirectError } from "../src/lib/home-cloud-sync.ts";
import {
HOME_BOOTSTRAP_TIMEOUT_MESSAGE,
runHomeBootstrap,
type HomeBootstrapDeps,
type HomeBootstrapIo,
} from "../src/lib/home-bootstrap-run.ts";
import { HOME_BOOTSTRAP_SLOW_MS } from "../src/lib/home-bootstrap.ts";
import {
emptyProfile,
pendingConsultationStorageKey,
previewModelCatalog,
type Account,
type ChatSession,
type ConsultationStatus,
} from "../src/lib/home-types.ts";
import type { SessionListBoot } from "../src/lib/session-list-context.tsx";
import { installWindow, withFakeTimers } from "./home-bootstrap-fake-timers.ts";
function installBrowserHosts() {
installWindow();
const host = globalThis as { localStorage?: Storage };
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 memoryStorage() {
const data = new Map<string, string>();
return {
getItem: (key: string) => data.get(key) ?? null,
removeItem: (key: string) => { data.delete(key); },
setItem: (key: string, value: string) => { data.set(key, value); },
};
}
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,
};
}
function createHarness(options: {
sessions: ChatSession[];
signedOut?: boolean;
io?: Partial<HomeBootstrapIo>;
restoreConsultationRecovery?: HomeBootstrapIo["restoreConsultationRecovery"];
} ) {
installBrowserHosts();
const state: {
account: Account | null;
error: string;
hydrated: boolean;
phase: string;
sessions: ChatSession[];
active: string;
notice: string;
profileSet: boolean;
catalogSet: boolean;
} = {
account: null,
error: "",
hydrated: false,
phase: "",
sessions: [] as ChatSession[],
active: "",
notice: "",
profileSet: false,
catalogSet: false,
};
const commits: string[] = [];
const storage = memoryStorage();
const boot: SessionListBoot = {
sessions: options.sessions,
rawRows: [],
cursor: "cursor-1",
account: options.signedOut ? null : account(),
signedOut: options.signedOut ?? false,
};
const note = (name: string) => { commits.push(name); };
const deps: HomeBootstrapDeps = {
sessionListReady: Promise.resolve(),
sessionListBoot: () => boot,
uiPreview: { current: false },
uiPreviewMode: { current: null },
consultationStatusMissingCount: { current: 0 },
isDevelopment: false,
readSearch: () => "",
storage,
restoreConsultationRecovery: options.restoreConsultationRecovery ?? (() => undefined),
setComposerNotice: (notice) => { state.notice = notice; note("notice"); },
commit: {
setAccount: (value) => { state.account = value; note("account"); },
setAccountError: (value) => { state.error = value; note("error"); },
setActiveSessionId: (id) => { state.active = id; note("active"); },
setBirthTimeAssessmentPhase: () => note("assessment"),
setBirthTimeJourney: () => note("journey"),
setBootstrapPhase: (phase) => { state.phase = phase; note("phase"); },
setGuidedJourneyPreview: () => note("preview"),
setHydrated: (value) => { state.hydrated = value; note("hydrated"); },
setModelCatalog: () => { state.catalogSet = true; note("catalog"); },
setOnboardingStep: () => note("onboarding"),
setProfile: () => { state.profileSet = true; note("profile"); },
setProfileDraft: () => note("draft"),
setSessions: (sessions) => { state.sessions = sessions; note("sessions"); },
setSessionsCursor: () => note("cursor"),
setStartGreeting: () => note("greeting"),
},
io: {
fetchModelCatalog: async () => previewModelCatalog,
fetchActiveConsultationStatus: async () => null,
fetchConsultationStatus: async () => {
throw new Error("unexpected status read");
},
resolveLookupBootstrap: async (input) => ({
selection: {
sessionId: input.defaultSessionId,
urlAction: "none",
missing: false,
clearStoredReturn: false,
},
sessions: input.sessions,
notice: null,
}),
...options.io,
},
};
return { deps, state, commits, storage };
}
async function waitUntil(predicate: () => boolean) {
for (let attempt = 0; attempt < 30; attempt += 1) {
if (predicate()) return;
await Promise.resolve();
}
assert.equal(predicate(), true);
}
test("a successful bootstrap fills the home once, after every read has settled", async () => {
const session = createSession(previewModelCatalog.defaultModelId);
let lookupStarted = false;
let releaseLookup: () => void = () => undefined;
const lookupGate = new Promise<void>((resolve) => { releaseLookup = resolve; });
const { deps, state, commits } = createHarness({
sessions: [session],
io: {
resolveLookupBootstrap: async (input) => {
lookupStarted = true;
await lookupGate;
return {
selection: {
sessionId: input.defaultSessionId,
urlAction: "none",
missing: false,
clearStoredReturn: false,
},
sessions: input.sessions,
notice: null,
};
},
},
});
const pending = runHomeBootstrap(deps, new AbortController().signal);
await waitUntil(() => lookupStarted);
assert.equal(state.account === null, true);
assert.equal(state.sessions.length, 0);
assert.equal(state.phase, "");
assert.equal(state.hydrated, false);
releaseLookup();
await pending;
assert.equal(state.account?.user.id, "account-1");
assert.equal(state.sessions[0]?.id, session.id);
assert.equal(state.active, session.id);
assert.equal(state.profileSet, true);
assert.equal(state.catalogSet, true);
assert.equal(state.phase, "prepare");
assert.equal(state.hydrated, false);
assert.equal(state.error, "");
assert.ok(commits.indexOf("account") < commits.indexOf("phase"));
});
test("a model catalog failure stays on the fatal screen and does not half-reveal", async () => {
const { deps, state } = createHarness({
sessions: [createSession(previewModelCatalog.defaultModelId)],
io: {
fetchModelCatalog: async () => { throw new Error("模型列表读取失败"); },
},
});
await runHomeBootstrap(deps, new AbortController().signal);
assert.equal(state.account, null);
assert.equal(state.sessions.length, 0);
assert.equal(state.catalogSet, false);
assert.equal(state.phase, "");
assert.equal(state.hydrated, true);
assert.equal(state.error, "模型列表读取失败");
});
test("a stored pending reservation wins over the global active status", async () => {
const older = createSession(previewModelCatalog.defaultModelId);
const pendingSession = createSession(previewModelCatalog.defaultModelId);
const globalStatus: ConsultationStatus = {
sessionId: older.id,
requestId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
status: "reserved",
};
const storedRequestId = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
let restored: { sessionId: string; requestId: string } | null = null;
const { deps, storage } = createHarness({
sessions: [older, pendingSession],
restoreConsultationRecovery: (session, requestId) => {
restored = { sessionId: session.id, requestId };
},
io: {
fetchActiveConsultationStatus: async () => globalStatus,
fetchConsultationStatus: async (sessionId, requestId) => ({
sessionId,
requestId,
status: "reserved",
}),
},
});
storage.setItem(pendingConsultationStorageKey, JSON.stringify({
sessionId: pendingSession.id,
requestId: storedRequestId,
question: "稍后继续",
}));
await runHomeBootstrap(deps, new AbortController().signal);
assert.deepEqual(restored, { sessionId: pendingSession.id, requestId: storedRequestId });
assert.equal(deps.consultationStatusMissingCount.current, 0);
});
test("a stored 404 still reserves that request and counts the first miss", async () => {
const session = createSession(previewModelCatalog.defaultModelId);
const requestId = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
let restored: string | null = null;
const { deps, storage } = createHarness({
sessions: [session],
restoreConsultationRecovery: (_session, restoredRequestId) => { restored = restoredRequestId; },
io: {
fetchActiveConsultationStatus: async () => null,
fetchConsultationStatus: async () => { throw new ConsultationStatusError(404, "missing"); },
},
});
storage.setItem(pendingConsultationStorageKey, JSON.stringify({
sessionId: session.id,
requestId,
question: "",
}));
await runHomeBootstrap(deps, new AbortController().signal);
assert.equal(deps.consultationStatusMissingCount.current, 1);
assert.equal(restored, requestId);
});
test("the slow-network timeout aborts the run and does not enter prepare", async () => {
installBrowserHosts();
await withFakeTimers(async (tick) => {
const { deps, state } = createHarness({
sessions: [createSession(previewModelCatalog.defaultModelId)],
io: {
fetchModelCatalog: (signal) => new Promise((_resolve, reject) => {
const fail = () => {
const error = new Error("Aborted");
error.name = "AbortError";
reject(error);
};
if (signal?.aborted) fail();
else signal?.addEventListener("abort", fail, { once: true });
}),
},
});
const pending = runHomeBootstrap(deps, new AbortController().signal);
await Promise.resolve();
tick(HOME_BOOTSTRAP_SLOW_MS);
await pending;
assert.equal(state.error, HOME_BOOTSTRAP_TIMEOUT_MESSAGE);
assert.equal(state.hydrated, true);
assert.equal(state.phase, "");
assert.equal(state.account, null);
});
});
test("a login redirect leaves the bootstrap timeout running", async () => {
installBrowserHosts();
await withFakeTimers(async (tick) => {
const { deps, state } = createHarness({
sessions: [],
signedOut: true,
io: {
redirectToLogin: () => { throw new LoginRedirectError(); },
},
});
await runHomeBootstrap(deps, new AbortController().signal);
assert.equal(state.error, "");
assert.equal(state.hydrated, false);
tick(HOME_BOOTSTRAP_SLOW_MS);
await Promise.resolve();
assert.equal(state.error, HOME_BOOTSTRAP_TIMEOUT_MESSAGE);
assert.equal(state.hydrated, true);
assert.equal(state.phase, "");
});
});
@@ -0,0 +1,152 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { HomeBootstrapError } from "../src/components/home-bootstrap-error.tsx";
import { HomeOnboardingShell } from "../src/components/home-onboarding-shell.tsx";
import type { BirthTimeGuidedController } from "../src/hooks/use-birth-time-guided-journey.ts";
import { emptyProfile, presetOnboardingMessage } from "../src/lib/home-types.ts";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
const page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
test("the fatal screen keeps its copy and retries through retryHomeBootstrap", async () => {
const accountError = "连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。";
const markup = renderToStaticMarkup(
<HomeBootstrapError accountError={accountError} onRetry={() => undefined} />,
);
assert.match(markup, /暂时无法进入 Jyotisha/);
assert.match(markup, /连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。/);
assert.match(markup, /重试/);
assert.match(markup, /返回登录/);
assert.match(markup, /app-loading app-loading-error/);
assert.match(markup, /role="alert"/);
const harness = createClientLifecycleHarness();
let retried = 0;
try {
await harness.render(<HomeBootstrapError accountError={accountError} onRetry={() => { retried += 1; }} />);
const retry = harness.elements().find((node) => node.tagName === "BUTTON" && node.text.includes("重试"));
assert.ok(retry);
await harness.event(retry);
assert.equal(retried, 1);
} finally {
await harness.close();
}
// 原值:错误屏按钮内联在 page.tsx,onClick 直接调用 retryHomeBootstrap。
// 新值:按钮在 HomeBootstrapError,page.tsx 把 onRetry 接到同一个 retryHomeBootstrap(...reloadSessionList)。
// 原因:错误屏抽成组件后,重试仍局部重跑,不整页刷新。
assert.match(page, /<HomeBootstrapError[\s\S]*onRetry=\{\(\) => retryHomeBootstrap\([\s\S]*reloadSessionList,[\s\S]*\)\}/);
assert.doesNotMatch(page, /window\.location\.reload\(\)/);
});
test("onboarding keeps the name prompt, birth card, place card, and retry card", () => {
const guided = {} as BirthTimeGuidedController;
const nameMarkup = renderToStaticMarkup(
<HomeOnboardingShell
profileComplete={false}
profileDraft={emptyProfile}
setProfileDraft={() => undefined}
onboardingStep="name"
presetMessageLength={presetOnboardingMessage.length}
currentOnboardingMessage={presetOnboardingMessage}
onboardingJustCompleted={false}
onboardingCardReady={false}
presetMessageFinished
birthTimeJourney={null}
birthTimeAssessmentPhase={null}
birthTimeError=""
birthTimeGuided={guided}
accountError=""
profileSaving={false}
saveOnboardingBirth={() => undefined}
saveOnboardingPlace={() => undefined}
retryBirthTimeAssessment={() => undefined}
/>,
);
assert.match(nameMarkup, /你好,我是 Jyotisha/);
assert.match(nameMarkup, /请问我该怎么称呼你/);
assert.doesNotMatch(nameMarkup, /出生日期与时间/);
const birthMarkup = renderToStaticMarkup(
<HomeOnboardingShell
profileComplete={false}
profileDraft={{ ...emptyProfile, name: "林遥" }}
setProfileDraft={() => undefined}
onboardingStep="birth"
presetMessageLength={80}
currentOnboardingMessage="出生时间"
onboardingJustCompleted={false}
onboardingCardReady
presetMessageFinished
birthTimeJourney={null}
birthTimeAssessmentPhase={null}
birthTimeError=""
birthTimeGuided={guided}
accountError=""
profileSaving={false}
saveOnboardingBirth={() => undefined}
saveOnboardingPlace={() => undefined}
retryBirthTimeAssessment={() => undefined}
/>,
);
assert.match(birthMarkup, /出生日期与时间/);
assert.match(birthMarkup, />继续</);
const placeMarkup = renderToStaticMarkup(
<HomeOnboardingShell
profileComplete={false}
profileDraft={{ ...emptyProfile, name: "林遥" }}
setProfileDraft={() => undefined}
onboardingStep="place"
presetMessageLength={80}
currentOnboardingMessage="出生地点"
onboardingJustCompleted={false}
onboardingCardReady
presetMessageFinished
birthTimeJourney={null}
birthTimeAssessmentPhase={null}
birthTimeError=""
birthTimeGuided={guided}
accountError=""
profileSaving={false}
saveOnboardingBirth={() => undefined}
saveOnboardingPlace={() => undefined}
retryBirthTimeAssessment={() => undefined}
/>,
);
assert.match(placeMarkup, /出生地点/);
assert.match(placeMarkup, /搜索全球城市或区县,系统会匹配出生当日的时区。/);
assert.match(placeMarkup, />确定</);
const retryMarkup = renderToStaticMarkup(
<HomeOnboardingShell
profileComplete={false}
profileDraft={{ ...emptyProfile, name: "林遥" }}
setProfileDraft={() => undefined}
onboardingStep="rectification"
presetMessageLength={80}
currentOnboardingMessage="校正"
onboardingJustCompleted={false}
onboardingCardReady
presetMessageFinished
birthTimeJourney={null}
birthTimeAssessmentPhase={null}
birthTimeError=""
birthTimeGuided={guided}
accountError=""
profileSaving={false}
saveOnboardingBirth={() => undefined}
saveOnboardingPlace={() => undefined}
retryBirthTimeAssessment={() => undefined}
/>,
);
assert.match(retryMarkup, /出生时间尚未完成评估/);
assert.match(retryMarkup, /资料已经保留,但暂时无法恢复校正进度。系统不会应用未经验证的具体时间。/);
assert.match(retryMarkup, />重新评估</);
});
@@ -4,16 +4,13 @@ import test from "node:test";
const pageSource = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
// Measured 2026-09-16 after home-state-lowering batch 2 on origin/staging @ 37e6c519.
// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1846.
// Measured 2026-09-25 after home-page-split on codex/home-page-split-20260925.
// Line count: (pageSource.match(/\n/g) ?? []).length, same as `wc -l` = 1417.
// Hook counts use \buseState[<(] / \buseRef[<(] so `useState<Type>(` is not missed.
// Former caps were useState 52 / useRef 39 / lines 1931 after batch 1 @ f8e607c2,
// and useState 66 / useRef 41 / lines 1951 on freeze-metric @ 51a65d92.
// Batch 2 moved 4 synastry useStates into useSynastry, 6 profile useStates into
// useProfileOnboarding and 6 session useStates into useSessionManagement, and
// dropped the two guided-birth-time callback refs that the profile hook no longer
// needs now that it runs before useBirthTimeGuidedJourney.
const PAGE_LINE_COUNT_BASELINE = 1846;
// Former line baseline was 1846 (cap 1996) after batch 2 @ 37e6c519.
// This split moved bootstrap, recovery polling, and the error / onboarding
// screens out of Home(). useState stayed 34 (cap 36) and useRef stayed 37.
const PAGE_LINE_COUNT_BASELINE = 1417;
const PAGE_LINE_COUNT_CAP = PAGE_LINE_COUNT_BASELINE + 150;
const HOME_USE_STATE_CAP = 36;
const HOME_USE_REF_CAP = 37;
+145 -32
View File
@@ -5,9 +5,11 @@ 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 page = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
const run = readFileSync(new URL("../src/hooks/use-consultation-run.ts", import.meta.url), "utf8");
function sourceBetween(source: string, start: string, end: string): string {
@@ -17,63 +19,174 @@ function sourceBetween(source: string, start: string, end: string): string {
return source.slice(from, to);
}
function bootstrapWithRecovery(urlAction: "new-chat" | "none") {
// Exercise the real recovery function and Home's activation block together.
// The synthetic ordinary chat has no engine payload or private user data.
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 };
const state = {
active: "",
pendingSession: "",
pendingRequest: "",
phase: "",
notice: "",
storedCleared: false,
sessions: [] as ChatSession[],
};
const writes: unknown[][] = [];
const recovery = sourceBetween(run, " function restoreConsultationRecovery(", " consultationRecoveryCheck.current =");
// 原值:切到已删除的模型不可用分支作为源码边界。
// 新值:以稳定的 fallback-session 分支作为边界;模型不可用现在直接走 fatal screen。
// 原因:T1/D2 移除了已验收的半揭幕路径,测试不能再依赖被删除的语句。
const activation = sourceBetween(page, " if (bootstrapSelection.urlAction === \"new-chat\") writeSessionUrl", " if (parsedSessions.fallbackSessionIds.length > 0)");
const compiled = ts.transpileModule(`${recovery}\n${activation}`, {
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS },
}).outputText;
runInNewContext(compiled, {
const memory = new Map<string, string>();
const sandbox = {
AbortController,
navigator: { onLine: true },
pendingConsultation: pending,
bootstrapSelection: { urlAction, clearStoredReturn: urlAction === "new-chat" },
landingSessionId: localSession.id,
nextSessions: [localSession, oldSession],
reservedConsultation: { status: "reserved", sessionId: oldSession.id, requestId: "test-request" },
storedPending: null,
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,
isUnsavedEmptyConsultation,
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; },
setStreamingReply: () => {},
clearLoginSessionReturn: () => { state.storedCleared = true; },
writeSessionUrl: (...args: unknown[]) => { writes.push(args); },
updateSession: () => { assert.fail("the existing user question must not be duplicated"); },
});
};
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", () => {
const { state, pending, writes, localSession, oldSession } = bootstrapWithRecovery("new-chat");
assert.equal(state.active, localSession.id);
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(isUnsavedEmptyConsultation(localSession), true);
assert.equal(state.pendingSession, oldSession.id);
assert.equal(state.pendingRequest, "test-request");
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", () => {
const { state, pending, writes, oldSession } = bootstrapWithRecovery("none");
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);
+6 -1
View File
@@ -20,7 +20,12 @@ test("the app layout owns one SessionListProvider and one SidebarProvider", () =
test("the provider fetches the session list once and home does not fetch it again", () => {
assert.match(provider, /fetch\(`\/api\/sessions\?limit=\$\{SESSION_PAGE_SIZE\}`/);
assert.match(provider, /fetch\("\/api\/account"/);
assert.match(page, /await sessionListReady;/);
// 原值: page.tsx 里 `await sessionListReady;`
// 新值: page.tsx 把 sessionListReady 交给 runHomeBootstrap,函数里 `await deps.sessionListReady`。
// 原因: 启动流程搬出首页后,会话列表仍只由 provider 拉一次。
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
assert.match(page, /sessionListReady,/);
assert.match(bootstrapRun, /await deps\.sessionListReady;/);
assert.doesNotMatch(page, /fetchSessions\(/);
assert.doesNotMatch(page, /fetchAccount\(/);
assert.match(page, /useHomeShellRegistration\(/);
+10 -5
View File
@@ -89,14 +89,19 @@ test("edge and layout recover a stale client without looping forever", () => {
assert.match(recoverySource, /window\.location\.reload\(\)/);
assert.match(recoverySource, /pageshow/);
assert.match(recoverySource, /main\.app-loading\[aria-busy="true"\]/);
assert.match(pageSource, /clearStaleClientReload\(sessionStorage\)/);
// 原值: page.tsx 里 `clearStaleClientReload(sessionStorage)`。
// 新值: 首页把 `storage: sessionStorage` 交给 runHomeBootstrap,函数内 `clearStaleClientReload(deps.storage)`。
// 原因: 清的仍是会话存储,不是别的 storage。
const bootstrapRun = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
assert.match(pageSource, /storage: sessionStorage/);
assert.match(bootstrapRun, /clearStaleClientReload\(deps\.storage\)/);
});
test("a login redirect leaves the bootstrap timeout running so a failed navigation can escape the spinner", () => {
const bootstrap = pageSource.slice(
pageSource.indexOf("async function loadCloudData()"),
pageSource.indexOf("void loadCloudData();"),
);
// 原值: 从 page.tsx 的 loadCloudData 切片。
// 新值: 同一段 try/finally 在 home-bootstrap-run.ts。
// 原因: 登录跳转仍不清除 20 秒超时,失败的导航还能离开加载屏。
const bootstrap = readFileSync(new URL("../src/lib/home-bootstrap-run.ts", import.meta.url), "utf8");
assert.match(bootstrap, /if \(caught instanceof LoginRedirectError\) \{\n\s*redirectedToLogin = true;\n\s*return;/);
assert.match(bootstrap, /if \(redirectedToLogin\) return;/);
assert.match(bootstrap, /window\.clearTimeout\(bootstrapTimeout\)/);