You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/scripts/layer-check.ts

236 lines
8.6 KiB

/**
* Shared-visual-layer COMPUTED check.
*
* A shared layer is a stylesheet keyed on a hook several components stamp
* (`lib/viewport-placement.css` → `data-viewport-placement`). Its failure modes are
* all computed-value ones, and NONE of them is visible to a text lint:
*
* - a specificity tie lost to a composed primitive (`button.css` declares
* `position: relative` on `[data-button]` at the same 0,1,0 as the layer's
* base rule, and both files are code-split, so source order decides);
* - a `revert-layer` from another recipe rolling the property back to the UA
* value (`box.css` does this for `position` and all four insets);
* - a custom-property CYCLE, which CSS resolves to guaranteed-invalid, killing
* the `calc()` that consumes it.
*
* Types were green for every one of those. This script is the eye.
*
* WHAT IT DELIBERATELY DOES NOT CHECK — and the reason is measured, not assumed.
* The obvious assertion ("the anchored inset is not `auto`") is USELESS:
* `getComputedStyle` reports the USED value for a positioned element, so an
* `inset: auto` reads back as a pixel number (measured: `-1976.7px` on a probe,
* and `324px` on the real cycle failure). There is no property-level way to tell
* "324px because the calc died" from "324px because the author asked for it".
* Discriminating that needs GEOMETRY — the element's edge against its containing
* block's — which in turn needs an ancestor walk for `transform` / `filter` /
* `contain` / `will-change`. Deferred until the layer has a third consumer; the
* hole is stated here rather than papered over with an assertion that passes on
* the broken case.
*
* The consumer list is DERIVED, not configured: any eidos component whose
* wrapper imports the layer is checked. A component joins the day it migrates.
*
* Exit codes:
* 0 — every element carrying a layer hook computes its contract.
* 1 — at least one violation.
* 2 — no dev server / no consumers found.
*/
import { chromium } from 'playwright';
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, '..');
const EIDOS_COMPONENTS = join(REPO, 'src/uix/eidos/components');
/** The layers this script knows how to assert, and what their hook promises. */
const LAYERS = [
{
name: 'affix',
/** The component that OWNS the layer — its wrapper imports it relatively. */
owner: 'affix',
/** Matches a consumer wrapper that imports the layer stylesheet. */
importRe: /import ['"][^'"]*viewport-placement\.css['"]/,
hook: 'data-viewport-placement',
/** Public token that must always resolve — empty means invalid or renamed. */
publicTokens: ['--viewport-placement-offset', '--viewport-placement-z'],
/** Per-instance override slot the wrappers write. */
overrideSlots: ['--_viewport-placement-offset', '--_viewport-placement-z'],
expectedPosition: 'fixed'
}
] as const;
/**
* Eidos component dirs whose wrapper imports the layer, plus the layer's OWNER
* (whose wrapper imports it relatively, so the shared regex misses it — and
* missing it meant the layer's own demo went unchecked on the first run).
*/
function consumersOf(importRe: RegExp, owner: string): string[] {
if (!existsSync(EIDOS_COMPONENTS)) return [];
const out: string[] = [];
for (const dir of readdirSync(EIDOS_COMPONENTS)) {
const componentDir = join(EIDOS_COMPONENTS, dir);
let files: string[];
try {
files = readdirSync(componentDir).filter((f) => f.endsWith('.svelte'));
} catch {
continue;
}
const imports = files.some((f) => importRe.test(readFileSync(join(componentDir, f), 'utf8')));
if (imports) out.push(dir);
}
return out.includes(owner) ? out : [owner, ...out];
}
async function probePort(startPort: number, endPort: number): Promise<string | null> {
for (let port = startPort; port <= endPort; port++) {
try {
const res = await fetch(`http://localhost:${port}/`, {
signal: AbortSignal.timeout(500)
});
if (res.status < 500) return `http://localhost:${port}`;
} catch {
/* next */
}
}
return null;
}
type Violation = { route: string; message: string };
const BASE = process.argv[2] ?? (await probePort(5173, 5180));
if (!BASE) {
console.error('Could not find a running dev server on 5173-5180.');
console.error('Start one with `npm run dev`, or pass a base URL as the first argument.');
process.exit(2);
}
console.error(`Using dev server at ${BASE}`);
const browser = await chromium.launch();
const ctx = await browser.newContext();
const violations: Violation[] = [];
let checkedElements = 0;
let checkedRoutes = 0;
for (const layer of LAYERS) {
const consumers = consumersOf(layer.importRe, layer.owner);
if (consumers.length === 0) {
console.log(`SKIP ${layer.name}: no component imports the layer`);
continue;
}
console.log(`Layer ${layer.name} — ${consumers.length} consumer(s): ${consumers.join(', ')}`);
for (const consumer of consumers) {
const route = `/uix/components/${consumer}`;
const page = await ctx.newPage();
try {
await page.goto(BASE + route, { waitUntil: 'networkidle', timeout: 20000 });
await page.waitForTimeout(500);
checkedRoutes += 1;
const found = await page.$$eval(
`[${layer.hook}]`,
(nodes, cfg) =>
nodes.map((el) => {
const cs = getComputedStyle(el);
return {
hookValue: el.getAttribute(cfg.hook),
position: cs.position,
zIndex: cs.zIndex,
publics: cfg.publicTokens.map((t) => [t, cs.getPropertyValue(t).trim()]),
// An inline declaration that computes to nothing is the exact
// signature of a custom-property cycle.
deadSlots: cfg.overrideSlots.filter(
(slot) =>
(el as HTMLElement).style.getPropertyValue(slot).trim() !== '' &&
cs.getPropertyValue(slot).trim() === ''
)
};
}),
{
hook: layer.hook,
publicTokens: [...layer.publicTokens],
overrideSlots: [...layer.overrideSlots]
}
);
if (found.length === 0) {
// NOT a warning. A demo whose default state never stamps the hook
// leaves this consumer unchecked, and the run would go green having
// looked at nothing — which is exactly what happened the first time
// this script ran (fab's demo defaulted to `placement="static"`).
violations.push({
route,
message: `no element stamps ${layer.hook} — the demo's default state does not exercise the layer, so this consumer is UNCHECKED`
});
console.log(`FAIL ${consumer.padEnd(20)} ${route}`);
console.log(` ${violations[violations.length - 1].message}`);
continue;
}
for (const el of found) {
checkedElements += 1;
const where = `${layer.hook}="${el.hookValue}"`;
if (el.position !== layer.expectedPosition) {
violations.push({
route,
message: `${where}: position is "${el.position}", expected "${layer.expectedPosition}" — the layer lost a cascade fight (a composed primitive's own position, or a revert-layer)`
});
}
if (el.zIndex === 'auto') {
violations.push({
route,
message: `${where}: z-index computes to "auto" — the stacking token did not resolve`
});
}
for (const [token, value] of el.publics) {
if (value === '') {
violations.push({
route,
message: `${where}: public token ${token} resolves to nothing (renamed, or invalid)`
});
}
}
for (const slot of el.deadSlots) {
violations.push({
route,
message: `${where}: ${slot} is declared inline but computes to nothing — a custom-property CYCLE; the calc() that consumes it is dead and the inset fell back silently`
});
}
}
const bad = violations.filter((v) => v.route === route);
if (bad.length === 0) {
console.log(`PASS ${consumer.padEnd(20)} ${route} — ${found.length} element(s)`);
} else {
console.log(`FAIL ${consumer.padEnd(20)} ${route}`);
bad.forEach((v) => console.log(` ${v.message}`));
}
} catch (e) {
console.log(`ERROR ${consumer.padEnd(20)} ${route} ${(e as Error).message}`);
violations.push({ route, message: `navigation error: ${(e as Error).message}` });
} finally {
await page.close();
}
}
}
await browser.close();
console.log('');
if (checkedRoutes === 0) {
console.log('No layer consumers with a routed demo.');
process.exit(2);
} else if (violations.length === 0) {
console.log(
`layer-check: 0 violation(s) across ${checkedElements} element(s) on ${checkedRoutes} route(s)`
);
process.exit(0);
} else {
console.log(`layer-check: ${violations.length} violation(s) across ${checkedRoutes} route(s)`);
process.exit(1);
}

Powered by TurnKey Linux.