Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions docs/BROWSER-LIVE-QA.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ Each run captures public pages at 1440, 768, and 390-pixel widths (nine
screenshots). It checks actual navigation, HTTP 200, nonblank text, broken
images, uncaught JS exceptions, and horizontal overflow. Incomplete or undecodable
images fail closed rather than being counted as healthy. The isolated browser
first requests deferred lazy images and waits up to four seconds for load/error
before measurement; images still pending afterward are recorded as failures. Mobile lazy-image
loading has a second bounded 3-second settle phase for _pending-only_ images;
first traverses the current document in a fixed, bounded scroll (at most 24,000
pixels in 600-pixel increments), promoting native lazy images to eager as they
enter view; this also notices images inserted by late hydration. It returns to
the original page top and restores scroll behavior. The traversal is bounded
to 41 steps with a 60-millisecond delay per step. It then waits up to four
seconds for load/error before measurement. Images still pending afterward
remain failures. Mobile lazy-image loading has a second bounded 3-second
settle phase for _pending-only_ images;
hard-failed or undecodable images still fail immediately. Each sample now
reports safe aggregate `pendingImages` and `failedImages` counts (no URLs,
DOM content, cookies or image bytes). The initial and second windows are
Expand Down
49 changes: 47 additions & 2 deletions scripts/browser-public-qa.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,52 @@ export function assess(sample) {
const digest = (buf) => createHash("sha256").update(buf).digest("hex");
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Fixed browser-internal QA expression. Chromium may not request below-fold
// native lazy images simply because a test flips loading="eager" once.
// The mobile homepage is much taller than a desktop viewport, so explicitly
// traverse a bounded distance before settling. Never classify pending assets
// as PASS, navigate to arbitrary URLs, or inject browser-side user inputs.
export const IMAGE_SETTLE_EXPRESSION = String.raw`(async()=>{
const pause=ms=>new Promise(resolve=>setTimeout(resolve,ms));
const html=document.documentElement;
const previous=html.style.scrollBehavior;
const promote=()=>{
for(const image of document.images)if(image.loading==='lazy')image.loading='eager';
};
html.style.scrollBehavior='auto';
try{
const MAX_SCROLL_PX=24000;
const SCROLL_STEP_PX=600;
const MAX_SCROLL_STEPS=41;
for(let n=0;n<MAX_SCROLL_STEPS;n++){
const height=Math.min(MAX_SCROLL_PX,Math.max(
Number(html.scrollHeight)||0,Number(document.body?.scrollHeight)||0
));
const y=Math.min(height,n*SCROLL_STEP_PX);
window.scrollTo(0,y);
promote();
await pause(60);
if(y>=height)break;
}
promote();
const images=[...document.images];
await Promise.race([
Promise.all(images.map(image=>image.complete?Promise.resolve():
new Promise(resolve=>{
image.addEventListener('load',resolve,{once:true});
image.addEventListener('error',resolve,{once:true});
})
)),
pause(4000)
]);
await document.fonts.ready;
return true;
}finally{
window.scrollTo(0,0);
html.style.scrollBehavior=previous;
}
})()`;

// A load event can precede client hydration. Permit a tightly bounded retry
// only when text is initially blank; persistent blanks remain QA failures.
export async function measureAfterTextReady(
Expand Down Expand Up @@ -313,8 +359,7 @@ export async function publicBrowserQa(targetName, outputDir) {
{
awaitPromise: true,
returnByValue: true,
expression:
"(async()=>{const images=[...document.images];for(const image of images)if(image.loading==='lazy')image.loading='eager';await Promise.race([Promise.all(images.map(image=>image.complete?Promise.resolve():new Promise(resolve=>{image.addEventListener('load',resolve,{once:true});image.addEventListener('error',resolve,{once:true})}))),new Promise(resolve=>setTimeout(resolve,4000))]);await document.fonts.ready;return true})()",
expression: IMAGE_SETTLE_EXPRESSION,
},
sessionId,
);
Expand Down
116 changes: 116 additions & 0 deletions tests/browser-public-qa.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import vm from "node:vm";
import {
TARGETS,
VIEWPORTS,
Expand All @@ -12,6 +13,7 @@ import {
createChromePrivateEnvironment,
measureAfterTextReady,
measureAfterImagesReady,
IMAGE_SETTLE_EXPRESSION,
} from "../scripts/browser-public-qa.mjs";

test("browser test destinations and viewports are fixed", () => {
Expand Down Expand Up @@ -202,6 +204,120 @@ test("browser compatibility profile uses captured commands without widening exec
assert.match(src, /timeoutMs: 120000/);
});

test("mobile lazy-image traversal reaches below fold and restores scroll state", async () => {
const positions = [];
const image = {
loading: "lazy",
complete: false,
naturalWidth: 0,
listeners: {},
addEventListener(type, listener) {
this.listeners[type] = listener;
},
};
const second = {
loading: "lazy",
complete: false,
naturalWidth: 0,
listeners: {},
addEventListener(type, listener) {
this.listeners[type] = listener;
},
};
const html = { scrollHeight: 3200, style: { scrollBehavior: "smooth" } };
const document = {
documentElement: html,
body: { scrollHeight: 3200 },
fonts: { ready: Promise.resolve() },
images: [image],
};
const window = {
scrollTo(_x, y) {
positions.push(y);
if (y >= 600) {
image.complete = true;
image.naturalWidth = 240;
image.listeners.load?.();
}
if (y >= 1200 && !document.images.includes(second))
document.images.push(second);
if (y >= 1800) {
second.complete = true;
second.naturalWidth = 280;
second.listeners.load?.();
}
},
};
const done = await vm.runInNewContext(IMAGE_SETTLE_EXPRESSION, {
document,
window,
setTimeout: (callback) => {
callback();
return 0;
},
});
assert.equal(done, true);
assert.ok(positions.includes(600));
assert.ok(positions.includes(1800));
assert.equal(positions.at(-1), 0);
assert.equal(html.style.scrollBehavior, "smooth");
assert.equal(document.images.length, 2);
assert.ok(document.images.every((i) => i.complete && i.naturalWidth > 0));
assert.ok(document.images.every((i) => i.loading === "eager"));
});

test("lazy image traversal is capped and never turns unresolved images into PASS", async () => {
const positions = [];
const pending = {
loading: "lazy",
complete: false,
naturalWidth: 0,
addEventListener() {},
};
const html = { scrollHeight: 1000000000, style: { scrollBehavior: "auto" } };
const document = {
documentElement: html,
body: { scrollHeight: 1000000000 },
fonts: { ready: Promise.resolve() },
images: [pending],
};
const window = {
scrollTo(_x, y) {
positions.push(y);
},
};
assert.equal(
await vm.runInNewContext(IMAGE_SETTLE_EXPRESSION, {
document,
window,
setTimeout: (callback) => {
callback();
return 0;
},
}),
true,
);
assert.ok(positions.length <= 42);
assert.ok(positions.every((n) => n >= 0 && n <= 24000));
assert.equal(positions.at(-1), 0);
assert.equal(pending.complete, false);
const sample = {
actualUrl: "https://kmjtechno.com/",
expectedUrl: "https://kmjtechno.com/",
httpStatus: 200,
bodyLength: 600,
overflowPx: 0,
exceptions: 0,
brokenImages: 1,
pendingImages: 1,
failedImages: 0,
};
assert.deepEqual(assess(sample), ["PENDING_IMAGES_TIMEOUT"]);
const source = fs.readFileSync("scripts/browser-public-qa.mjs", "utf8");
assert.match(source, /expression: IMAGE_SETTLE_EXPRESSION/);
assert.doesNotMatch(source, /--no-sandbox|--remote-debugging-port/);
});

test("mobile lazy images settle within bounded retry without suppressing actual failures", async () => {
let now = 0;
let checks = 0;
Expand Down