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([[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 }); } } });