diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6c3f76d497..3452f38ec2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
diff --git a/docs/browser-capture-harness.md b/docs/browser-capture-harness.md
index 0a53e9a775..21f80eba39 100644
--- a/docs/browser-capture-harness.md
+++ b/docs/browser-capture-harness.md
@@ -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
diff --git a/packages/desktop/e2e/browser-tabs.e2e.mjs b/packages/desktop/e2e/browser-tabs.e2e.mjs
index f0c34060c9..20c9866bc0 100644
--- a/packages/desktop/e2e/browser-tabs.e2e.mjs
+++ b/packages/desktop/e2e/browser-tabs.e2e.mjs
@@ -198,7 +198,14 @@ async function startTargetPage() {
response.writeHead(200, { "content-type": "text/html; charset=utf-8" });
response.end(`
-
Desktop browser target
+ Desktop browser target
+
+
+
Bridge target
Typing target
@@ -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,
});
diff --git a/packages/desktop/e2e/browser-throttling.e2e.cjs b/packages/desktop/e2e/browser-throttling.e2e.cjs
deleted file mode 100644
index c04d33ff99..0000000000
--- a/packages/desktop/e2e/browser-throttling.e2e.cjs
+++ /dev/null
@@ -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 });
-}
diff --git a/packages/desktop/e2e/browser-throttling.electron.cjs b/packages/desktop/e2e/browser-throttling.electron.cjs
deleted file mode 100644
index bc71e68a7a..0000000000
--- a/packages/desktop/e2e/browser-throttling.electron.cjs
+++ /dev/null
@@ -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(
- '
',
- ),
- );
- const url = pageUrl(
- ``,
- );
- 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);
- });
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 64cd20bf50..06491865b4 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -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"