Files
Jyotisha/frontend/tests/chat-session-url.test.ts
T
jesse-uxandClaude Code 8902e48468
Independent Staging Quality Gate / validate (push) Successful in 13m41s
Independent Staging Quality Gate / publish (push) Successful in 3m30s
fix(chat): honor new-chat intent from secondary pages
Create a fresh local consultation for explicit new-chat navigation and keep reserved recovery from taking over its landing. Add regression tests and record validation gaps for remote review.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 00:25:28 +08:00

360 lines
15 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 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", () => {
const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();");
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("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,
},
);
const bootstrap = sourceBetween(page, "async function loadCloudData()", "void loadCloudData();");
assert.match(bootstrap, /defaultSessionId: 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");
const preview = sourceBetween(
pageFile,
"if (previewMode) {",
"await sessionListReady;",
);
assert.doesNotMatch(preview, /writeSessionUrl|persistLoginSessionReturn|readLoginSessionReturn/);
assert.match(pageFile, /await sessionListReady;/);
assert.match(pageFile, /sessionListBoot\(\)/);
assert.doesNotMatch(pageFile, /fetchSessions\(/);
assert.match(page, /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 });
}
}
});