test(01-04): add failing off-token audit tests (RED)
- TDD harness for the D-13 audit: 13 checks across 7 test groups - clean/dirty/drift/components-layer/used-utilities fixtures under /tmp/opencode - real-build integration test (npm run build + default dist audit → exit 0) - contract: exit 0 clean, 1 violations (file:line: declaration), 2 token drift
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* audit.test.mjs — TDD harness for the off-token audit (D-13)
|
||||
* ------------------------------------------------------------------
|
||||
* RED phase: every test below fails while scripts/audit.mjs does
|
||||
* not exist (spawn ENOENT); all must pass once it is implemented.
|
||||
*
|
||||
* Run: node scripts/audit.test.mjs (exit 0 = all green)
|
||||
*
|
||||
* Fixtures are (re)created under /tmp/opencode/audit-test/ —
|
||||
* never inside the repo. Zero runtime dependencies.
|
||||
*
|
||||
* Contract under test (from plan 01-04 task 1):
|
||||
* T1 stylesheet using ONLY token values → exit 0
|
||||
* T2 stylesheet with off-token color / non-zero
|
||||
* border-radius / off-token font / box-shadow /
|
||||
* blur / backdrop-filter → exit 1, each
|
||||
* offending declaration listed as file:line: declaration
|
||||
* T3 tokens.css drifting below 40 colors → exit 2 (drift guard)
|
||||
* T4 any --radius-* token other than 0 → exit 2 (drift guard)
|
||||
* T5 real build (dist/rams.css) → exit 0
|
||||
* T6 off-token values inside @layer components → flagged (exit 1)
|
||||
* T7 @layer utilities: only classes used by the
|
||||
* implementation pages are audited (compiled
|
||||
* mockup vocabulary is reference material per D-06,
|
||||
* never worn by implementation markup)
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPTS = dirname(fileURLToPath(import.meta.url));
|
||||
const THEME = resolve(SCRIPTS, '..');
|
||||
const AUDIT = join(SCRIPTS, 'audit.mjs');
|
||||
const FIX = '/tmp/opencode/audit-test';
|
||||
|
||||
const passes = [];
|
||||
const failures = [];
|
||||
function check(name, cond, detail = '') {
|
||||
if (cond) passes.push(name);
|
||||
else failures.push(detail ? `${name} — ${detail}` : name);
|
||||
}
|
||||
function runAudit(args, cwd = THEME) {
|
||||
const r = spawnSync(process.execPath, [AUDIT, ...args], { encoding: 'utf8', cwd });
|
||||
return {
|
||||
status: r.status,
|
||||
error: r.error ? String(r.error.code || r.error) : null,
|
||||
out: `${r.stdout || ''}${r.stderr || ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
/* ————— fixtures ————— */
|
||||
mkdirSync(FIX, { recursive: true });
|
||||
const clean = join(FIX, 'clean.css');
|
||||
const dirty = join(FIX, 'dirty.css');
|
||||
const fewTokens = join(FIX, 'tokens-few.css');
|
||||
const badRadiusTokens = join(FIX, 'tokens-bad-radius.css');
|
||||
const compLayerCss = join(FIX, 'components-layer.css');
|
||||
const usedCss = join(FIX, 'used-utilities.css');
|
||||
const usedPage = join(FIX, 'used-page.html');
|
||||
|
||||
/* T1 — token values only: var() refs, verbatim token hex,
|
||||
* token-hue+alpha 8-digit form, zero radius, sanctioned font
|
||||
* families, box-shadow none, non-blur filter, token-valued
|
||||
* custom property, non-literal color keywords. */
|
||||
writeFileSync(clean, `/* clean fixture — token values only */
|
||||
.ok-var { color: var(--color-signal-orange); }
|
||||
.ok-hex { background: #EB8008; border-color: #1a1a1a99; }
|
||||
.ok-radius { border-radius: 0; }
|
||||
.ok-radius-var { border-radius: var(--radius-md); }
|
||||
.ok-font { font-family: "Space Grotesk", Geist, ui-monospace, monospace; }
|
||||
.ok-shadow { box-shadow: none; }
|
||||
.ok-filter { filter: grayscale(1); }
|
||||
.ok-slot { --shell-slot: var(--color-surface); }
|
||||
.ok-keyword { color: currentColor; }
|
||||
.ok-rgb-token { background: rgba(235, 128, 8, 0.5); }
|
||||
`);
|
||||
|
||||
/* T2 — every declaration below must be flagged, each printed
|
||||
* as file:line: declaration (the plan's example set plus the
|
||||
* rgb()/hsl() literal-evasion guard). */
|
||||
writeFileSync(dirty, `/* dirty fixture — every declaration must be flagged */
|
||||
.a-off-token-hex { color: #00ff00; }
|
||||
.b-non-zero-radius { border-radius: 12px; }
|
||||
.c-off-token-font { font-family: Arial; }
|
||||
.d-shadow { box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); }
|
||||
.e-blur { filter: blur(2px); }
|
||||
.f-glass { backdrop-filter: blur(4px); }
|
||||
.g-rgb-literal { background-color: rgb(255, 0, 0); }
|
||||
.h-hsl-literal { fill: hsl(120, 100%, 50%); }
|
||||
/* control: a verbatim token hex passes even in this file */
|
||||
.i-control-token-hex { color: #d9d2c6; }
|
||||
`);
|
||||
|
||||
/* T3 — token sheet that drifted below the 40-color floor. */
|
||||
writeFileSync(fewTokens, `@theme static {
|
||||
--color-surface: #fff8f0;
|
||||
--color-signal-orange: #EB8008;
|
||||
--color-matte-black: #1A1A1A;
|
||||
--color-soft-beige: #D9D2C6;
|
||||
--color-functional-red: #ED3F1C;
|
||||
--spacing-space-md: 1rem;
|
||||
--font-sans: "Geist", ui-sans-serif, system-ui, sans-serif;
|
||||
--radius-xs: 0px;
|
||||
--radius-md: 0px;
|
||||
}
|
||||
`);
|
||||
|
||||
/* T4 — real token sheet with one mutated radius token. */
|
||||
const realTokens = readFileSync(join(THEME, 'src', 'tokens.css'), 'utf8');
|
||||
if (!realTokens.includes('--radius-md: 0px;')) {
|
||||
console.error('fixture setup: --radius-md: 0px; not found in src/tokens.css');
|
||||
process.exit(1);
|
||||
}
|
||||
writeFileSync(badRadiusTokens, realTokens.replace('--radius-md: 0px;', '--radius-md: 4px;'));
|
||||
|
||||
/* T6 — off-token value inside @layer components (implementation-
|
||||
* owned layer) must never be skipped. */
|
||||
writeFileSync(compLayerCss, `@layer components {
|
||||
.comp-bad { background: #ff00ff; }
|
||||
}
|
||||
`);
|
||||
|
||||
/* T7 — utilities layer: used classes audited, unused
|
||||
* compiled vocabulary skipped. */
|
||||
writeFileSync(usedCss, `@layer utilities {
|
||||
.used-bad { background: #00ff00; }
|
||||
.unused-bad { background: #00ff00; }
|
||||
}
|
||||
`);
|
||||
writeFileSync(usedPage, `<!DOCTYPE html>
|
||||
<html><body>
|
||||
<p class="used-bad text-label-sm">specimen</p>
|
||||
</body></html>
|
||||
`);
|
||||
|
||||
/* ————— tests ————— */
|
||||
|
||||
// T1 — clean stylesheet exits 0
|
||||
{
|
||||
const r = runAudit([clean]);
|
||||
check('T1 clean stylesheet → exit 0', r.status === 0,
|
||||
`status=${r.status} error=${r.error} out=${r.out.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
// T2 — dirty stylesheet exits 1 and lists every offending declaration
|
||||
{
|
||||
const r = runAudit([dirty]);
|
||||
const markers = ['#00ff00', 'border-radius: 12px', 'font-family: Arial',
|
||||
'box-shadow', 'blur', 'backdrop-filter', 'rgb(255, 0, 0)', 'hsl(120'];
|
||||
const missing = markers.filter((m) => !r.out.includes(m));
|
||||
check('T2 dirty stylesheet → exit 1', r.status === 1,
|
||||
`status=${r.status} error=${r.error}`);
|
||||
check('T2 every violation listed', missing.length === 0,
|
||||
`missing markers: ${missing.join(', ')}`);
|
||||
check('T2 file:line: declaration format', /dirty\.css:\d+:/.test(r.out),
|
||||
'no "file:line:" prefixed lines in output');
|
||||
}
|
||||
|
||||
// T3 — color-token drift guard (below 40 colors) → exit 2
|
||||
{
|
||||
const r = runAudit(['--tokens', fewTokens, clean]);
|
||||
check('T3 < 40 color tokens → exit 2', r.status === 2,
|
||||
`status=${r.status} error=${r.error} out=${r.out.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
// T4 — radius-token drift guard (non-zero --radius-*) → exit 2
|
||||
{
|
||||
const r = runAudit(['--tokens', badRadiusTokens, clean]);
|
||||
check('T4 non-zero --radius-* token → exit 2', r.status === 2,
|
||||
`status=${r.status} error=${r.error} out=${r.out.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
// T5 — real build audits clean (dist/rams.css from the sheet build)
|
||||
{
|
||||
const b = spawnSync('npm', ['run', 'build'], { encoding: 'utf8', cwd: THEME });
|
||||
check('T5 npm run build succeeds', b.status === 0,
|
||||
`status=${b.status} ${(b.stderr || '').slice(0, 200)}`);
|
||||
const r = runAudit([]);
|
||||
check('T5 audit of real build → exit 0', r.status === 0,
|
||||
`status=${r.status} error=${r.error} out=${r.out.slice(0, 400)}`);
|
||||
}
|
||||
|
||||
// T6 — @layer components is implementation-owned: fully audited
|
||||
{
|
||||
const r = runAudit([compLayerCss]);
|
||||
check('T6 components-layer off-token → exit 1', r.status === 1,
|
||||
`status=${r.status} error=${r.error}`);
|
||||
check('T6 violation listed', r.out.includes('#ff00ff') && r.out.includes('comp-bad'),
|
||||
`out=${r.out.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
// T7 — utilities layer: only classes used by implementation pages audited
|
||||
{
|
||||
const r = runAudit(['--pages', usedPage, usedCss]);
|
||||
check('T7 used off-token utility → exit 1', r.status === 1,
|
||||
`status=${r.status} error=${r.error}`);
|
||||
check('T7 used class flagged', r.out.includes('used-bad') && r.out.includes('#00ff00'),
|
||||
`out=${r.out.slice(0, 300)}`);
|
||||
check('T7 unused vocabulary skipped', !r.out.includes('unused-bad'),
|
||||
`out=${r.out.slice(0, 300)}`);
|
||||
}
|
||||
|
||||
/* ————— report ————— */
|
||||
console.log(`audit.test: ${passes.length} passed, ${failures.length} failed`);
|
||||
passes.forEach((p) => console.log(` PASS ${p}`));
|
||||
failures.forEach((f) => console.error(` FAIL ${f}`));
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
Reference in New Issue
Block a user