mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
96ea94f87a
> Stacked on #574. Replaces the route-fingerprint approach that #727 and #729 carried; both are closed. > > Rebased off the #715-#726 chain so it can merge on #574 alone. That chain owns `DirectiveOutcome.warning` / `StepReport.warning` and the `indeterminate` scoring rule; the small amount of each that this condition needs is carried here, scoped to `idle`, and will conflict textually with #724/#725 for whichever lands second. ## One check a selector cannot express ```yaml - await: { idle: true } ``` Has the screen stopped moving? Flows were substituting fixed `wait:` steps for it, which either wait too long on every run or too little on the run that mattered. ## Why it reads pixels as well as the tree Each signal is blind to what the other sees. The tree cannot see presentation-layer motion. An iOS push or modal dismissal commits its hierarchy up front and then animates a layer for a few hundred milliseconds; a cross-fade or a scrim moves no node at all. Measured on a real Android emulator: a page animating a large element continuously reads as a single static `WebView` node, and the existing `await-screen-idle` tool called it settled in **465 ms, five times out of five**. On a deterministic Chromium page whose DOM is byte-identical across the animation, that tool returned `settled: true` in **407 ms** while this condition failed 8/8. Pixels alone would not do either: they cannot see a tree still churning behind an unchanged surface, and anything animated forever (a video, a shimmer) would make a pixel-only settle unsatisfiable on a screen the tree calls ready. Unlike that tool, this cannot be silently stepped over. Readiness is not an acceptance criterion — the verdict belongs to the identity and outcome checks around it — so a screen that never settles still **passes**, carrying a `warning` that says what the green actually bought. That is what makes it safe to persist in a flow: it reports on every run without ever failing one over a video or a shimmer. The only thing that stops a run is a tree source that cannot be read. ## The rule the design follows The absence of evidence is never evidence. - A capture that did not arrive cannot complete the hold. - Neither can a single agreeing pair. Every animation that reverses has a turning point, and two samples straddling it come back identical while the screen is moving. On a live 3 s cross-fade that passed a default-shaped step on roughly **one run in three**; settling now takes two consecutive still intervals, so `minStableMs: 0` still means three reads. - A round is never started without the budget to observe it with. A round begun with nothing left neither captures nor reads, and both absences were otherwise recorded as facts about the device. - No verdict comes from a latch. A screen that settles and then moves again, or goes blank, has not settled. ## `timeout:` is a real bound No describe path takes an abort signal, so the tree read is raced against what is left of the budget. Without that, a wedged ViewInspector RPC ran 2.25 s past an 8000 ms budget, and a busy Android screen ran 5.4-6.0 s against a 3000 ms one. A read that runs out of step budget is the step ending, not the source failing, and only the latter is reported as an environment problem. ## Failure modes stay apart They call for opposite responses. A screen that never stopped moving is a verdict about the app. A tree that could not be read is not, and it names the foreground check first, because a backgrounded app reads identically to an uninstrumented one and relaunching is the wrong repair. A run whose captures never produced a comparable pair still settles on the tree and says so in a warning, rather than passing off half a proof as the whole one. ## Capture routing and tolerance Captures route exactly as the `screenshot` tool routes them, so tvOS and Vega, which have no simulator-server backend but are perfectly screenshottable through `xcrun` and the emulator console, are covered rather than written off. The pixel comparison owns its tolerance rather than borrowing `screenshot-diff`'s. That one holds a baseline stored across sessions, machines and OS versions against a live capture and must absorb real drift; this one holds two captures one poll apart from a single session, where a static screen reads back byte-identical (measured: zero changed pixels across five consecutive pairs on an idle simulator). The margin is load-bearing, because uniform change is all-or-nothing: at the baseline tolerance any cross-fade slower than ~2 s counted zero changed pixels and read as settled mid-animation. ## Not a screen check A dropped tap leaves the source screen perfectly idle. It belongs after the element check that names the destination, never instead of one. #730 documents the pairing. ## Verification - 3307 tests pass; typecheck, `typecheck:tests`, lint and prettier clean. Eleven mutations applied to the source, all eleven caught by tests. - Driven end to end against a real iOS simulator, a real Android emulator, and a Chromium app through a tool-server built from this branch: static screens pass with no warning (proving captures ran), animating screens pass carrying the warning that names the motion, and the pre-existing tool disagrees exactly where it should. --------- Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
828 lines
36 KiB
TypeScript
828 lines
36 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { PNG } from "pngjs";
|
|
import type { DeviceInfo } from "@argent/registry";
|
|
import type { ActionEnv } from "../../src/tools/flows/flow-actions";
|
|
import {
|
|
capturePixelsWithin,
|
|
FIRST_PIXEL_CAPTURE_TIMEOUT_MS,
|
|
MOTION_FRACTION,
|
|
PIXEL_CAPTURE_TIMEOUT_MS,
|
|
PIXEL_THRESHOLD,
|
|
comparePixels,
|
|
pixelCaptureTimeoutMs,
|
|
statusBarMaskFraction,
|
|
type PixelFrame,
|
|
} from "../../src/tools/flows/flow-pixels";
|
|
import { isAndroidTv } from "../../src/utils/adb";
|
|
import { isTvOsSimulator } from "../../src/utils/ios-devices";
|
|
import { captureVegaScreenshotPng } from "../../src/utils/vega-screen";
|
|
import { tvScreenshot } from "../../src/tools/screenshot";
|
|
import { FIRST_FRAME_WAIT_MS } from "../../src/utils/simulator-client";
|
|
|
|
// The capture backends shell out to xcrun / adb / a live simulator-server, so
|
|
// stub the four routes and assert which one a device is sent down.
|
|
vi.mock("../../src/utils/ios-devices", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../../src/utils/ios-devices")>()),
|
|
isTvOsSimulator: vi.fn(async () => false),
|
|
}));
|
|
vi.mock("../../src/utils/vega-screen", () => ({
|
|
captureVegaScreenshotPng: vi.fn(),
|
|
}));
|
|
// Android TV is tagged `android` like a phone, so the mask asks adb which one
|
|
// this serial is. A real probe would shell out per case.
|
|
vi.mock("../../src/utils/adb", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../../src/utils/adb")>()),
|
|
isAndroidTv: vi.fn(async () => false),
|
|
}));
|
|
vi.mock("../../src/tools/screenshot", () => ({ tvScreenshot: vi.fn() }));
|
|
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-pixels-"));
|
|
vi.mocked(isTvOsSimulator).mockReset().mockResolvedValue(false);
|
|
vi.mocked(isAndroidTv).mockReset().mockResolvedValue(false);
|
|
vi.mocked(captureVegaScreenshotPng).mockReset();
|
|
vi.mocked(tvScreenshot).mockReset();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
/** A solid-color RGBA frame — the unit under test only compares RGB. */
|
|
function solid(width: number, height: number, [r, g, b]: [number, number, number]): PixelFrame {
|
|
const data = Buffer.alloc(width * height * 4);
|
|
for (let i = 0; i < width * height; i++) {
|
|
data[i * 4] = r;
|
|
data[i * 4 + 1] = g;
|
|
data[i * 4 + 2] = b;
|
|
data[i * 4 + 3] = 255;
|
|
}
|
|
return { width, height, data };
|
|
}
|
|
|
|
/** Flip `count` pixels of `base` to `color`, in place, returning it. */
|
|
function withChangedPixels(base: PixelFrame, count: number, color: number): PixelFrame {
|
|
for (let i = 0; i < count; i++) {
|
|
base.data[i * 4] = color;
|
|
base.data[i * 4 + 1] = color;
|
|
base.data[i * 4 + 2] = color;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
/** Rewrite every pixel's alpha in place, returning the frame. */
|
|
function withAlpha(base: PixelFrame, alpha: number): PixelFrame {
|
|
for (let i = 0; i < base.width * base.height; i++) {
|
|
base.data[i * 4 + 3] = alpha;
|
|
}
|
|
return base;
|
|
}
|
|
|
|
describe("comparePixels", () => {
|
|
it("reports no motion for two identical frames", () => {
|
|
expect(comparePixels(solid(30, 30, [10, 20, 30]), solid(30, 30, [10, 20, 30]))).toBe("still");
|
|
});
|
|
|
|
it("reports motion when the whole frame changes", () => {
|
|
expect(comparePixels(solid(30, 30, [0, 0, 0]), solid(30, 30, [255, 255, 255]))).toBe("moving");
|
|
});
|
|
|
|
it.each<[string, [number, number, number]]>([
|
|
["red", [255, 0, 0]],
|
|
["green", [0, 255, 0]],
|
|
["blue", [0, 0, 255]],
|
|
])("registers a full-frame change confined to the %s channel as motion", (_channel, color) => {
|
|
// Motion that lives in a single channel must clear the per-pixel gate on
|
|
// that channel's term alone — the other two contribute zero, so dropping
|
|
// any one term from the distance goes blind to exactly one of these.
|
|
expect(comparePixels(solid(30, 30, [0, 0, 0]), solid(30, 30, color))).toBe("moving");
|
|
});
|
|
|
|
it("ignores a change confined to the alpha channel (a screen capture is opaque)", () => {
|
|
// Identical RGB, alpha 255 → 0 on every pixel: the docstring promises alpha
|
|
// is ignored, so this must read as still. This also pins the byte offsets —
|
|
// a comparator that read o+3 (alpha) where it meant o+2 (blue) would count
|
|
// every pixel here as changed.
|
|
expect(
|
|
comparePixels(solid(30, 30, [10, 20, 30]), withAlpha(solid(30, 30, [10, 20, 30]), 0))
|
|
).toBe("still");
|
|
});
|
|
|
|
it("treats a dimension change as motion (a resized window)", () => {
|
|
expect(comparePixels(solid(30, 30, [0, 0, 0]), solid(30, 31, [0, 0, 0]))).toBe("moving");
|
|
});
|
|
|
|
it("reads a frame with no pixels to compare as still, never as motion", () => {
|
|
// Same dimensions, so the branch above does not catch it, and there is
|
|
// nothing to count — a decoder that handed back an empty frame must not
|
|
// manufacture a verdict either way. Also the shape a full-height mask
|
|
// would take.
|
|
expect(comparePixels(solid(0, 0, [0, 0, 0]), solid(0, 0, [0, 0, 0]))).toBe("still");
|
|
expect(comparePixels(solid(30, 30, [0, 0, 0]), solid(30, 30, [255, 255, 255]), 1)).toBe(
|
|
"still"
|
|
);
|
|
});
|
|
|
|
it("compares only the bytes both frames actually carry", () => {
|
|
// Same declared dimensions but a truncated buffer — a partially decoded
|
|
// capture. Reading past the shorter one would compare against undefined
|
|
// and count NaN distances, so the loop stops at the shared length.
|
|
const short = solid(200, 200, [0, 0, 0]);
|
|
short.data = short.data.subarray(0, 40); // ten pixels' worth
|
|
expect(comparePixels(solid(200, 200, [0, 0, 0]), short)).toBe("still");
|
|
// Only those ten differ, and ten of 40k is localized — not the whole
|
|
// frame a run past the buffer's end would report.
|
|
expect(comparePixels(solid(200, 200, [255, 255, 255]), short)).toBe("localized");
|
|
});
|
|
|
|
it("ignores a sub-threshold per-pixel color drift (encoder / resample noise)", () => {
|
|
// +5 on every channel is well under the per-pixel tolerance, so no pixel
|
|
// counts as changed — two captures of a static screen must read as still.
|
|
expect(comparePixels(solid(30, 30, [100, 100, 100]), solid(30, 30, [105, 105, 105]))).toBe(
|
|
"still"
|
|
);
|
|
});
|
|
|
|
it("brackets the per-pixel tolerance from both sides", () => {
|
|
// The tolerance is 0.03 x ~441.7 ~= 13.25. A uniform +7 per channel is a
|
|
// distance of ~12.1 (just under) and +8 is ~13.9 (just over), so this pair
|
|
// pins the constant tightly: loosening it trips the second expectation and
|
|
// tightening it trips the first.
|
|
expect(comparePixels(solid(30, 30, [100, 100, 100]), solid(30, 30, [107, 107, 107]))).toBe(
|
|
"still"
|
|
);
|
|
expect(comparePixels(solid(30, 30, [100, 100, 100]), solid(30, 30, [108, 108, 108]))).toBe(
|
|
"moving"
|
|
);
|
|
});
|
|
|
|
it("registers two consecutive samples of a slow uniform cross-fade as motion", () => {
|
|
// The case this tolerance sits at its current value for. A spatially
|
|
// uniform fade moves every pixel by the same amount, so it clears the
|
|
// per-pixel gate on all pixels or on none — the motion fraction is never
|
|
// the deciding term. These are two samples one poll apart of a 2s
|
|
// indigo-over-white dismissal, a per-channel delta of (16, 23, 12) —
|
|
// distance ~30.5. Above the current ~13.25 gate, but BELOW the ~44.2 that
|
|
// screenshot-diff's baseline-sized 0.1 imposes, where the settle would
|
|
// count zero pixels and report stillness while the overlay was still
|
|
// painted and still hit-testing.
|
|
expect(comparePixels(solid(30, 30, [165, 128, 193]), solid(30, 30, [149, 105, 181]))).toBe(
|
|
"moving"
|
|
);
|
|
});
|
|
|
|
it("keeps its tolerance pinned, and stricter than a stored-baseline one", () => {
|
|
// Pin the value so the gate — the whole motion oracle — cannot drift
|
|
// silently, and so "restore parity with screenshot-diff" (0.1) is a
|
|
// deliberate act that trips a test rather than a quiet 3.3x widening of
|
|
// the cross-fade blind spot the case above measures.
|
|
expect(PIXEL_THRESHOLD).toBe(0.03);
|
|
});
|
|
|
|
it("ignores a handful of changed pixels below the motion threshold", () => {
|
|
// 900 px, so the fraction's budget is 1.8 pixels and the absolute floor is
|
|
// what decides: on a frame this small, the screen moving means a large
|
|
// share of it moved. A stray pixel or three is noise at any frame size —
|
|
// taking the fraction alone here made three of them the screen in motion.
|
|
const base = solid(30, 30, [0, 0, 0]);
|
|
expect(comparePixels(base, withChangedPixels(solid(30, 30, [0, 0, 0]), 1, 255))).toBe("still");
|
|
expect(comparePixels(base, withChangedPixels(solid(30, 30, [0, 0, 0]), 3, 255))).toBe("still");
|
|
// A fifth of the frame is not noise.
|
|
expect(comparePixels(base, withChangedPixels(solid(30, 30, [0, 0, 0]), 180, 255))).toBe(
|
|
"moving"
|
|
);
|
|
});
|
|
|
|
// Every case above runs on a 30x30 frame, where the fraction's budget is 1.8
|
|
// pixels, the absolute floor decides instead, and any change that covers the
|
|
// frame trips it. A real capture at CAPTURE_SCALE is
|
|
// 158k-198k pixels, and at that size the small-but-permanent movers a
|
|
// readiness check exists to notice — a spinner above all — sit two orders of
|
|
// magnitude below the same fraction. Measured on real captures taken at the
|
|
// scale the check uses: a stock spinner moved 66 pixels of an iPhone 16 Pro
|
|
// frame (302x656) and 57 of a Pixel 5 one (270x585).
|
|
describe("at a real capture size", () => {
|
|
const IPHONE = [302, 656] as const; // 198k px: 396 px of motion budget
|
|
const PIXEL5 = [270, 585] as const; // 158k px: 316 px of motion budget
|
|
|
|
function changed([w, h]: readonly [number, number], count: number): [PixelFrame, PixelFrame] {
|
|
return [
|
|
solid(w, h, [255, 255, 255]),
|
|
withChangedPixels(solid(w, h, [255, 255, 255]), count, 0),
|
|
];
|
|
}
|
|
|
|
it.each([
|
|
["iPhone 16 Pro", IPHONE, 66],
|
|
["Pixel 5", PIXEL5, 57],
|
|
] as const)("sees a spinner on a %s frame", (_device, size, spinnerPixels) => {
|
|
// Under the motion fraction, so the screen is not called unsettled — but
|
|
// never "still", which is what let a still-loading screen report ready
|
|
// with nothing said about it.
|
|
expect(comparePixels(...changed(size, spinnerPixels))).toBe("localized");
|
|
});
|
|
|
|
it("still calls a real transition motion at that size", () => {
|
|
// 1% of the frame — a sheet edge, a scrolling row, a moving cursor bar.
|
|
expect(comparePixels(...changed(IPHONE, Math.round(IPHONE[0] * IPHONE[1] * 0.01)))).toBe(
|
|
"moving"
|
|
);
|
|
});
|
|
|
|
it("keeps a few stray pixels below even the localized floor", () => {
|
|
// The floor exists so a backend that is not bit-exact between two
|
|
// captures of a static screen does not warn on every settle. Three
|
|
// pixels of 198k is an order of magnitude under a caret.
|
|
expect(comparePixels(...changed(IPHONE, 3))).toBe("still");
|
|
});
|
|
|
|
// The floor is a pixel COUNT, not a share of the frame: a spinner and a
|
|
// caret are the same handful of captured pixels whatever window they sit
|
|
// in, while the fraction it replaced was derived from phone-sized frames
|
|
// and drifted up toward them as the window grew.
|
|
it("still sees a caret on a desktop-sized window", () => {
|
|
const DESKTOP = [1200, 767] as const; // 920k px, where the old floor was ~46
|
|
expect(comparePixels(...changed(DESKTOP, 10))).toBe("localized");
|
|
expect(comparePixels(...changed(DESKTOP, 45))).toBe("localized");
|
|
// And the floor still holds at that size: noise stays noise.
|
|
expect(comparePixels(...changed(DESKTOP, 9))).toBe("still");
|
|
});
|
|
|
|
// The other end of the same requirement. The ceiling scales with the frame
|
|
// and the floor does not, so on a small capture the two used to meet and
|
|
// the band closed — and a closed band is not a lost warning but the
|
|
// opposite verdict: `moving` resets the hold every round, runs the step to
|
|
// its deadline and reports a carousel where a spinner was.
|
|
describe("on a frame small enough for the fraction to reach the floor", () => {
|
|
// A 400x300 CSS Chromium window at dpr 1: 7500 captured pixels, where
|
|
// 0.2% is 15 — under every indicator ever measured for the floor.
|
|
const SMALL = [100, 75] as const;
|
|
|
|
it.each([50, 57, 66])("still calls a %s-pixel indicator localized", (indicator) => {
|
|
expect(comparePixels(...changed(SMALL, indicator))).toBe("localized");
|
|
});
|
|
|
|
it("gives the same indicator the same verdict at every window size", () => {
|
|
const sizes = [SMALL, [125, 100], [175, 130], [300, 200]] as const;
|
|
for (const size of sizes) {
|
|
expect(comparePixels(...changed(size, 57)), `${size[0]}x${size[1]}`).toBe("localized");
|
|
}
|
|
});
|
|
|
|
it("still calls a real transition motion at that size", () => {
|
|
// 5% of a 7500-pixel frame — far more than the floor, and the least a
|
|
// transition on a window that small moves.
|
|
expect(comparePixels(...changed(SMALL, Math.round(SMALL[0] * SMALL[1] * 0.05)))).toBe(
|
|
"moving"
|
|
);
|
|
});
|
|
|
|
it("keeps noise below the localized floor there too", () => {
|
|
expect(comparePixels(...changed(SMALL, 9))).toBe("still");
|
|
});
|
|
|
|
it("never lets the floor grow past the frame it is applied to", () => {
|
|
// The floor must not close off a verdict the way the fraction did. On a
|
|
// 100-pixel frame an uncapped 100-pixel floor is unreachable: every
|
|
// pixel could change and the screen would still not be moving.
|
|
const TINY = [10, 10] as const;
|
|
expect(comparePixels(...changed(TINY, TINY[0] * TINY[1]))).toBe("moving");
|
|
});
|
|
});
|
|
|
|
// The two frames below are the ones the runner was actually caught
|
|
// comparing on a static iPhone 16 Pro screen: the run-level status-bar pin
|
|
// lands a few hundred milliseconds AFTER the run starts, so frame A holds
|
|
// the real clock and frame B the pinned one. Both changes sit inside the
|
|
// top band, which is why masking it is what fixes them.
|
|
describe("with the status bar masked", () => {
|
|
const MASK = 0.06;
|
|
|
|
/** Change `count` pixels confined to rows [top, bottom] of an iPhone frame. */
|
|
function changedInRows(count: number, top: number): [PixelFrame, PixelFrame] {
|
|
const before = solid(IPHONE[0], IPHONE[1], [255, 255, 255]);
|
|
const after = solid(IPHONE[0], IPHONE[1], [255, 255, 255]);
|
|
for (let i = 0; i < count; i++) {
|
|
const o = (top * IPHONE[0] + i) * 4;
|
|
after.data[o] = 0;
|
|
after.data[o + 1] = 0;
|
|
after.data[o + 2] = 0;
|
|
}
|
|
return [before, after];
|
|
}
|
|
|
|
it("stops the pin's own clock repaint from reading as a moving screen", () => {
|
|
// 408 changed pixels at y[19..29] — over the 396-pixel motion budget,
|
|
// so unmasked this static screen was judged to be in motion.
|
|
const frames = changedInRows(408, 19);
|
|
expect(comparePixels(...frames)).toBe("moving");
|
|
expect(comparePixels(...frames, MASK)).toBe("still");
|
|
});
|
|
|
|
it("stops the pin's battery-fill tail from reading as a spinner", () => {
|
|
// The same repaint a moment later: 13 pixels at y[19..25], which is
|
|
// over the localized floor and became "a spinner, a caret, a progress
|
|
// dot ... the screen had not finished loading" on a loaded screen.
|
|
const frames = changedInRows(13, 19);
|
|
expect(comparePixels(...frames)).toBe("localized");
|
|
expect(comparePixels(...frames, MASK)).toBe("still");
|
|
});
|
|
|
|
it("still sees a spinner just below the masked band", () => {
|
|
// The mask must cost the check only the system's own band. 39 rows of
|
|
// 656 are masked, so a spinner at row 40 is still fully visible.
|
|
expect(comparePixels(...changedInRows(66, 40), MASK)).toBe("localized");
|
|
});
|
|
|
|
it("still sees a transition below the masked band", () => {
|
|
const frames = changedInRows(0, 0);
|
|
for (let i = 0; i < Math.round(IPHONE[0] * IPHONE[1] * 0.01); i++) {
|
|
const o = (39 * IPHONE[0] + i) * 4;
|
|
frames[1].data[o] = 0;
|
|
frames[1].data[o + 1] = 0;
|
|
frames[1].data[o + 2] = 0;
|
|
}
|
|
expect(comparePixels(...frames, MASK)).toBe("moving");
|
|
});
|
|
|
|
it("takes its fractions against the unmasked area, not the whole frame", () => {
|
|
// The count has to separate the two denominators, or the case passes
|
|
// under either and pins nothing: 0.2% of the 186334 visible pixels is
|
|
// 373, of the whole 198112-pixel frame 397, so only a count between
|
|
// them reads as motion against the visible area and as less than
|
|
// motion against the frame. 466 — 0.25% of either — cleared both.
|
|
const visible = IPHONE[0] * (IPHONE[1] - 39);
|
|
const whole = IPHONE[0] * IPHONE[1];
|
|
const between = 380;
|
|
expect(between).toBeGreaterThan(visible * MOTION_FRACTION);
|
|
expect(between).toBeLessThanOrEqual(whole * MOTION_FRACTION);
|
|
expect(comparePixels(...changedInRows(between, 39), MASK)).toBe("moving");
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("statusBarMaskFraction", () => {
|
|
// Only a phone or tablet paints a status bar into the capture. Masking a
|
|
// Chromium window's top band would hide page content, and Vega, tvOS and
|
|
// Android TV render full-screen with no system chrome at all.
|
|
it("masks the band on Android", async () => {
|
|
await expect(
|
|
statusBarMaskFraction({ platform: "android", kind: "emulator", id: "emulator-5554" })
|
|
).resolves.toBe(0.06);
|
|
});
|
|
|
|
it("masks nothing on an Android TV, which shares the android platform tag", async () => {
|
|
// Leanback renders full-screen, so the 6% band is app content there — the
|
|
// same reason Vega and tvOS are excluded. Only the runtime probe can tell
|
|
// it from a phone.
|
|
vi.mocked(isAndroidTv).mockResolvedValue(true);
|
|
await expect(
|
|
statusBarMaskFraction({ platform: "android", kind: "emulator", id: "emulator-5554" })
|
|
).resolves.toBe(0);
|
|
});
|
|
|
|
it("masks the band on an iOS simulator", async () => {
|
|
vi.mocked(isTvOsSimulator).mockResolvedValue(false);
|
|
await expect(
|
|
statusBarMaskFraction({ platform: "ios", kind: "simulator", id: "ios-udid" })
|
|
).resolves.toBe(0.06);
|
|
});
|
|
|
|
it("masks the band on a remote iOS simulator too", async () => {
|
|
// sim-remote drives an ordinary iOS simulator, so its status bar ticks
|
|
// like a local one — and the run-level `pinStatusBar` does not cover the
|
|
// platform either. The tvOS probe reads the local simulator list, which
|
|
// cannot see another machine's device, so it is not asked.
|
|
await expect(
|
|
statusBarMaskFraction({ platform: "ios-remote", kind: "simulator", id: "remote:ios-udid" })
|
|
).resolves.toBe(0.06);
|
|
expect(isTvOsSimulator).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("masks nothing on a tvOS simulator, which shares the iOS platform tag", async () => {
|
|
vi.mocked(isTvOsSimulator).mockResolvedValue(true);
|
|
await expect(
|
|
statusBarMaskFraction({ platform: "ios", kind: "simulator", id: "tv-udid" })
|
|
).resolves.toBe(0);
|
|
});
|
|
|
|
it.each(["chromium", "vega"] as const)("masks nothing on %s", async (platform) => {
|
|
await expect(
|
|
statusBarMaskFraction({ platform, kind: "unknown", id: "some-device" })
|
|
).resolves.toBe(0);
|
|
expect(isTvOsSimulator).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
/** Write a decodable 2x1 PNG and return its path. */
|
|
async function pngAt(dir: string, name: string): Promise<string> {
|
|
const file = path.join(dir, name);
|
|
const png = new PNG({ width: 2, height: 1 });
|
|
png.data.set([10, 20, 30, 255, 40, 50, 60, 255]);
|
|
await fs.writeFile(file, PNG.sync.write(png));
|
|
return file;
|
|
}
|
|
|
|
function envFor(device: DeviceInfo, resolveService?: unknown): ActionEnv {
|
|
return { device, registry: { resolveService } } as unknown as ActionEnv;
|
|
}
|
|
|
|
/** The production call path, with a deadline generous enough to stay out of the way. */
|
|
function capture(env: ActionEnv): Promise<PixelFrame | undefined> {
|
|
return capturePixelsWithin(env, Date.now() + 30_000, false);
|
|
}
|
|
|
|
/** What the fake page below paints inside the rendered window. */
|
|
const VISIBLE_BAND_RGB: [number, number, number] = [200, 30, 10];
|
|
/** What Chrome hands back for a clip rectangle outside the rendered window. */
|
|
const OFF_SCREEN_RGB: [number, number, number] = [255, 255, 255];
|
|
|
|
/** The RGB triple of one pixel of a decoded frame. */
|
|
function pixelAt(frame: PixelFrame | undefined, index: number): [number, number, number] {
|
|
if (!frame) throw new Error("expected a decoded frame");
|
|
const o = index * 4;
|
|
return [frame.data[o], frame.data[o + 1], frame.data[o + 2]];
|
|
}
|
|
|
|
interface Clip {
|
|
x: number;
|
|
y: number;
|
|
width: number;
|
|
height: number;
|
|
scale: number;
|
|
}
|
|
|
|
/**
|
|
* A Chromium page that answers `Page.captureScreenshot` the way the compositor
|
|
* does: `clip` is in DOCUMENT coordinates, and with `captureBeyondViewport`
|
|
* false only the rendered window has pixels — a rectangle outside it comes back
|
|
* blank white. That is what makes a wrong clip origin visible as a colour here
|
|
* rather than only as an argument.
|
|
*/
|
|
function fakeChromiumPage(opts: { metricsError?: Error } = {}): {
|
|
api: unknown;
|
|
scrollTo(y: number): void;
|
|
lastClip(): Clip | undefined;
|
|
} {
|
|
const viewport = { width: 900, height: 700, devicePixelRatio: 2 };
|
|
let scrollY = 0;
|
|
let lastClip: Clip | undefined;
|
|
|
|
const send = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
|
if (method === "Page.getLayoutMetrics") {
|
|
if (opts.metricsError) throw opts.metricsError;
|
|
return {
|
|
cssVisualViewport: {
|
|
pageX: 0,
|
|
pageY: scrollY,
|
|
clientWidth: viewport.width,
|
|
clientHeight: viewport.height,
|
|
},
|
|
};
|
|
}
|
|
if (method !== "Page.captureScreenshot") throw new Error(`unexpected CDP call ${method}`);
|
|
lastClip = params?.clip as Clip;
|
|
const insideWindow =
|
|
lastClip !== undefined &&
|
|
lastClip.y >= scrollY &&
|
|
lastClip.y + lastClip.height <= scrollY + viewport.height;
|
|
const [r, g, b] = insideWindow ? VISIBLE_BAND_RGB : OFF_SCREEN_RGB;
|
|
const png = new PNG({ width: 2, height: 1 });
|
|
png.data.set([r, g, b, 255, r, g, b, 255]);
|
|
return { data: PNG.sync.write(png).toString("base64") };
|
|
});
|
|
|
|
return {
|
|
api: {
|
|
cdp: { send },
|
|
getViewport: () => viewport,
|
|
// The route that WOULD go through sharp. Reaching for it is the bug.
|
|
captureScreenshot: vi.fn(() => {
|
|
throw new Error("the sharp-backed capture must not be used for a settle");
|
|
}),
|
|
},
|
|
scrollTo(y: number) {
|
|
scrollY = y;
|
|
},
|
|
lastClip: () => lastClip,
|
|
};
|
|
}
|
|
|
|
describe("capturePixels routing", () => {
|
|
// Every platform argent can screenshot has a route here, and each one is a
|
|
// different backend — sending a device down the wrong one silently costs the
|
|
// settle its visual half, which then degrades to a tree-only pass.
|
|
it("captures and cleans up decodable pixels through the simulator-server backend", async () => {
|
|
const file = await pngAt(tmpDir, "native.png");
|
|
const screenshot = vi.fn(async () => ({ path: file, url: `file://${file}` }));
|
|
const device: DeviceInfo = { platform: "ios", kind: "simulator", id: "ios-device" };
|
|
const resolveService = vi.fn(async () => ({ transport: { screenshot } }));
|
|
|
|
const pixels = await capture(envFor(device, resolveService));
|
|
|
|
expect(pixels).toMatchObject({ width: 2, height: 1 });
|
|
expect([...pixels!.data]).toEqual([10, 20, 30, 255, 40, 50, 60, 255]);
|
|
expect(resolveService).toHaveBeenCalledWith(`SimulatorServer:${device.id}`, { device });
|
|
expect(screenshot).toHaveBeenCalledWith({
|
|
rotation: undefined,
|
|
scale: 0.25,
|
|
signal: undefined,
|
|
});
|
|
// The temp PNG is scratch, never an artifact — it must not outlive the decode.
|
|
await expect(fs.access(file)).rejects.toThrow();
|
|
});
|
|
|
|
it("routes a tvOS simulator to xcrun, not to the simulator-server it has no backend for", async () => {
|
|
vi.mocked(isTvOsSimulator).mockResolvedValue(true);
|
|
vi.mocked(tvScreenshot).mockImplementation(async () => pngAt(tmpDir, "tv.png"));
|
|
const resolveService = vi.fn(() => {
|
|
throw new Error("simulator-server must not be resolved for tvOS");
|
|
});
|
|
// A tvOS simulator's platform is "ios" — only the runtime tells them apart.
|
|
const device: DeviceInfo = { platform: "ios", kind: "simulator", id: "tv-udid" };
|
|
|
|
await expect(capture(envFor(device, resolveService))).resolves.toMatchObject({
|
|
width: 2,
|
|
height: 1,
|
|
});
|
|
expect(tvScreenshot).toHaveBeenCalledWith("tv-udid", 0.25, expect.any(AbortSignal));
|
|
expect(resolveService).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// `tvScreenshot` forwards its signal to `execFileAsync`. Without one, a
|
|
// wedged `xcrun simctl io screenshot` is never killed and the next poll
|
|
// 200ms later spawns another, so one stuck subprocess becomes a pile of
|
|
// them — the round abandons the promise, but nothing abandons the process.
|
|
it("kills a wedged tvOS capture when its budget runs out", async () => {
|
|
vi.mocked(isTvOsSimulator).mockResolvedValue(true);
|
|
let signal: AbortSignal | undefined;
|
|
vi.mocked(tvScreenshot).mockImplementation(
|
|
(_udid, _scale, sig) =>
|
|
new Promise<string>((_resolve, reject) => {
|
|
signal = sig;
|
|
sig?.addEventListener("abort", () => reject(new Error("aborted")));
|
|
})
|
|
);
|
|
const env = envFor({ platform: "ios", kind: "simulator", id: "tv-udid" });
|
|
|
|
expect(await capturePixelsWithin(env, Date.now() + 50, false)).toBeUndefined();
|
|
// The budget bounds the round and the subprocess with the same deadline,
|
|
// so which of the two timers lands first is not fixed — only that the
|
|
// capture does not outlive the round it belonged to.
|
|
await vi.waitFor(() => expect(signal?.aborted).toBe(true));
|
|
});
|
|
|
|
it("kills a tvOS capture when the run itself is cancelled", async () => {
|
|
vi.mocked(isTvOsSimulator).mockResolvedValue(true);
|
|
const controller = new AbortController();
|
|
let signal: AbortSignal | undefined;
|
|
vi.mocked(tvScreenshot).mockImplementation(
|
|
(_udid, _scale, sig) =>
|
|
new Promise<string>((_resolve, reject) => {
|
|
signal = sig;
|
|
sig?.addEventListener("abort", () => reject(new Error("aborted")));
|
|
})
|
|
);
|
|
const env = { ...envFor({ platform: "ios", kind: "simulator", id: "tv-udid" }) } as ActionEnv;
|
|
(env as { signal?: AbortSignal }).signal = controller.signal;
|
|
|
|
const pending = capturePixelsWithin(env, Date.now() + 30_000, false);
|
|
await vi.waitFor(() => expect(signal).toBeDefined());
|
|
controller.abort();
|
|
|
|
expect(await pending).toBeUndefined();
|
|
expect(signal?.aborted).toBe(true);
|
|
});
|
|
|
|
it("routes Vega to the emulator console, and never probes the iOS runtime for it", async () => {
|
|
vi.mocked(captureVegaScreenshotPng).mockImplementation(async () => pngAt(tmpDir, "vega.png"));
|
|
const resolveService = vi.fn(() => {
|
|
throw new Error("simulator-server must not be resolved for vega");
|
|
});
|
|
|
|
await expect(
|
|
capture(envFor({ platform: "vega", kind: "vvd", id: "vega-serial" }, resolveService))
|
|
).resolves.toMatchObject({ width: 2, height: 1 });
|
|
expect(captureVegaScreenshotPng).toHaveBeenCalledWith({ scale: 0.25 });
|
|
expect(isTvOsSimulator).not.toHaveBeenCalled();
|
|
expect(resolveService).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// Chromium is the one route that never touches the filesystem, and the one
|
|
// that cannot use the `screenshot` tool's scaling: that resizes with `sharp`,
|
|
// an optional dependency nothing here installs, so asking it for a quarter
|
|
// scale returned a full-resolution PNG and a settle decoded a 23MB buffer
|
|
// twice a second. The compositor applies `clip.scale` while rasterizing, so
|
|
// the small frame is the only one that exists.
|
|
it("captures Chromium through the compositor's own scale, and never via a file", async () => {
|
|
const device: DeviceInfo = { platform: "chromium", kind: "app", id: "chromium-cdp-9222" };
|
|
const page = fakeChromiumPage();
|
|
const resolveService = vi.fn(async () => page.api);
|
|
|
|
const pixels = await capture(envFor(device, resolveService));
|
|
|
|
expect(pixels).toMatchObject({ width: 2, height: 1 });
|
|
expect(resolveService).toHaveBeenCalledWith(`ChromiumCdp:${device.id}`, { device });
|
|
// The clip is the viewport in CSS pixels; its scale composes with the
|
|
// page's device scale factor, so the plain capture scale lands on a quarter
|
|
// of the frame this route used to return.
|
|
expect(page.lastClip()).toMatchObject({ width: 900, height: 700, scale: 0.25 });
|
|
});
|
|
|
|
// `clip` is measured from the top of the DOCUMENT. Pinning its origin at
|
|
// (0, 0) therefore aimed the capture at the top of the page rather than at
|
|
// the window, and on a scrolled document Chrome rasterizes that off-screen
|
|
// rectangle as blank white. Two blank captures compare as identical, so the
|
|
// pixel half of the settle voted "still" on every interval of a visibly
|
|
// animating screen — and voted it silently, because the capture succeeded.
|
|
//
|
|
// The mock below is the compositor's actual behaviour rather than an
|
|
// argument matcher: it serves whatever the clip rectangle overlaps in the
|
|
// rendered window, and white for anything outside it. A capture aimed at the
|
|
// wrong origin therefore comes back the wrong COLOR, which is the thing the
|
|
// comparison acts on.
|
|
it("captures the scrolled window, not the top of the document", async () => {
|
|
const device: DeviceInfo = { platform: "chromium", kind: "app", id: "chromium-cdp-9222" };
|
|
const page = fakeChromiumPage();
|
|
page.scrollTo(1839);
|
|
|
|
const pixels = await capture(
|
|
envFor(
|
|
device,
|
|
vi.fn(async () => page.api)
|
|
)
|
|
);
|
|
|
|
expect(page.lastClip()).toMatchObject({ x: 0, y: 1839 });
|
|
// The visible band, not the blank rectangle above the fold.
|
|
expect(pixelAt(pixels, 0)).toEqual(VISIBLE_BAND_RGB);
|
|
expect(pixelAt(pixels, 0)).not.toEqual(OFF_SCREEN_RGB);
|
|
});
|
|
|
|
it("still clips at the document origin for an unscrolled page", async () => {
|
|
const device: DeviceInfo = { platform: "chromium", kind: "app", id: "chromium-cdp-9222" };
|
|
const page = fakeChromiumPage();
|
|
|
|
const pixels = await capture(
|
|
envFor(
|
|
device,
|
|
vi.fn(async () => page.api)
|
|
)
|
|
);
|
|
|
|
expect(page.lastClip()).toMatchObject({ x: 0, y: 0 });
|
|
expect(pixelAt(pixels, 0)).toEqual(VISIBLE_BAND_RGB);
|
|
});
|
|
|
|
it("reads a Chromium capture that came back with no data as no evidence", async () => {
|
|
// The compositor answering without `data` is a capture failure like any
|
|
// other: soft, so the settle records "no visual evidence this round"
|
|
// rather than failing the step on it.
|
|
const device: DeviceInfo = { platform: "chromium", kind: "app", id: "chromium-cdp-9222" };
|
|
const send = vi.fn(async (method: string) =>
|
|
method === "Page.getLayoutMetrics" ? { cssVisualViewport: { pageX: 0, pageY: 0 } } : {}
|
|
);
|
|
const api = { cdp: { send }, getViewport: () => ({ width: 900, height: 700 }) };
|
|
|
|
expect(
|
|
await capture(
|
|
envFor(
|
|
device,
|
|
vi.fn(async () => api)
|
|
)
|
|
)
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("falls back to the document origin when the layout metrics cannot be read", async () => {
|
|
// A renderer that will not answer the metrics read leaves the capture no
|
|
// worse off than never asking — an unscrolled clip, not a failed settle.
|
|
const device: DeviceInfo = { platform: "chromium", kind: "app", id: "chromium-cdp-9222" };
|
|
const page = fakeChromiumPage({ metricsError: new Error("renderer is navigating") });
|
|
page.scrollTo(1839);
|
|
|
|
const pixels = await capture(
|
|
envFor(
|
|
device,
|
|
vi.fn(async () => page.api)
|
|
)
|
|
);
|
|
|
|
expect(page.lastClip()).toMatchObject({ x: 0, y: 0 });
|
|
expect(pixels).toMatchObject({ width: 2, height: 1 });
|
|
});
|
|
|
|
it("leaves Android on the simulator-server route without an iOS runtime probe", async () => {
|
|
const file = await pngAt(tmpDir, "android.png");
|
|
const resolveService = vi.fn(async () => ({
|
|
transport: { screenshot: async () => ({ path: file, url: `file://${file}` }) },
|
|
}));
|
|
|
|
await expect(
|
|
capture(
|
|
envFor({ platform: "android", kind: "emulator", id: "emulator-5554" }, resolveService)
|
|
)
|
|
).resolves.toMatchObject({ width: 2, height: 1 });
|
|
expect(isTvOsSimulator).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["ios", "android", "chromium", "vega"] as const)(
|
|
"returns undefined (never throws) on %s when the capture backend fails",
|
|
async (platform) => {
|
|
// Soft by design: the caller reads undefined as "no visual evidence", so
|
|
// a throw escaping here would fail the step on an environment problem.
|
|
vi.mocked(captureVegaScreenshotPng).mockRejectedValue(new Error("no vvd"));
|
|
const env = envFor({ platform, kind: "unknown", id: "some-device" }); // no resolveService
|
|
|
|
expect(await capture(env)).toBeUndefined();
|
|
}
|
|
);
|
|
|
|
it("returns undefined when the capture succeeds but the file is not a decodable PNG", async () => {
|
|
const file = path.join(tmpDir, "garbage.png");
|
|
await fs.writeFile(file, "not a png");
|
|
const resolveService = vi.fn(async () => ({
|
|
transport: { screenshot: async () => ({ path: file, url: `file://${file}` }) },
|
|
}));
|
|
|
|
expect(
|
|
await capture(envFor({ platform: "ios", kind: "simulator", id: "x" }, resolveService))
|
|
).toBeUndefined();
|
|
// Still cleaned up — a failed decode must not leak the file either.
|
|
await expect(fs.access(file)).rejects.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("capturePixelsWithin", () => {
|
|
const iosDevice: DeviceInfo = { platform: "ios", kind: "simulator", id: "ios-udid" };
|
|
|
|
function envWith(screenshot: () => Promise<{ path: string; url: string }>): ActionEnv {
|
|
return envFor(
|
|
iosDevice,
|
|
vi.fn(async () => ({ transport: { screenshot } }))
|
|
);
|
|
}
|
|
|
|
it("returns the frame when the capture lands inside the deadline", async () => {
|
|
const env = envWith(async () => {
|
|
const file = await pngAt(tmpDir, "in-time.png");
|
|
return { path: file, url: `file://${file}` };
|
|
});
|
|
await expect(capturePixelsWithin(env, Date.now() + 5_000, false)).resolves.toMatchObject({
|
|
width: 1 + 1,
|
|
height: 1,
|
|
});
|
|
});
|
|
|
|
it("gives up rather than overrunning the caller's deadline", async () => {
|
|
// A capture that never returns must not hold the settle past the step's
|
|
// own timeout — the caller degrades to tree-only, it does not wait.
|
|
const env = envWith(() => new Promise(() => {}));
|
|
const started = Date.now();
|
|
await expect(capturePixelsWithin(env, started + 120, false)).resolves.toBeUndefined();
|
|
expect(Date.now() - started).toBeLessThan(2_000);
|
|
});
|
|
|
|
it("does not attempt a capture once the deadline has already passed", async () => {
|
|
const screenshot = vi.fn(async () => {
|
|
const file = await pngAt(tmpDir, "too-late.png");
|
|
return { path: file, url: `file://${file}` };
|
|
});
|
|
await expect(capturePixelsWithin(envWith(screenshot), Date.now() - 1, false)).resolves.toBe(
|
|
undefined
|
|
);
|
|
expect(screenshot).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows the first capture the cold-stream wait and later ones the warm bound", () => {
|
|
// The simulator-server serves captures from a live frame stream, so the
|
|
// first read after it starts can spend the whole first-frame window; every
|
|
// later one is answered from a stream that is already producing.
|
|
expect(pixelCaptureTimeoutMs(iosDevice, true)).toBe(FIRST_PIXEL_CAPTURE_TIMEOUT_MS);
|
|
expect(FIRST_PIXEL_CAPTURE_TIMEOUT_MS).toBeGreaterThan(FIRST_FRAME_WAIT_MS);
|
|
expect(pixelCaptureTimeoutMs(iosDevice, false)).toBe(PIXEL_CAPTURE_TIMEOUT_MS);
|
|
// Chromium answers from CDP with no stream to warm up, so its first
|
|
// capture gets no extra grace.
|
|
expect(
|
|
pixelCaptureTimeoutMs({ platform: "chromium", kind: "app", id: "chromium-cdp-9222" }, true)
|
|
).toBe(PIXEL_CAPTURE_TIMEOUT_MS);
|
|
// Nor does Vega, which shells out to the emulator console — no stream
|
|
// either. Untested, this arm could be deleted and only Chromium would tell.
|
|
expect(pixelCaptureTimeoutMs({ platform: "vega", kind: "vvd", id: "vega-serial" }, true)).toBe(
|
|
PIXEL_CAPTURE_TIMEOUT_MS
|
|
);
|
|
// A tvOS simulator shells out too, but nothing here can tell it from an
|
|
// iOS one without an async probe, so it keeps the wider bound it will not
|
|
// spend.
|
|
expect(pixelCaptureTimeoutMs({ platform: "ios", kind: "simulator", id: "tv-udid" }, true)).toBe(
|
|
FIRST_PIXEL_CAPTURE_TIMEOUT_MS
|
|
);
|
|
});
|
|
});
|