mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
Generates a single-file control-vs-treatment HTML report from two batch-design-system-test (#1515) JSONL outputs. For each brand that appears in both arms, the reporter: - pulls the agent-produced HTML artifact from the daemon (via the existing /api/projects/:id/files + /api/projects/:id/raw/* routes) - renders a desktop screenshot via headless chromium (playwright-core added to root devDeps; Chromium binary is reused from the e2e workspace's existing Playwright install) - runs token-usage analysis on the artifact: - varRefCount: total var(--*) references outside the :root paste - hardcodedColorCount: literal #hex / rgb / hsl / oklab values - tokenHitRate: varRefs / (varRefs + literals) - brandTokenRecall: fraction of the brand's declared tokens that the artifact actually references (loaded from the brand's structured tokens.css if present) The output bundle is self-contained — report.html with embedded screenshots, plus data.json and the raw downloaded artifacts / PNGs — so it can be attached to a PR or shared in chat without re-running the batch. Why now: PR-D (#1544) made the structured-token channel default-on and PR-C (#1385) added the prerequisite tokens.css/components.html extension point. Track A is hand-authoring 5 high-priority brands (cursor first in #1652). Once enough brands have structured assets, we can run a full control-vs-treatment batch and need a way to see the result without scrolling 7×2 raw artifact tabs. The reporter is also useful before all 5 brands ship — running control vs treatment on default + kami today already validates the end-to-end pipeline. Verified: - pnpm guard — 13/13 design-system + style-policy checks pass - 11/11 unit tests pass: - 5 new analyzer / renderer tests in scripts/design-system-batch-report.test.ts: - analyzer reports near-perfect hit rate on default's reference fixture (varRefCount > 20, hardcodedColorCount = 0, tokenHitRate = 1.0, brandTokenRecall ≥ 0.5) - same for kami - analyzer downgrades a synthetic literal-heavy artifact (4 literals, 0 var refs → tokenHitRate = 0) - analyzer ignores literals INSIDE the :root paste (so brand :root values do not double-count as hardcoded) - renderReportHtml emits a self-contained HTML doc with sign-aware delta cells and key data fields - 6 existing style-policy.test.ts tests - pnpm exec tsc -p scripts/tsconfig.json --noEmit clean Open questions deferred to follow-up: - Daemon-API integration tested only via fetch types; first real end-to-end batch + report run will happen in track C1 once 5 structured brands are in. - Mobile viewport rendering deferred (per-batch desktop-only was the agreed scope). - Token-usage analyzer is regex-based (good enough for scoring; not a real CSS parser). Promote to a parser-based version if artifacts start using nested CSS or complex calc() expressions. Usage: OD_DESIGN_TOKEN_CHANNEL=0 node --experimental-strip-types \\ scripts/batch-design-system-test.ts \\ --prompt "..." --design-systems default,kami \\ --output .tmp/batch-control.jsonl node --experimental-strip-types scripts/batch-design-system-test.ts \\ --prompt "..." --design-systems default,kami \\ --output .tmp/batch-treatment.jsonl node --experimental-strip-types scripts/design-system-batch-report.ts \\ --control .tmp/batch-control.jsonl \\ --treatment .tmp/batch-treatment.jsonl \\ --out .tmp/design-system-batch-report Co-authored-by: Cursor <cursoragent@cursor.com>
180 lines
7.0 KiB
TypeScript
180 lines
7.0 KiB
TypeScript
// Sanity checks on the design-system-batch-report analyzer + report
|
|
// renderer, run as part of `pnpm guard` (see package.json `guard` script).
|
|
//
|
|
// Concretely we want three guarantees in CI:
|
|
//
|
|
// 1. The analyzer says "near-perfect" on each brand's reference
|
|
// components.html. If the reference fixture itself has hardcoded
|
|
// colors outside the :root paste, the structured channel is broken
|
|
// at its source — agents will copy those literals into artifacts.
|
|
//
|
|
// 2. The analyzer correctly DOWNGRADES a synthetic bad artifact (one
|
|
// that hardcodes hex colors and doesn't reference any tokens).
|
|
// This pins the analyzer's discriminative power: if the formula
|
|
// ever flips signs, the test breaks.
|
|
//
|
|
// 3. renderReportHtml emits a self-contained HTML document with the
|
|
// key data fields (control vs treatment delta, summary cards) so
|
|
// the report stays usable without re-running batch tests.
|
|
|
|
import { access, readFile, readdir } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
analyzeTokenUsage,
|
|
loadBrandTokens,
|
|
renderReportHtml,
|
|
} from './design-system-batch-report.ts';
|
|
|
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = path.resolve(HERE, '..');
|
|
|
|
async function readBrandFixture(brand: string): Promise<string> {
|
|
return await readFile(path.join(REPO_ROOT, 'design-systems', brand, 'components.html'), 'utf8');
|
|
}
|
|
|
|
/** Discover every brand on this branch that ships a components.html. */
|
|
async function discoverStructuredBrands(): Promise<string[]> {
|
|
const root = path.join(REPO_ROOT, 'design-systems');
|
|
const entries = await readdir(root, { withFileTypes: true });
|
|
const brands: string[] = [];
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory() || entry.name.startsWith('_')) continue;
|
|
const fixture = path.join(root, entry.name, 'components.html');
|
|
try {
|
|
await access(fixture);
|
|
brands.push(entry.name);
|
|
} catch {
|
|
// Brand has no structured fixture on this branch — fine, skip.
|
|
}
|
|
}
|
|
return brands;
|
|
}
|
|
|
|
const structuredBrands = await discoverStructuredBrands();
|
|
assert.ok(structuredBrands.length >= 2, `expected ≥2 structured brands, got ${structuredBrands.length}: ${structuredBrands.join(', ')}`);
|
|
|
|
for (const brand of structuredBrands) {
|
|
test(`analyzer reports near-perfect hit rate on ${brand}'s reference fixture`, async () => {
|
|
const [html, tokens] = await Promise.all([readBrandFixture(brand), loadBrandTokens(brand)]);
|
|
const usage = analyzeTokenUsage(html, tokens);
|
|
assert.ok(
|
|
usage.varRefCount > 20,
|
|
`${brand} fixture should reference 20+ tokens, got ${usage.varRefCount}`,
|
|
);
|
|
// The reference fixtures intentionally use NO literal colors outside
|
|
// their :root paste. If a fixture starts leaking literals, agents
|
|
// pattern-matching it will copy those literals into artifacts.
|
|
assert.equal(
|
|
usage.hardcodedColorCount,
|
|
0,
|
|
`${brand} fixture leaks ${usage.hardcodedColorCount} literal colors outside :root: ` +
|
|
usage.hardcodedColorSamples.join(', '),
|
|
);
|
|
assert.equal(usage.tokenHitRate, 1, `${brand} hit rate should be 1.0, got ${usage.tokenHitRate}`);
|
|
assert.ok(
|
|
usage.brandTokenRecall >= 0.5,
|
|
`${brand} reference fixture should use ≥50% of declared tokens, got ${usage.brandTokenRecall}`,
|
|
);
|
|
});
|
|
}
|
|
|
|
test('analyzer downgrades a synthetic literal-heavy artifact', () => {
|
|
const html = `
|
|
<html>
|
|
<head>
|
|
<style>
|
|
.danger { color: #ff0000; background: rgb(255, 200, 0); border: 1px solid hsl(120, 80%, 50%); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div style="color: #abcdef">Hi</div>
|
|
</body>
|
|
</html>
|
|
`;
|
|
const tokens = { declared: new Set(['fg', 'bg', 'accent']), hasStructuredTokens: true };
|
|
const usage = analyzeTokenUsage(html, tokens);
|
|
assert.equal(usage.varRefCount, 0);
|
|
// Three literal colors in the <style> (#ff0000, rgb(...), hsl(...)) +
|
|
// one in the inline style attribute (#abcdef).
|
|
assert.equal(usage.hardcodedColorCount, 4);
|
|
assert.equal(usage.tokenHitRate, 0);
|
|
assert.equal(usage.brandTokenRecall, 0);
|
|
assert.equal(usage.brandTokensMissed.length, 3);
|
|
});
|
|
|
|
test('analyzer ignores literals INSIDE the :root paste', () => {
|
|
// Agents that paste a brand's :root block verbatim will technically
|
|
// include the brand's own #hex values inside :root. Those should NOT
|
|
// be counted as "hardcoded" since they are the brand's source of truth.
|
|
const html = `
|
|
<style>
|
|
:root { --fg: #111111; --bg: #fafafa; }
|
|
.card { color: var(--fg); background: var(--bg); }
|
|
</style>
|
|
`;
|
|
const tokens = { declared: new Set(['fg', 'bg']), hasStructuredTokens: true };
|
|
const usage = analyzeTokenUsage(html, tokens);
|
|
assert.equal(usage.hardcodedColorCount, 0, 'literals inside :root must not count');
|
|
assert.equal(usage.varRefCount, 2);
|
|
assert.equal(usage.tokenHitRate, 1);
|
|
assert.equal(usage.brandTokenRecall, 1);
|
|
});
|
|
|
|
test('renderReportHtml produces a self-contained HTML report with key data', () => {
|
|
const fakeUsage = (varRefs: number, literals: number, recall: number) => ({
|
|
varRefCount: varRefs,
|
|
uniqueVarRefs: ['fg', 'bg'].slice(0, Math.min(2, varRefs)),
|
|
hardcodedColorCount: literals,
|
|
hardcodedColorSamples: literals > 0 ? ['#ff0000'] : [],
|
|
brandTokenRecall: recall,
|
|
brandTokensUsed: recall > 0 ? ['fg', 'bg'] : [],
|
|
brandTokensMissed: recall < 1 ? ['accent'] : [],
|
|
tokenHitRate: varRefs / Math.max(1, varRefs + literals),
|
|
});
|
|
const rows = [
|
|
{
|
|
brand: 'default',
|
|
control: {
|
|
brand: 'default',
|
|
arm: 'control' as const,
|
|
projectId: 'p1',
|
|
primaryFile: null,
|
|
htmlBytes: 1234,
|
|
htmlPath: 'artifacts/default-control.html',
|
|
screenshotPath: 'screenshots/default-control.png',
|
|
usage: fakeUsage(10, 4, 0.5),
|
|
},
|
|
treatment: {
|
|
brand: 'default',
|
|
arm: 'treatment' as const,
|
|
projectId: 'p2',
|
|
primaryFile: null,
|
|
htmlBytes: 1500,
|
|
htmlPath: 'artifacts/default-treatment.html',
|
|
screenshotPath: 'screenshots/default-treatment.png',
|
|
usage: fakeUsage(20, 0, 1.0),
|
|
},
|
|
delta: { tokenHitRate: 0.286, brandTokenRecall: 0.5, hardcodedColorCount: -4 },
|
|
},
|
|
];
|
|
const html = renderReportHtml(rows, {
|
|
generatedAt: '2026-05-14T10:00:00Z',
|
|
controlPath: '.tmp/control.jsonl',
|
|
treatmentPath: '.tmp/treatment.jsonl',
|
|
});
|
|
assert.match(html, /<!doctype html>/i);
|
|
assert.match(html, /Design system batch/);
|
|
assert.match(html, /default/);
|
|
assert.match(html, /default-control\.png/);
|
|
assert.match(html, /default-treatment\.png/);
|
|
assert.match(html, /\+50pp/, 'positive recall delta must render with explicit + sign');
|
|
assert.match(html, /-4/, 'negative literal-color delta should render with - sign');
|
|
assert.match(html, /Brands compared/);
|
|
assert.match(html, /delta-pos/, 'positive delta should carry the delta-pos class');
|
|
assert.match(html, /delta-neg/, 'negative delta should carry the delta-neg class');
|
|
});
|