Files
Jyotisha/frontend/tests/consultation-recovery.test.ts
T
Jesse_Chen 54269fcfcc
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
refactor(chat): extract home helpers, chart library, and starter surfaces
page.tsx still owns the chat main chain, but the first product surfaces now
live in their own modules so later splits can land without editing the 4k-line
Home. Source-lock tests follow the moved tokens; the orphan user-data contract
is aligned and added to the quick gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 22:58:02 +08:00

154 lines
11 KiB
TypeScript

import assert from "node:assert/strict";
import test from "node:test";
import { homeSurface as source } from "./home-surface.ts";
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", () => {
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\)/);
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(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", () => {
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\(\)/);
});
test("tab-local pending ids drive strict bootstrap recovery before the global fallback", () => {
const bootstrap = source.slice(
source.indexOf("let reservedConsultation: ConsultationStatus | null = null"),
source.indexOf("if (controller.signal.aborted) return;", source.indexOf("let reservedConsultation: ConsultationStatus | null = null")),
);
const storageSync = source.slice(
source.indexOf("if (!hydrated || uiPreview.current) return;", source.indexOf("}, []);")),
source.indexOf('if (consultationPhase !== "recovering"'),
);
assert.match(bootstrap, /readStoredPendingConsultation\([\s\S]*pendingConsultationStorageKey[\s\S]*nextSessions\.map\(\(session\) => session\.id\)/);
assert.match(bootstrap, /if \(!storedPending && sessionStorage\.getItem\(pendingConsultationStorageKey\)\) \{\s*sessionStorage\.removeItem\(pendingConsultationStorageKey\)/);
assert.ok(bootstrap.indexOf("if (storedPending)") < bootstrap.indexOf("fetchActiveConsultationStatus(controller.signal)"));
assert.match(bootstrap, /if \(storedPending\) \{[\s\S]*fetchConsultationStatus\([\s\S]*storedPending\.sessionId,[\s\S]*storedPending\.requestId,[\s\S]*\} else \{[\s\S]*fetchActiveConsultationStatus/);
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\)/);
});
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/);
assert.match(source, /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 completedTitle = reply\.title && !isGenericSessionTitle\(reply\.title\)/);
assert.match(sendSource, /resolveSessionTitle\(question, reply\.title/);
});
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/);
});