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
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]>
153 lines
6.0 KiB
TypeScript
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([]);
|
|
});
|
|
});
|