Files
pbakaus_impeccable/browser-bundle/60-offscreen.js
T
Paul BakausandClaude Fable 5.1 0547ed6a63 reorg C: the open Rust runtime joins this repo as one Cargo workspace
The engine no longer lives in a separate repo. `crates/` is a snapshot of the
open crates (foundation, core, common, context, live, hook, skills, comp,
comp-verbs, html, browser, detect, cli) plus `Cargo.lock`, taken as a git
archive of the engine repo at the commit that finished the boundary split.
None of that repo's history comes with it, and none of it should: the closed
half stays private.

The closed half is the rule engine. It ships as a prebuilt native archive per
target, `libimpeccable_detector.a`, published as a `detector-v<X>` GitHub
Release on this repo. `crates/core/build.rs` resolves and links it three ways:
`IMPECCABLE_DETECTOR_LIB=<dir>` for a local detector build, else the
`~/.impeccable/detector/<version>/<target>/` cache, else a download verified
against its `.sha256` sidecar. `crates/core` is a thin shim over a three-symbol
C ABI; nothing above it knows the boundary exists.

What changed versus the engine repo copy:

- Every crate manifest moves from `license-file.workspace` to
  `license.workspace` (this workspace declares Apache-2.0), and the workspace
  gains the `postcard` dependency the boundary encoding needs.
- The launcher contract test reads `skill/scripts/impeccable{,.cmd}` instead of
  a sibling `launcher/` dir, and `engine_binary` downloads from
  `github.com/pbakaus/impeccable/releases/download/engine-v<version>/` instead
  of the retired dist repo. No oracle golden carried the old URL, so no
  re-recording was owed.
- The tests that hunted for a public repo through `IMPECCABLE_PUBLIC_REPO`,
  `../impeccable-second` or a hardcoded home directory now resolve the root as
  `CARGO_MANIFEST_DIR/../..`, because they are in it. The env var stays as an
  override for an out-of-tree checkout.
- The in-page bundle (`detect-antipatterns-browser.js`, 2 MB of generated wasm
  glue) is no longer tracked. `crates/core/build.rs` resolves it beside the
  archive, hands the path to `impeccable_core::browser::IN_PAGE_BUNDLE_JS`, and
  live mode serves that. `scripts/check-detector-release.mjs` now requires it
  and its `.sha256` in a detector release.
- The live crate embeds `skill/scripts/live-browser*.js` and
  `modern-screenshot.umd.js` directly rather than through vendored copies, so
  the binary and the installed skill cannot drift.
- `crates/browser/assets/` (an unused second copy of the bundle) is gone.
- `tests/lib/engine-bin.mjs` also accepts `target/release/impeccable`, so a
  plain `cargo build --release -p impeccable` is enough to run `bun run test`.

Verified with the archive from a local detector build: `cargo test --workspace`
267 pass, oracle 795 pass / 0 fail / 0 missing, `bun run build` clean, the
default suite green, and the launcher's `engine-probe` handshake answering
through `skill/scripts/impeccable`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
2026-09-01 15:31:26 -07:00

248 lines
11 KiB
JavaScript

// --- browser-bundle/60-offscreen.js ---
// The extension's offscreen document: hosts the WASM core (its own CSP
// allows 'wasm-unsafe-eval'; a page's never has to) and runs the same scan
// the in-page bundle runs, over a page snapshot the content script captured
// (15-snapshot.js -> crates/core/src/browser/snapshot.rs). No rule logic
// here: marshalling, the session protocol, and the visual-contrast IO
// adapter whose every read is a question back to the content script.
//
// Protocol (content script <-> this document, chrome.runtime messages with
// `target: 'impeccable-offscreen'`; each request is answered exactly once):
//
// { action: 'scan-start', session, snapshot, config }
// -> { ask: { hitTests: [[x, y]] } } answer: { hits: [...] }
// -> { ask: { io: { kind: 'loadImage', src } } }
// answer: { ref, w, h } | null
// -> { ask: { io: { kind: 'readPixel', ref, plan, px, py } } }
// answer: { data } | { error } | { noContext }
// -> { stage: 'findings', groups, pageLevel, serialized } answer: {}
// -> { stage: 'visual', groups, serialized, lazy } answer: {}
// -> { done: true }
// -> { error: message }
// -> { superseded: true } (a newer scan-start took the session over)
// { action: 'scan-continue', session, answer } (the answer to the last ask/stage)
// { action: 'analyze-candidate', session, snapshot, candidate, groups }
// -> asks as above, then { result, el, finding, serialized } (el 0 = no addition)
// { action: 'antipatterns' } -> the registry slice for the overlay labels
// { action: 'ping' } -> { ok: true, ready }
//
// `groups` are `[{ el, findings }]` with snapshot ids; the content script
// maps ids to Elements through the capture it made.
(function () {
const TARGET = 'impeccable-offscreen';
const sessions = new Map();
let corePromise = null;
function coreReady() {
if (!corePromise) corePromise = __impeccableLoadCore();
return corePromise;
}
// Coroutine over messages: `ask` answers the pending request with a
// question and parks until the next 'scan-continue' brings the answer.
function ask(session, payload) {
return new Promise((resolve, reject) => {
const respond = session.respond;
session.respond = null;
session.resume = { resolve, reject };
if (!respond) {
reject(new Error('session has no pending request'));
return;
}
respond(payload);
});
}
function finish(session, payload) {
const respond = session.respond;
session.respond = null;
if (sessions.get(session.id) === session) sessions.delete(session.id);
if (respond) respond(payload);
}
// The core holds one loaded snapshot at a time, so scans (which park at
// asks) run one after another; a second tab's scan waits its turn.
let chain = Promise.resolve();
function serialized(fn) {
const run = chain.then(fn, fn);
chain = run.catch(() => {});
return run;
}
// The visual-contrast IO over the snapshot: the core over the loaded
// snapshot (hit-test needs answered by the content script between calls),
// node = snapshot id, images and pixels read by the content script.
function createOffscreenVisualIO(wasm, session) {
async function core(fn, ...args) {
for (;;) {
const out = wasm[fn](...args);
if (!wasm.snapshot_has_needs()) return out;
const needs = JSON.parse(wasm.snapshot_take_needs());
const facts = await ask(session, { ask: { hitTests: needs.hitTests || [] } });
wasm.snapshot_add_facts(JSON.stringify(facts || { hits: [] }));
}
}
const media = (id) => JSON.parse(wasm.snapshot_media(id)) || {};
return {
core,
coreSync() { throw new Error('the offscreen adapter is asynchronous'); },
node: (handle) => handle,
handle: (id) => id,
parentOrBody: (id) => wasm.snapshot_parent_or_body(id),
intrinsicImg(id) { const m = media(id); return [m.nw || m.vw || m.w || 0, m.nh || m.vh || m.h || 0]; },
intrinsicRaster(id) { const m = media(id); return [m.w || m.vw || 0, m.h || m.vh || 0]; },
imgSrc(id) { const m = media(id); return m.cur || m.src || ''; },
loadImage: (src) => ask(session, { ask: { io: { kind: 'loadImage', src } } }),
readPixel: (ref, plan, px, py) => ask(session, { ask: { io: { kind: 'readPixel', ref, plan, px, py } } }),
// Scrolling the page from a snapshot is not meaningful; the extension
// never sets scrollOffscreen, and the lazy pass re-captures instead.
querySelector: () => null,
scroll() { const v = JSON.parse(wasm.snapshot_viewport()) || {}; return { x: v.scrollX || 0, y: v.scrollY || 0 }; },
scrollTo() {},
scrollIntoView: () => false,
waitForPaint: () => Promise.resolve(),
};
}
function configJson(config) {
config = config || {};
return JSON.stringify({
extensionMode: true,
disabledRules: Array.isArray(config.disabledRules) ? config.disabledRules : [],
designSystem: config.designSystem == null ? null : config.designSystem,
lineLengthMax: config.lineLengthMax == null ? null : config.lineLengthMax,
skipScan: config.skipScan === true,
});
}
function serialize(wasm, groups) {
return JSON.parse(wasm.serialize_findings(JSON.stringify(groups)));
}
// addVisualContrastResult over id-keyed groups: the two decisions are the
// core's; this only keeps the map.
function addVisualContrastResult(wasm, groups, result) {
const elId = wasm.visual_contrast_result_el(JSON.stringify(result));
if (!elId) return 0;
let group = groups.find(g => g.el === elId);
const existing = group ? group.findings : [];
const finding = JSON.parse(wasm.visual_contrast_result_finding(elId, JSON.stringify(existing), JSON.stringify(result)));
if (!finding) return 0;
if (group) group.findings.push(finding);
else groups.push({ el: elId, findings: [finding] });
return elId;
}
async function runScan(session, msg) {
const wasm = await coreReady();
const n = wasm.snapshot_load(msg.snapshot);
if (n === 0xFFFFFFFF) throw new Error('snapshot did not parse');
const config = msg.config || {};
const IO = createOffscreenVisualIO(wasm, session);
const vc = createVisualContrast(IO);
const t0 = performance.now();
const collected = JSON.parse(await IO.core('collect_browser_findings', configJson(config)));
const groups = collected.groups;
const stats = { elements: n, coreMs: performance.now() - t0, unknownStyleProps: JSON.parse(wasm.snapshot_unknown_style_props()) };
await ask(session, {
stage: 'findings',
groups,
pageLevel: collected.pageLevel,
serialized: serialize(wasm, groups),
stats,
});
const options = config;
// An ignoreFiles-waived page (config.skipScan) answers every stage empty:
// the core already emptied the collect pass, and the visual pass would
// repopulate it, so it is skipped with everything else (mirrors
// skipScanActive() in 50-scan.js; offscreen is always extension mode).
if (config.skipScan !== true && __visualContrastMode(options, config) !== false) {
const resolved = __visualContrastOptions(options, config);
if (__visualContrastMode(options, config) === 'image-only') resolved.imageOnly = true;
const analyses = await vc.analyzeVisualContrast(resolved);
const added = [];
for (const result of analyses) {
const el = addVisualContrastResult(wasm, groups, result);
if (el) added.push(el);
}
const lazy = (resolved.visualContrastLazy === false || resolved.scrollOffscreen !== false)
? []
: __lazyVisualContrastCandidates(analyses);
await ask(session, {
stage: 'visual',
groups,
added,
analyses,
serialized: serialize(wasm, groups),
lazy,
stats: { visualMs: performance.now() - t0 - stats.coreMs },
});
}
wasm.snapshot_clear();
finish(session, { done: true });
}
async function runCandidate(session, msg) {
const wasm = await coreReady();
const n = wasm.snapshot_load(msg.snapshot);
if (n === 0xFFFFFFFF) throw new Error('snapshot did not parse');
const IO = createOffscreenVisualIO(wasm, session);
const vc = createVisualContrast(IO);
const groups = Array.isArray(msg.groups) ? msg.groups : [];
const result = await vc.analyzeVisualContrastCandidate(msg.candidate);
const el = addVisualContrastResult(wasm, groups, result);
const out = { result, el, groups, serialized: el ? serialize(wasm, groups) : null };
wasm.snapshot_clear();
finish(session, out);
}
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
if (!msg || msg.target !== TARGET) return false;
if (msg.action === 'ping') {
coreReady().then(() => sendResponse({ ok: true, ready: true }), (err) => sendResponse({ ok: false, error: err?.message || String(err) }));
return true;
}
if (msg.action === 'antipatterns') {
coreReady().then((wasm) => sendResponse({ antipatterns: JSON.parse(wasm.antipatterns_json()) }), (err) => sendResponse({ error: err?.message || String(err) }));
return true;
}
if (msg.action === 'scan-start' || msg.action === 'analyze-candidate') {
const prior = sessions.get(msg.session);
if (prior) {
// A restarted session (the content script re-scanned): drop the old
// coroutine so it never answers a stale request.
prior.superseded = true;
if (prior.resume) prior.resume.reject(new Error('superseded'));
if (prior.respond) { try { prior.respond({ superseded: true }); } catch { /* channel gone */ } }
prior.respond = null;
sessions.delete(msg.session);
}
const session = { id: msg.session, respond: sendResponse, resume: null, superseded: false };
sessions.set(msg.session, session);
const run = msg.action === 'scan-start' ? runScan : runCandidate;
serialized(() => {
if (session.superseded) return;
return run(session, msg);
}).catch((err) => {
if (err && err.message === 'superseded') return;
finish(session, { error: err?.message || String(err) });
});
return true;
}
if (msg.action === 'scan-continue') {
const session = sessions.get(msg.session);
if (!session || !session.resume) {
sendResponse({ error: 'no such session' });
return false;
}
session.respond = sendResponse;
const resume = session.resume;
session.resume = null;
resume.resolve(msg.answer);
return true;
}
return false;
});
})();