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
385 lines
17 KiB
TypeScript
385 lines
17 KiB
TypeScript
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 });
|
||
}
|
||
}
|
||
});
|