test(desktop): reuse browser E2E for hidden screenshot regression

Exercise the running app and browser MCP tools within the existing browser journey. Remove the separate Electron fixtures, npm script, and CI step.
This commit is contained in:
Mohamed Boudra
2026-09-10 20:55:54 +02:00
parent 4d9c2900b3
commit 97c473797b
6 changed files with 136 additions and 204 deletions
-6
View File
@@ -211,12 +211,6 @@ jobs:
if: ${{ runner.os == 'Linux' }}
run: npm run test:e2e:renderer --workspace=@getpaseo/desktop
- name: Verify hidden browser throttling and screenshots
if: ${{ runner.os == 'Linux' }}
run: xvfb-run -a npm run test:e2e:browser-throttling --workspace=@getpaseo/desktop
env:
PASEO_BROWSER_THROTTLING_ARTIFACT_DIR: ${{ runner.temp }}/desktop-browser-e2e/throttling
- name: Run desktop browser E2E
if: ${{ runner.os == 'Linux' }}
run: npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop
+5 -7
View File
@@ -77,13 +77,11 @@ including fresh, settled, 75-second soak, multi-tab, viewport, and full-page che
PNG sizes may be device-pixel scaled; on a Retina display the 1280x800 logical viewport
is usually saved as 2560x1600.
Run `npm run test:e2e:browser-throttling --workspace=@getpaseo/desktop` to verify
that hiding the window stops guest animation, viewport screenshots contain fresh
pixels, and restoring or closing the browser still works. Pass `-- --full-page` to
also run the below-viewport pixel assertion: this reproduces the existing
[full-page repetition bug](https://github.com/getpaseo/paseo/issues/3196), which the
capture harness's frame-availability checks do not detect.
On Linux, run it under `xvfb-run -a` when no display is available.
The existing `npm run test:e2e:browser-tabs --workspace=@getpaseo/desktop` journey
verifies that a hidden window stops guest animation, captures fresh viewport pixels,
and resumes animation after restoring the window. Its artifacts include the screenshot
and animation measurements. Full-page content correctness remains separately tracked in
[the full-page repetition bug](https://github.com/getpaseo/paseo/issues/3196).
## Mechanism
+131 -3
View File
@@ -198,7 +198,14 @@ async function startTargetPage() {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`<!doctype html>
<html>
<head><title>Desktop browser target</title></head>
<head><title>Desktop browser target</title>
<style>body { min-height: 100vh; background: rgb(255, 0, 0); }</style>
<script>
window.frameCount = 0;
function tick() { window.frameCount++; requestAnimationFrame(tick); }
requestAnimationFrame(tick);
</script>
</head>
<body>
<button id="bridge-target" onclick="this.textContent = 'Clicked'">Bridge target</button>
<label for="typing-target">Typing target</label>
@@ -464,7 +471,122 @@ function recordViewportMismatch(failures, label, actual, expected) {
);
}
async function runRegression({ page, client, serverId, targetUrl, callerAgentId, artifactDir }) {
async function setWindowHidden(inspectorPort, hidden) {
const [target] = await (await fetch(`http://127.0.0.1:${inspectorPort}/json/list`)).json();
const socket = new WebSocket(target.webSocketDebuggerUrl);
try {
await new Promise((resolve, reject) => {
socket.addEventListener("error", reject, { once: true });
socket.addEventListener("message", ({ data }) => {
const response = JSON.parse(data);
if (response.id !== 1) return;
if (response.error || response.result?.exceptionDetails) reject(new Error(data));
else resolve();
});
socket.addEventListener(
"open",
() =>
socket.send(
JSON.stringify({
id: 1,
method: "Runtime.evaluate",
params: {
expression: `(() => { const win = process.mainModule.require('electron').BrowserWindow.getAllWindows().find(win => win.webContents.getURL().includes('localhost:')); win.${hidden ? "hide" : "show"}(); if (win.isVisible() !== ${!hidden}) throw new Error('Window visibility did not change'); })()`,
},
}),
),
{ once: true },
);
});
} finally {
socket.close();
}
}
async function verifyHiddenBrowserScreenshots({
page,
client,
browserId,
artifactDir,
inspectorPort,
}) {
const readFrames = async () => {
const result = await callBrowserTool(client, "browser_evaluate", {
browserId,
function: "() => window.frameCount",
});
return JSON.parse(result.resultJson);
};
const expectAnimation = async () => {
const start = await readFrames();
assert(typeof start === "number", "Animation fixture was not loaded");
const deadline = Date.now() + 5_000;
while (Date.now() < deadline) {
if ((await readFrames()) > start + 2) return;
await delay(50);
}
throw new Error("Browser did not animate in the restored window");
};
const measurements = [];
const expectIdle = async (label) => {
await delay(500); // Let Chromium consume the visibility change before sampling.
const start = await readFrames();
await delay(1_000); // A fixed sampling interval is the behavior under test.
const frames = (await readFrames()) - start;
measurements.push({ label, frames });
writeJson(path.join(artifactDir, "hidden-browser-frames.json"), measurements);
assert(frames === 0, `${label}: hidden browser produced ${frames} animation frames`);
};
try {
await expectAnimation();
await setWindowHidden(inspectorPort, true);
await expectIdle("before-capture");
await callBrowserTool(client, "browser_evaluate", {
browserId,
function: "() => { document.body.style.background = 'rgb(0,255,0)'; }",
});
const response = await client.callTool({ name: "browser_screenshot", args: { browserId } });
mcpPayload(response, "browser_screenshot");
const screenshot = response.content.find((item) => item.type === "image");
assert(screenshot, "browser_screenshot returned no image");
fs.writeFileSync(
path.join(artifactDir, "hidden-browser-viewport.png"),
Buffer.from(screenshot.data, "base64"),
);
const pixel = await page.evaluate(async (base64) => {
const image = new Image();
image.src = `data:image/png;base64,${base64}`;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext("2d");
context.drawImage(image, 0, 0);
return [
...context.getImageData(Math.floor(image.width / 2), Math.floor(image.height / 2), 1, 1)
.data,
];
}, screenshot.data);
assert(
JSON.stringify(pixel) === "[0,255,0,255]",
`Hidden screenshot returned stale pixels: ${pixel}`,
);
await expectIdle("after-capture");
} finally {
await setWindowHidden(inspectorPort, false);
}
await expectAnimation();
}
async function runRegression({
page,
client,
serverId,
targetUrl,
callerAgentId,
artifactDir,
inspectorPort,
}) {
const failures = [];
const originalWorkspaceId = workspaceIds[0];
const originalWorkspaceRow = page.getByTestId(
@@ -719,6 +841,8 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId,
const parkedGuest = await readGuest(page, browserId);
assert(parkedGuest, "Browser guest was not parked after workspace eviction");
await verifyHiddenBrowserScreenshots({ page, client, browserId, artifactDir, inspectorPort });
const listed = await callBrowserTool(client, "browser_list_tabs");
assert(
listed.tabs.some((tab) => tab.browserId === browserId),
@@ -886,6 +1010,7 @@ async function runRegression({ page, client, serverId, targetUrl, callerAgentId,
guestFocus: "passed",
overlayPlane: "passed",
inactiveCapture: "passed",
hiddenWindowCapture: "passed",
list: "passed",
snapshot: "passed",
click: "passed",
@@ -904,7 +1029,8 @@ async function main() {
const workspaceRoot = path.join(runtimeDir, "workspaces");
fs.mkdirSync(paseoHome, { recursive: true });
const [daemonPort, expoPort, cdpPort] = await Promise.all([
const [daemonPort, expoPort, cdpPort, inspectorPort] = await Promise.all([
reservePort(),
reservePort(),
reservePort(),
reservePort(),
@@ -942,6 +1068,7 @@ async function main() {
const desktopArgs = [
process.execPath,
devRunner,
`--inspect=127.0.0.1:${inspectorPort}`,
...(process.platform === "linux" ? ["--no-sandbox"] : []),
];
const desktopCommand = process.platform === "linux" ? "xvfb-run" : desktopArgs.shift();
@@ -996,6 +1123,7 @@ async function main() {
client,
serverId: status.serverId,
targetUrl: target.url,
inspectorPort,
callerAgentId,
artifactDir,
});
@@ -1,27 +0,0 @@
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const electron = require("electron");
// Chromium can recreate profile files during shutdown. The parent owns cleanup
// after Electron exits, including failed assertions and capture timeouts.
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-browser-throttling-profile-"));
try {
const result = spawnSync(
electron,
[
"--no-sandbox",
path.join(__dirname, "browser-throttling.electron.cjs"),
...process.argv.slice(2),
],
{
stdio: "inherit",
env: { ...process.env, PASEO_BROWSER_THROTTLING_PROFILE_DIR: profile },
},
);
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
} finally {
fs.rmSync(profile, { recursive: true, force: true });
}
@@ -1,160 +0,0 @@
// Run with the repository Electron after npm run build:main.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
const { app, BrowserWindow, nativeImage } = require("electron");
const { preparePaseoBrowserWebContents } = require("../dist/features/browser-webviews/index.js");
const { adaptWebContents } = require("../dist/features/browser-automation/ipc.js");
const { executeAutomationCommand } = require("../dist/features/browser-automation/service.js");
const output =
process.env.PASEO_BROWSER_THROTTLING_ARTIFACT_DIR ||
fs.mkdtempSync(path.join(os.tmpdir(), "paseo-browser-throttling-"));
fs.mkdirSync(output, { recursive: true });
const profile = process.env.PASEO_BROWSER_THROTTLING_PROFILE_DIR;
assert.ok(profile, "Run through test:e2e:browser-throttling so the launcher owns profile cleanup");
app.setPath("userData", profile);
if (process.platform === "darwin") app.setActivationPolicy("accessory");
const measurements = [];
// The existing full-page repetition bug (#3196) is independently reproducible.
// Keep its strict pixel assertion available without conflating it with throttling.
const captureModes = process.argv.includes("--full-page") ? [false, true] : [false];
function pageUrl(html) {
return `data:text/html,${encodeURIComponent(html)}`;
}
async function openParkedBrowser() {
const win = new BrowserWindow({
width: 900,
height: 600,
show: false,
skipTaskbar: true,
webPreferences: { webviewTag: true },
});
const attached = new Promise((resolve) =>
win.webContents.once("did-attach-webview", (_event, guest) => {
preparePaseoBrowserWebContents(guest);
resolve(guest);
}),
);
await win.loadURL(
pageUrl(
'<div id="parking" style="position:fixed;left:0;top:0;width:1px;height:1px;overflow:hidden;opacity:1;pointer-events:none"></div>',
),
);
const url = pageUrl(
`<style>html,body{margin:0;width:640px}section{height:480px}#top{background:rgb(255,0,0)}#bottom{background:rgb(0,0,255)}</style><section id="top">TOP</section><section id="bottom">BOTTOM</section><script>window.frameCount=0;function tick(){window.frameCount++;requestAnimationFrame(tick)}requestAnimationFrame(tick)</script>`,
);
await win.webContents.executeJavaScript(
`new Promise(resolve => { const view = document.createElement('webview'); view.addEventListener('dom-ready', () => resolve(), { once: true }); view.style.cssText='display:inline-flex;width:640px;height:480px'; view.src=${JSON.stringify(url)}; document.getElementById('parking').appendChild(view); })`,
);
const guest = await attached;
win.showInactive();
return { win, guest };
}
async function expectBrowserToAnimate(guest) {
const start = await guest.executeJavaScript("window.frameCount");
assert.equal(typeof start, "number", "Animation fixture must be loaded before sampling");
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
if ((await guest.executeJavaScript("window.frameCount")) > start + 2) return;
await delay(50);
}
assert.fail("Browser did not resume animation after restoring the window");
}
async function expectHiddenBrowserToIdle(win, guest, label) {
win.hide();
await delay(500); // Allow the compositor to consume the visibility change before sampling.
const start = await guest.executeJavaScript("window.frameCount");
await delay(1000); // Measure a fixed interval; this is the behavior under test.
const frames = (await guest.executeJavaScript("window.frameCount")) - start;
measurements.push({ label, frames, backgroundThrottling: guest.getBackgroundThrottling() });
fs.writeFileSync(path.join(output, "measurements.json"), JSON.stringify(measurements, null, 2));
assert.equal(frames, 0, `${label}: hidden browser continues animating`);
}
function browserRegistry(guest) {
const contents = adaptWebContents(guest);
return {
getTabContents: () => contents,
getBrowserWorkspaceId: () => "fixture",
listRegisteredBrowserIds: () => ["preview"],
listRegisteredBrowserIdsForWorkspace: () => ["preview"],
getWorkspaceActiveBrowserId: () => "preview",
};
}
function expectPixel(image, x, y, rgb) {
const { width } = image.getSize();
const offset = (y * width + x) * 4;
const bitmap = image.toBitmap();
assert.deepEqual(
[bitmap[offset + 2], bitmap[offset + 1], bitmap[offset]],
rgb,
`Incorrect page content at ${x},${y}`,
);
}
async function expectFreshScreenshots(guest, label) {
// Mutate while backgrounded: successful capture must show fresh pixels, not a cached frame.
await guest.executeJavaScript("document.getElementById('top').style.background='rgb(0,255,0)'");
for (const fullPage of captureModes) {
const response = await executeAutomationCommand(
{
requestId: label,
workspaceId: "fixture",
command: { command: "screenshot", args: { browserId: "preview", fullPage } },
},
browserRegistry(guest),
);
assert.equal(response.ok, true, JSON.stringify(response));
const image = nativeImage.createFromBuffer(Buffer.from(response.result.dataBase64, "base64"));
fs.writeFileSync(
path.join(output, `${label}-${fullPage ? "full-page" : "viewport"}.png`),
image.toPNG(),
);
assert.deepEqual(image.getSize(), { width: 640, height: fullPage ? 960 : 480 });
expectPixel(image, 320, 240, [0, 255, 0]);
if (fullPage) expectPixel(image, 320, 720, [0, 0, 255]);
}
await guest.executeJavaScript("document.getElementById('top').style.background='rgb(255,0,0)'");
}
async function verifyBrowserLifecycle() {
const { win, guest } = await openParkedBrowser();
try {
await expectBrowserToAnimate(guest);
await expectHiddenBrowserToIdle(win, guest, "before-capture");
await expectFreshScreenshots(guest, "hidden");
await expectHiddenBrowserToIdle(win, guest, "after-capture");
win.showInactive();
await expectBrowserToAnimate(guest);
await expectFreshScreenshots(guest, "parked");
const destroyed = new Promise((resolve) => guest.once("destroyed", resolve));
await win.webContents.executeJavaScript("document.querySelector('webview').remove()");
await destroyed;
console.log(
`PASS hidden browser idles; fresh screenshot pixels; restore and close. Artifacts: ${output}`,
);
} finally {
win.destroy();
}
}
setTimeout(() => {
console.error("Browser throttling regression timed out");
app.exit(1);
}, 30000);
app
.whenReady()
.then(verifyBrowserLifecycle)
.then(() => app.exit(0))
.catch((error) => {
console.error(error);
app.exit(1);
});
-1
View File
@@ -22,7 +22,6 @@
"dev:win": "powershell ./scripts/dev.ps1",
"test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop",
"test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs",
"test:e2e:browser-throttling": "npm run build:main && node e2e/browser-throttling.e2e.cjs",
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
"test": "vitest run --exclude \"e2e/**\"",
"typecheck": "tsgo --noEmit -p tsconfig.json"