Files
Ekklesia/tests/native-dialogs.test.ts
ekklesiaandClaude Opus 5.5 5a7d44781b
CI / verify (pull_request) Successful in 1m8s
CI / infrastructure-smoke (pull_request) Successful in 34s
CI / package-release (pull_request) Successful in 51s
CI / migration-smoke (pull_request) Successful in 2m27s
CI / image-build (pull_request) Successful in 1m6s
CI / deploy-smoke (same-origin) (pull_request) Successful in 50s
CI / package-publication (pull_request) Successful in 56s
CI / deploy-smoke (split) (pull_request) Successful in 2m44s
feat(ui): shared confirmation, toasts, error, skeleton, and Back primitives
Stage 1's later screens and modules need the interactions DESIGN.md
fixes once for the product (ADR 0059). @ekklesia/ui gains them,
additively:

- ConfirmationDialog: simple, destructive, or typed-phrase
  confirmation, with severity chosen by the caller. Destructive is
  marked by symbol and frame, not colour alone, and starts on Cancel.
  A required phrase must match exactly (forgiving only surrounding
  whitespace and NFC). While busy, Cancel and Escape are disabled.
- ToastProvider / useToasts: a top-center toast system per React tree,
  never a package singleton (ADR 0040). Live regions are mounted
  before any toast arrives; errors are assertive and persist until
  dismissed; routine toasts last at least three seconds and longer for
  longer text. Timers pause on hover and focus; duplicates refresh;
  errors are never displaced; every timer is cleared on unmount.
- ErrorState: recovery actions, plus collapsed technical details and a
  reference, rendered as bounded inert text.
- Skeleton / SkeletonGroup: an aria-busy region labelled once, with
  every animation stopped under prefers-reduced-motion.
- PageHeader `back` (link or button) above the page's one h1.
- Dialog: the focus trap ADR 0039 deferred, plus focus restoration and
  an accessible description. Escape is now handled on the dialog
  itself, so a drawer it was opened from stays open.

The shell validates Back targets (parseReturnTarget, dashboardBack):
only a canonical Dashboard page on this origin is accepted, otherwise
Back goes to the page's parent. It never uses history.back().

Biome's scope-aware suspicious/noAlert is now an error in the root
biome.json. tests/native-dialogs.test.ts proves it catches real
dialogs and not lookalikes, and scans for the forms the rule misses.
It supersedes the shell-only regex test.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 18:24:07 -05:00

153 lines
6.0 KiB
TypeScript

import { execFile } from "node:child_process";
import { cp, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* Browser-native dialogs — `alert`, `confirm`, `prompt` — are prohibited in product UI (DESIGN.md
* §8.1, ADR 0059). The guard is repository-wide and two-part:
*
* 1. Biome's scope-aware `suspicious/noAlert`, an error in the root `biome.json`, so `pnpm lint`
* refuses a dialog call anywhere in this repository — the shell, `@ekklesia/ui`, the module
* template — while leaving a local function or a method that shares the name alone.
* 2. A narrow scan of browser source for the two forms that rule cannot see: a call through `self`,
* `top`, `parent`, or `frames`, and borrowing the function without calling it.
*
* This file proves both against fixtures, with the repository's own lint configuration, so the
* guard is shown to catch real usage and to stay quiet about lookalikes.
*/
const execFileAsync = promisify(execFile);
const repositoryRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const fixtures = path.join(repositoryRoot, "tests", "fixtures", "native-dialogs");
const biome = path.join(repositoryRoot, "node_modules", ".bin", "biome");
/** Where browser code lives in this repository. */
const BROWSER_SOURCE_ROOTS = [
"surfaces",
path.join("packages", "ui", "src"),
path.join("packages", "module-ui-sdk", "src"),
path.join("packages", "module-ui-host", "src"),
path.join("templates", "module", "src"),
];
/** A dialog reached through a window alias, called or not: `self.prompt(`, `= window.confirm;`. */
const DIALOG_THROUGH_A_WINDOW =
/\b(?:window|globalThis|self|top|parent|frames)\s*(?:\.\s*|\[\s*["'`])(?:alert|confirm|prompt)\b/u;
function withoutComments(source: string): string {
return source.replace(/\/\*[\s\S]*?\*\//gu, "").replace(/(^|[^:\\])\/\/.*$/gmu, "$1");
}
/** Lines that reach a dialog in a way Biome's rule does not report. */
function scan(source: string): number[] {
return withoutComments(source)
.split("\n")
.flatMap((line, index) => {
const code = line.replace(/\/(?:\\.|[^/\n])+\/[a-z]*/gu, "").replace(/"[^"]*"|'[^']*'/gu, "");
return DIALOG_THROUGH_A_WINDOW.test(code) ? [index + 1] : [];
});
}
async function sourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true }).catch(() => []);
const files: string[] = [];
for (const entry of entries) {
const full = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (!["node_modules", "dist", ".turbo", "tests", "test"].includes(entry.name)) {
files.push(...(await sourceFiles(full)));
}
} else if (/\.(?:ts|tsx|js|jsx|mjs)$/u.test(entry.name)) {
files.push(full);
}
}
return files;
}
let workspace: string;
/** Lints a fixture with exactly the root configuration's rules, from a directory the root
* configuration's own `files.includes` does not exclude. */
async function lint(
fixture: string,
): Promise<Array<{ readonly line: number; readonly rule: string }>> {
try {
await execFileAsync(biome, ["lint", "--max-diagnostics=100", fixture], {
cwd: workspace,
});
return [];
} catch (error) {
const output = `${(error as { stdout?: string }).stdout ?? ""}${(error as { stderr?: string }).stderr ?? ""}`;
return [...output.matchAll(/^\S*?(\d+):\d+ (lint\/\S+)/gmu)].map((match) => ({
line: Number(match[1]),
rule: match[2] ?? "",
}));
}
}
beforeAll(async () => {
workspace = await mkdtemp(path.join(tmpdir(), "ekklesia-native-dialogs-"));
const config = JSON.parse(await readFile(path.join(repositoryRoot, "biome.json"), "utf8")) as {
files?: unknown;
};
delete config.files;
await writeFile(path.join(workspace, "biome.json"), JSON.stringify(config));
await cp(fixtures, workspace, { recursive: true });
});
afterAll(async () => {
await rm(workspace, { force: true, recursive: true });
});
describe("the repository's lint refuses browser-native dialogs", () => {
it("makes noAlert an error in the root configuration", async () => {
const config = JSON.parse(await readFile(path.join(repositoryRoot, "biome.json"), "utf8")) as {
linter?: { rules?: { suspicious?: { noAlert?: unknown } } };
};
expect(config.linter?.rules?.suspicious?.noAlert).toBe("error");
});
it("reports every dialog call", async () => {
const findings = await lint("prohibited.ts");
expect(findings.filter((finding) => finding.rule === "lint/suspicious/noAlert")).toEqual(
[2, 3, 4, 5, 6, 7, 8, 9].map((line) => ({ line, rule: "lint/suspicious/noAlert" })),
);
}, 30_000);
it("reports nothing about a local function, a method, a property, a string, or a comment", async () => {
expect(await lint("permitted.ts")).toEqual([]);
}, 30_000);
});
describe("the repository scan covers what the lint rule cannot see", () => {
it("catches a dialog called through self, or borrowed without being called", async () => {
const source = await readFile(path.join(fixtures, "prohibited-references.ts"), "utf8");
expect(scan(source)).toEqual([2, 3]);
});
it("stays quiet about the permitted fixture", async () => {
const source = await readFile(path.join(fixtures, "permitted.ts"), "utf8");
expect(scan(source)).toEqual([]);
});
it("finds no dialog anywhere in this repository's browser source", async () => {
const offenders: string[] = [];
let scanned = 0;
for (const sourceRoot of BROWSER_SOURCE_ROOTS) {
for (const file of await sourceFiles(path.join(repositoryRoot, sourceRoot))) {
scanned += 1;
for (const line of scan(await readFile(file, "utf8"))) {
offenders.push(`${path.relative(repositoryRoot, file)}:${line}`);
}
}
}
expect(scanned).toBeGreaterThan(20);
expect(offenders).toEqual([]);
});
});