mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 07:06:45 +03:00
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
132 lines
6.7 KiB
JavaScript
132 lines
6.7 KiB
JavaScript
#!/usr/bin/env node
|
|
// A/B differential of the in-page bundles: inject an older checkout's JS
|
|
// bundle (cli/engine/detect-antipatterns-browser.js, --public) and this repo's WASM
|
|
// bundle (dist/detect-antipatterns-browser.js) into the SAME page in the same
|
|
// Chrome, and diff the raw `impeccableDetect({ decorate: false, serialize:
|
|
// true })` arrays (plus impeccableMeasureHiddenText and, with --visual, the
|
|
// impeccableAnalyzeVisualContrast results). Verification tooling only: it
|
|
// needs puppeteer and a pre-Rust checkout for the JS side, and is not part
|
|
// of any build.
|
|
//
|
|
// node crates/wasm/tools/ab-diff.mjs [--public <other checkout>]
|
|
// [--only fixture.html,...] [--rules side-tab,low-contrast]
|
|
// [--url https://impeccable.style] [--visual] [--verbose]
|
|
//
|
|
// Exit 0 when every page is identical, 1 otherwise. Prints per-page timing.
|
|
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 OLD = fs.readFileSync(path.join(publicRepo, 'cli/engine/detect-antipatterns-browser.js'), 'utf8');
|
|
const NEW = fs.readFileSync(path.join(engineRoot, 'dist/detect-antipatterns-browser.js'), 'utf8');
|
|
const rules = flag('--rules') ? flag('--rules').split(',') : null;
|
|
const only = flag('--only') ? flag('--only').split(',') : null;
|
|
const verbose = has('--verbose');
|
|
const visual = has('--visual');
|
|
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 = [];
|
|
async function runBundle(page, url, code) {
|
|
await page.goto(url, { waitUntil: 'networkidle0', timeout: 60000 });
|
|
await page.evaluate(() => { window.__IMPECCABLE_CONFIG__ = { autoScan: false }; });
|
|
const t0 = performance.now();
|
|
await page.evaluate(code);
|
|
const tLoad = performance.now() - t0;
|
|
const t1 = performance.now();
|
|
const results = await page.evaluate(() => window.impeccableDetect({ decorate: false, serialize: true }));
|
|
const tScan = performance.now() - t1;
|
|
const hidden = await page.evaluate(() => window.impeccableMeasureHiddenText());
|
|
let vis = null;
|
|
if (visual) vis = await page.evaluate(() => window.impeccableAnalyzeVisualContrast({ maxCandidates: 12, scrollOffscreen: true }));
|
|
return { results, hidden, vis, tLoad, tScan };
|
|
}
|
|
function project(results) {
|
|
return results.map((g) => ({ ...g, findings: rules ? g.findings.filter((f) => rules.includes(f.type)) : g.findings }))
|
|
.filter((g) => !rules || g.findings.length > 0);
|
|
}
|
|
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 a, b;
|
|
try {
|
|
a = await runBundle(page, url, OLD);
|
|
b = await runBundle(page, url, NEW);
|
|
} catch (e) {
|
|
console.log(`ERROR ${url}: ${e.message}`);
|
|
failures++;
|
|
await page.close();
|
|
continue;
|
|
}
|
|
const A = JSON.stringify(project(a.results));
|
|
const B = JSON.stringify(project(b.results));
|
|
const H = JSON.stringify(a.hidden) === JSON.stringify(b.hidden);
|
|
const V = visual ? JSON.stringify(a.vis) === JSON.stringify(b.vis) : true;
|
|
const same = A === B && H && V;
|
|
const label = path.basename(url) || url;
|
|
timing.push({ label, oldLoad: a.tLoad, newLoad: b.tLoad, oldScan: a.tScan, newScan: b.tScan });
|
|
console.log(`${same ? 'IDENTICAL' : 'DIFF '} ${label} js ${a.tScan.toFixed(0)}ms wasm ${b.tScan.toFixed(0)}ms (load ${b.tLoad.toFixed(0)}ms)${errs.length ? ' pageerrors: ' + errs.join(' | ') : ''}`);
|
|
if (!same) {
|
|
failures++;
|
|
if (!H) console.log(' hidden-text differs', JSON.stringify(a.hidden), JSON.stringify(b.hidden));
|
|
if (!V) console.log(' visual differs', JSON.stringify(a.vis).slice(0, 400), '\n vs', JSON.stringify(b.vis).slice(0, 400));
|
|
if (A !== B) {
|
|
const pa = project(a.results), pb = project(b.results);
|
|
const flat = (r) => r.flatMap((g) => g.findings.map((f) => `${g.selector} :: ${f.type} :: ${f.detail}${f.severity !== undefined ? ' [' + f.severity + ']' : ''}`));
|
|
const fa = flat(pa), fb = flat(pb);
|
|
const onlyA = fa.filter((x) => !fb.includes(x));
|
|
const onlyB = fb.filter((x) => !fa.includes(x));
|
|
console.log(` js ${fa.length} findings, wasm ${fb.length}`);
|
|
for (const x of onlyA.slice(0, verbose ? 200 : 8)) console.log(' - js only: ', x);
|
|
for (const x of onlyB.slice(0, verbose ? 200 : 8)) console.log(' + wasm only:', x);
|
|
if (onlyA.length === 0 && onlyB.length === 0) {
|
|
// same set, different order or metadata
|
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
if (JSON.stringify(pa[i]) !== JSON.stringify(pb[i])) {
|
|
console.log(' group', i, '\n js: ', JSON.stringify(pa[i]).slice(0, 500), '\n wasm:', JSON.stringify(pb[i]).slice(0, 500));
|
|
if (!verbose) break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
await page.close();
|
|
}
|
|
await browser.close();
|
|
server.close();
|
|
const sum = (k) => timing.reduce((s, t) => s + t[k], 0);
|
|
console.log(`\n${targets.length - failures}/${targets.length} identical. total scan: js ${sum('oldScan').toFixed(0)}ms, wasm ${sum('newScan').toFixed(0)}ms; wasm bundle load avg ${(sum('newLoad') / Math.max(1, timing.length)).toFixed(0)}ms`);
|
|
process.exit(failures ? 1 : 0);
|