Files
Jyotisha/frontend/tests/new-chat-recovery.test.ts
T
Jesse_ChenandClaude Opus 5.5 d2551437c4 fix(home): land on the blank starter home after login and on bare / (BUG-1052)
Product decision 2026-09-27 (D1-D4), overriding the BUG-1038 login-return
stash and the "latest session" default landing:

- A full load of / without ?c= (login, typed address, bookmark, refresh of
  bare /) lands on the current person's blank starter home; an existing
  empty draft of that person is reused, otherwise one is created locally.
- ?c= and ?new=1 are unchanged; a refresh inside a conversation keeps its ?c=.
- The login-return stash is removed: redirectToLogin and sidebar links no
  longer write it, bootstrap no longer reads it and clears a leftover value
  once. replace-selected, the lookup origin/other-subject branch and the warm
  resumeRectification dependency go with it.
- A background answer being recovered no longer takes over the blank home
  (same rule BUG-1015 set for ?new=1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
2026-09-27 00:51:51 +08:00

211 lines
8.9 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" | "keep") {
// 原值:把 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,
},
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("bare / keeps the blank starter home while a reserved older consultation recovers (BUG-1052)", async () => {
// 原值: 标题 "ordinary home landing still activates and announces the reserved consultation";
// none → state.active === oldSession.id,notice「回答仍在后台生成,正在自动恢复。」,storedCleared === false。
// 新值: none → 停在空白首页(本地空对话 localSession),notice 清空,恢复照常在后台进行;storedCleared === true。
// 原因: BUG-1052 D1——裸 `/` 永远落空白首页、不落旧对话,与 BUG-1015 的 ?new=1 同一条「不抢落点」规则;
// D3——启动一律清掉旧版本留下的存根值。「地址栏点名会话时恢复可以接管」改由下一条 keep 用例锁住。
const { state, pending, writes, localSession, oldSession } = await bootstrapWithRecovery("none");
assert.equal(state.active, localSession.id);
assert.equal(isUnsavedEmptyConsultation(state.sessions.find((item) => item.id === state.active)!), true);
assert.equal(state.notice, "");
assert.equal(state.storedCleared, true);
assert.deepEqual(writes, []);
assert.equal(state.pendingSession, oldSession.id);
assert.equal(state.phase, "recovering");
assert.equal(pending.current?.sessionId, oldSession.id);
});
test("a ?c= landing still activates and announces the reserved consultation", async () => {
const { state, pending, writes, oldSession } = await bootstrapWithRecovery("keep");
assert.equal(state.active, oldSession.id);
assert.equal(state.notice, "回答仍在后台生成,正在自动恢复。");
assert.deepEqual(writes, []);
assert.equal(pending.current?.sessionId, oldSession.id);
});