Files
Jyotisha/frontend/tests/chat-session-url.test.ts
T
Jesse_ChenandClaude Opus 5.5 0a8350cc01 fix(home): one new-chat intent and a scoped login-return stash (BUG-1038)
Coming back to / from /people could show the previous rectification session
as a locked page: its title in the header, the composer stuck on
"正在打开生时校正…", and the new-chat greeting in the middle.

The state that survived between pages is the sessionStorage login-return
stash that secondary-page sidebar links write from the current ?c=:
- /people「和 TA 对话」used a second intent (?newChat=1) parsed by a
  component mounted inside Home after bootstrap, so the bootstrap new-chat
  branch never ran and the stash won.
- A stash id not in the current person's loaded list was looked up and
  landed with urlAction "keep", which assumes ?c= is already in the address
  bar. It was not, so the rectification auto-open never fired. The stash
  also ignored which person was current.
- An in-page new chat left the stash in place.

Fix: delete NewChatDeepLink / ?newChat and route「和 TA 对话」through
newChatHref(); a looked-up stash writes ?c= back (replace-selected) and is
dropped when it belongs to another person; startNewChat and
openChatBoundToProfile clear the stash. ?c= deep links, BUG-989 and BUG-705
are unchanged.

Tests: new real-lifecycle suite mounting the real Home, sidebar and people
page (10 cases: four secondary pages + mobile drawer, 和 TA 对话 for self and
another person, out-of-scope stash, same-person stash beyond the first page,
in-page new chat), plus two contract/unit tests. Six fail on origin/staging,
all pass here. Full suite 3928 / 61 failing, failure names identical to the
0ab061b9 baseline (3916 / 61).

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

385 lines
17 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,
parseSessionUrlQuery,
persistLoginSessionReturn,
readLoginSessionReturn,
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 plus login return", () => {
for (const requestedId of [sessionB, sessionC, "not-a-uuid"]) {
assert.deepEqual(resolveBootstrapSessionSelection({
listedIds: [sessionA, sessionB],
defaultSessionId: sessionA,
search: `?new=1&c=${requestedId}`,
storedReturnId: sessionB,
}), {
sessionId: sessionA,
urlAction: "new-chat",
missing: false,
clearStoredReturn: true,
});
}
});
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)"));
assert.match(bootstrap, /if \(bootstrapSelection\.clearStoredReturn\) clearLoginSessionReturn\(\)/);
const landing = sourceBetween(bootstrap, "if (starterHomeLandingNeedsConsultation(", "const activeListed");
assert.equal((landing.match(/createSession\(/g) ?? []).length, 1);
assert.match(landing, /nextSessions = \[homeSession, \.\.\.nextSessions\]/);
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 and ignores login storage", () => {
assert.deepEqual(
resolveBootstrapSessionSelection({
listedIds: [sessionA, sessionB],
defaultSessionId: sessionA,
search: `?${SESSION_URL_QUERY_KEY}=${sessionB}`,
storedReturnId: sessionC,
}),
{
sessionId: sessionB,
urlAction: "keep",
missing: false,
clearStoredReturn: true,
},
);
});
test("bootstrap restores a stored login return only when the URL has no c", () => {
assert.deepEqual(
resolveBootstrapSessionSelection({
listedIds: [sessionA, sessionB],
defaultSessionId: sessionA,
search: "",
storedReturnId: sessionB,
}),
{
sessionId: sessionB,
urlAction: "replace-selected",
missing: false,
clearStoredReturn: true,
},
);
});
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,
}),
{
sessionId: sessionA,
urlAction: "replace-clear",
missing: true,
clearStoredReturn: true,
},
);
// 原值: 未出现在已加载页的合法 UUID 直接 missing + replace-clear
// 新值: urlAction lookup,先问服务端
// 原因: BUG-705,分页里找不到不等于已删除
assert.deepEqual(
resolveBootstrapSessionSelection({
listedIds: [sessionA],
defaultSessionId: sessionA,
search: `?${SESSION_URL_QUERY_KEY}=${sessionB}`,
storedReturnId: null,
}),
{
sessionId: sessionB,
urlAction: "lookup",
missing: false,
clearStoredReturn: 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,
}),
{
sessionId: sessionA,
urlAction: "none",
missing: false,
clearStoredReturn: 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"/);
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 the current session id without changing the login page", () => {
const redirect = sourceBetween(page, "function redirectToLogin(): never {", "function waitForUndoWindow");
// Former value: the body was only `window.location.replace("/login")`.
// 401 now stashes a UUID ?c= so returning to `/` can restore the session
// without adding a `next` query to the login page.
assert.match(redirect, /persistLoginSessionReturn\(\);/);
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("login return storage only accepts a UUID session id", () => {
const memory = new Map<string, string>();
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 {
persistLoginSessionReturn();
assert.equal(readLoginSessionReturn(), sessionA);
memory.set(SESSION_URL_RETURN_STORAGE_KEY, "not-a-uuid");
assert.equal(readLoginSessionReturn(), null);
} 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 });
}
}
});