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.
285 lines
16 KiB
TypeScript
285 lines
16 KiB
TypeScript
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");
|
|
const sendSource = source.slice(source.indexOf(" async function send("), source.indexOf("\n\n consultationReplay.current"));
|
|
const stopSource = source.slice(source.indexOf(" async function stopResponse("), source.indexOf("\n\n function completeConsultationInterface"));
|
|
|
|
test("consultation does not persist the optimistic user message before generation starts", () => {
|
|
// Former value: `await persistSession(userSession)` ran after the undo window and
|
|
// before `/api/consult`, locking the client full-replace write. The server now
|
|
// appends the question after a successful reserve.
|
|
const undo = sendSource.indexOf("await waitForUndoWindow(controller.signal)");
|
|
const consult = sendSource.indexOf('fetch("/api/consult"');
|
|
|
|
assert.ok(undo >= 0 && undo < consult);
|
|
assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1);
|
|
assert.doesNotMatch(sendSource, /问题保存失败,未开始生成;问题已放回输入框。/);
|
|
});
|
|
|
|
test("only explicit stop can request consultation cancellation", () => {
|
|
const streamCatch = sendSource.indexOf(" } catch (caught) {", sendSource.indexOf('fetch("/api/consult"'));
|
|
|
|
assert.equal(source.match(/confirmCancellation\(/g)?.length, 2);
|
|
assert.match(stopSource, /await confirmCancellation\(/);
|
|
assert.ok(streamCatch >= 0);
|
|
assert.doesNotMatch(sendSource.slice(streamCatch), /confirmCancellation\(|\/api\/consult\/cancel/);
|
|
});
|
|
|
|
test("durable partial stop cancels before preserving content and recovers completed conflicts", () => {
|
|
const partialStop = stopSource.slice(
|
|
stopSource.indexOf(" if (pending.partialReply)"),
|
|
stopSource.indexOf("\n updateSession(pending.sessionId, () => pending.previousSession)"),
|
|
);
|
|
const cancel = partialStop.indexOf("await requestCancellation(pending.requestId)");
|
|
const conflict = partialStop.indexOf("error.status === 409");
|
|
const recoveryPersist = partialStop.slice(conflict);
|
|
|
|
assert.ok(cancel >= 0 && conflict > cancel);
|
|
// Former value: persistSession(stoppedSession) after cancel. Refunded partial
|
|
// answers stay in page memory only and must not overwrite the server transcript.
|
|
assert.equal(partialStop.indexOf("await persistSession(stoppedSession)"), -1);
|
|
assert.doesNotMatch(recoveryPersist, /persistSession\(stoppedSession\)/);
|
|
assert.match(recoveryPersist, /fetchConsultationStatus\(pending.sessionId, pending.requestId\)/);
|
|
assert.match(recoveryPersist, /status.status === "completed"[\s\S]*?fetchSessionDetail\(pending.sessionId, modelCatalog\)/);
|
|
assert.match(partialStop, /已停止回答,现有内容已保留,本次点数已退回。/);
|
|
assert.match(source, /停止回答,保留已生成内容并退回本次点数/);
|
|
assert.match(source, /停止回答,保留现有内容并申请退回本次点数/);
|
|
});
|
|
|
|
test("explicit consultation HTTP failures unlock instead of entering recovery", () => {
|
|
assert.match(sendSource, /!response\.ok[\s\S]*throw new ConsultationResponseError\([\s\S]*response\.status/);
|
|
const explicitFailure = sendSource.slice(
|
|
sendSource.indexOf("caught instanceof ConsultationResponseError"),
|
|
sendSource.indexOf("if (!cancelled && ownsInterface && pendingConsultation.current)"),
|
|
);
|
|
assert.match(explicitFailure, /caught\.message === "request_conflict"[\s\S]*setConsultationPhase\("recovering"\)/);
|
|
const genericFailure = explicitFailure.slice(explicitFailure.indexOf("setRequestError({ sessionId, message: caught.message })"));
|
|
assert.match(genericFailure, /setRequestError\(\{ sessionId, message: caught\.message \}\)/);
|
|
assert.match(genericFailure, /completeConsultationInterface\(requestId\)/);
|
|
assert.doesNotMatch(genericFailure, /phase: "recovering"|setConsultationPhase\("recovering"\)/);
|
|
});
|
|
|
|
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" \}\)/);
|
|
// 原值: 预留判断、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(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", 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/);
|
|
assert.match(source, /const consultationStatusMissingCount = useRef\(0\)/);
|
|
assert.match(source, /const consultationReplayStarted = useRef<string \| null>\(null\)/);
|
|
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", 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(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\)/);
|
|
});
|
|
|
|
test("pending storage keeps the question so a refresh during the undo window can replay", () => {
|
|
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/);
|
|
// 原值: 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/);
|
|
assert.match(source, /consultationReplay\.current = \(\) => \{[\s\S]*resumeRequestId: pending\.requestId/);
|
|
});
|
|
|
|
test("the first default consultation title is persisted with the user question", () => {
|
|
const userSessionBlock = sendSource.slice(
|
|
sendSource.indexOf("const userSession: ChatSession"),
|
|
sendSource.indexOf("const requestId = resumeRequestId ?? globalThis.crypto.randomUUID()"),
|
|
);
|
|
assert.match(userSessionBlock, /currentSession\.messages\.length === 0 && isGenericSessionTitle\(currentSession\.title\)[\s\S]*resolveSessionTitle\(question/);
|
|
// Former value: persistSession(userSession) before consult wrote the title by
|
|
// replacing the whole messages array. Title now comes from SQL append, and the
|
|
// completed metadata patch only updates title/theme/model/chart binding.
|
|
assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1);
|
|
assert.match(sendSource, /await persistSession\(completedSession\)/);
|
|
assert.match(sendSource, /const modelTitle = streamedTitle \?\? reply\.title/);
|
|
assert.match(sendSource, /const completedTitle = modelTitle && !isGenericSessionTitle\(modelTitle\)/);
|
|
assert.match(sendSource, /resolveSessionTitle\(question, modelTitle/);
|
|
});
|
|
|
|
test("a truncated generation keeps the partial answer and does not wait for a successful run", () => {
|
|
const stream = sendSource.slice(sendSource.indexOf('fetch("/api/consult"'));
|
|
assert.match(stream, /event\.code === "answer_truncated"/);
|
|
assert.match(stream, /truncatedFailure = event/);
|
|
assert.match(stream, /const truncatedSession: ChatSession = \{[\s\S]*role: "assistant"[\s\S]*text: reply\.text/);
|
|
// Former value: persistSession(truncatedSession) saved a refunded partial answer.
|
|
assert.doesNotMatch(stream, /await persistSession\(truncatedSession\)/);
|
|
assert.match(stream, /setComposerNotice\(truncatedFailure\.message\)/);
|
|
assert.match(stream, /if \(!runCompleted && !truncatedFailure\) \{\s*throw new ConsultationResponseError/);
|
|
assert.doesNotMatch(stream.slice(stream.indexOf("if (truncatedFailure)")), /runCompleted = true/);
|
|
});
|