Files
Jyotisha/frontend/tests/chat-session-url.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

405 lines
20 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 {
NEW_CHAT_QUERY_KEY,
newChatHref,
parseNewChatIntent,
SESSION_LOOKUP_FAILED_NOTICE,
SESSION_MISSING_NOTICE,
SESSION_URL_QUERY_KEY,
SESSION_URL_RETURN_STORAGE_KEY,
clearLoginSessionReturn,
parseSessionUrlQuery,
resolveBootstrapSessionSelection,
sessionHref,
writeSessionUrl,
} 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";
const sessionB = "22222222-2222-4222-8222-222222222222";
const sessionC = "33333333-3333-4333-8333-333333333333";
function sourceBetween(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker);
const end = source.indexOf(endMarker, start);
assert.notEqual(start, -1, startMarker);
assert.notEqual(end, -1, endMarker);
return source.slice(start, end);
}
test("new chat intent is presence-based and has one canonical href", () => {
assert.equal(NEW_CHAT_QUERY_KEY, "new");
assert.equal(newChatHref(), "/?new=1");
for (const search of ["?new=1", "?new=", "?new", "new=0"]) {
assert.equal(parseNewChatIntent(search), true);
}
assert.equal(parseNewChatIntent(""), false);
assert.equal(parseNewChatIntent("?renew=1"), false);
});
test("new chat intent overrides listed, unlisted and invalid c", () => {
for (const requestedId of [sessionB, sessionC, "not-a-uuid"]) {
// 原值: 入参带 storedReturnId: sessionB,期望对象含 clearStoredReturn: true。
// 新值: 没有 storedReturnId 入参,期望对象没有 clearStoredReturn 字段;new-chat 优先级不变。
// 原因: BUG-1052 D3 删除登录返回存根,选择函数不再读它,也不再决定是否清它(启动一律清旧值)。
assert.deepEqual(resolveBootstrapSessionSelection({
listedIds: [sessionA, sessionB],
defaultSessionId: sessionA,
search: `?new=1&c=${requestedId}`,
}), {
sessionId: sessionA,
urlAction: "new-chat",
missing: false,
});
}
});
test("clearing a new chat intent removes c but preserves unrelated query keys", () => {
assert.equal(sessionHref("?new=1&x=1", null), "/?x=1");
assert.equal(sessionHref(`?new=&c=${sessionA}&x=1`, null), "/?x=1");
assert.equal(sessionHref("?new=1", null), "/");
});
test("persisting a new chat replaces the intent with the saved session id", () => {
assert.equal(sessionHref("?new=1", sessionA), `/?c=${sessionA}`);
assert.equal(sessionHref(`?new=1&c=${sessionB}`, sessionA), `/?c=${sessionA}`);
});
test("new chat bootstrap consumes the URL before activation and reuses local-only creation", () => {
// 原值: 从 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)"));
// 原值: /if \(bootstrapSelection\.clearStoredReturn\) clearLoginSessionReturn\(\)/(按选择结果决定清存根)。
// 新值: 无条件 `clearLoginSessionReturn();`,只为清掉旧版本留下的值。
// 原因: BUG-1052 D3 存根不再写入也不再读取,启动时清一次旧值即可。
assert.match(bootstrap, /\n clearLoginSessionReturn\(\);\n/);
const landing = sourceBetween(bootstrap, "if (starterHomeLandingNeedsConsultation(", "const activeListed");
assert.equal((landing.match(/createSession\(/g) ?? []).length, 1);
// 原值: /nextSessions = \[homeSession, \.\.\.nextSessions\]/(直接前插)。
// 新值: replaceUnsavedEmptyConsultations(nextSessions, homeSession)(前插并去掉其他未落库空对话)。
// 原因: BUG-1052 D1 复用/不重复空草稿,与 startNewChat 同一个去重函数;仍只本地创建一条。
assert.match(landing, /nextSessions = replaceUnsavedEmptyConsultations\(nextSessions, homeSession\)/);
assert.match(landing, /landingSessionId = homeSession\.id/);
assert.doesNotMatch(landing, /persistSession|writeChatSession|fetch\(|writeSessionUrl/);
});
test("there is one new-chat intent: /people「和 TA 对话」 links through newChatHref, no ?newChat parser remains", () => {
// BUG-1038: a second intent (`?newChat=1`, parsed by a component mounted in
// Home) never reached bootstrap's new-chat branch, so the stashed `?c=` won.
const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8");
const links = readFileSync(new URL("../src/components/people-home-links.tsx", import.meta.url), "utf8");
const home = readFileSync(new URL("../src/app/(app)/page.tsx", import.meta.url), "utf8");
assert.match(people, /openPerson\(selected\.id, newChatHref\(\)\)\}>[\s\S]{0,160}?和 TA 对话/);
for (const source of [people, links, home]) {
assert.doesNotMatch(source, /newChat=|["']newChat["']|NewChatDeepLink/);
}
});
test("bootstrap reads a listed ?c= session", () => {
// 原值: 入参 storedReturnId: sessionC,期望含 clearStoredReturn: true;标题 "…and ignores login storage"。
// 新值: 无 storedReturnId 入参与 clearStoredReturn 字段;?c= 仍 keep。
// 原因: BUG-1052 D2 `?c=` 行为不变;D3 存根已删除,没有可忽略的存储。
assert.deepEqual(
resolveBootstrapSessionSelection({
listedIds: [sessionA, sessionB],
defaultSessionId: sessionA,
search: `?${SESSION_URL_QUERY_KEY}=${sessionB}`,
}),
{
sessionId: sessionB,
urlAction: "keep",
missing: false,
},
);
});
test("bare / after login never returns to a stored conversation (BUG-1052)", () => {
// 原值(BUG-1038 起): 标题 "bootstrap restores a stored login return only when the URL has no c",
// storedReturnId: sessionB + search "" → { sessionId: sessionB, urlAction: "replace-selected", clearStoredReturn: true }。
// 新值: 选择函数没有存根入参;裸 `/` 一律 { sessionId: 默认, urlAction: "none" },落点再换成空白首页。
// 原因: 产品 2026-09-27 决策 D3——401 / 重新登录不再回到旧对话(真机:登录后落在旧生时校正里提问)。
assert.deepEqual(
resolveBootstrapSessionSelection({
listedIds: [sessionA, sessionB],
defaultSessionId: sessionA,
search: "",
}),
{
sessionId: sessionA,
urlAction: "none",
missing: false,
},
);
const lib = readFileSync(new URL("../src/lib/chat-session-url.ts", import.meta.url), "utf8");
assert.doesNotMatch(lib, /persistLoginSessionReturn|readLoginSessionReturn|storedReturnId|replace-selected/);
});
test("bootstrap clears an illegal or unknown session query", () => {
assert.equal(parseSessionUrlQuery("").present, false);
assert.deepEqual(parseSessionUrlQuery(`?${SESSION_URL_QUERY_KEY}=not-a-uuid`), {
present: true,
sessionId: null,
});
assert.deepEqual(
resolveBootstrapSessionSelection({
listedIds: [sessionA],
defaultSessionId: sessionA,
search: `?${SESSION_URL_QUERY_KEY}=not-a-uuid`,
}),
// 原值: 入参带 storedReturnId: sessionB,期望含 clearStoredReturn: true。
// 新值: 去掉这两项;非法 ?c= 仍 replace-clear + missing。
// 原因: BUG-1052 D3 删除存根字段。
{
sessionId: sessionA,
urlAction: "replace-clear",
missing: true,
},
);
// 原值: 未出现在已加载页的合法 UUID 直接 missing + replace-clear
// 新值: urlAction lookup,先问服务端
// 原因: BUG-705,分页里找不到不等于已删除
assert.deepEqual(
resolveBootstrapSessionSelection({
listedIds: [sessionA],
defaultSessionId: sessionA,
search: `?${SESSION_URL_QUERY_KEY}=${sessionB}`,
}),
// 原值: 入参 storedReturnId: null,期望含 clearStoredReturn: false。新值: 去掉这两项。原因: BUG-1052 D3。
{
sessionId: sessionB,
urlAction: "lookup",
missing: false,
},
);
assert.equal(SESSION_LOOKUP_FAILED_NOTICE.includes("暂时读不到"), true);
});
test("default bootstrap selection does not write a session URL", () => {
assert.deepEqual(
resolveBootstrapSessionSelection({
listedIds: [sessionA, sessionB],
defaultSessionId: sessionA,
search: "",
}),
// 原值: 入参 storedReturnId: null,期望含 clearStoredReturn: false。新值: 去掉这两项。原因: BUG-1052 D3。
{
sessionId: sessionA,
urlAction: "none",
missing: false,
},
);
// 原值: 从 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"/)(存根落点写回 ?c=)。
// 新值: 反向断言不再出现。
// 原因: BUG-1052 D3 存根删除后 replace-selected 只剩存根一个来源,已随之删除。
assert.doesNotMatch(bootstrap, /replace-selected/);
assert.doesNotMatch(bootstrap, /writeSessionUrl\([^)]*, "push"\)/);
assert.match(bootstrap, /setComposerNotice\(SESSION_MISSING_NOTICE\)/);
assert.match(bootstrap, /resolveLookupBootstrap/);
});
test("session href keeps sibling query keys and can drop a dead c", () => {
assert.equal(sessionHref("", sessionA), `/?${SESSION_URL_QUERY_KEY}=${sessionA}`);
assert.equal(
sessionHref("?preview=conversation", sessionA),
`/?preview=conversation&${SESSION_URL_QUERY_KEY}=${sessionA}`,
);
assert.equal(sessionHref(`?${SESSION_URL_QUERY_KEY}=${sessionA}`, null), "/");
assert.equal(
sessionHref(`?preview=conversation&${SESSION_URL_QUERY_KEY}=${sessionA}`, null),
"/?preview=conversation",
);
});
test("user session switches push history; popstate reuses selectSession without a second push", () => {
const selectSession = sourceBetween(
page,
"function selectSession(sessionId: string)",
"async function selectSessionModel",
);
assert.match(selectSession, /sessionSelectionSource\.current === "user"/);
assert.match(selectSession, /writeSessionUrl\(sessionId, "push"\)/);
assert.match(selectSession, /sessionSelectionSource\.current = "history";\n selectSession\(/);
assert.equal((selectSession.match(/writeSessionUrl\([^)]*, "push"\)/g) ?? []).length, 1);
assert.match(page, /window\.addEventListener\("popstate", onPopState\)/);
});
test("popstate to a missing session query reuses selectSession side effects for the default chat", () => {
const pop = sourceBetween(
page,
"applySessionPopStateRef.current =",
"async function selectSessionModel",
);
// Former empty-id branch: `if (!requestedId) { setActiveSessionId(""); return; }`
// Back to `/` with a listed chat now runs the same selectSession side effects
// as a sidebar click, still tagged history so it does not push a second URL.
assert.match(
pop,
/if \(!requestedId\) \{\n if \(fallbackId\) \{\n sessionSelectionSource\.current = "history";\n selectSession\(fallbackId\);\n \} else \{\n setActiveSessionId\(""\);\n \}\n return;/,
);
assert.equal((pop.match(/writeSessionUrl\([^)]*, "push"\)/g) ?? []).length, 0);
});
test("creating and leaving a session keep the address bar in sync", () => {
const startNewChat = sourceBetween(page, "async function startNewChat(", "function selectSession(");
// 原值:新建立刻 writeSessionUrl(nextSession.id, "push"),失败再 replaceState 回旧地址
// 新值:本地创建不写 ?c=;POST 成功后 persistSession(create) 才 push
// 原因:第一问前不落库(BUG-989),未落库会话没有服务端身份
assert.doesNotMatch(startNewChat, /writeSessionUrl\(nextSession\.id, "push"\)/);
assert.doesNotMatch(startNewChat, /persistSession\(/);
const persistSession = sourceBetween(page, "async function persistSession(", "async function ensureSessionMessages(");
assert.match(persistSession, /if \(mode === "create"\) \{/);
assert.match(persistSession, /writeSessionUrl\(session\.id, "push"\)/);
// 原值:删/归档当前会话走 `activateFallbackSession`
// 新值:只剩删除走 `activateFallbackSession`;归档函数已删
// 原因:BUG-991 归档下线,删除路径不动
const deleteSession = sourceBetween(page, "async function deleteSession(", "function togglePinnedSession");
assert.match(deleteSession, /if \(activeSessionId === session\.id\) \{\s*activateFallbackSession\(nextSessions\);/);
assert.doesNotMatch(page, /function toggleArchivedSession\(/);
const fallback = sourceBetween(page, "function activateFallbackSession(", "async function loadMoreSessions");
assert.match(fallback, /writeSessionUrl\(null, "replace"\)/);
assert.match(fallback, /writeSessionUrl\(fallbackId, "replace"\)/);
const recovery = sourceBetween(
page,
"if (status.status === \"completed\") {",
"pendingConsultation.current = null;",
);
assert.match(recovery, /setActiveSessionId\(\(current\) => current \|\| detailed\.id\)/);
assert.doesNotMatch(recovery, /writeSessionUrl/);
});
test("401 login redirect stashes nothing and the login page still returns to bare /", () => {
const redirect = sourceBetween(page, "function redirectToLogin(): never {", "function waitForUndoWindow");
// 原值: 标题 "401 login redirect stashes the current session id …",assert.match(redirect, /persistLoginSessionReturn\(\);/)。
// 新值: 反向断言 redirect 里不再有任何存根写入;登录页仍只回 `/`。
// 原因: BUG-1052 D3——重新登录落在空白首页,不再回到 401 时所在的对话。
assert.doesNotMatch(redirect, /persistLoginSessionReturn|sessionStorage/);
assert.match(redirect, /window\.location\.replace\("\/login"\);/);
assert.doesNotMatch(login, /sessionStorage|SESSION_URL_RETURN|searchParams\.get\("next"\)/);
assert.match(login, /successPath\?: "\/" \| "\/admin"/);
assert.doesNotMatch(loginPage, /next=/);
assert.equal(SESSION_URL_RETURN_STORAGE_KEY, "jyotisha.session-url-return");
});
test("home stays a client-read query on a static route", () => {
const lib = readFileSync(new URL("../src/lib/chat-session-url.ts", import.meta.url), "utf8");
assert.doesNotMatch(page, /useSearchParams/);
assert.doesNotMatch(page, /export const dynamic/);
assert.match(page, /writeSessionUrl\(sessionId, "push"\)/);
assert.match(page, /window\.history\.replaceState/);
assert.match(lib, /history\.pushState\(null, "", next\)/);
assert.match(lib, /history\.replaceState\(null, "", next\)/);
// 原值:首页自己并发拉账户/目录/列表,`const [nextAccount, modelCatalogResult, sessionsPayload]`
// 新值:`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;等待必须跟随当前 Provider generation。
const preview = sourceBetween(
bootstrapRun,
"if (previewMode) {",
"await (deps.waitForSessionList?.() ?? deps.sessionListReady);",
);
assert.doesNotMatch(preview, /writeSessionUrl|persistLoginSessionReturn|readLoginSessionReturn/);
assert.match(bootstrapRun, /await \(deps\.waitForSessionList\?\.\(\) \?\? deps\.sessionListReady\);/);
assert.match(bootstrapRun, /deps\.sessionListBoot\(\)/);
assert.match(pageFile, /sessionListReady,/);
assert.match(pageFile, /sessionListBoot,/);
assert.doesNotMatch(pageFile, /fetchSessions\(/);
assert.doesNotMatch(bootstrapRun, /fetchSessions\(/);
assert.match(bootstrapRun, /SESSION_MISSING_NOTICE/);
assert.equal(SESSION_MISSING_NOTICE, "该对话不存在或已被删除");
});
test("a leftover login-return stash from an older build is only ever cleared", () => {
// 原值: 标题 "login return storage only accepts a UUID session id":persistLoginSessionReturn() 写入后
// readLoginSessionReturn() === sessionA,非法值读回 null。
// 新值: 写 / 读两个函数已删除;旧浏览器里残留的值由 clearLoginSessionReturn() 清掉,且清除在隐私模式抛错时不影响启动。
// 原因: BUG-1052 D3——删除存根写入与读取,只保留一次性清理旧值。
const memory = new Map<string, string>([[SESSION_URL_RETURN_STORAGE_KEY, sessionA]]);
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
location: { search: `?${SESSION_URL_QUERY_KEY}=${sessionA}` },
},
});
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
value: {
setItem(key: string, value: string) { memory.set(key, value); },
getItem(key: string) { return memory.get(key) ?? null; },
removeItem(key: string) { memory.delete(key); },
},
});
try {
clearLoginSessionReturn();
assert.equal(memory.has(SESSION_URL_RETURN_STORAGE_KEY), false);
Object.defineProperty(globalThis, "sessionStorage", {
configurable: true,
value: { removeItem() { throw new Error("private mode"); } },
});
assert.doesNotThrow(() => clearLoginSessionReturn());
} finally {
if (previousWindow === undefined) {
Reflect.deleteProperty(globalThis, "window");
} else {
Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow });
}
Reflect.deleteProperty(globalThis, "sessionStorage");
}
});
test("writeSessionUrl no-ops when the address is already the target", () => {
const calls: string[] = [];
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
location: { pathname: "/", search: `?${SESSION_URL_QUERY_KEY}=${sessionA}` },
history: {
pushState(_state: unknown, _unused: string, url: string) { calls.push(`push:${url}`); },
replaceState(_state: unknown, _unused: string, url: string) { calls.push(`replace:${url}`); },
},
},
});
try {
writeSessionUrl(sessionA, "push");
assert.deepEqual(calls, []);
writeSessionUrl(sessionB, "push");
assert.deepEqual(calls, [`push:/?${SESSION_URL_QUERY_KEY}=${sessionB}`]);
writeSessionUrl(null, "replace");
assert.deepEqual(calls, [
`push:/?${SESSION_URL_QUERY_KEY}=${sessionB}`,
"replace:/",
]);
} finally {
if (previousWindow === undefined) {
Reflect.deleteProperty(globalThis, "window");
} else {
Object.defineProperty(globalThis, "window", { configurable: true, value: previousWindow });
}
}
});