mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
11f384d37f
## 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)
109 lines
4.2 KiB
TypeScript
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();
|
|
});
|
|
});
|