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
646 lines
33 KiB
TypeScript
646 lines
33 KiB
TypeScript
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();
|
||
}
|
||
});
|