Files
Jyotisha/frontend/tests/chat-session-url.test.ts
T
jesse-ux e71e4f9200
Independent Staging Quality Gate / validate (push) Failing after 16m52s
Independent Staging Quality Gate / publish (push) Skipped
fix(web): keep rectification sessions listed and persist chats on first send
Empty-consultation filtering is now session_type scoped so birth-time rows stay in the sidebar. Subtitles use updatedAt with sort/group/cursor. New chats stay local until the first send.
2026-09-21 16:39:32 +08:00

309 lines
13 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 {
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("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"\)/);
// 原值:delete/archive 函数体内直接 `writeSessionUrl(fallbackId || null, "replace")`
// 新值:删/归档当前会话走 `activateFallbackSession`,由它 `writeSessionUrl(null|fallbackId, "replace")`
// 原因:BUG-924 回退优先非校正会话,落到校正会话时要走 selectSession,不能在 delete 里写死 URL。
const deleteSession = sourceBetween(page, "async function deleteSession(", "function togglePinnedSession");
assert.match(deleteSession, /if \(activeSessionId === session\.id\) \{\s*activateFallbackSession\(nextSessions\);/);
const archiveSession = sourceBetween(page, "function toggleArchivedSession(", "async function shareSession");
assert.match(archiveSession, /activateFallbackSession\(visibleSessions\.filter\(\(item\) => item\.id !== sessionId\)\)/);
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 });
}
}
});