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 + + + @@ -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( - `
TOP
BOTTOM
`, - ); - 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"