From 1501d8008d9f149d616778f3d1f10528de1f1299 Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 25 Sep 2026 08:43:20 +0700 Subject: [PATCH 1/5] fix(shim): report the engine's live viewport size The engine owns innerWidth and innerHeight as live accessors over the document viewport, but a page's scripts run before the host sets that viewport, so at boot they read 0. The shim took the 0 for a missing value and replaced the accessors with a fixed 1440x960 for good. A 1344-wide headless page reported 1440 for its whole life, and anything clamped to the viewport was placed against a width the page does not have. The shim now captures the engine's getters and answers with them, falling back to 1440x960 only while they still read 0. screen and the dimension branch of matchMedia read the same live size. Assigning innerWidth keeps the assigned value, as a browser's replaceable attribute does. Measured in chuzz-headless on a 1344x900 page: innerWidth read 1440x960 after the viewport was set; it now reads 1344x900. --- apps/chuzz/src/document_loader.rs | 89 ++++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 25 deletions(-) diff --git a/apps/chuzz/src/document_loader.rs b/apps/chuzz/src/document_loader.rs index 2bedc27..34c23a3 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; } } From cc61ea7ae28ce8121fd0a64cef1ebf0232fbdfbe Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 25 Sep 2026 08:43:51 +0700 Subject: [PATCH 2/5] fix(shim): back ResizeObserver with measured boxes The stub never fired, on the grounds that nothing could measure a box from script so any entry would carry a zero size. getBoundingClientRect now returns laid-out boxes, and the silence had become the bug: a component that measures, moves and relies on the observer to settle was left where its first frame put it. @pathscale/ui's Select opened its list at the left edge of the window and kept it there. The observer measures border boxes on animation frames. The engine has no resize notification, so measuring is armed by observe(), resize and scroll, pointer, keyboard and input events, and by a size change it finds, and disarms after 30 frames with no change, so a page that observes something permanently still goes idle. Entries follow the specification's shape; contentRect and the box sizes describe the border box because computed style does not report padding to script. The first observation reports any size other than 0x0, and a throwing callback is reported without stopping the others. Measured in chuzz-headless: an observed table reported nothing before; it now reports 0 to 968 on observe and 968 to 600 when resized. The stock Select list moved from left 88, under a trigger at 512, to left 479, under its trigger at 479. --- apps/chuzz/src/document_loader.rs | 139 +++++++++++++++++++++++++++--- 1 file changed, 126 insertions(+), 13 deletions(-) diff --git a/apps/chuzz/src/document_loader.rs b/apps/chuzz/src/document_loader.rs index 34c23a3..ac69fc2 100644 --- a/apps/chuzz/src/document_loader.rs +++ b/apps/chuzz/src/document_loader.rs @@ -1481,20 +1481,133 @@ 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. `contentRect` and the box + * sizes all describe the border box, because the engine's computed style + * does not report padding or border widths to script; for the common case + * of a box without padding the two are the same. 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 borderBox = function (target) { + try { + var rect = target.getBoundingClientRect(); + return { width: Number(rect.width) || 0, height: Number(rect.height) || 0 }; + } catch (error) { + return { width: 0, height: 0 }; + } + }; + var resizeEntry = function (target, size) { + var boxSize = [{ inlineSize: size.width, blockSize: size.height }]; + return { + target: target, + contentRect: { + x: 0, y: 0, top: 0, left: 0, + width: size.width, height: size.height, + right: size.width, bottom: size.height + }, + borderBoxSize: boxSize, + contentBoxSize: boxSize, + devicePixelContentBoxSize: boxSize + }; + }; + 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 size = borderBox(observation.target); + if (size.width !== observation.width || size.height !== observation.height) { + observation.width = size.width; + observation.height = size.height; + entries.push(resizeEntry(observation.target, size)); + } + } + 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) { + if (!target) return; + if (resizeObservers.indexOf(record) === -1) resizeObservers.push(record); + for (var i = 0; i < record.observations.length; i++) { + if (record.observations[i].target === target) 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, 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') { From 0a1ff36884307522c1524da7ca05c21ac327ff20 Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 25 Sep 2026 09:30:00 +0700 Subject: [PATCH 3/5] fix(shim): report the content box from ResizeObserver The observer measured border boxes only, because computed style reported no padding or border to script. ps-blitz now reports them (pathscale/ps-blitz#102), so the content box is the border box less both, which is what an observer watches by default; { box: 'border-box' } is honoured. On an engine without the edges they read as zero and the content box falls back to the border box, which is the previous behaviour. Measured on a box with content 100x20, padding 10 and border 2: the published engine reports 124x44 (the fallback); ps-blitz#102 reports a content box of 100x20 at x 12 and a border box of 124x44. --- apps/chuzz/src/document_loader.rs | 77 +++++++++++++++++++++---------- 1 file changed, 53 insertions(+), 24 deletions(-) diff --git a/apps/chuzz/src/document_loader.rs b/apps/chuzz/src/document_loader.rs index ac69fc2..61e89aa 100644 --- a/apps/chuzz/src/document_loader.rs +++ b/apps/chuzz/src/document_loader.rs @@ -1500,12 +1500,14 @@ pub(crate) const WEB_API_SHIM: &str = r#" * 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. `contentRect` and the box - * sizes all describe the border box, because the engine's computed style - * does not report padding or border widths to script; for the common case - * of a box without padding the two are the same. Like a browser, the - * first observation reports any size other than 0x0, and a callback that - * throws is reported without stopping the others. + * 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 = []; @@ -1514,26 +1516,48 @@ pub(crate) const WEB_API_SHIM: &str = r#" var nextFrame = typeof globalThis.requestAnimationFrame === 'function' ? function (run) { globalThis.requestAnimationFrame(run); } : function (run) { setTimeout(run, 16); }; - var borderBox = function (target) { + var measureBoxes = function (target) { + var border = { width: 0, height: 0 }; try { var rect = target.getBoundingClientRect(); - return { width: Number(rect.width) || 0, height: Number(rect.height) || 0 }; - } catch (error) { - return { width: 0, height: 0 }; - } + 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, size) { - var boxSize = [{ inlineSize: size.width, blockSize: size.height }]; + var resizeEntry = function (target, boxes) { + var content = boxes.content; + var contentBoxSize = [{ inlineSize: content.width, blockSize: content.height }]; return { target: target, contentRect: { - x: 0, y: 0, top: 0, left: 0, - width: size.width, height: size.height, - right: size.width, bottom: size.height + 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: boxSize, - contentBoxSize: boxSize, - devicePixelContentBoxSize: boxSize + borderBoxSize: [{ inlineSize: boxes.border.width, blockSize: boxes.border.height }], + contentBoxSize: contentBoxSize, + devicePixelContentBoxSize: contentBoxSize }; }; var measureObservations = function () { @@ -1546,11 +1570,12 @@ pub(crate) const WEB_API_SHIM: &str = r#" for (var j = 0; j < observer.observations.length; j++) { var observation = observer.observations[j]; observing = true; - var size = borderBox(observation.target); + 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, size)); + entries.push(resizeEntry(observation.target, boxes)); } } if (entries.length) { @@ -1587,15 +1612,19 @@ pub(crate) const WEB_API_SHIM: &str = r#" throw new TypeError("Failed to construct 'ResizeObserver': callback is not a function"); } var record = { instance: this, callback: callback, observations: [] }; - this.observe = function (target) { + 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) return; + 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, width: 0, height: 0 }); + record.observations.push({ target: target, box: box, width: 0, height: 0 }); armMeasure(); }; this.unobserve = function (target) { From e221bc1d3df6187a01903a5a77a9130eedae6ff3 Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 25 Sep 2026 13:42:16 +0700 Subject: [PATCH 4/5] release: 0.1.40 Ships the live viewport size and a ResizeObserver that fires, so consumers of the headless host (every fleet site's QA) get them. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From 883d8a381e6368ab908b3a52e0e1dfa21d7daa4f Mon Sep 17 00:00:00 2001 From: meh Date: Fri, 25 Sep 2026 13:59:07 +0700 Subject: [PATCH 5/5] fix(qa): make the visible-app artifacts under TMPDIR, not /private/tmp /private/tmp exists only on macOS, and naming it pinned the script to one kind of machine. The default artifact directory now goes under TMPDIR, falling back to /tmp, which is where the rest of the script already looks. --- scripts/qa-visible-app.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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=""