be5e810ac9
The streaming and settled versions of the trailing assistant reply were two components, so settling unmounted one and mounted the other and the entrance tween replayed over text the reader was already on. One LatestAssistantEntry now owns that row under a single key, and the history list excludes it. The CSS entrance keyframe that doubled the GSAP tween is gone and the tween matches the documented 160ms. The step timeline no longer remounts on settle: it is a button-controlled disclosure with a 180ms grid-rows transition, the reader's own toggle wins over the live default, and an in-flight request with no events yet shows a queued row instead of an empty shell. BUG-474 BUG-475 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
140 lines
5.8 KiB
TypeScript
140 lines
5.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync, readdirSync } from "node:fs";
|
|
import test from "node:test";
|
|
|
|
// Guards the defect class behind BUG-434 / BUG-435: a className the CSS never
|
|
// defines renders as an unstyled element and silently breaks the layout, with
|
|
// nothing in the build or the type checker to catch it.
|
|
//
|
|
// Scope: only the project's own class families. A token counts as ours when its
|
|
// first segment already names a family in our CSS (`paywall-`, `message-`,
|
|
// `dialog-`, `danger-`, ...), which keeps Tailwind utilities out without having
|
|
// to enumerate them.
|
|
|
|
const projectRoot = new URL("../", import.meta.url);
|
|
const sourceRoot = new URL("src/", projectRoot);
|
|
|
|
const walk = (dir: URL, ext: readonly string[]): string[] =>
|
|
readdirSync(dir, { recursive: true, encoding: "utf8" })
|
|
.filter((entry) => ext.some((suffix) => entry.endsWith(suffix)))
|
|
.map((entry) => `src/${entry.split("\\").join("/")}`);
|
|
|
|
const read = (path: string) => readFileSync(new URL(path, projectRoot), "utf8");
|
|
|
|
const styleSheets = walk(sourceRoot, [".css"]).map(read);
|
|
const componentFiles = walk(sourceRoot, [".tsx", ".ts"]);
|
|
// Boundary pages under the root layout carry their rules in an inline <style>.
|
|
const inlineStyles = componentFiles.flatMap((path) =>
|
|
[...read(path).matchAll(/<style>\{`([\s\S]*?)`\}<\/style>/g)].map((hit) => hit[1]));
|
|
|
|
// Comments are stripped first: a class name that only survives in a `/* ... */`
|
|
// note is not a definition, and counting it would hide exactly the bug we want.
|
|
const styleSource = [...styleSheets, ...inlineStyles].join("\n").replace(/\/\*[\s\S]*?\*\//g, " ");
|
|
const definedClasses = new Set(
|
|
[...styleSource.matchAll(/\.(-?[A-Za-z_][A-Za-z0-9_-]*)/g)].map((hit) => hit[1]),
|
|
);
|
|
|
|
const projectFamilies = new Set([...definedClasses].map((name) => name.split("-")[0]));
|
|
|
|
// Tailwind utilities whose first segment collides with one of our families.
|
|
const tailwindCollisions = new Set([
|
|
"inline-flex", "inline-block", "inline-grid", "inline-table",
|
|
"select-none", "select-all", "select-text", "select-auto",
|
|
]);
|
|
|
|
// Classes that carry no styling on purpose: dead modifiers on already-styled
|
|
// elements, or hooks handed to a component that styles itself. Each one is a
|
|
// no-op today; the point of listing them is that a NEW undefined class fails.
|
|
//
|
|
// The `is-*` entries are state hooks whose visual is carried by a sibling
|
|
// element instead — the thinking step's marker icon, the board minute's
|
|
// "当前时间" / "已更新" note. `sidebar-header` and `chart-nav` land on shadcn
|
|
// components that style themselves. The rest are modifiers sitting beside an
|
|
// already-styled class on the same element.
|
|
const knownUnstyled = new Set([
|
|
"birth-time-evidence-receipt",
|
|
"chart-nav",
|
|
"consultation-step-tree",
|
|
"is-changed",
|
|
"is-done",
|
|
"is-live",
|
|
"is-working",
|
|
"membership-orders",
|
|
"membership-segment",
|
|
"onboarding-step-card",
|
|
"personal-report-additional-charts",
|
|
"rectification-pending-note",
|
|
"rectification-terminal-actions",
|
|
"rectification-terminal-note",
|
|
"sidebar-header",
|
|
"starter-workbench",
|
|
]);
|
|
|
|
/** Static class tokens; `${...}` holes split the string and their ragged edges are dropped. */
|
|
function staticClassTokens(source: string): string[] {
|
|
const segments = [...source.matchAll(/className\s*=\s*"([^"]*)"/g)].map((hit) => hit[1]);
|
|
for (const opener of source.matchAll(/className\s*=\s*\{/g)) {
|
|
let depth = 1;
|
|
let cursor = opener.index + opener[0].length;
|
|
const start = cursor;
|
|
while (cursor < source.length && depth > 0) {
|
|
if (source[cursor] === "{") depth += 1;
|
|
else if (source[cursor] === "}") depth -= 1;
|
|
cursor += 1;
|
|
}
|
|
const expression = source.slice(start, cursor - 1);
|
|
for (const literal of expression.matchAll(/`([^`]*)`|"([^"]*)"|'([^']*)'/g)) {
|
|
segments.push(literal[1] ?? literal[2] ?? literal[3] ?? "");
|
|
}
|
|
}
|
|
return segments.flatMap((segment) => (
|
|
` ${segment} `.split(/\$\{[^}]*\}/).flatMap((piece) => {
|
|
const tokens = piece.split(/\s+/).filter(Boolean);
|
|
if (!/^\s/.test(piece)) tokens.shift();
|
|
if (!/\s$/.test(piece)) tokens.pop();
|
|
return tokens;
|
|
})
|
|
));
|
|
}
|
|
|
|
const ownershipSuspects = new Map<string, Set<string>>();
|
|
for (const path of componentFiles) {
|
|
for (const token of staticClassTokens(read(path))) {
|
|
if (definedClasses.has(token)) continue;
|
|
if (tailwindCollisions.has(token)) continue;
|
|
if (!token.includes("-") || /[:[\]()/@!$.{}]/.test(token)) continue;
|
|
if (!projectFamilies.has(token.split("-")[0])) continue;
|
|
const seen = ownershipSuspects.get(token) ?? new Set<string>();
|
|
seen.add(path);
|
|
ownershipSuspects.set(token, seen);
|
|
}
|
|
}
|
|
|
|
test("every project class a component renders has a rule in our CSS", () => {
|
|
const undefinedClasses = [...ownershipSuspects.entries()]
|
|
.filter(([name]) => !knownUnstyled.has(name))
|
|
.map(([name, files]) => `${name} (${[...files].sort().join(", ")})`)
|
|
.sort();
|
|
assert.deepEqual(
|
|
undefinedClasses,
|
|
[],
|
|
"These class names style nothing. Define them in CSS, drop them, or add them "
|
|
+ "to knownUnstyled with a reason:\n " + undefinedClasses.join("\n "),
|
|
);
|
|
});
|
|
|
|
test("the knownUnstyled allowlist has no stale entries", () => {
|
|
// An entry that got a rule, or stopped being rendered, must leave the list —
|
|
// otherwise the allowlist grows into a blindfold.
|
|
const stale = [...knownUnstyled].filter((name) => (
|
|
definedClasses.has(name) || !ownershipSuspects.has(name)
|
|
));
|
|
assert.deepEqual(stale, [], `no longer undefined-and-used: ${stale.join(", ")}`);
|
|
});
|
|
|
|
test("the guard actually sees the codebase", () => {
|
|
assert.ok(componentFiles.length > 100, `only walked ${componentFiles.length} components`);
|
|
assert.ok(definedClasses.size > 500, `only parsed ${definedClasses.size} classes`);
|
|
assert.ok(projectFamilies.has("paywall") && projectFamilies.has("message"));
|
|
});
|