Files
Jyotisha/frontend/tests/new-chat-from-people-lifecycle.test.tsx
T
Jesse_ChenandClaude Opus 5.5 d2551437c4 fix(home): land on the blank starter home after login and on bare / (BUG-1052)
Product decision 2026-09-27 (D1-D4), overriding the BUG-1038 login-return
stash and the "latest session" default landing:

- A full load of / without ?c= (login, typed address, bookmark, refresh of
  bare /) lands on the current person's blank starter home; an existing
  empty draft of that person is reused, otherwise one is created locally.
- ?c= and ?new=1 are unchanged; a refresh inside a conversation keeps its ?c=.
- The login-return stash is removed: redirectToLogin and sidebar links no
  longer write it, bootstrap no longer reads it and clears a leftover value
  once. replace-selected, the lookup origin/other-subject branch and the warm
  resumeRectification dependency go with it.
- A background answer being recovered no longer takes over the blank home
  (same rule BUG-1015 set for ?new=1).

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

646 lines
33 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 test from "node:test";
import React, { type ReactNode } from "react";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
Object.assign(globalThis, { React });
// BUG-1038. Real React lifecycle, not source assertions: the real Home page,
// the real sidebar and the real /people page, composed like app/(app)/layout.
// Until BUG-1052 the state that survived between pages was the sessionStorage
// login-return stash a secondary-page link wrote (`jyotisha.session-url-return`);
// that stash is gone and a bare `/` lands on the blank starter home.
// Synthetic ids and profiles only (no real person data).
const USER = "11111111-1111-4111-8111-111111111111";
const RECT = "33333333-3333-4333-8333-333333333333";
const CASE = "44444444-4444-4444-8444-444444444444";
const CONS = "55555555-5555-4555-8555-555555555555";
const OTHER = "66666666-6666-4666-8666-666666666666";
const selfProfile = {
name: "林遥", date: "1990-06-15", time: "12:30", reportedTime: "12:30", birthTimeSource: "legacy_import",
birthTimePeriod: "", declaredWindowStart: "", declaredWindowEnd: "", birthTimeClue: "",
uncertaintyBeforeMinutes: null, uncertaintyAfterMinutes: null, birthTimeStatus: "confirmed", rectificationCaseId: "",
countryCode: "CN", provinceCode: "110000", cityCode: "110000-city", districtCode: "110101", ayanamsa: "lahiri",
};
const otherProfile = { ...selfProfile, name: "阿澈", date: "1992-02-02" };
const rectRow = {
id: RECT, title: "生时校正 · 04:45–05:15", theme: "general", model_id: "deepseek-pro", messages: [],
session_type: "birth_time_rectification", rectification_case_id: CASE, chart_profile_id: "self",
chart_profile_name: "林遥", chart_profile_role: "self", pinned: false, archived_at: null,
created_at: "2026-09-20T00:00:00Z", updated_at: "2026-09-25T00:00:00Z",
};
const consultationRow = {
id: CONS, title: "事业方向", theme: "career", model_id: "deepseek-pro",
messages: [{ role: "user", text: "虚构问题" }, { role: "assistant", text: "虚构回答" }],
session_type: "consultation", rectification_case_id: null, chart_profile_id: "self",
chart_profile_name: "林遥", chart_profile_role: "self", pinned: false, archived_at: null,
created_at: "2026-09-01T00:00:00Z", updated_at: "2026-09-01T00:00:00Z",
};
type Api = { requests: string[]; opens: () => number };
function installApi(options: { listRectification?: boolean } = {}): { api: Api; restore: () => void } {
const listed = options.listRectification === false ? [consultationRow] : [rectRow, consultationRow];
const originalFetch = globalThis.fetch;
const requests: string[] = [];
const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = new URL(String(input), "http://localhost");
const method = init?.method ?? "GET";
const path = url.pathname;
requests.push(`${method} ${path}${url.search}`);
if (path === "/api/account") {
return json({
user: { id: USER, email: "fictional@example.test" }, profile: selfProfile, avatar: null, credits: 9,
isAdmin: false, adminUrl: null, rectificationPriceCredits: 1, activeSubscription: null,
hasConfirmedBirthTime: true, hasUsableBirthTime: true,
});
}
if (path === "/api/chart-profiles") return json({ profiles: [{ id: OTHER, role: "other", profile: otherProfile }] });
if (path === "/api/models") {
return json({ defaultModelId: "deepseek-pro", models: [{ id: "deepseek-pro", label: "DeepSeek V4 Pro", description: "x", creditCost: 1, isDefault: true }] });
}
if (path === "/api/sessions" && method === "GET") {
const subject = url.searchParams.get("subject");
return json({ sessions: subject && subject !== "self" ? [] : listed, nextCursor: null });
}
if (path === `/api/sessions/${RECT}`) return json(method === "GET" ? { session: rectRow } : { ok: true });
if (path === `/api/sessions/${CONS}`) return json(method === "GET" ? { session: consultationRow } : { ok: true });
if (path.startsWith("/api/sessions")) return json({ ok: true });
if (path === "/api/consult/status") return new Response(null, { status: 204 });
if (path === "/api/rectification/cases/entry-summary") {
return json({ has_resumable_case: false, has_terminal_case_with_time: true, latest_resumable: null, latest_terminal: { case_id: CASE, status: "completed" } });
}
if (path === "/api/rectification/cases/open") {
return json({ disposition: "readonly", caseId: CASE, sessionId: RECT, status: "completed", shouldStartOpening: false, skillVersion: "test" });
}
if (path === `/api/rectification/cases/${CASE}`) return json({ turns: [], snapshot: null });
if (path === "/api/daily-starlanguage") return new Response(null, { status: 204 });
return json({ error: "not_mocked" }, 404);
}) as typeof fetch;
return {
api: { requests, opens: () => requests.filter((line) => line === "POST /api/rectification/cases/open").length },
restore: () => { globalThis.fetch = originalFetch; },
};
}
function memoryStorage(): Storage {
const values = new Map<string, string>();
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => { values.set(key, String(value)); },
removeItem: (key) => { values.delete(key); },
clear: () => values.clear(),
key: (index) => Array.from(values.keys())[index] ?? null,
get length() { return values.size; },
};
}
/** One browser tab: storages survive document loads, module state does not. */
function createTab() {
const session = memoryStorage();
const local = memoryStorage();
const location = {
origin: "http://localhost", protocol: "http:", host: "localhost", hostname: "localhost",
href: "http://localhost/", pathname: "/", search: "", hash: "",
assigned: [] as string[],
assign(href: string) { this.assigned.push(href); },
replace(href: string) { this.assigned.push(href); },
};
const setUrl = (href: string) => {
const next = new URL(href, "http://localhost");
location.pathname = next.pathname;
location.search = next.search;
location.href = next.href;
};
const history = {
pushState(_state: unknown, _title: string, url: string) { setUrl(url); },
replaceState(_state: unknown, _title: string, url: string) { setUrl(url); },
};
return { session, local, location, history, setUrl };
}
type Tab = ReturnType<typeof createTab>;
async function openDocument(tab: Tab, href: string, options: { mobile?: boolean } = {}) {
const h = createClientLifecycleHarness();
const win = globalThis.window as unknown as Record<string, unknown>;
tab.setUrl(href);
Object.assign(win, {
location: tab.location,
history: tab.history,
navigator: { onLine: true, userAgent: "node" },
sessionStorage: tab.session,
localStorage: tab.local,
innerWidth: options.mobile ? 390 : 1440,
// floating-ui (tooltips / menus) checks `instanceof win.Element`.
Element: (globalThis as unknown as { Element: unknown }).Element,
Node: (globalThis as unknown as { Element: unknown }).Element,
ShadowRoot: class {},
matchMedia: (query: string) => ({
matches: Boolean(options.mobile) && query.includes("max-width"),
media: query, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {},
}),
});
// The shared host DOM has no layout. Home's scroll anchor and menus measure
// and query; give them inert answers (no geometry is asserted here).
const hostProto = (globalThis as unknown as { HTMLElement: { prototype: Record<string, unknown> } }).HTMLElement.prototype;
Object.assign(hostProto, {
querySelector: () => null,
querySelectorAll: () => [],
closest: () => null,
matches: () => false,
getBoundingClientRect: () => ({ top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0 }),
getClientRects: () => [],
scrollTo() {},
scrollIntoView() {},
scrollHeight: 0, clientHeight: 0, scrollTop: 0, offsetHeight: 0, offsetTop: 0,
});
if (!Object.getOwnPropertyDescriptor(hostProto, "children")) {
Object.defineProperty(hostProto, "children", { configurable: true, get(this: { childNodes: unknown[] }) { return this.childNodes; } });
}
const doc = globalThis.document as unknown as Record<string, unknown>;
const createElement = (doc.createElement as (tag: string) => Record<string, unknown>).bind(doc);
doc.createElement = (tag: string) => {
const node = createElement(tag);
node.style = { setProperty() {}, removeProperty() {}, getPropertyValue: () => "" };
return node;
};
doc.getElementsByTagName = () => [doc.head];
doc.head = createElement("head");
doc.documentElement = Object.assign(createElement("html"), { dataset: {} });
// Floating portals (tooltips, popovers) mount into document.body.
doc.body = createElement("body");
const previous = {
sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"),
localStorage: Object.getOwnPropertyDescriptor(globalThis, "localStorage"),
navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"),
matchMedia: Object.getOwnPropertyDescriptor(globalThis, "matchMedia"),
Node: Object.getOwnPropertyDescriptor(globalThis, "Node"),
requestAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame"),
cancelAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "cancelAnimationFrame"),
};
Object.defineProperty(globalThis, "sessionStorage", { value: tab.session, configurable: true, writable: true });
Object.defineProperty(globalThis, "localStorage", { value: tab.local, configurable: true, writable: true });
Object.defineProperty(globalThis, "navigator", { value: win.navigator, configurable: true, writable: true });
Object.defineProperty(globalThis, "matchMedia", { value: win.matchMedia, configurable: true, writable: true });
Object.defineProperty(globalThis, "Node", { value: win.Node, configurable: true, writable: true });
Object.defineProperty(globalThis, "requestAnimationFrame", { value: win.requestAnimationFrame, configurable: true, writable: true });
Object.defineProperty(globalThis, "cancelAnimationFrame", { value: win.cancelAnimationFrame, configurable: true, writable: true });
// A document load re-evaluates every module. Reset the current-person store
// (it decides which list the provider reads); it re-reads localStorage.
const subject = await import("../src/lib/current-subject.ts");
subject.resetSubjectCatalogForTests();
// BUG-1040: the home warm snapshot is module memory too; a document load
// starts without it. (Setup only; no assertion in this file changes.)
(await import("../src/lib/home-warm-snapshot.ts")).clearHomeWarmSnapshot();
(await import("../src/lib/home-warm-start.ts")).resetHomeWarmStartForTests();
const { SessionListProvider, useSessionList, useShellRegistration, sessionListSidebarModel } = await import("../src/lib/session-list-context.tsx");
const { SidebarInset, SidebarProvider } = await import("../src/components/ui/sidebar.tsx");
const { AppSidebar } = await import("../src/components/app-sidebar.tsx");
// Same composition as app/(app)/layout.tsx (which cannot be imported here
// because it pulls global CSS). The route child is swapped like a client
// navigation: the provider and sidebar stay mounted, the page remounts.
function AppShell({ children }: { children: ReactNode }) {
const list = useSessionList();
const registration = useShellRegistration();
const { sessions, account } = sessionListSidebarModel(list, registration);
return (
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
<main className="chat-app">
<AppSidebar
sessions={sessions}
activeSessionId={registration?.activeSessionId ?? null}
openingSessionId={registration?.openingSessionId ?? null}
openErrorSessionId={registration?.openErrorSessionId ?? null}
openErrorMessage={registration?.openErrorMessage ?? ""}
account={account}
settled={list.settled}
signedOut={list.signedOut}
controls={registration?.controls}
/>
<SidebarInset className={registration?.insetClassName ?? "chat-panel secondary-panel"} inert={registration?.insetInert}>
{children}
</SidebarInset>
</main>
</SidebarProvider>
);
}
const Home = (await import("../src/app/(app)/page.tsx")).default;
const { PeoplePage } = await import("../src/components/people/people-page.tsx");
const route = (pathname: string): ReactNode => {
if (pathname === "/") return <Home />;
if (pathname === "/people") return <PeoplePage />;
// /chart, /ephemeris, /reports: their bodies do not take part in the
// new-chat path; the read-only sidebar above them is the shared one.
return <section data-secondary-route={pathname} />;
};
const renderRoute = () => h.render(
<SessionListProvider><AppShell>{route(tab.location.pathname)}</AppShell></SessionListProvider>,
);
await renderRoute();
const byClass = (name: string) => h.elements().filter((node) => String(node.props.className ?? "").split(/\s+/).includes(name));
const view = () => {
const title = byClass("chat-header-title")[0]?.childNodes.find((node) => node.tagName === "STRONG")?.text ?? null;
const textarea = h.elements().find((node) => node.tagName === "TEXTAREA");
return {
url: `${tab.location.pathname}${tab.location.search}`,
title,
placeholder: (textarea?.props.placeholder as string | undefined) ?? null,
inputDisabled: Boolean(textarea?.props.disabled),
rectificationSurface: byClass("is-rectification").length > 0,
chartName: byClass("chat-header-title")[0]?.text ?? "",
};
};
const link = (label: string) => {
const found = h.elements().find((node) => node.tagName === "A"
&& (node.text.trim() === label || node.props["aria-label"] === label));
assert.ok(found, `sidebar link ${label} is rendered`);
return found;
};
const button = (label: string) => {
const found = h.elements().find((node) => node.tagName === "BUTTON" && node.text.includes(label));
assert.ok(found, `button ${label} is rendered`);
return found;
};
/** Follow an in-app link the way the client router does. */
async function followLink(label: string) {
const anchor = link(label);
const href = String(anchor.props.href);
// The link's own click handler (leaveChat / closeDrawer) runs first.
const own = anchor.props.onClick as ((event: unknown) => void) | undefined;
const click = {
button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false, defaultPrevented: false,
preventDefault() { click.defaultPrevented = true; }, stopPropagation() {},
currentTarget: anchor, target: anchor, nativeEvent: {},
};
await h.update(() => { own?.(click); });
tab.history.pushState(null, "", href);
await renderRoute();
return href;
}
async function settle(until: () => boolean, label: string, ms = 12000) {
const started = Date.now();
while (Date.now() - started < ms) {
await h.idle();
if (until()) return;
}
assert.fail(`timed out waiting for ${label}: ${JSON.stringify(view())}`);
}
async function close() {
try { await h.close(); } finally {
for (const [key, descriptor] of Object.entries(previous)) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
}
const drawerOpen = () => h.elements().some((node) => node.tagName === "ASIDE" && node.props["data-mobile-open"] === true);
async function openDrawer() {
const trigger = h.elements().find((node) => node.tagName === "BUTTON" && node.props["data-sidebar"] === "trigger");
assert.ok(trigger, "the inset sidebar trigger is rendered");
await h.event(trigger);
assert.equal(drawerOpen(), true, "the mobile drawer opens");
}
return { h, view, link, button, followLink, settle, close, drawerOpen, openDrawer, errors: h.errors };
}
async function openRectificationThenGoTo(tab: Tab, api: Api, secondaryLabel: string, options: { mobile?: boolean } = {}) {
const doc = await openDocument(tab, `/?c=${RECT}`, options);
await doc.settle(() => doc.view().rectificationSurface, "the rectification session to open");
assert.equal(doc.view().title, "生时校正 · 04:45–05:15");
assert.equal(api.opens(), 1);
await doc.followLink(secondaryLabel);
return doc;
}
const secondaryPages = [
{ label: "星盘", path: "/chart" },
{ label: "星历", path: "/ephemeris" },
{ label: "我的报告", path: "/reports" },
{ label: "星盘档案", path: "/people" },
] as const;
for (const page of [
...secondaryPages.map((entry) => ({ ...entry, mobile: false })),
{ ...secondaryPages[3], mobile: true },
]) {
test(`from ${page.path}${page.mobile ? " (mobile drawer)" : ""}, the sidebar 新建对话 after a rectification session lands on a clean new chat`, async () => {
const tab = createTab();
const { api, restore } = installApi();
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
try {
doc = await openDocument(tab, `/?c=${RECT}`, { mobile: page.mobile });
await doc.settle(() => doc!.view().rectificationSurface, "the rectification session to open");
assert.equal(doc.view().title, "生时校正 · 04:45–05:15");
assert.equal(api.opens(), 1);
if (page.mobile) await doc.openDrawer();
await doc.followLink(page.label);
assert.equal(tab.location.pathname, page.path);
// 原值: assert.equal(tab.session.getItem("jyotisha.session-url-return"), RECT)(次级页链接存下离开的会话)。
// 新值: === null,离开首页什么都不存。
// 原因: BUG-1052 D3 删除登录返回存根;本用例要守的「新建对话不回旧校正」由下方断言继续锁住。
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
if (page.path === "/people") {
await doc.settle(() => doc!.h.elements().some((node) => node.text.includes("和 TA 对话")), "the people page");
}
if (page.mobile) await doc.openDrawer();
const href = await doc.followLink("新建对话");
assert.equal(href, "/?new=1");
if (page.mobile) assert.equal(doc.drawerOpen(), false, "the drawer closes on 新建对话");
const opensBefore = api.opens();
await doc.settle(() => doc!.view().title !== null, "home to reveal");
for (let i = 0; i < 10; i += 1) await doc.h.idle();
const view = doc.view();
assert.equal(view.title, "新对话");
assert.equal(view.url, "/");
assert.equal(view.rectificationSurface, false);
assert.equal(view.inputDisabled, false);
assert.notEqual(view.placeholder, "正在打开生时校正…");
assert.equal(api.opens(), opensBefore, "no rectification session is re-opened");
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null, "the stashed return is consumed");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
restore();
}
});
}
for (const person of [
{ row: "林遥", label: "本人" },
{ row: "阿澈", label: "另一位档案人物" },
]) {
test(`from /people, 「和 TA 对话」 for ${person.label} after a rectification session opens a new chat bound to that person`, async () => {
const tab = createTab();
const { api, restore } = installApi();
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
try {
doc = await openRectificationThenGoTo(tab, api, "星盘档案");
await doc.settle(() => doc!.h.elements().some((node) => node.tagName === "BUTTON" && node.text.includes("阿澈")), "the people list");
await doc.h.event(doc.button(person.row));
await doc.h.event(doc.button("和 TA 对话"));
const assigned = tab.location.assigned.at(-1);
assert.ok(assigned, "和 TA 对话 navigates");
await doc.close();
doc = undefined;
const opensBefore = api.opens();
// 和 TA 对话 is a document load: module state resets, storages survive.
doc = await openDocument(tab, assigned);
await doc.settle(() => doc!.view().title !== null, "home to reveal");
// Past the reveal: a background re-open of the old case would land here.
for (let i = 0; i < 20; i += 1) await doc.h.idle();
const view = doc.view();
assert.equal(view.title, "新对话");
assert.equal(view.rectificationSurface, false);
assert.equal(view.inputDisabled, false);
assert.notEqual(view.placeholder, "正在打开生时校正…");
assert.doesNotMatch(view.url, /[?&]c=/, "the old session id is not written back into the URL");
assert.ok(view.chartName.includes(person.row), `the new chat is bound to ${person.row}: ${view.chartName}`);
assert.equal(api.opens(), opensBefore, "no rectification session is re-opened");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
restore();
}
});
}
test("a stashed rectification session of the person you left is not reopened as a locked page", async () => {
// Product screenshot state (BUG-1038): title = old rectification session,
// composer「正在打开生时校正…」and disabled, starter greeting in the middle.
const tab = createTab();
const { api, restore } = installApi();
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
try {
doc = await openRectificationThenGoTo(tab, api, "星盘档案");
// 原值: === RECT(离开时存根)。新值: === null。原因: BUG-1052 D3 不再存根;落点断言不变。
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
await doc.settle(() => doc!.h.elements().some((node) => node.tagName === "BUTTON" && node.text.includes("阿澈")), "the people list");
await doc.h.event(doc.button("阿澈"));
await doc.h.event(doc.button("看星盘"));
const assigned = tab.location.assigned.at(-1);
assert.equal(assigned, "/chart");
await doc.close();
doc = undefined;
doc = await openDocument(tab, "/chart");
await doc.settle(() => doc!.h.elements().some((node) => node.tagName === "A" && node.props["aria-label"] === "林遥,打开账户"), "the read-only account link");
const opensBefore = api.opens();
// Read-only footer: the account block links to bare `/`.
const href = await doc.followLink("林遥,打开账户");
assert.equal(href, "/");
await doc.settle(() => doc!.view().title !== null, "home to reveal");
for (let i = 0; i < 10; i += 1) await doc.h.idle();
const view = doc.view();
assert.notEqual(view.placeholder, "正在打开生时校正…");
assert.equal(view.inputDisabled, false);
assert.equal(view.title, "新对话");
assert.ok(view.chartName.includes("阿澈"), `lands in the current person's chat: ${view.chartName}`);
assert.equal(api.opens(), opensBefore, "the other person's rectification session is not opened");
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null, "nothing is stashed");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
restore();
}
});
test("a leftover stash from an older build is ignored and cleared: bare / lands on the blank starter home (BUG-1052)", async () => {
// 原值(BUG-1038): 标题 "a stashed session of the same person found beyond the first page opens with its URL, not as a locked page";
// 存根 RECT 不在首屏列表 → lookup 找到 → 打开校正面、地址栏写回 `/?c=RECT`、opens === 1、存根清空。
// 新值: 同样的残留存根 + 裸 `/` → 空白首页「新对话」,不查这条会话、不打开校正,地址栏仍是 `/`,残留值被清掉。
// 原因: BUG-1052 D3 删除登录返回存根(旧浏览器里的残留值只清不读);D1 裸 `/` 永远空白首页。
const tab = createTab();
tab.session.setItem("jyotisha.session-url-return", RECT);
const { api, restore } = installApi({ listRectification: false });
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
try {
doc = await openDocument(tab, "/");
await doc.settle(() => doc!.view().title !== null, "home to reveal");
for (let i = 0; i < 10; i += 1) await doc.h.idle();
const view = doc.view();
assert.equal(view.title, "新对话");
assert.equal(view.url, "/");
assert.equal(view.rectificationSurface, false);
assert.equal(view.inputDisabled, false);
assert.equal(api.opens(), 0);
assert.equal(api.requests.includes(`GET /api/sessions/${RECT}`), false, "the stashed id is not looked up");
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
restore();
}
});
test("an in-page new chat leaves no return trail, so a later bare / cannot reopen the old session", async () => {
// 原值: 标题 "an in-page new chat drops the stashed return, …";预置存根 RECT,startNewChat 后断言存根被清成 null。
// 新值: 不预置存根,断言 startNewChat 前后这把钥匙都不存在(新建对话不写任何返回记录)。
// 原因: BUG-1052 D3 删除存根后 startNewChat 不再负责清它(启动时统一清旧值,见上一条用例)。
const { useRef, useState } = React;
const { useSessionManagement } = await import("../src/hooks/use-session-management.ts");
const { previewModelCatalog, emptyProfile } = await import("../src/lib/home-types.ts");
type Params = Parameters<typeof useSessionManagement>[0];
const tab = createTab();
const h = createClientLifecycleHarness();
const previous = {
sessionStorage: Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"),
requestAnimationFrame: Object.getOwnPropertyDescriptor(globalThis, "requestAnimationFrame"),
};
Object.defineProperty(globalThis, "sessionStorage", { value: tab.session, configurable: true, writable: true });
Object.defineProperty(globalThis, "requestAnimationFrame", { value: (callback: () => void) => setTimeout(callback, 0), configurable: true, writable: true });
let api!: ReturnType<typeof useSessionManagement>;
const noop = () => {};
function Probe() {
const [sessions, setSessions] = useState<Params["sessions"]>([]);
const [active, setActive] = useState("");
const popState = useRef<(search: string) => void>(noop);
const known: Partial<Params> = {
account: { user: { id: USER, email: null }, profile: emptyProfile, avatar: null, credits: 1, isAdmin: false, adminUrl: null, rectificationPriceCredits: 1, activeSubscription: null, hasConfirmedBirthTime: false, hasUsableBirthTime: false },
accountId: USER, activeSession: undefined, activeSessionId: active, activeSessionIdRef: { current: active },
applySessionPopStateRef: popState, cancellationPending: false, chartLibrary: [], creatingSession: false,
modelCatalog: previewModelCatalog, pendingSessionId: null, profile: emptyProfile, rectificationSessionId: null,
sessionDetailInFlight: { current: new Set() }, sessionSelectionSource: { current: "user" },
sessions, sessionsRef: { current: sessions }, setActiveSessionId: setActive, setSessions, uiPreview: { current: false },
};
const params = new Proxy(known, { get: (target, key) => Reflect.get(target, key) ?? noop }) as Params;
api = useSessionManagement(params);
return null;
}
try {
await h.render(<Probe />);
let created: Awaited<ReturnType<typeof api.startNewChat>> = null;
await h.update(() => { void api.startNewChat().then((session) => { created = session; }); });
await h.idle();
assert.ok(created, "the new chat is created");
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null);
assert.deepEqual(h.errors, []);
} finally {
await h.close();
for (const [key, descriptor] of Object.entries(previous)) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
// BUG-1052 (product decision 2026-09-27). Real-device incident: after signing
// in, a user typed a question into what looked like the home page but was the
// previous birth-time rectification conversation. A full load of `/` without
// `?c=` now always lands on the blank starter home; `?c=` still opens its
// conversation, and a refresh inside a conversation keeps it.
const hasText = (doc: Awaited<ReturnType<typeof openDocument>>, text: string) => doc.h.elements().some((node) => node.text.includes(text));
for (const variant of [
{ label: "an ordinary conversation with messages", listRectification: false },
{ label: "a birth-time rectification", listRectification: true },
]) {
test(`bare / whose most recent session is ${variant.label} lands on the blank starter home`, async () => {
const tab = createTab();
const { api, restore } = installApi({ listRectification: variant.listRectification });
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
try {
doc = await openDocument(tab, "/");
await doc.settle(() => doc!.view().title !== null, "home to reveal");
for (let i = 0; i < 10; i += 1) await doc.h.idle();
const view = doc.view();
assert.equal(view.title, "新对话");
assert.equal(view.url, "/", "no old session id is written into the URL");
assert.equal(view.rectificationSurface, false);
assert.equal(view.inputDisabled, false);
assert.notEqual(view.placeholder, "正在打开生时校正…");
assert.equal(hasText(doc, "虚构回答"), false, "the old conversation's transcript is not shown");
assert.equal(api.opens(), 0, "no rectification case is opened");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
restore();
}
});
}
test("/?c=<id> still opens that conversation, and a refresh keeps it", async () => {
const tab = createTab();
const { api, restore } = installApi();
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
try {
doc = await openDocument(tab, `/?c=${CONS}`);
await doc.settle(() => doc!.view().title === "事业方向" && hasText(doc!, "虚构回答"), "the linked conversation");
assert.equal(doc.view().url, `/?c=${CONS}`);
await doc.close();
doc = undefined;
// Refresh: same tab, same address, a new document.
doc = await openDocument(tab, `/?c=${CONS}`);
await doc.settle(() => doc!.view().title === "事业方向" && hasText(doc!, "虚构回答"), "the same conversation after refresh");
assert.equal(doc.view().url, `/?c=${CONS}`);
assert.equal(api.opens(), 0);
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
restore();
}
});
test("a refresh inside a rectification conversation stays in it (its ?c= is in the address bar)", async () => {
const tab = createTab();
const { api, restore } = installApi();
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
try {
doc = await openDocument(tab, `/?c=${RECT}`);
await doc.settle(() => doc!.view().rectificationSurface, "the rectification session to open");
const address = doc.view().url;
assert.equal(address, `/?c=${RECT}`);
await doc.close();
doc = undefined;
doc = await openDocument(tab, address);
await doc.settle(() => doc!.view().rectificationSurface, "the rectification session after refresh");
assert.equal(doc.view().title, "生时校正 · 04:45–05:15");
assert.equal(doc.view().url, `/?c=${RECT}`);
assert.equal(api.opens(), 2);
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
restore();
}
});
test("a 401 inside a rectification conversation, then signing in again, lands on the blank starter home", async () => {
const tab = createTab();
const { api, restore } = installApi();
let doc: Awaited<ReturnType<typeof openDocument>> | undefined;
try {
doc = await openDocument(tab, `/?c=${RECT}`);
await doc.settle(() => doc!.view().rectificationSurface, "the rectification session to open");
// The session expires: any 401 goes through redirectToLogin.
const { redirectToLogin, LoginRedirectError } = await import("../src/lib/home-cloud-sync.ts");
assert.throws(() => redirectToLogin(), LoginRedirectError);
assert.equal(tab.location.assigned.at(-1), "/login");
assert.equal(tab.session.getItem("jyotisha.session-url-return"), null, "nothing is stashed for the way back");
await doc.close();
doc = undefined;
const opensBefore = api.opens();
// email-otp-login's successPath is "/": a document load of bare `/`.
doc = await openDocument(tab, "/");
await doc.settle(() => doc!.view().title !== null, "home to reveal after sign-in");
for (let i = 0; i < 10; i += 1) await doc.h.idle();
const view = doc.view();
assert.equal(view.title, "新对话");
assert.equal(view.url, "/");
assert.equal(view.rectificationSurface, false);
assert.equal(view.inputDisabled, false);
assert.equal(api.opens(), opensBefore, "the rectification conversation is not reopened");
assert.deepEqual(doc.errors, []);
} finally {
await doc?.close();
restore();
}
});