Files
Jyotisha/frontend/tests/chat-panel-scroll-guard.test.ts
T
jesse-ux e4e73f56c0
Independent Staging Quality Gate / publish (push) Canceled after 0s
Independent Staging Quality Gate / validate (push) Canceled after 9m33s
fix(web): 四个页面共用一份会话列表,空会话不入列
对话、星盘、星历、报告进同一 (app) 外壳,列表只拉一次。服务端不再列出空咨询;新建复用已有空会话。新标题改成「生时校正 · M月D日」,侧栏副标题用创建时间。
2026-09-17 21:27:40 +08:00

85 lines
4.0 KiB
TypeScript

import assert from "node:assert/strict";
import { readdirSync, readFileSync } from "node:fs";
import test from "node:test";
/**
* The chat panel must never be scrollable, and nothing inside it may ask the
* browser to scroll an ancestor.
*
* Observed on staging: after answering three rectification questions the 46px
* header was gone. The grid was right (`46px 1297px`, panel at y=0) but the
* header's own rect was at **y = -88** — the panel had been scrolled 88px.
* `overflow: hidden` still creates a scroll container: it only removes the
* scrollbar, so a programmatic scroll sticks and the user cannot undo it.
*
* The cause was `BirthTimeChoiceQuestion` focusing the first option after every
* answered question. Focus scrolls every scrollable ancestor by default, and the
* transcript's own `useConversationScrollAnchor` was not the box that moved.
*/
const globalStyles = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8");
function rule(selector: string): string {
const match = globalStyles.match(new RegExp(`^\\${selector} \\{([^}]*)\\}`, "m"));
assert.ok(match, `${selector} must exist in globals.css`);
return match![1];
}
test("the chat panel and app shell clip rather than hide, so neither is a scroll container", () => {
// `hidden` would pass a naive "does it overflow" check while still being
// scrollable; `clip` is the only value that makes the box unscrollable.
assert.match(rule(".chat-panel"), /overflow: clip/);
assert.match(rule(".chat-app"), /overflow: clip/);
assert.doesNotMatch(rule(".chat-panel"), /overflow: hidden/);
assert.doesNotMatch(rule(".chat-app"), /overflow: hidden/);
});
test("nothing inside the chat panel focuses without preventScroll", () => {
// Components that render inside `.chat-panel`. A focus() here reaches the
// panel through the default scroll-into-view; the transcript has its own
// anchor and does not want the browser's.
const insidePanel = [
"src/app/(app)/page.tsx",
"src/hooks/use-consultation-run.ts",
"src/components/birth-time-choice-question.tsx",
"src/components/birth-time-rectification.tsx",
"src/components/rectification-agentic-chat.tsx",
"src/components/rectification-board.tsx",
];
/* Dialog focus management is the one legitimate bare focus() in these files:
an account dialog is an overlay above the panel, not content inside it, and
moving focus into it must not be suppressed. */
const dialogFocus = /closeButton|returnTarget|focusTrap/;
for (const path of insidePanel) {
const source = readFileSync(new URL(`../${path}`, import.meta.url), "utf8");
for (const [line] of source.matchAll(/^.*\.focus\((.*)$/gm)) {
if (/focus-visible|:focus|onFocus/.test(line)) continue;
if (dialogFocus.test(line)) continue;
assert.match(
line,
/focus\(\{ preventScroll: true \}\)/,
`${path}: ${line.trim()}\n`
+ "A bare focus() inside the chat panel scrolls it. Pass { preventScroll: true } "
+ "and let useConversationScrollAnchor own the transcript's scroll position.",
);
}
}
});
test("the guard covers every component that renders inside the panel today", () => {
// If a new rectification/consultation component starts calling focus(), this
// list has to grow with it — otherwise the contract above silently stops
// covering the surface it was written for.
const components = readdirSync(new URL("../src/components/", import.meta.url), { recursive: true, encoding: "utf8" })
.filter((entry) => entry.endsWith(".tsx") && /rectification|birth-time|chat-/.test(entry));
const missing: string[] = [];
for (const entry of components) {
const source = readFileSync(new URL(`../src/components/${entry}`, import.meta.url), "utf8");
for (const [line] of source.matchAll(/^.*\.focus\((.*)$/gm)) {
if (/focus-visible|:focus|onFocus/.test(line)) continue;
if (!/preventScroll: true/.test(line)) missing.push(`${entry}: ${line.trim()}`);
}
}
assert.deepEqual(missing, [], "these focus() calls inside the chat surface would scroll the panel");
});