162 lines
12 KiB
JavaScript
162 lines
12 KiB
JavaScript
// Theme engine v3 invariant suite over the static x.com fixtures (plan 6): every built-in theme x cell x
|
|
// width, rendered in Chromium with the real theme runtime, measured by waybetter's themeInvariants
|
|
// (I1-I8, I10) and I9 (forced :hover over CDP). Numbers first; crops only for failures.
|
|
//
|
|
// node scripts/theme-invariants.mjs [--themes=a,b] [--cells=home,account-menu] [--widths=1280,1920]
|
|
// [--json] [--baseline] [--strict] [--out=dir]
|
|
//
|
|
// --json prints per-theme, per-invariant failure counts and the delta against the committed baseline
|
|
// --baseline writes verification/theme-v3/baseline.json from this run (all themes, cells, widths only)
|
|
// --strict exit 1 when any non-exempt count is above 0 (report-only otherwise, plan step 2)
|
|
// Output: <out>/report.json, <out>/REPORT.md (under 8 KB), <out>/crops/ (at most 3 per invariant per theme).
|
|
// The fixtures approximate x.com: passing here is not a live X test (the live runner is verification/theme-qa).
|
|
|
|
import { build } from 'esbuild';
|
|
import { mkdir, writeFile, readFile, rm } from 'node:fs/promises';
|
|
import { existsSync } from 'node:fs';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import { join } from 'node:path';
|
|
import { chromium } from 'playwright';
|
|
import { CELLS, CELL_WIDTHS, fixtureHtml } from './theme-matrix/fixtures.mjs';
|
|
|
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
const args = Object.fromEntries(process.argv.slice(2).map(arg => { const [k, v] = arg.replace(/^--/, '').split('='); return [k, v ?? true]; }));
|
|
const list = value => (typeof value === 'string' ? value.split(',').filter(Boolean) : null);
|
|
const v3 = join(root, '../../verification/theme-v3');
|
|
const out = args.out ?? v3;
|
|
const IDS = ['I1', 'I1b', 'I2', 'I3', 'I4', 'I5', 'I6', 'I7', 'I8', 'I9', 'I10'];
|
|
const WIDTHS = [600, 1000, 1280, 1440, 1920, 2560];
|
|
|
|
const bundle = async (contents, globalName) => (await build({ stdin: { contents, resolveDir: root, loader: 'ts' }, bundle: true, format: 'iife', globalName, write: false, target: 'chrome120', minify: true })).outputFiles[0].text;
|
|
const themeJs = await bundle("import './src/inject/xSite.ts'; export { installTheme } from './src/inject/theme.ts';", 'BT');
|
|
const invJs = await bundle("export { runInvariants } from '../../waybetter/packages/runtime/src/themeInvariants.ts';", 'WBInv');
|
|
const { BUILTIN_THEMES } = await import(pathToFileURL(join(root, 'src/inject/themeSpec.ts')).href);
|
|
const SPEC = Object.fromEntries(BUILTIN_THEMES.map(t => [t.id, t]));
|
|
|
|
const themes = list(args.themes) ?? BUILTIN_THEMES.map(t => t.id);
|
|
const cells = list(args.cells) ?? Object.keys(CELLS).filter(c => !c.startsWith('chat-') || c === 'chat-group');
|
|
const widthsFor = cell => (list(args.widths)?.map(Number) ?? (CELL_WIDTHS[cell] ? CELL_WIDTHS[cell].filter(w => w >= 600) : WIDTHS));
|
|
const optsFor = id => ({ exemptLeak: id === 'x', pack: !!SPEC[id]?.icons?.pack, access: SPEC[id]?.decor?.focusRing === 'bold' ? 'high' : 'standard' });
|
|
|
|
/** I9: rows under forced :hover. The hover fill must not be an accent-hue tint of a theme without its own hover colour. */
|
|
async function hoverCheck(page, cdp, theme) {
|
|
if (theme === 'x') return { count: 0, findings: [] };
|
|
const { root: doc } = await cdp.send('DOM.getDocument', { depth: 0 });
|
|
const findings = [];
|
|
const sels = ['article[data-testid="tweet"]', '[data-testid="cellInnerDiv"] > div', '[role="menuitem"]', 'nav[role="navigation"] > a'];
|
|
for (const s of sels) {
|
|
const { nodeIds } = await cdp.send('DOM.querySelectorAll', { nodeId: doc.nodeId, selector: s });
|
|
for (const nodeId of nodeIds.slice(0, 3)) {
|
|
const before = await page.evaluate(([sel, i]) => { const e = document.querySelectorAll(sel)[i]; return e && getComputedStyle(e).backgroundColor; }, [s, nodeIds.indexOf(nodeId)]);
|
|
await cdp.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: ['hover'] });
|
|
const r = await page.evaluate(([sel, i]) => {
|
|
const e = document.querySelectorAll(sel)[i]; if (!e) return null;
|
|
const accent = getComputedStyle(document.documentElement).getPropertyValue('--wb-accent').trim() || getComputedStyle(document.documentElement).getPropertyValue('--bt-accent').trim();
|
|
return { bg: getComputedStyle(e).backgroundColor, accent, rect: (r => [r.left, r.top, r.width, r.height].map(Math.round))(e.getBoundingClientRect()) };
|
|
}, [s, nodeIds.indexOf(nodeId)]);
|
|
await cdp.send('CSS.forcePseudoState', { nodeId, forcedPseudoClasses: [] });
|
|
if (!r || r.bg === before) continue;
|
|
const m = /rgba?\(([\d.]+), ([\d.]+), ([\d.]+)(?:, ([\d.]+))?/.exec(r.bg); if (!m) continue;
|
|
const [rr, gg, bb] = [+m[1], +m[2], +m[3]]; const max = Math.max(rr, gg, bb), min = Math.min(rr, gg, bb);
|
|
const sat = max ? (max - min) / max : 0;
|
|
// A tinted hover: saturated and translucent (X's accent-alpha wash), on a theme that did not choose a hover colour.
|
|
if (sat > 0.25 && m[4] !== undefined && +m[4] < 0.3 && !SPEC[theme]?.colors?.hover) findings.push({ id: 'I9', sel: s, rect: r.rect, detail: `hover ${r.bg} is an accent-hue tint` });
|
|
}
|
|
}
|
|
return { count: findings.length, findings };
|
|
}
|
|
|
|
await rm(join(out, 'crops'), { recursive: true, force: true });
|
|
await mkdir(join(out, 'crops'), { recursive: true });
|
|
await mkdir(join(out, 'fixtures'), { recursive: true });
|
|
const totals = Object.fromEntries(themes.map(t => [t, Object.fromEntries(IDS.map(id => [id, 0]))]));
|
|
const byWidth = {}; const failures = []; const crops = {}; const errors = [];
|
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE || undefined });
|
|
try {
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
|
const cdp = await page.context().newCDPSession(page);
|
|
await cdp.send('DOM.enable'); await cdp.send('CSS.enable');
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
page.on('pageerror', e => errors.push(e.message));
|
|
for (const cell of cells) {
|
|
// One X mode per theme: the owner's (Lights out); X default is measured in it too, as a baseline.
|
|
const file = join(out, 'fixtures', `${cell}.dark.html`);
|
|
await writeFile(file, fixtureHtml(cell, 'dark'));
|
|
for (const width of widthsFor(cell)) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.goto(pathToFileURL(file).href);
|
|
await page.addScriptTag({ content: `${themeJs}\n${invJs}\nwindow.__bt = BT.installTheme(window);` });
|
|
await page.waitForTimeout(1700);
|
|
await page.addStyleTag({ content: ':root:root:root *,:root:root:root *::before,:root:root:root *::after{transition:none !important;animation-duration:0s !important}' });
|
|
for (const theme of themes) {
|
|
await page.evaluate(preset => window.__bt.apply({ preset }), theme);
|
|
await page.evaluate(() => new Promise(r => requestAnimationFrame(() => setTimeout(r, 150))));
|
|
const rep = await page.evaluate(o => WBInv.runInvariants(document, o), { ...optsFor(theme), keep: 3 });
|
|
const hover = await hoverCheck(page, cdp, theme);
|
|
rep.counts.I9 = hover.count; rep.findings.push(...hover.findings);
|
|
const row = (byWidth[`${theme}@${width}`] ??= Object.fromEntries(IDS.map(id => [id, 0])));
|
|
for (const id of IDS) { totals[theme][id] += rep.counts[id] ?? 0; row[id] += rep.counts[id] ?? 0; }
|
|
for (const f of rep.findings) {
|
|
const key = `${theme}:${f.id}`;
|
|
failures.push({ theme, width, cell, ...f });
|
|
if ((crops[key] ?? 0) >= 3 || f.rect[2] <= 0 || f.rect[3] <= 0) continue;
|
|
crops[key] = (crops[key] ?? 0) + 1;
|
|
const [x, y, w, h] = f.rect; const pad = 24;
|
|
const clip = { x: Math.max(0, x - pad), y: Math.max(0, y - pad), width: Math.min(700, w + 2 * pad), height: Math.min(400, h + 2 * pad) };
|
|
clip.width = Math.min(clip.width, width - clip.x); clip.height = Math.min(clip.height, 900 - clip.y);
|
|
if (clip.width > 4 && clip.height > 4) {
|
|
const name = `${theme}-${f.id}-${crops[key]}.png`;
|
|
await page.screenshot({ path: join(out, 'crops', name), clip }).catch(() => {});
|
|
failures.at(-1).crop = `crops/${name}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} finally { await browser.close(); }
|
|
|
|
// I11 theme purity (static): theme documents carry no css or selector-shaped strings; the engine (runtime,
|
|
// X inject, shell) never branches on a theme id. Counted per theme for documents, under "engine" for code.
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
const purity = { engine: [] };
|
|
const SELECTORY = /[.#\[][\w-]+.*[{:>]|\{[^}]*:[^}]*\}/;
|
|
const walkStrings = (v, path, hit) => { if (typeof v === 'string') { if (SELECTORY.test(v) && !/^https?:/.test(v)) hit(path); } else if (v && typeof v === 'object') for (const [k, x] of Object.entries(v)) { if (k === 'css' && x) hit(`${path}.css`); else walkStrings(x, `${path}.${k}`, hit); } };
|
|
const storeDir = join(root, '../web/public/store/themes');
|
|
for (const t of themes) {
|
|
const hits = []; walkStrings(SPEC[t] ?? {}, t, p => hits.push(p));
|
|
const f = join(storeDir, `${t}.json`); if (existsSync(f)) walkStrings(JSON.parse(readFileSync(f, 'utf8')), `store:${t}`, p => hits.push(p));
|
|
purity[t] = hits; totals[t].I11 = hits.length;
|
|
}
|
|
const ids = BUILTIN_THEMES.map(t => t.id).filter(id => id !== 'x');
|
|
const idCompare = new RegExp(`(\\.id|preset|theme)\\s*[!=]==?\\s*['"](${ids.join('|')})['"]|['"](${ids.join('|')})['"]\\s*[!=]==?`);
|
|
const walk = d => readdirSync(d).flatMap(n => { const q = join(d, n); return statSync(q).isDirectory() ? (n === 'node_modules' || n === 'dist' ? [] : walk(q)) : /\.(ts|tsx)$/.test(n) && !/\.test\./.test(n) ? [q] : []; });
|
|
for (const dir of [join(root, '../../waybetter/packages/runtime/src'), join(root, 'src/inject'), join(root, '../../waybetter/packages/shell/src')]) {
|
|
for (const file of walk(dir)) readFileSync(file, 'utf8').split('\n').forEach((line, i) => { if (idCompare.test(line)) purity.engine.push(`${file.slice(file.indexOf('src'))}:${i + 1}`); });
|
|
}
|
|
IDS.push('I11'); for (const t of themes) totals[t].I11 ??= 0;
|
|
|
|
// Report
|
|
const sum = t => IDS.reduce((n, id) => n + (t === 'x' && id === 'I2' ? 0 : totals[t][id]), 0);
|
|
const basePath = join(v3, 'baseline.json');
|
|
const baseline = existsSync(basePath) ? JSON.parse(await readFile(basePath, 'utf8')) : null;
|
|
const delta = t => baseline?.totals?.[t] ? Object.fromEntries(IDS.map(id => [id, totals[t][id] - (baseline.totals[t][id] ?? 0)])) : null;
|
|
const meta = { generated: new Date().toISOString(), cells: cells.length, widths: [...new Set(cells.flatMap(widthsFor))], themes: themes.length, note: 'Static x.com fixtures (Lights out), not live X. X default is a baseline (I2 exempt).' };
|
|
await writeFile(join(out, 'report.json'), JSON.stringify({ ...meta, totals, purity, byWidth, failures, errors }, null, 1));
|
|
let md = `# Theme invariants (fixtures)\n\n${meta.generated.slice(0, 16)}Z. ${meta.themes} themes, ${meta.cells} cells, widths ${meta.widths.join(', ')}. ${meta.note}\n\n| theme | ${IDS.join(' | ')} | total |\n|---|${IDS.map(() => '---|').join('')}---|\n`;
|
|
for (const t of themes) md += `| ${t} | ${IDS.map(id => totals[t][id]).join(' | ')} | ${sum(t)} |\n`;
|
|
md += '\n## Worst findings\n\n';
|
|
const seen = new Set();
|
|
for (const f of failures.sort((a, b) => (a.value ?? 99) - (b.value ?? 99))) {
|
|
const k = `${f.theme}:${f.id}:${f.sel}`; if (seen.has(k)) continue; seen.add(k);
|
|
const line = `- ${f.theme} ${f.width} ${f.cell} ${f.id}: ${f.detail.slice(0, 90)} \`${f.sel.slice(-70)}\`${f.crop ? ` [crop](${f.crop})` : ''}\n`;
|
|
if (md.length + line.length > 7800) { md += '- (more in report.json)\n'; break; }
|
|
md += line;
|
|
}
|
|
await writeFile(join(out, 'REPORT.md'), md);
|
|
if (args.baseline) {
|
|
if (list(args.themes) || list(args.cells) || list(args.widths)) throw new Error('--baseline needs the full matrix (no --themes/--cells/--widths)');
|
|
await writeFile(basePath, JSON.stringify({ generated: meta.generated, cells, widths: meta.widths, totals }, null, 1));
|
|
}
|
|
if (args.json) console.log(JSON.stringify(Object.fromEntries(themes.map(t => [t, { counts: totals[t], total: sum(t), delta: delta(t) }])), null, 1));
|
|
else { console.log(md.split('\n## ')[0]); if (errors.length) console.log(`page errors: ${errors.length} (${errors[0]})`); }
|
|
if (args.strict && themes.some(t => t !== 'x' && sum(t) > 0)) process.exitCode = 1;
|