Files
software-mansion__argent/packages/tool-server/test/chromium-window-hidden-guard.test.ts
filip131311 11f384d37f fix(chromium): make Electron apps testable without window focus (#605)
## Problem

Field report: *"electron needs focus to be tested."* Analysis confirmed
three focus/visibility dependencies in the Chromium/Electron surface:

1. **Hidden window → throttled renderer.** A minimized/occluded window's
compositor throttles: mouse-input acks stall **~5 s per event** (they
wait on hit-testing), wheel scrolls hang (`gesture-scroll` already
refused), `visibilityState` flips to `hidden`.
2. **Unfocused window → focus-gated app logic breaks.**
`document.hasFocus()` is `false` before any input arrives;
focus/blur-dependent UI misbehaves.
3. Focus-gated web APIs (clipboard already worked around it locally with
`Page.bringToFront`).

## Fixes

**1. CDP focus emulation on every page session** (`primePageSession`, né
`enableCoreDomains`): `Emulation.setFocusEmulationEnabled(true)`,
best-effort, re-applied automatically after tab switches (Playwright
precedent). Live A/B on the Electron testbed: identical
boot→unfocus→probe sequence gives `hasFocus:false` on main, `true` on
this branch. Bonus verified by A/B (kill server → same minimized window
flips to `hidden`): in current Chromium/Electron, emulation also pins
reported visibility to `visible` and keeps input unthrottled while
genuinely minimized — minimized tap latency **~5 s → ~65 ms**, wheel
scroll works.
*Trade-off (same as Playwright ships):* while attached, the app can
observe neither real blur nor real hidden, so pause-on-hidden behavior
is unobservable during a test.

**2. Anti-throttling switches for every argent-booted Electron app**
(`--disable-background-timer-throttling
--disable-backgrounding-occluded-windows
--disable-renderer-backgrounding`). Sessions are created lazily and die
with the idle-exiting tool-server while the app outlives it — the flags
cover that detached lifecycle plus emulation-less runtimes. Also covers
flow-booted apps via the shared `bootElectronApp`.

**3. Hidden-window fail-fast extended from gesture-scroll to gesture-tap
+ gesture-drag** via shared `assertChromiumWindowVisible`, under a new
dedicated `CHROMIUM_WINDOW_HIDDEN` failure code (scroll migrated;
`failure_stage` strings unchanged so telemetry stays joinable —
analytics keyed on `CHROMIUM_INPUT_INVALID` +
`chromium_scroll_window_hidden` should switch to the new code). With
emulation active the guard is a backstop that fires only where emulation
couldn't be applied — exactly where the stall is real. Keyboard/button
stay unguarded on purpose: measured live, key events skip hit-testing
and stay fast on hidden windows.

## Verification

- Unit: 3075 tool-server + 87 registry tests green, `typecheck:tests`
green, new tests for emulation priming, boot args, and the guard (all
three tools).
- Live matrix on the Electron testbed (dev tool-server): focused
baseline; unfocused (`hasFocus` true pre-input, typing lands); minimized
booted app (tap 69 ms, scroll works, typing + slider drag land,
screenshot fine); minimized **external** un-flagged app on :9222 (tap 63
ms, scroll 460 ms — emulation alone suffices while attached).
- Plan + implementation went through three adversarial review rounds
(scope, CDP semantics, failure-code taxonomy/telemetry compat, doc
accuracy).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-31 15:36:36 +02:00

109 lines
4.2 KiB
TypeScript

import { describe, it, expect, vi } from "vitest";
import { FAILURE_CODES, FailureError, getFailureSignal } from "@argent/registry";
import { gestureTapTool } from "../src/tools/gesture-tap";
import { gestureDragTool } from "../src/tools/gesture-drag";
import { gestureScrollTool } from "../src/tools/gesture-scroll";
// The hidden-window guard: a minimized / fully occluded Chromium window
// throttles compositor hit-testing, so every MOUSE dispatch stalls ~5s
// (measured live on an Electron testbed). tap, drag and scroll refuse up
// front with an actionable error; keyboard/button skip the guard because key
// events bypass hit-testing and stay fast on hidden windows. In practice the
// guard is a backstop: primePageSession's focus emulation pins reported
// visibility to "visible" while a session is attached, so the probe reads
// "hidden" only on sessions where emulation could not be applied — exactly
// where the stall is real.
function fakeChromiumApi(visibility = "visible") {
return {
getViewport: () => ({ width: 800, height: 600, devicePixelRatio: 1 }),
dispatchMouseEvent: vi.fn().mockResolvedValue(undefined),
server: { sendWheel: vi.fn().mockResolvedValue(undefined) },
cdp: { send: vi.fn().mockResolvedValue({ result: { value: visibility } }) },
};
}
async function captureFailure(promise: Promise<unknown>): Promise<FailureError> {
try {
await promise;
} catch (err) {
expect(err).toBeInstanceOf(FailureError);
return err as FailureError;
}
throw new Error("expected the tool to refuse on a hidden window");
}
describe("hidden-window guard on chromium mouse tools", () => {
it("gesture-tap refuses before dispatching any mouse event", async () => {
const api = fakeChromiumApi("hidden");
const err = await captureFailure(
gestureTapTool.execute(
{ chromium: api } as never,
{
udid: "chromium-cdp-19222",
x: 0.5,
y: 0.5,
} as never
)
);
expect(err.message).toMatch(/hidden/);
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.CHROMIUM_WINDOW_HIDDEN);
expect(getFailureSignal(err)?.failure_stage).toBe("chromium_tap_window_hidden");
expect(api.dispatchMouseEvent).not.toHaveBeenCalled();
});
it("gesture-drag refuses before pressing the mouse button", async () => {
const api = fakeChromiumApi("hidden");
const err = await captureFailure(
gestureDragTool.execute(
{ chromium: api } as never,
{
udid: "chromium-cdp-19222",
fromX: 0.2,
fromY: 0.5,
toX: 0.8,
toY: 0.5,
} as never
)
);
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.CHROMIUM_WINDOW_HIDDEN);
expect(getFailureSignal(err)?.failure_stage).toBe("chromium_drag_window_hidden");
expect(api.dispatchMouseEvent).not.toHaveBeenCalled();
});
it("gesture-scroll reports the dedicated failure code (migrated off CHROMIUM_INPUT_INVALID)", async () => {
const api = fakeChromiumApi("hidden");
const err = await captureFailure(
gestureScrollTool.execute(
{ chromium: api } as never,
{
udid: "chromium-cdp-19222",
x: 0.5,
y: 0.5,
deltaY: 0.25,
} as never
)
);
expect(getFailureSignal(err)?.error_code).toBe(FAILURE_CODES.CHROMIUM_WINDOW_HIDDEN);
// The stage string is unchanged so old and new telemetry stay joinable.
expect(getFailureSignal(err)?.failure_stage).toBe("chromium_scroll_window_hidden");
});
it("proceeds when the visibility probe rejects (mid-navigation, bare test fakes)", async () => {
// A failed probe proves nothing about visibility; only an explicit
// "hidden" refuses. This also covers api objects with no `cdp` at all.
const api = fakeChromiumApi();
api.cdp.send = vi.fn().mockRejectedValue(new Error("Execution context was destroyed"));
const result = (await gestureTapTool.execute(
{ chromium: api } as never,
{
udid: "chromium-cdp-19222",
x: 0.5,
y: 0.5,
} as never
)) as { tapped: boolean };
expect(result.tapped).toBe(true);
expect(api.dispatchMouseEvent).toHaveBeenCalled();
});
});