diff --git a/Cargo.toml b/Cargo.toml index fa7a3e5..14cf3d5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["apps/chuzz", "crates/chuzz-control", "crates/chuzz-qa-fixture"] resolver = "3" [workspace.package] -version = "0.1.39" +version = "0.1.40" edition = "2024" rust-version = "1.91" license = "MIT OR Apache-2.0" diff --git a/apps/chuzz/src/document_loader.rs b/apps/chuzz/src/document_loader.rs index 2bedc27..61e89aa 100644 --- a/apps/chuzz/src/document_loader.rs +++ b/apps/chuzz/src/document_loader.rs @@ -710,22 +710,54 @@ pub(crate) const WEB_API_SHIM: &str = r#" * — which is unbounded recursion the moment both exist, and it took the * whole shim down with it. * - * The engine does not expose its real size to script: `innerWidth`, - * `outerWidth` and the client dimensions all read 0, and the layout rect - * comes back zero-width. So this is a stated default rather than a - * measurement, chosen to match the driver's default screenshot size. A page - * asking whether it has room for the desktop layout gets a truthful-looking - * desktop answer instead of the zero that silently forces every responsive - * design into its narrowest branch. + * The engine owns `innerWidth`/`innerHeight` as live accessors over the + * document's viewport, but a page's scripts run before the host sets that + * viewport, so at boot they read 0. The defaults below cover only that + * window, chosen to match the driver's default screenshot size: a page + * asking at boot whether it has room for the desktop layout gets a + * truthful-looking desktop answer instead of the zero that forces every + * responsive design into its narrowest branch. Once the engine reports a + * size, that size is the answer. The shim used to replace the accessors + * with the default for good, so a 1344-wide page reported 1440 forever and + * anything clamped to the viewport (an overlay, a tooltip) was placed + * against a width the page does not have. + * + * `engineSize` reads the engine's own getters, captured before anything + * here redefines the names, so nothing reads a shimmed size to compute a + * shimmed size: the recursion described above cannot come back. */ var CHUZZ_VIEWPORT_WIDTH = 1440; var CHUZZ_VIEWPORT_HEIGHT = 960; + var engineSize = (function () { + var read = function (name) { + try { + var descriptor = Object.getOwnPropertyDescriptor(globalThis, name); + if (descriptor && typeof descriptor.get === 'function') { + var get = descriptor.get; + return function () { + try { + var value = Number(get.call(globalThis)); + return value > 0 ? value : 0; + } catch (error) { return 0; } + }; + } + } catch (error) {} + return function () { return 0; }; + }; + return { width: read('innerWidth'), height: read('innerHeight') }; + })(); + var viewportWidth = function () { + return engineSize.width() || CHUZZ_VIEWPORT_WIDTH; + }; + var viewportHeight = function () { + return engineSize.height() || CHUZZ_VIEWPORT_HEIGHT; + }; if (typeof globalThis.screen === 'undefined') { globalThis.screen = { - get width() { return CHUZZ_VIEWPORT_WIDTH; }, - get height() { return CHUZZ_VIEWPORT_HEIGHT; }, - get availWidth() { return CHUZZ_VIEWPORT_WIDTH; }, - get availHeight() { return CHUZZ_VIEWPORT_HEIGHT; }, + get width() { return viewportWidth(); }, + get height() { return viewportHeight(); }, + get availWidth() { return viewportWidth(); }, + get availHeight() { return viewportHeight(); }, colorDepth: 24, pixelDepth: 24, orientation: { type: 'landscape-primary', angle: 0 } @@ -870,28 +902,35 @@ pub(crate) const WEB_API_SHIM: &str = r#" * must not skip the rest, and none of them may throw out of the shim. */ (function () { - var assign = function (name, value) { + var publish = function (name, size) { try { - if (globalThis[name]) { return; } /* - * `defineProperty`, not assignment. The engine owns these names as + * `defineProperty`, not assignment: the engine owns these names as * read-only accessors, so `globalThis.innerWidth = 1440` fails - * silently and the page keeps reading 0. Redefining the property is - * what actually takes. The value is a plain number, never a getter: - * a getter that read another shimmed size recursed without bound. + * silently. The getter reads `viewportWidth`/`viewportHeight`, which + * read the engine's captured getters, never these names, so it cannot + * recurse. The setter mirrors a browser's [Replaceable] attribute: a + * page that assigns its own value keeps it. */ Object.defineProperty(globalThis, name, { configurable: true, enumerable: true, - writable: true, - value: value + get: size, + set: function (value) { + Object.defineProperty(globalThis, name, { + configurable: true, + enumerable: true, + writable: true, + value: value + }); + } }); } catch (error) {} }; - assign('innerWidth', CHUZZ_VIEWPORT_WIDTH); - assign('innerHeight', CHUZZ_VIEWPORT_HEIGHT); - assign('outerWidth', CHUZZ_VIEWPORT_WIDTH); - assign('outerHeight', CHUZZ_VIEWPORT_HEIGHT); + publish('innerWidth', viewportWidth); + publish('innerHeight', viewportHeight); + publish('outerWidth', viewportWidth); + publish('outerHeight', viewportHeight); })(); /* * `location.origin`, and `location.host` with its port. @@ -1069,8 +1108,8 @@ pub(crate) const WEB_API_SHIM: &str = r#" var bound = parseFloat(dimension[3]); if (dimension[4] === 'em' || dimension[4] === 'rem') bound = bound * 16; var actual = dimension[2] === 'width' - ? CHUZZ_VIEWPORT_WIDTH - : CHUZZ_VIEWPORT_HEIGHT; + ? viewportWidth() + : viewportHeight(); matches = dimension[1] === 'min' ? actual >= bound : actual <= bound; } } @@ -1442,20 +1481,162 @@ pub(crate) const WEB_API_SHIM: &str = r#" }; } if (typeof globalThis.ResizeObserver === 'undefined') { - // A stub, and deliberately silent rather than firing once the way - // `IntersectionObserver` above does. The difference is what an invented - // entry would have to say: visibility has an answer that is right for most - // of a page ("yes"), and a size does not. Nothing here can measure a box - // from JavaScript, so the only entry this could deliver carries a zero - // `contentRect`, and a grid or carousel that divides by that width computes - // zero columns and renders nothing. Never firing leaves such a component on - // whatever it renders before it has measured, which is the better of the - // two wrong answers. Backing this with real box data is engine work. + /* + * A real observer over `getBoundingClientRect`, which returns laid-out + * boxes to script. + * + * This was a silent stub on the grounds that nothing could measure a box + * from JavaScript, so any entry would carry a zero size. That stopped + * being true, and the stub's silence became the bug: a component that + * measures, moves, and relies on the observer to settle (an overlay whose + * first measurement preceded its width limits) was left wherever its first + * frame put it. + * + * The engine has no resize notification to hook, so this measures. It does + * not measure forever: a page that observes something permanently would + * otherwise keep the frame loop busy, which is exactly the idle and drift + * that rendered QA fails a page for. Measuring is armed by anything that + * can change a size (an `observe`, a resize or scroll, pointer, keyboard + * and input events) and by a size change it finds, and it disarms after + * `QUIET_FRAMES` frames in which nothing changed. + * + * Entries follow the specification's shape. The content box is the border + * box less the padding and border `getComputedStyle` reports; an engine + * that does not report them (ps-blitz before its box-edge fix) reads as + * zero, and the content box falls back to the border box. `observe` + * honours `{ box: 'border-box' }`, and otherwise watches the content box + * as a browser does. Like a browser, the first observation reports any + * size other than 0x0, and a callback that throws is reported without + * stopping the others. + */ + var QUIET_FRAMES = 30; + var resizeObservers = []; + var measureFrames = 0; + var measureScheduled = false; + var nextFrame = typeof globalThis.requestAnimationFrame === 'function' + ? function (run) { globalThis.requestAnimationFrame(run); } + : function (run) { setTimeout(run, 16); }; + var measureBoxes = function (target) { + var border = { width: 0, height: 0 }; + try { + var rect = target.getBoundingClientRect(); + border = { width: Number(rect.width) || 0, height: Number(rect.height) || 0 }; + } catch (error) {} + var edge = { top: 0, right: 0, bottom: 0, left: 0 }; + try { + if (typeof globalThis.getComputedStyle === 'function') { + var style = globalThis.getComputedStyle(target); + var px = function (name) { return parseFloat(style && style[name]) || 0; }; + edge = { + top: px('paddingTop') + px('borderTopWidth'), + right: px('paddingRight') + px('borderRightWidth'), + bottom: px('paddingBottom') + px('borderBottomWidth'), + left: px('paddingLeft') + px('borderLeftWidth') + }; + } + } catch (error) {} + return { + border: border, + content: { + width: Math.max(0, border.width - edge.left - edge.right), + height: Math.max(0, border.height - edge.top - edge.bottom) + }, + left: edge.left, + top: edge.top + }; + }; + var resizeEntry = function (target, boxes) { + var content = boxes.content; + var contentBoxSize = [{ inlineSize: content.width, blockSize: content.height }]; + return { + target: target, + contentRect: { + x: boxes.left, y: boxes.top, top: boxes.top, left: boxes.left, + width: content.width, height: content.height, + right: boxes.left + content.width, bottom: boxes.top + content.height + }, + borderBoxSize: [{ inlineSize: boxes.border.width, blockSize: boxes.border.height }], + contentBoxSize: contentBoxSize, + devicePixelContentBoxSize: contentBoxSize + }; + }; + var measureObservations = function () { + measureScheduled = false; + var changed = false; + var observing = false; + for (var i = 0; i < resizeObservers.length; i++) { + var observer = resizeObservers[i]; + var entries = []; + for (var j = 0; j < observer.observations.length; j++) { + var observation = observer.observations[j]; + observing = true; + var boxes = measureBoxes(observation.target); + var size = observation.box === 'border-box' ? boxes.border : boxes.content; + if (size.width !== observation.width || size.height !== observation.height) { + observation.width = size.width; + observation.height = size.height; + entries.push(resizeEntry(observation.target, boxes)); + } + } + if (entries.length) { + changed = true; + try { + observer.callback.call(observer.instance, entries, observer.instance); + } catch (error) { + setTimeout(function () { throw error; }, 0); + } + } + } + if (!observing) { measureFrames = 0; return; } + measureFrames = changed ? QUIET_FRAMES : measureFrames - 1; + if (measureFrames > 0) scheduleMeasure(); + }; + var scheduleMeasure = function () { + if (measureScheduled) return; + measureScheduled = true; + nextFrame(measureObservations); + }; + var armMeasure = function () { + if (!resizeObservers.length) return; + measureFrames = QUIET_FRAMES; + scheduleMeasure(); + }; + ['resize', 'scroll', 'pointerdown', 'pointerup', 'click', 'keydown', 'input'] + .forEach(function (type) { + try { + globalThis.addEventListener(type, armMeasure, { capture: true, passive: true }); + } catch (error) {} + }); globalThis.ResizeObserver = function (callback) { - this.callback = callback; - this.observe = function () {}; - this.unobserve = function () {}; - this.disconnect = function () {}; + if (typeof callback !== 'function') { + throw new TypeError("Failed to construct 'ResizeObserver': callback is not a function"); + } + var record = { instance: this, callback: callback, observations: [] }; + this.observe = function (target, options) { + if (!target) return; + var box = options && options.box === 'border-box' ? 'border-box' : 'content-box'; + if (resizeObservers.indexOf(record) === -1) resizeObservers.push(record); + for (var i = 0; i < record.observations.length; i++) { + if (record.observations[i].target === target) { + record.observations[i].box = box; + return; + } + } + // 0x0 is the specification's initial "last reported" size, so the + // first measurement reports the target unless it has no box. + record.observations.push({ target: target, box: box, width: 0, height: 0 }); + armMeasure(); + }; + this.unobserve = function (target) { + record.observations = record.observations.filter(function (observation) { + return observation.target !== target; + }); + }; + this.disconnect = function () { + record.observations = []; + resizeObservers = resizeObservers.filter(function (other) { return other !== record; }); + }; + this.takeRecords = function () { return []; }; }; } if (typeof globalThis.Image === 'undefined') { diff --git a/scripts/qa-visible-app.sh b/scripts/qa-visible-app.sh index 49e6e6d..12e2e44 100755 --- a/scripts/qa-visible-app.sh +++ b/scripts/qa-visible-app.sh @@ -5,7 +5,7 @@ root=$(cd "$(dirname "$0")/.." && pwd) app=${1:-"$root/target/release/bundle/macos/Chuzz.app"} qa_bin=${PS_QA_BIN:-$(command -v ps-qa || true)} fixture_bin=${CHUZZ_QA_FIXTURE_BIN:-"$root/target/release/chuzz-qa-fixture"} -artifacts=${QA_ARTIFACT_DIR:-$(mktemp -d /private/tmp/chuzz-visible-XXXXXX)} +artifacts=${QA_ARTIFACT_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/chuzz-visible-XXXXXX")} qa_home="$artifacts/home" descriptor="" app_pid=""