Files
pbakaus_impeccable/crates/wasm/tools/snapshot-diff.mjs
T
Paul BakausandClaude Fable 5.1 4369ad538d Open the detector: the rule crates join the workspace, the C-ABI goes away
The detector is open source. The rules it ships were already public in this
repo's git history and in every npm tarball of the JS engine, so a closed
binary bought nothing it could keep; the moat is the service (the catalog,
the labs, the review pipeline), not the check functions. Keeping them behind
a prebuilt archive cost a C-ABI, an exact toolchain pin, a build-time
download, a second release to order ahead of every engine release, and a
serde layer that had to serve two encodings.

Deleted
- crates/core/src/ffi.rs, crates/core/build.rs, crates/core/tests/boundary.rs
  and the shim modules under src/checks and src/browser.
- crates/foundation/src/boundary.rs and the postcard dependency.
- DETECTOR_VERSION, scripts/check-detector-release.mjs and its test, the
  check:detector-release script, the detector gate and
  IMPECCABLE_SKIP_DETECTOR_CHECK in scripts/release.mjs.
- scripts/lib/detector-bundle.mjs and tests/detector-bundle.test.mjs (the
  vendoring path for the closed browser bundle).
- scripts/build-browser-detector.js and the build:browser script (a stub
  since the JS engine left the tree).
- xtask's detector-archive subcommand and its public-repo lookup.

Came back
- crates/core is now the rule logic itself: every check_* / scan_*, the
  browser adapters, the visual-contrast decisions. It re-exports foundation
  as before, so no consumer changed. Its vectors dispatcher is the union of
  both id tables again, and tests/vectors.rs replays the frozen vectors
  straight through it.
- crates/wasm and crates/xtask join the workspace. cargo xtask bundle builds
  the in-page bundle from browser-bundle/ plus the wasm core, writes
  dist/, refreshes the tracked crates/live/assets/detect-antipatterns-
  browser.js, and writes extension/detector/. bun run build:extension runs
  it instead of downloading.
- crates/live/assets/detect-antipatterns-browser.js is tracked again; live
  mode embeds it and serves it as /detect.js.
- Serde is back to plain derives: no is_human_readable branch in
  js::json_number, derived Serialize for Rgba and BrowserFinding with their
  skip_serializing_if attributes.
- profile.release has lto = "fat" again; rust-toolchain.toml is plain
  stable plus the wasm32 target. The rust, rust-windows and oracle CI jobs
  lose continue-on-error and can be required.

Verified
- cargo build --workspace --all-targets: clean, no warnings.
- cargo test --workspace: 346 pass, 0 fail (the 8 boundary tests are gone
  with the boundary).
- cargo build -p impeccable-wasm --target wasm32-unknown-unknown --release: ok.
- cargo xtask bundle && cargo xtask bundle --check: reproducible; the
  regenerated bundle is committed (it differs from the archived one, which
  was built with a pinned rustc and lto = false).
- cargo build --release -p impeccable: no linker warnings, 12.5 MB (the
  same source at lto = false is 13.1 MB).
- oracle: 795 pass, 0 fail, 0 accepted deltas, 0 missing goldens.
- bun run build, bun run build:extension, web-ext lint (0 errors,
  8 warnings), bun run test: 363 + 80 + 1 + 1 + 133 + 180 + 4 pass, 0 fail.
- impeccable detect --no-config --json tests/fixtures/antipatterns: 128.7 ms
  median of 5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
2026-09-03 09:11:33 -07:00

112 lines
5.8 KiB
JavaScript

#!/usr/bin/env node
// A/B of the two probes inside one bundle: inject dist/detect-antipatterns-
// browser.js and diff `impeccableDetect()` (rules over the live-DOM probe)
// against `impeccableDetectFromSnapshot()` (the same rules over a page
// snapshot, hit tests answered on demand) on the same page in the same
// Chrome. Verification tooling only (needs this repo's puppeteer).
//
// node crates/wasm/tools/snapshot-diff.mjs [--public <other checkout>]
// [--only fixture.html,...] [--url https://impeccable.style] [--verbose]
//
// Exit 0 when every page is identical, 1 otherwise. Prints per-page timing,
// snapshot size, rounds, and any computed-style property the capture missed.
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
const args = process.argv.slice(2);
const flag = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : null; };
const has = (n) => args.includes(n);
const here = path.dirname(fileURLToPath(import.meta.url));
const engineRoot = path.resolve(here, '../../..');
// The fixtures and puppeteer live in this repo; --public points at another
// checkout (an older one, for A/B against the pre-Rust engine).
const publicRepo = path.resolve(flag('--public') || process.env.IMPECCABLE_PUBLIC_REPO || engineRoot);
const require = createRequire(path.join(publicRepo, 'package.json'));
const puppeteer = require('puppeteer');
const BUNDLE = fs.readFileSync(path.join(engineRoot, 'dist/detect-antipatterns-browser.js'), 'utf8');
const only = flag('--only') ? flag('--only').split(',') : null;
const verbose = has('--verbose');
const extraUrl = flag('--url');
const dir = path.join(publicRepo, 'tests/fixtures/antipatterns');
const server = http.createServer((req, res) => {
const f = path.join(dir, decodeURIComponent(req.url.split('?')[0]));
try {
const body = fs.readFileSync(f);
res.setHeader('Content-Type', f.endsWith('.css') ? 'text/css' : f.endsWith('.js') ? 'application/javascript' : f.endsWith('.svg') ? 'image/svg+xml' : f.endsWith('.png') ? 'image/png' : 'text/html; charset=utf-8');
res.end(body);
} catch { res.statusCode = 404; res.end(); }
}).listen(0);
const port = server.address().port;
let names = fs.readdirSync(dir).filter((n) => n.endsWith('.html')).sort();
if (only) names = names.filter((n) => only.includes(n));
const targets = names.map((n) => `http://127.0.0.1:${port}/${n}`);
if (extraUrl) targets.push(extraUrl);
const browser = await puppeteer.launch({ headless: true, executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined });
let failures = 0;
const timing = [];
const flat = (r) => r.flatMap((g) => g.findings.map((f) => `${g.selector} :: ${f.type} :: ${f.detail}${f.severity !== undefined ? ' [' + f.severity + ']' : ''}`));
for (const url of targets) {
const page = await browser.newPage();
await page.setViewport({ width: 1280, height: 800 });
const errs = [];
page.on('pageerror', (e) => errs.push(e.message));
let live, snap;
try {
await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
await page.evaluate(BUNDLE);
live = await page.evaluate(() => {
const t0 = performance.now();
const findings = window.impeccableDetect({ decorate: false, serialize: true });
return { findings, ms: performance.now() - t0 };
});
snap = await page.evaluate(() => {
const t0 = performance.now();
const r = window.impeccableDetectFromSnapshot();
return { ...r, ms: performance.now() - t0 };
});
} catch (e) {
console.log(`ERROR ${url}: ${e.message}`);
failures++;
await page.close();
continue;
}
const A = JSON.stringify(live.findings);
const B = JSON.stringify(snap.findings);
const same = A === B;
const label = path.basename(url) || url;
const st = snap.stats;
timing.push({ label, liveMs: live.ms, snapMs: snap.ms, captureMs: st.captureMs, coreMs: st.coreMs, bytes: st.bytes, elements: st.elements, rounds: st.rounds });
console.log(`${same ? 'IDENTICAL' : 'DIFF '} ${label} live ${live.ms.toFixed(0)}ms snapshot ${snap.ms.toFixed(0)}ms (capture ${st.captureMs.toFixed(0)}ms + core ${st.coreMs.toFixed(0)}ms, ${(st.bytes / 1024).toFixed(0)} KB, ${st.elements} els, ${st.rounds} round${st.rounds === 1 ? '' : 's'})${st.unknownStyleProps.length ? ' UNKNOWN STYLE PROPS: ' + st.unknownStyleProps.join(',') : ''}${errs.length ? ' pageerrors: ' + errs.join(' | ') : ''}`);
if (!same) {
failures++;
const fa = flat(live.findings), fb = flat(snap.findings);
const onlyA = fa.filter((x) => !fb.includes(x));
const onlyB = fb.filter((x) => !fa.includes(x));
console.log(` live ${fa.length} findings, snapshot ${fb.length}`);
for (const x of onlyA.slice(0, verbose ? 200 : 8)) console.log(' - live only: ', x);
for (const x of onlyB.slice(0, verbose ? 200 : 8)) console.log(' + snapshot only:', x);
if (onlyA.length === 0 && onlyB.length === 0) {
for (let i = 0; i < Math.max(live.findings.length, snap.findings.length); i++) {
if (JSON.stringify(live.findings[i]) !== JSON.stringify(snap.findings[i])) {
console.log(' group', i, '\n live: ', JSON.stringify(live.findings[i]).slice(0, 500), '\n snapshot:', JSON.stringify(snap.findings[i]).slice(0, 500));
if (!verbose) break;
}
}
}
}
await page.close();
}
await browser.close();
server.close();
const sum = (k) => timing.reduce((a, t) => a + t[k], 0);
console.log(`\n${targets.length - failures}/${targets.length} identical. total scan: live ${sum('liveMs').toFixed(0)}ms, snapshot ${sum('snapMs').toFixed(0)}ms (capture ${sum('captureMs').toFixed(0)}ms, core ${sum('coreMs').toFixed(0)}ms); snapshot bytes total ${(sum('bytes') / 1024).toFixed(0)} KB`);
process.exit(failures ? 1 : 0);