mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
c3bb2990ea
Real-timer assertions in the flows suite were pinned to exact wall-clock boundaries, so a batch run reddened them for reasons unrelated to whatever was being tested. Each now pins the same property with slack, or with no clock at all — every one was mutation-tested to confirm it still fails loudly when the behaviour it guards is removed. Test-only; no docs change. Fixes #1000 Fixes #795 <details> <summary>Per-assertion: what it pins, and what changed</summary> | Site | Pinned property | Before | After | | --- | --- | --- | --- | | `flow-type-focus` "skips the focus poll…" | the fixed 500ms settle still runs on a source that cannot report focus | `gap >= 500` | `gap >= 450` | | `flow-type-focus` "waits for the tapped field…" | settle **plus** at least one 300ms focus poll | `gap >= 800` | `gap >= 720` | | `flow-idle-run` "holds a still screen…" | `stableFor` is served, not ignored | run timed, `[750, 2400)` | span between first and last tree read, `>= 750` | | `flow-idle-run` "settles inside the smallest timeout…" | the hold is counted across the polls, so the parser's floor is servable | run timed, `[750, 1000)` | read span `>= 750`; "inside the wait" carried by `warning === undefined` | | `flow-idle-run` "does not call a slow but answering source a wedged one" | an abandoned read is not a wedge when the source's own slowest answer explains it | 2300ms reads / 4600ms wait, 5s default test timeout | 2800ms / 5400ms, own 20s timeout | | `flow-idle-run` "does not report a tree-only settle…" | the tree-only settle owes the same hold | churn window clocked from before the run | clocked from the first read | | `flow-idle-run` H1/H2 "settled early and then moved / went blank" | the tree-only verdict is not a write-once latch | 4 quiet reads in a 2500ms wait | 3 quiet reads in a 6000ms wait, reads asserted | The two focus bounds had zero slack in the one direction that matters: a 500ms `setTimeout` measured on `Date.now()` spans 499ms roughly once in 200 runs (400 samples, idle machine: `499ms x2, 500ms x48, 501ms x289, 502ms x55, 503ms x2, 505ms x1`). 10% of slack keeps them failing by hundreds of milliseconds if either wait is dropped. The two idle `elapsed` bounds timed the whole run, so the flow's start-up — parse, device resolve — counted against the step's own budget. Timing between the reads the hold is actually measured across removes that, and the upper bounds went with it: every way out of the wait except the settle carries a warning, so `warning === undefined` already says it landed inside. The remaining three are fixtures, not assertions: each assumed a number of poll rounds, or a wall-clock window opened before the run, that a batch run does not serve. They now count reads, or anchor the window on the first read. </details> <details> <summary>Repro — before</summary> `npx vitest run packages/tool-server/test/flows` on `origin/main`: ``` FAIL flow-idle-run.test.ts > settles inside the smallest timeout the parser allows for the hold AssertionError: expected 2439 to be less than 1000 FAIL flow-idle-run.test.ts > does not call a slow but answering source a wedged one Error: Test timed out in 5000ms. FAIL flow-idle-run.test.ts > does not report a tree-only settle when the hold was never served AssertionError: expected 'settled on the UI tree alone — this s…' not to contain 'UI tree alone' FAIL flow-idle-run.test.ts > does not pass on a screen that settled early and then started moving again AssertionError: expected 'settled on the UI tree alone — this s…' to contain 'never held still' ``` #1000's own intermittency did not surface in 10 rounds of `npx vitest run --shard=3/3` (4 idle, 6 under a 10-way CPU load), so it was forced instead: the `setTimeout`-vs-`Date.now()` measurement above is the whole mechanism, and the assertion had no margin over it. </details> <details> <summary>Repro — after</summary> Six batch rounds of `npx vitest run packages/tool-server/test/flows`, on a machine carrying other work (load average 22-30 throughout). Rounds 2-6 were additionally narrowed to `--maxWorkers=3`, which is harsher than CI: the 13 `flow-composition` cases of #1067 pin one of the three workers for ~13 minutes and starve the rest. | round | workers | result for the assertions this PR touches | | --- | --- | --- | | 1 | default | all clean | | 2 | 3 | H1 starved to 3 reads | | 3 | 3 | all clean | | 4 | 3 | all clean | | 5 | 3 | all clean | | 6 | 3 | H2 starved to 3 reads | Every other failure in those rounds was the same 13 `flow-composition` timeouts (#1067), plus load artifacts in `flow-selector-scopes`, `flow-tool-step-tree-target-e2e`, `flow-launch-pins-tree-target` and `flow-gesture-settle` — none of whose assertions this branch touches. The two starved rounds were a real gap, not noise: H1 and H2 each need a fourth read to reach a verdict at all, and a 3500ms wait serves eighteen on an idle machine but three on a starved one. The case then saw a screen that never moved and passed the wait cleanly, so the assertion ran against `undefined`: ``` AssertionError: the given combination of arguments (undefined and string) is invalid for this assertion. ``` Each case now budgets for the starved run and asserts the reads it was served, so an environment that cannot reach the verdict says so: ``` AssertionError: reads served — under four, nothing moved to be judged: expected 3 to be greater than or equal to 4 ``` Confirmed by stretching each read to 1800ms, which starves the wait to three reads without touching the wait itself. </details> <details> <summary>Mutation tests (source broken, assertion must fail)</summary> Each mutation was applied to `flow-actions.ts`, the test run, then reverted. | Mutation | Assertion under test | Result | | --- | --- | --- | | delete `sleepOrAbort(TYPE_FOCUS_SETTLE_MS, …)` | both focus gaps | FAIL — `expected 0 to be greater than or equal to 450`; `expected 303 to be greater than or equal to 720` | | focus poll sleeps `0` instead of `POLL_INTERVAL_MS` | `>= 720` | FAIL — `expected 504 to be greater than or equal to 720` | | drop `&& heldForMs >= stableFor` from the settle | both idle read spans | FAIL — `expected 403 to be greater than or equal to 750` (both) | | drop `slowestAnsweredReadMs +` from the hung check | "slow but answering" | FAIL — step errors, `expected false to be true` on `r.ok` | | drop `&& now - treeSince >= stableFor` from `treeSettledAtLastRead` | "tree-only settle" | FAIL — `expected 'settled on the UI tree alone…' not to contain 'UI tree alone'` | | `if (lastRead === "timeout")` without `&& treeReadHung` | "still warns, rather than erroring" | FAIL — `expected 'error' to be 'pass'` | | make `treeSettledAtLastRead` a write-once latch | H1 "settled early then moved again" | FAIL — reports the tree-only settle | | drop `treeSettledAtLastRead = false` from the blank-read branch | H2 "settled early then went blank" | FAIL — reports the tree-only settle | The `treeReadHung` one did **not** fail at `timeout: 3000`: with the round period dividing the wait exactly, drift decided whether the closing read was abandoned at all. `3200` leaves the seventh read 200ms — less than the source needs — so the case ends on an abandoned read by arithmetic. </details> <details> <summary>Why the latch waits stop at 6000ms, and H3 is untouched</summary> The first attempt at these two used a 12000ms wait, which passed CI and broke the budget-tail case below them in the same file — that one has to fit three reads into a 2000ms window, and with 24s of polling ahead of it it saw two: ``` expected 'the screen came back with content on 2 reads in 2000ms…' to contain 'never held still' ``` Deterministic, not load: 4/4 runs on the branch, 0/4 on `origin/main`, and it passes in isolation. Bisected across the file: | latch wait | whole file | | --- | --- | | 2500 (main) | 46 passed | | 3500 | 46 passed | | 6000 | 46 passed | | 12000 | budget-tail fails every run | So the latch cases take 6000ms and no more. H3 itself is left exactly as it is on `main`: widening its wait scores its abandoned closing read as a wedged source (`HUNG_TREE_READ_MS`) rather than a step that ran out of time, and slowing its source starves it below the three reads a verdict takes — both were tried against CI and both moved the failure rather than removing it. </details> <details> <summary>Deliberately not changed</summary> - `flow-idle-run`'s `elapsed < 3000` bounds at "honours its timeout", "rides over a dropped capture" and "stops on abort": 1.8–2.2s of slack over a sub-second expectation already clears a scheduling delay of hundreds of milliseconds. - `flow-composition.test.ts` times out 13 cases at 60s each in the same batch run, on `origin/main` as well as here, while passing in 27s on its own. It is a batch artifact of a different kind and belongs in its own issue, not papered over from here. </details> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Tests** * Improved idle-flow timing validation by measuring intervals between reads for more reliable settle detection. * Updated timing scenarios and thresholds to account for delayed reads, startup overhead, slow tree changes, and early-settle behavior. * Added checks to ensure sufficient reads occur before slow-flow warnings and to detect later movement or blanking. * Relaxed Android and iOS focus-wait timing thresholds to accommodate minor timer measurement jitter. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1401 lines
57 KiB
TypeScript
1401 lines
57 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 type { Registry, ToolContext } from "@argent/registry";
|
|
import type { DescribeNode, DescribeTreeData } from "../../src/tools/describe/contract";
|
|
import type { PixelFrame } from "../../src/tools/flows/flow-pixels";
|
|
|
|
// The status-bar mask asks the iOS runtime whether this UDID is a tvOS
|
|
// simulator. These UDIDs are fabricated, so a real probe would shell out to
|
|
// `xcrun simctl list` on every step and answer "unknown" each time — pin it to
|
|
// the mobile answer the cases are written against.
|
|
vi.mock("../../src/utils/ios-devices", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("../../src/utils/ios-devices")>()),
|
|
isTvOsSimulator: vi.fn(async () => false),
|
|
}));
|
|
|
|
// Serve the flow tree directly (see flow-when.test.ts) — `idle` polls it.
|
|
let currentTree: () => DescribeNode;
|
|
/** Simulates a tree source that is slow, or wedged when it exceeds the step. */
|
|
let treeDelayMs = 0;
|
|
/**
|
|
* The degradation flags a reader attaches to a read it could not trust — an
|
|
* unattached Vega toolkit, an iOS AX service that wants a relaunch. They ride
|
|
* along with an EMPTY tree, which is what makes the read blind rather than an
|
|
* observation that the screen drew nothing.
|
|
*/
|
|
let treeHint: string | undefined;
|
|
let treeShouldRestart: boolean | undefined;
|
|
vi.mock("../../src/tools/flows/flow-tree", () => ({
|
|
fetchFlowTree: vi.fn(async (): Promise<DescribeTreeData> => {
|
|
// Pinned to the source this call started against, not to whatever the
|
|
// module-level `currentTree` holds when the delay elapses. A read the
|
|
// runner abandoned (`settleWithin` gives up; the underlying fetch does not)
|
|
// resolves after its own step, and often after `beforeEach` has repointed
|
|
// `currentTree` at the NEXT case — so an unpinned call reported an orphan
|
|
// read into a case that never made it, moving where that case's blank read
|
|
// landed and how many it saw.
|
|
const source = currentTree;
|
|
if (treeDelayMs > 0) await new Promise((r) => setTimeout(r, treeDelayMs));
|
|
return {
|
|
tree: source(),
|
|
source: "native-devtools",
|
|
screen: { width: 390, height: 844 },
|
|
...(treeHint === undefined ? {} : { hint: treeHint }),
|
|
...(treeShouldRestart === undefined ? {} : { should_restart: treeShouldRestart }),
|
|
};
|
|
}),
|
|
}));
|
|
|
|
// Stub only the capture; the real `comparePixels` decides whether two frames
|
|
// moved, so the comparison the check depends on is the one under test.
|
|
let currentFrame: () => PixelFrame | undefined;
|
|
/**
|
|
* How long a capture takes to come back. A live backend is not instant, and a
|
|
* round is `Promise.all([read, capture])` — so this, not the poll, is what a
|
|
* round lasts once the capture is the slow half.
|
|
*/
|
|
let captureDelayMs = 0;
|
|
/** Every `firstCapture` flag the runner passed, in order. */
|
|
const captureFirstFlags: boolean[] = [];
|
|
vi.mock("../../src/tools/flows/flow-pixels", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("../../src/tools/flows/flow-pixels")>();
|
|
return {
|
|
...actual,
|
|
// Carries the same three inputs the real one has, so nothing the runner
|
|
// hands it can go unexercised:
|
|
// - `deadline`, which it honours by returning undefined without capturing
|
|
// once the budget is gone. A stub that ignored it hid the case where the
|
|
// runner judges a screen on a round it had no time to observe, which is
|
|
// where a missing frame used to read as stillness.
|
|
// - the abort signal, which the real capture is abandoned on.
|
|
// - `firstCapture`, which buys a cold stream's first frame a wider bound
|
|
// and must be true exactly once per step.
|
|
capturePixelsWithin: vi.fn(
|
|
async (
|
|
env: { signal?: AbortSignal },
|
|
deadline: number,
|
|
firstCapture: boolean
|
|
): Promise<PixelFrame | undefined> => {
|
|
captureFirstFlags.push(firstCapture);
|
|
if (env.signal?.aborted) return undefined;
|
|
const budget = deadline - Date.now();
|
|
if (budget <= 0) return undefined;
|
|
if (captureDelayMs > 0) {
|
|
// Bounded by what is left, the way `settleWithin` bounds the real
|
|
// one, and abandoned rather than answered late if it outlasts that.
|
|
await new Promise((r) => setTimeout(r, Math.min(captureDelayMs, budget)));
|
|
if (captureDelayMs > budget || env.signal?.aborted) return undefined;
|
|
}
|
|
return currentFrame();
|
|
}
|
|
),
|
|
};
|
|
});
|
|
|
|
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
|
|
|
|
const DEVICE = "00000000-0000-0000-0000-0000000000ab"; // iOS UDID shape
|
|
let tmpDir: string;
|
|
|
|
function n(partial: Partial<DescribeNode> & { frame: DescribeNode["frame"] }): DescribeNode {
|
|
return { role: "AXOther", children: [], ...partial };
|
|
}
|
|
|
|
const FULL: DescribeNode["frame"] = { x: 0, y: 0, width: 1, height: 1 };
|
|
|
|
function screenWith(label: string): DescribeNode {
|
|
return n({
|
|
role: "AXWindow",
|
|
frame: FULL,
|
|
children: [n({ frame: { x: 0, y: 0, width: 1, height: 0.1 }, label })],
|
|
});
|
|
}
|
|
|
|
/** A 10x10 frame filled with one grey level — a uniform "screen". */
|
|
function frameAt(level: number): PixelFrame {
|
|
const data = Buffer.alloc(10 * 10 * 4, level);
|
|
return { width: 10, height: 10, data };
|
|
}
|
|
|
|
/**
|
|
* A capture-sized frame (180k pixels, the order a real one has at
|
|
* CAPTURE_SCALE) that is still apart from `movingPixels` of it. Sized so a
|
|
* spinner's share of a screen can be expressed at all: on the 10x10 frames
|
|
* above, one pixel is already 1% of the screen.
|
|
*/
|
|
function frameWithMovingPixels(movingPixels: number, level: number): PixelFrame {
|
|
const [width, height] = [300, 600];
|
|
const data = Buffer.alloc(width * height * 4, 255);
|
|
for (let i = 0; i < movingPixels; i++) {
|
|
const o = (STATUS_BAR_ROWS * width + i) * 4;
|
|
data[o] = level;
|
|
data[o + 1] = level;
|
|
data[o + 2] = level;
|
|
}
|
|
return { width, height, data };
|
|
}
|
|
|
|
/**
|
|
* The first row of a 600-row frame the comparison actually looks at: the
|
|
* comparator masks the top 6% on a device with a system status bar, so motion
|
|
* a case means to be SEEN has to be placed below it. Frames that move
|
|
* everywhere (frameAt) are unaffected, as is a 10-row one, where 6% floors to
|
|
* no rows at all.
|
|
*/
|
|
const STATUS_BAR_ROWS = Math.floor(600 * 0.06);
|
|
|
|
function mockRegistry(): Registry {
|
|
return {
|
|
invokeTool: vi.fn(async (id: string) => {
|
|
if (id === "list-devices") return { devices: [] };
|
|
if (id === "restart-app") return { restarted: true };
|
|
return { ok: true };
|
|
}),
|
|
getTool: vi.fn(() => undefined),
|
|
resolveService: vi.fn(async () => ({ isConnected: () => true })),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
async function writeFlow(name: string, yaml: string): Promise<void> {
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(path.join(dir, `${name}.yaml`), yaml, "utf8");
|
|
}
|
|
|
|
async function run(name: string, signal?: AbortSignal): Promise<FlowRunResult> {
|
|
const tool = createRunFlowTool(mockRegistry());
|
|
const result = await tool.execute(
|
|
{},
|
|
{ name, project_root: tmpDir, device: DEVICE },
|
|
// Only the signal matters here; the runner does not touch the rest.
|
|
signal ? ({ signal } as unknown as ToolContext) : undefined
|
|
);
|
|
if (!("steps" in result)) throw new Error("expected a run result");
|
|
return result;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-idle-"));
|
|
currentTree = () => screenWith("Home");
|
|
currentFrame = () => frameAt(120);
|
|
treeDelayMs = 0;
|
|
treeHint = undefined;
|
|
treeShouldRestart = undefined;
|
|
captureDelayMs = 0;
|
|
captureFirstFlags.length = 0;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
// `await: { idle: true }` is the readiness check: it returns the moment the
|
|
// screen is still, so the next tap resolves against a screen that has stopped.
|
|
// It never fails a run — a screen that never settles passes with a warning,
|
|
// because readiness is not an acceptance criterion and a screen that keeps
|
|
// moving (a video, a shimmer, live-updating text on Android) is usually a
|
|
// property of the app. Only an unreadable window is a hard stop, as an error.
|
|
describe("await: { idle }", () => {
|
|
it("passes once both the tree and the pixels hold still", async () => {
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, stableFor: 0 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
expect(r.steps.at(-1)).toMatchObject({ kind: "idle", status: "pass" });
|
|
// Both signals were available, so nothing about this pass is weakened.
|
|
expect(r.steps.at(-1)!.warning).toBeUndefined();
|
|
});
|
|
|
|
// Stillness is a property of an interval, and one interval can alias (see
|
|
// the reversing-animation case below), so `stableFor: 0` still means "the
|
|
// first two agreeing intervals" — three reads — not "the first read".
|
|
it("never settles on one read or one interval, even with no hold requested", async () => {
|
|
let reads = 0;
|
|
currentTree = () => {
|
|
reads += 1;
|
|
return screenWith("Home");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, stableFor: 0 }
|
|
`
|
|
);
|
|
expect((await run("ready")).ok).toBe(true);
|
|
// Exactly three, pinned from both sides: two would settle on a single
|
|
// agreeing pair (the aliasing case below), four would make every settle a
|
|
// poll slower than it needs to be.
|
|
expect(reads).toBe(3);
|
|
// And only the first capture of the step may claim the cold-stream bound.
|
|
expect(captureFirstFlags).toEqual([true, false, false]);
|
|
});
|
|
|
|
// `stableFor` is a clock, not a label: a screen that is still from the
|
|
// first read must still be held for it before the step returns. Without that
|
|
// the option means nothing, since three reads take about 400ms whatever it
|
|
// is set to.
|
|
//
|
|
// Timed between the READS the hold runs across, not around the run: the run
|
|
// also carries the flow's start-up, which a loaded suite stretches by
|
|
// seconds. That the settle landed inside the wait needs no clock — every
|
|
// other way out of it carries a warning.
|
|
it("holds a still screen for the whole requested hold before passing", async () => {
|
|
const readsAt: number[] = [];
|
|
currentTree = () => {
|
|
readsAt.push(Date.now());
|
|
return screenWith("Home");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 2500, stableFor: 800 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.steps.at(-1)).toMatchObject({ kind: "idle", status: "pass" });
|
|
expect(r.steps.at(-1)!.warning).toBeUndefined();
|
|
expect(readsAt.at(-1)! - readsAt[0]!).toBeGreaterThanOrEqual(750);
|
|
});
|
|
|
|
// The floor the parser enforces has to be one the runner can serve, or the
|
|
// parser is rejecting steps that work. This is the smallest wait it allows
|
|
// for an 800ms hold: the additive floor called it impossible and demanded
|
|
// 1400ms, while the settle lands at ~820ms because the hold is counted
|
|
// across the polls rather than after them.
|
|
it("settles inside the smallest timeout the parser allows for the hold", async () => {
|
|
const readsAt: number[] = [];
|
|
currentTree = () => {
|
|
readsAt.push(Date.now());
|
|
return screenWith("Home");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 1000, stableFor: 800 }
|
|
- echo: reached
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("pass");
|
|
// A clean settle, not a step that ran out of budget and warned its way out
|
|
// — which is also what says it fitted inside the 1000ms wait.
|
|
expect(step.warning).toBeUndefined();
|
|
// And the hold was served, not skipped (timed between reads: see above).
|
|
expect(readsAt.at(-1)! - readsAt[0]!).toBeGreaterThanOrEqual(750);
|
|
expect(r.steps.at(-1)).toMatchObject({ kind: "echo", status: "pass" });
|
|
});
|
|
|
|
it("warns, and does not fail, when the tree never stops changing", async () => {
|
|
let tick = 0;
|
|
currentTree = () => screenWith(`frame ${tick++}`);
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 900, stableFor: 300 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
const step = r.steps.at(-1)!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toContain("never held still");
|
|
// The warning has to say what to do next, not merely that it gave up.
|
|
expect(step.warning).toContain("stable element");
|
|
});
|
|
|
|
// The point of warning instead of failing: a screen that never stops moving
|
|
// is usually the app working as built (a video, a shimmer, a carousel, or —
|
|
// on Android, whose tree carries live text — a ticking timestamp). The run
|
|
// has to reach the checks that actually carry its verdict.
|
|
it("lets the rest of the flow run when the screen never settles", async () => {
|
|
let tick = 0;
|
|
currentTree = () => screenWith(`frame ${tick++}`);
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 900, stableFor: 300 }
|
|
- echo: reached
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
expect(r.failed).toBe(0);
|
|
expect(r.errored).toBe(0);
|
|
expect(r.steps.at(-1)).toMatchObject({ kind: "echo", status: "pass", message: "reached" });
|
|
});
|
|
|
|
// The reason this check reads pixels at all: an iOS push or modal dismissal
|
|
// commits its hierarchy up front and then animates a layer for a few hundred
|
|
// milliseconds. The tree is perfectly still the whole time.
|
|
it("warns when the pixels keep moving under a motionless tree", async () => {
|
|
let level = 0;
|
|
currentFrame = () => frameAt((level += 60) % 240);
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 600, stableFor: 0 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
const step = r.steps.at(-1)!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toContain("never held still");
|
|
});
|
|
|
|
// A spinner is the reason this warning exists. It is far too small to move
|
|
// the screen (a stock one covers ~0.1% of a phone display) and it does not
|
|
// move the tree either — it spins in a layer whose box never changes — so
|
|
// both halves of the check call the screen settled while it is still
|
|
// loading. The step still passes, because waiting out a caret or a spinner
|
|
// that never stops is worse than saying so, but it must SAY so.
|
|
it("warns when the screen settles with something small still moving on it", async () => {
|
|
let tick = 0;
|
|
// 40 of 180_000 pixels (0.022%) alternating: an order of magnitude under
|
|
// the motion fraction, an order above the noise floor.
|
|
currentFrame = () => frameWithMovingPixels(40, tick++ % 2 === 0 ? 0 : 40);
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, stableFor: 0 }
|
|
- echo: reached
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
const step = r.steps.at(-2)!;
|
|
expect(step).toMatchObject({ kind: "idle", status: "pass" });
|
|
expect(step.warning).toContain("small part of it was still changing");
|
|
expect(step.warning).toContain("spinner");
|
|
// The warning is about how the settle was reached, not a refusal to settle.
|
|
expect(step.warning).not.toContain("never held still");
|
|
});
|
|
|
|
// The runner pins the status bar for the whole run, but the pin lands a few
|
|
// hundred milliseconds AFTER the run starts and a nested `tool: flow-execute`
|
|
// clears it for the rest of the outer run — so this step regularly compared a
|
|
// real clock against a pinned one, or against a ticking one, and reported a
|
|
// static screen as moving or as carrying a spinner. The band is masked.
|
|
it("ignores the system status bar the run's own pin repaints", async () => {
|
|
let tick = 0;
|
|
// 400 pixels — over the motion budget for this frame — confined to the
|
|
// masked band, which is where the measured clock repaint landed.
|
|
currentFrame = () => {
|
|
const level = tick++ % 2 === 0 ? 0 : 255;
|
|
const [width, height] = [300, 600];
|
|
const data = Buffer.alloc(width * height * 4, 255);
|
|
for (let i = 0; i < 400; i++) {
|
|
const o = (width + i) * 4; // row 1, inside the status bar
|
|
data[o] = level;
|
|
data[o + 1] = level;
|
|
data[o + 2] = level;
|
|
}
|
|
return { width, height, data };
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, stableFor: 0 }
|
|
`
|
|
);
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
expect(step).toMatchObject({ kind: "idle", status: "pass" });
|
|
expect(step.warning).toBeUndefined();
|
|
});
|
|
|
|
it("says nothing about small motion when the screen is genuinely still", async () => {
|
|
currentFrame = () => frameWithMovingPixels(0, 0);
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, stableFor: 0 }
|
|
`
|
|
);
|
|
expect((await run("ready")).steps.at(-1)!.warning).toBeUndefined();
|
|
});
|
|
|
|
// Sub-threshold drift is encoder noise, not motion — treating it as motion
|
|
// would make the check unsatisfiable on a screen that is genuinely at rest.
|
|
it("tolerates capture noise below the motion threshold", async () => {
|
|
let level = 120;
|
|
currentFrame = () => frameAt((level += 1));
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 2000, stableFor: 0 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
// `ok` alone would hold for every idle outcome but an unreadable tree, so
|
|
// it says nothing about the threshold. What this case is about is that
|
|
// +1 per channel settles CLEANLY: no motion warning, and none about a
|
|
// spinner either — noise must not be reported as something small moving.
|
|
const step = r.steps.at(-1)!;
|
|
expect(step).toMatchObject({ kind: "idle", status: "pass" });
|
|
expect(step.warning).toBeUndefined();
|
|
});
|
|
|
|
// A reversing animation — a cross-fade, a pulse, a bounce — has a turning
|
|
// point, and two samples straddling it come back identical while the screen
|
|
// is still moving. Measured on a live 3s cross-fade: a default-shaped step
|
|
// passed on roughly one run in three until a second agreeing interval was
|
|
// required. Here every other capture repeats its predecessor, so a
|
|
// one-interval rule settles and a two-interval rule cannot.
|
|
it("does not settle on a single agreeing pair of a reversing animation", async () => {
|
|
let tick = 0;
|
|
currentFrame = () => {
|
|
// 0, 60, 60, 120, 120, 180, 180, … — one still interval, never two.
|
|
const level = Math.floor((tick + 1) / 2) * 60;
|
|
tick += 1;
|
|
return frameAt(level % 240);
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 1500, stableFor: 0 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.steps.at(-1)!.status).toBe("pass");
|
|
expect(r.steps.at(-1)!.warning).toContain("never held still");
|
|
});
|
|
|
|
// `timeout:` is the author's answer to "how long may this take", so it has to
|
|
// be the answer. No describe path takes an abort signal, so a wedged tree
|
|
// source (a hung ViewInspector RPC, an adb that stopped answering) used to
|
|
// run the round past the deadline — measured at 2.25s over an 8s budget on a
|
|
// live simulator.
|
|
it("honours its timeout even when the tree source stops answering", async () => {
|
|
treeDelayMs = 5_000;
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 800, stableFor: 0 }
|
|
`
|
|
);
|
|
const started = Date.now();
|
|
const r = await run("ready");
|
|
const elapsed = Date.now() - started;
|
|
|
|
expect(elapsed).toBeLessThan(3_000);
|
|
const step = r.steps.at(-1)!;
|
|
expect(step.status).toBe("error");
|
|
expect(step.reason).toContain("never answered within the step's 800ms");
|
|
});
|
|
|
|
// The sibling of the case above, and the one that used to slip through: a
|
|
// source that FAILS is caught by the unreadable-tree error, but one that
|
|
// HANGS is a different outcome internally, and after any earlier read had
|
|
// succeeded it fell through to the motion warning — telling the author that
|
|
// a screen frozen by a wedged renderer was a video or a carousel.
|
|
it("reports a tree source that wedges mid-wait as indeterminate, not as motion", async () => {
|
|
let reads = 0;
|
|
currentTree = () => {
|
|
reads += 1;
|
|
// Answer the first read, then wedge for longer than the step can wait.
|
|
treeDelayMs = 60_000;
|
|
return screenWith("Home");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 2500, stableFor: 0 }
|
|
- echo: reached
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(reads).toBe(1);
|
|
expect(r.ok).toBe(false);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("error");
|
|
expect(step.reason).toContain("answered and then stopped");
|
|
expect(step.reason).toContain("foreground");
|
|
// And it must not be dressed up as a verdict about what was on screen.
|
|
expect(step.reason).not.toContain("never held still");
|
|
});
|
|
|
|
// ...and the other side of that split. Every read here is abandoned at the
|
|
// deadline, so how much budget one had is a fact about the step's arithmetic
|
|
// rather than about the source: on its own it cannot tell a wedge from a
|
|
// source that is merely slow. Taking it on its own hard-stopped a run on the
|
|
// most settled screen there is — every read answering with the same tree —
|
|
// whenever the closing round happened to start with 2s in hand.
|
|
it("does not call a slow but answering source a wedged one", async () => {
|
|
// 2800ms reads in a 5400ms wait: the first answers, the second is cut off
|
|
// with 2400ms of budget — over the hung threshold, and under what this
|
|
// source has already been seen to need. Both margins are 400ms wide, since
|
|
// the closing round's budget is whatever the first read left over and a
|
|
// loaded suite that eats it down past the threshold leaves the split
|
|
// untested.
|
|
//
|
|
// The threshold is a constant the answering read has to beat, so the wait
|
|
// cannot be much shorter than the default test timeout — hence one of its
|
|
// own.
|
|
treeDelayMs = 2_800;
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 5400, stableFor: 0 }
|
|
- echo: reached
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("pass");
|
|
// Not the wedged-source verdict, in whichever field it would have landed.
|
|
expect(`${step.reason ?? ""} ${step.warning ?? ""}`).not.toContain("answered and then stopped");
|
|
// What it actually was: too few looks to judge the screen by.
|
|
expect(step.warning).toContain("content on 1 read in 5400ms");
|
|
expect(r.steps.at(-1)).toMatchObject({ kind: "echo", status: "pass" });
|
|
}, 20_000);
|
|
|
|
// A settle is three reads spanning two intervals. A step that got fewer has
|
|
// no evidence either way, and both of the verdicts it used to reach for —
|
|
// "the screen never stopped moving", "no screenshot could be read" — are
|
|
// claims about an app nobody observed for long enough to make one.
|
|
it("says it ran out of looks rather than judging a screen it barely read", async () => {
|
|
treeDelayMs = 700; // two of these do not fit in the wait
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 1200 }
|
|
`
|
|
);
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toContain("content on 1 read in 1200ms");
|
|
expect(step.warning).toContain("`timeout:`");
|
|
// The screen was static the whole time; it must not be described as moving.
|
|
expect(step.warning).not.toContain("never held still");
|
|
expect(step.warning).not.toContain("no pair of screenshots");
|
|
});
|
|
|
|
// A blank read is an observation — it resets both holds — but it measures no
|
|
// interval, so it is not one of the three a settle takes. Counting it let a
|
|
// window blank for all but its last two reads slip past the guard above and
|
|
// reach for the motion verdict instead, telling the author that "something on
|
|
// it never stops" on the strength of one measured interval.
|
|
it("does not count a blank read as a look at the screen", async () => {
|
|
let reads = 0;
|
|
// A 250ms read plus a 200ms poll fits three rounds in 1200ms; the first
|
|
// comes back blank, so only one interval could ever be measured.
|
|
treeDelayMs = 250;
|
|
currentTree = () => (reads++ < 1 ? n({ role: "AXWindow", frame: FULL }) : screenWith("Home"));
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 1200, stableFor: 0 }
|
|
`
|
|
);
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toContain("content on 2 reads in 1200ms");
|
|
expect(step.warning).not.toContain("never held still");
|
|
});
|
|
|
|
it("settles on the tree alone when no screenshot can be captured, and says so", async () => {
|
|
currentFrame = () => undefined;
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 900, stableFor: 0 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
const step = r.steps.at(-1)!;
|
|
expect(step.status).toBe("pass");
|
|
// The step has no target beyond the screen itself, and the renderer
|
|
// already prints the kind — a target here would read "idle screen idle".
|
|
expect(step.target).toBeUndefined();
|
|
expect(step.warning).toContain("UI tree alone");
|
|
// Attributed to the capture, not to the platform: on a device where
|
|
// screenshots normally work this is a per-capture failure, not a property
|
|
// of the OS.
|
|
expect(step.warning).not.toContain("could not be captured on");
|
|
});
|
|
|
|
// The tree-only report is a claim that the HIERARCHY settled, so it owes the
|
|
// same hold every other settle does. Every case that reaches it elsewhere
|
|
// runs with `stableFor: 0`, where the hold term is vacuous — drop it and
|
|
// the suite stays green while a tree that had only just stopped moving is
|
|
// reported as having settled.
|
|
it("does not report a tree-only settle when the hold was never served", async () => {
|
|
currentFrame = () => undefined;
|
|
// The tree keeps changing for the first 800ms of the WAIT — anchored on the
|
|
// first read, since a loaded suite can spend longer than that reaching it
|
|
// and every read would then see a tree still from the start. It holds after
|
|
// that, and the last read lands ~1400ms in, so the hierarchy has been still
|
|
// for well under the 800ms hold, however many agreeing intervals it managed.
|
|
let firstReadAt: number | undefined;
|
|
let churn = 0;
|
|
currentTree = () => {
|
|
firstReadAt ??= Date.now();
|
|
return screenWith(Date.now() - firstReadAt < 800 ? `Loading ${churn++}` : "Settled");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 1400, stableFor: 800 }
|
|
`
|
|
);
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).not.toContain("UI tree alone");
|
|
expect(step.warning).toContain(
|
|
"never held still for 2 consecutive 200ms intervals spanning 800ms"
|
|
);
|
|
});
|
|
|
|
// The localized flag describes the hold being REPORTED, so a hold that broke
|
|
// has to clear it. Without the reset, a spinner that stopped — then a screen
|
|
// that went still — still told the author something "kept changing the whole
|
|
// time".
|
|
it("forgets small motion that stopped before the settle that gets reported", async () => {
|
|
// Round 1 has no predecessor; 2 moves a spinner's worth; 3 moves the whole
|
|
// screen, breaking the hold; 4 and 5 are identical, which is the settle.
|
|
const WHOLE_FRAME = 300 * 600;
|
|
const frames = [
|
|
frameWithMovingPixels(0, 0),
|
|
frameWithMovingPixels(40, 0),
|
|
frameWithMovingPixels(WHOLE_FRAME, 90),
|
|
frameWithMovingPixels(WHOLE_FRAME, 90),
|
|
frameWithMovingPixels(WHOLE_FRAME, 90),
|
|
];
|
|
let i = 0;
|
|
currentFrame = () => frames[Math.min(i++, frames.length - 1)];
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, stableFor: 0 }
|
|
`
|
|
);
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
expect(step).toMatchObject({ kind: "idle", status: "pass" });
|
|
expect(step.warning).toBeUndefined();
|
|
});
|
|
|
|
// A capture that goes missing used to cost the settle TWO intervals, not
|
|
// one: the missing frame was also stored as the previous frame, so the next
|
|
// round had nothing to compare against either. Holding the last good frame
|
|
// asks the same question across the gap. The witness is the number of
|
|
// captures the settle takes — rounds run in lockstep, so it is exact.
|
|
it("loses only the interval a capture went missing in, not the one after it", async () => {
|
|
let captures = 0;
|
|
currentFrame = () => {
|
|
captures += 1;
|
|
return captures === 3 ? undefined : frameAt(120);
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, stableFor: 0 }
|
|
`
|
|
);
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
expect(step.status).toBe("pass");
|
|
// 1,2 hold — one interval; 3 is missing, which measures no interval and
|
|
// refutes none; 4 holds across the gap for the second, and settles. Five
|
|
// would mean round 3's absence had cost the hold an interval it had
|
|
// already measured, six that it had blinded round 4 as well.
|
|
expect(captures).toBe(4);
|
|
// And one missed capture out of four is not "no screenshot could be read".
|
|
expect(step.warning).toBeUndefined();
|
|
});
|
|
|
|
// A missing frame is the absence of an observation, exactly as an abandoned
|
|
// tree read is — and the loop says so about the read a few lines up. Letting
|
|
// it DESTROY the combined hold instead meant a backend that merely drops a
|
|
// frame now and then could serve no hold longer than the gap between drops:
|
|
// the step burned its whole timeout on a screen that never moved, and then
|
|
// reported that the screen "could not be screenshotted on enough polls to
|
|
// compare a pair of them" on a run where two captures in three arrived and
|
|
// every compared pair read still.
|
|
it("rides over a dropped capture instead of restarting the hold", async () => {
|
|
let captures = 0;
|
|
currentFrame = () => (++captures % 3 === 0 ? undefined : frameAt(120));
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, stableFor: 900, timeout: 6000 }
|
|
`
|
|
);
|
|
const started = Date.now();
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
const elapsed = Date.now() - started;
|
|
expect(step.status).toBe("pass");
|
|
// Every pair that was compared read still, so nothing about this pass is
|
|
// weakened — and it is a settle, not a step that ran out of budget.
|
|
expect(step.warning).toBeUndefined();
|
|
expect(elapsed).toBeGreaterThanOrEqual(900);
|
|
expect(elapsed).toBeLessThan(3_000);
|
|
// Captures did go missing; the point is that they cost only their own
|
|
// interval.
|
|
expect(captures).toBeGreaterThan(3);
|
|
// Room for the pre-fix behaviour — burning the whole 6000ms wait — to be
|
|
// reported as the failed assertion it is rather than as a harness timeout.
|
|
}, 15_000);
|
|
|
|
// The other half of that, and the reason a missing frame is never simply
|
|
// waved through: it is the ABSENCE of visual evidence, and treating it as
|
|
// evidence of stillness is how a moving screen used to pass — the round that
|
|
// outran the deadline skipped its capture, and the skip stood in for "the
|
|
// pixels held". Riding over one costs the hold nothing, but it can never
|
|
// MEASURE an interval either.
|
|
it("never lets a missing capture stand in for stillness while the pixels move", async () => {
|
|
let level = 0;
|
|
currentFrame = () => frameAt((level += 60) % 240);
|
|
await writeFlow(
|
|
"ready",
|
|
// A default-shaped step, whose last poll round routinely starts with no
|
|
// capture budget left.
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 1200 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
// Passing is fine; claiming the screen SETTLED is not — the warning must
|
|
// still report the motion, not the tree-only settle.
|
|
expect(r.steps.at(-1)!.warning).toContain("never held still");
|
|
expect(r.steps.at(-1)!.warning).not.toContain("UI tree alone");
|
|
});
|
|
|
|
// A settle needs an interval COUNT as well as a duration, and the count is
|
|
// the term a short wait usually misses. Naming only the duration reported a
|
|
// screen the run had watched go still as one that never held still — and at
|
|
// `stableFor: 0` it read "never held still for 0ms", a hold nothing can fail
|
|
// — then sent the author looking for a video or a carousel.
|
|
it("names the interval it fell short of, not a hold the screen did serve", async () => {
|
|
let reads = 0;
|
|
// Moving for the first four reads, then identical: one still interval,
|
|
// where a settle takes two. The 0ms hold is satisfied the instant that
|
|
// pair agrees.
|
|
currentTree = () => {
|
|
reads += 1;
|
|
return screenWith(reads <= 4 ? `frame ${reads}` : "Settled");
|
|
};
|
|
let frames = 0;
|
|
currentFrame = () => {
|
|
frames += 1;
|
|
return frameAt(frames <= 4 ? frames * 40 : 200);
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, stableFor: 0, timeout: 1100 }
|
|
`
|
|
);
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).not.toContain("never held still");
|
|
// What it did achieve, and the term it was short of.
|
|
expect(step.warning).toContain("still for the last");
|
|
expect(step.warning).toContain("1 200ms interval");
|
|
expect(step.warning).toContain("2 consecutive ones");
|
|
// The repair is the wait, not the app: nothing here is evidence of a video.
|
|
expect(step.warning).toContain("`timeout:`");
|
|
expect(step.warning).not.toContain("a video");
|
|
});
|
|
|
|
// The default hold is what nearly every step runs with, so it is worth
|
|
// pinning somewhere the number is actually used rather than only in a parser
|
|
// message.
|
|
it("holds for 250ms by default", async () => {
|
|
let tick = 0;
|
|
currentTree = () => screenWith(`frame ${tick++}`);
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 900 }
|
|
`
|
|
);
|
|
expect((await run("ready")).steps.at(-1)!.warning).toContain(
|
|
"never held still for 2 consecutive 200ms intervals spanning 250ms within 900ms"
|
|
);
|
|
});
|
|
|
|
// A tree source that never answers at all: no read succeeded, so there is
|
|
// nothing to reason from and the advice is about the window, not the app.
|
|
// The existing mid-wait case lets the first read land, so this branch — the
|
|
// one that produces the foreground advice from a standing start — was never
|
|
// taken.
|
|
it("reports a tree source that never answered as unreadable, naming the underlying error", async () => {
|
|
currentTree = () => {
|
|
throw new Error("native-devtools is not connected");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 900, stableFor: 0 }
|
|
- echo: unreachable
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(false);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("error");
|
|
expect(step.reason).toContain("could not read the UI tree");
|
|
expect(step.reason).toContain("foreground");
|
|
expect(step.reason).toContain("native-devtools is not connected");
|
|
// An indeterminate readiness check stops the run rather than recording a
|
|
// regression the app never had.
|
|
expect(r.steps.at(-1)!.status).toBe("skip");
|
|
});
|
|
|
|
// A blip mid-settle is expected — the hold restarts from the next good read
|
|
// rather than the step giving up or carrying its pre-blip state across.
|
|
it("restarts the hold after a failed read, and still settles", async () => {
|
|
let reads = 0;
|
|
currentTree = () => {
|
|
reads += 1;
|
|
if (reads === 2) throw new Error("transient describe failure");
|
|
return screenWith("Home");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, stableFor: 0 }
|
|
`
|
|
);
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toBeUndefined();
|
|
// 1 ok, 2 failed, 3 is a fresh start (nothing to compare against), 4 and 5
|
|
// are the two agreeing intervals. Three would mean the blip was ignored.
|
|
expect(reads).toBe(5);
|
|
});
|
|
|
|
// A blip on the read that ENDS the step is still a blip. Which poll it lands
|
|
// on used to decide the whole verdict: one poll earlier it restarted the hold
|
|
// and the step passed with a warning, on the last poll it stopped the run and
|
|
// skipped every later step. So a screen this check is explicit about wanting
|
|
// to pass — a video, a shimmer, a carousel, live-updating text — turned a run
|
|
// red on timing luck alone.
|
|
it("does not stop the run when only the read that ended the wait failed", async () => {
|
|
let firstReadAt: number | undefined;
|
|
let tick = 0;
|
|
// Never settles, so the step always exits through the bottom of the loop
|
|
// and its last read is the one that decides. Measured from the first read
|
|
// rather than from the run, the threshold sits midway between the closing
|
|
// two rounds of a 900ms step (600ms and 800ms in), so only the last throws.
|
|
currentTree = () => {
|
|
firstReadAt ??= Date.now();
|
|
if (Date.now() - firstReadAt >= 700) throw new Error("transient describe failure");
|
|
return screenWith(`frame ${tick++}`);
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 900, stableFor: 0 }
|
|
- echo: reached
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toContain("never held still");
|
|
// Tolerated, not swallowed: the read that failed is still named.
|
|
expect(step.warning).toContain("transient describe failure");
|
|
// And the checks that actually carry the flow's verdict still run.
|
|
expect(r.steps.at(-1)).toMatchObject({ kind: "echo", status: "pass" });
|
|
});
|
|
|
|
// ...and it stays a blip when the round runs longer than a poll. The tail
|
|
// between the last read that answered and the end of the wait is
|
|
// `sleep + max(read, capture)`, and nothing holds either half to a poll: a
|
|
// capture gets seconds of its own. Measuring the tolerance in milliseconds
|
|
// therefore expired it on any slow-but-working capture backend, which put the
|
|
// verdict back on where the blip landed — the exact thing the tolerance
|
|
// exists to remove. It is counted in rounds for that reason.
|
|
it("does not stop the run when the closing read failed and captures are slow", async () => {
|
|
captureDelayMs = 300; // > IDLE_POLL_MS, so the round outlasts the poll
|
|
let firstReadAt: number | undefined;
|
|
let tick = 0;
|
|
// Never settles. With a 300ms capture the rounds start every ~500ms, so of
|
|
// the five that fit in 2400ms only the last (t≈2000) is past the threshold.
|
|
currentTree = () => {
|
|
firstReadAt ??= Date.now();
|
|
if (Date.now() - firstReadAt >= 1750) throw new Error("transient describe failure");
|
|
return screenWith(`frame ${tick++}`);
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 2400, stableFor: 0 }
|
|
- echo: reached
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toContain("never held still");
|
|
expect(step.warning).toContain("transient describe failure");
|
|
expect(r.steps.at(-1)).toMatchObject({ kind: "echo", status: "pass" });
|
|
});
|
|
|
|
// The other side of that bound: a slow round does not buy a second failing
|
|
// read the same tolerance. Two consecutive dark reads are the window this
|
|
// step cannot describe, however long the rounds that carried them took.
|
|
it("still errors when two consecutive reads failed, however slow the captures", async () => {
|
|
captureDelayMs = 300;
|
|
let firstReadAt: number | undefined;
|
|
let tick = 0;
|
|
// Threshold one round earlier than the case above, so the last two reads
|
|
// (t≈1500 and t≈2000) both throw.
|
|
currentTree = () => {
|
|
firstReadAt ??= Date.now();
|
|
if (Date.now() - firstReadAt >= 1250) throw new Error("native-devtools is not connected");
|
|
return screenWith(`frame ${tick++}`);
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 2400, stableFor: 0 }
|
|
- echo: unreachable
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(false);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("error");
|
|
expect(step.reason).toContain("could not read the UI tree");
|
|
expect(r.steps.at(-1)!.status).toBe("skip");
|
|
});
|
|
|
|
// The dark tail is a CONSECUTIVE count, and that is what makes a blip a
|
|
// blip: the run of unanswered rounds at the END of the wait is the window
|
|
// the step cannot describe. Without the reset on a read that answers,
|
|
// `darkReads` becomes a total for the whole step, so two blips that were
|
|
// never consecutive — the case this design exists to ride out — sum past the
|
|
// tolerance and turn a passing step into a run-stopping error.
|
|
it("counts the dark tail from the last read that answered, not over the whole step", async () => {
|
|
let reads = 0;
|
|
currentTree = () => {
|
|
reads += 1;
|
|
// Fail on 2 and 4, answer on 1 and 3: two blips, never consecutive.
|
|
if (reads % 2 === 0) throw new Error("transient describe failure");
|
|
return screenWith("Home");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 700, stableFor: 0 }
|
|
- echo: reached
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
// Four rounds fit, so the wait ends on the second blip — the position that
|
|
// makes the tail one read long and the step total two.
|
|
expect(reads).toBe(4);
|
|
expect(r.ok).toBe(true);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("pass");
|
|
// Tolerated, not swallowed.
|
|
expect(step.warning).toContain("transient describe failure");
|
|
expect(r.steps.at(-1)).toMatchObject({ kind: "echo", status: "pass" });
|
|
});
|
|
|
|
// What makes a window unreadable is the dark tail, not the flavour of the
|
|
// last read in it. A closing round that simply ran out of budget ends as a
|
|
// `timeout` however dead the source is, and keying the guard on `error`
|
|
// discarded the whole tail with it — the dark rounds, the error, and the note
|
|
// that names it — so a source that had stopped answering passed silently
|
|
// whenever the deadline happened to land inside one read's latency.
|
|
it.each([1500, 1700])(
|
|
"errors on a dead source whichever way the closing round of a %sms wait ends",
|
|
async (timeout) => {
|
|
treeDelayMs = 150;
|
|
let reads = 0;
|
|
currentTree = () => {
|
|
reads += 1;
|
|
if (reads > 2) throw new Error("native-devtools is not connected");
|
|
return screenWith("Home");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: ${timeout}, stableFor: 0 }
|
|
- echo: unreachable
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(false);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("error");
|
|
// The failure is named either way, rather than dropped with the tail.
|
|
expect(step.reason).toContain("native-devtools is not connected");
|
|
expect(r.steps.at(-1)!.status).toBe("skip");
|
|
}
|
|
);
|
|
|
|
// The same blip against the other window it used to redden: a screen that
|
|
// read back empty throughout is an observation about the app, and a transient
|
|
// on the closing read does not turn it into a window nobody could see.
|
|
it("still reports an empty screen as empty when the closing read failed", async () => {
|
|
let firstReadAt: number | undefined;
|
|
currentTree = () => {
|
|
firstReadAt ??= Date.now();
|
|
if (Date.now() - firstReadAt >= 700) throw new Error("transient describe failure");
|
|
return n({ role: "AXWindow", frame: FULL, children: [] });
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 900, stableFor: 0 }
|
|
`
|
|
);
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toContain("never rendered content");
|
|
expect(step.warning).toContain("transient describe failure");
|
|
});
|
|
|
|
// The same for a screen that goes blank in the middle: an observation that
|
|
// resets both holds, not a gap and not a reason to give up.
|
|
it("restarts the hold after the screen goes blank, and still settles", async () => {
|
|
let reads = 0;
|
|
currentTree = () => {
|
|
reads += 1;
|
|
return reads === 2 ? n({ role: "AXWindow", frame: FULL, children: [] }) : screenWith("Home");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, stableFor: 0 }
|
|
`
|
|
);
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toBeUndefined();
|
|
expect(reads).toBe(5);
|
|
});
|
|
|
|
// Cancelling a run is not a verdict about the screen. The check has to stop
|
|
// promptly and report a skip, never a pass, a warning or an error.
|
|
// An empty tree that arrives with a relaunch hint is the reader saying it
|
|
// could not see the app — not the app saying it drew nothing. Scoring it as
|
|
// an observation produced a verdict about the author's screen ("this is
|
|
// where it did not render content") from a window that was never readable,
|
|
// and pointed them away from the toolkit that never attached. The selector
|
|
// conditions have refused to read such a tree since they were written.
|
|
it("does not draw a verdict about the screen from a degraded empty read", async () => {
|
|
currentTree = () => n({ role: "AXWindow", frame: FULL, children: [] });
|
|
treeHint = "the automation toolkit is not attached — relaunch the app so it can attach";
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 1200, stableFor: 0 }
|
|
- echo: unreachable
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(false);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("error");
|
|
// The window, named as the window — and the reader's own repair with it.
|
|
expect(step.reason).toContain("empty and degraded");
|
|
expect(step.reason).toContain("automation toolkit is not attached");
|
|
expect(step.reason).not.toContain("never rendered content");
|
|
expect(r.steps.at(-1)!.status).toBe("skip");
|
|
});
|
|
|
|
// `should_restart` is the same claim without a sentence attached.
|
|
it("treats a should_restart empty read as degraded too", async () => {
|
|
currentTree = () => n({ role: "AXWindow", frame: FULL, children: [] });
|
|
treeShouldRestart = true;
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 1200, stableFor: 0 }
|
|
`
|
|
);
|
|
const step = (await run("ready")).steps.at(-1)!;
|
|
expect(step.status).toBe("error");
|
|
expect(step.reason).toContain("empty and degraded");
|
|
});
|
|
|
|
// A degraded tail survives a closing round that merely ran out of budget,
|
|
// for the same reason a failing one does: the round that ends the wait is
|
|
// abandoned whenever the read outlasts the budget it was allowed to start
|
|
// with, and that says nothing about the source. `should_restart` is the
|
|
// spelling that arrives with no hint attached, so the tail cannot be
|
|
// recognised from the message alone.
|
|
it("keeps a degraded tail when the closing round runs out of budget", async () => {
|
|
treeDelayMs = 150;
|
|
// Set up front rather than from inside the source: a read the runner
|
|
// abandons still resolves later, and mutating shared state from there
|
|
// reports this case's degradation into the next one. Harmless on the
|
|
// reads that carry a tree — only an EMPTY one is blind.
|
|
treeShouldRestart = true;
|
|
let reads = 0;
|
|
currentTree = () => {
|
|
reads += 1;
|
|
return reads > 2 ? n({ role: "AXWindow", frame: FULL, children: [] }) : screenWith("Home");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 1500, stableFor: 0 }
|
|
- echo: unreachable
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(false);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("error");
|
|
expect(step.reason).toContain("empty and degraded");
|
|
expect(r.steps.at(-1)!.status).toBe("skip");
|
|
});
|
|
|
|
// ...and the guard must not swallow the ordinary blank screen it sits next
|
|
// to. An empty tree with nothing attached to it IS an observation: the app
|
|
// rendered no accessible content, which is a warning and not a stop.
|
|
it("still reports an undegraded empty screen as an empty screen", async () => {
|
|
currentTree = () => n({ role: "AXWindow", frame: FULL, children: [] });
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 1200, stableFor: 0 }
|
|
- echo: reached
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toContain("the UI tree stayed empty");
|
|
expect(r.steps.at(-1)).toMatchObject({ kind: "echo", status: "pass" });
|
|
});
|
|
|
|
it("stops on abort without judging the screen", async () => {
|
|
let tick = 0;
|
|
currentTree = () => screenWith(`frame ${tick++}`); // never settles
|
|
const controller = new AbortController();
|
|
setTimeout(() => controller.abort(), 300);
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 7500 }
|
|
- echo: unreachable
|
|
`
|
|
);
|
|
const started = Date.now();
|
|
const r = await run("ready", controller.signal);
|
|
expect(Date.now() - started).toBeLessThan(3_000);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("skip");
|
|
expect(step.reason).toContain("aborted");
|
|
expect(step.warning).toBeUndefined();
|
|
});
|
|
|
|
// One good read early does not license an app verdict drawn from a window
|
|
// that went dark afterwards: a backgrounded app or a dropped instrumentation
|
|
// session reads as "unknown", never as "still animating".
|
|
it("reports a tree source that dies mid-wait as indeterminate, not as motion", async () => {
|
|
let reads = 0;
|
|
currentTree = () => {
|
|
reads += 1;
|
|
if (reads > 1) throw new Error("native-devtools is not connected");
|
|
return screenWith("Home");
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 700, stableFor: 0 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(false);
|
|
const step = r.steps.at(-1)!;
|
|
// `indeterminate` is scored `error`, which is what stops a QA run rather
|
|
// than recording a regression the app never had.
|
|
expect(step.status).toBe("error");
|
|
expect(step.reason).toContain("could not read the UI tree");
|
|
expect(step.reason).toContain("foreground");
|
|
});
|
|
|
|
// H1: a screen that settles and then moves again has NOT settled. The
|
|
// tree-only verdict used to be a write-once latch, so an early quiet stretch
|
|
// licensed a pass drawn from a window that spent the rest of its time
|
|
// churning — which is precisely the regression this step exists to catch.
|
|
it("does not pass on a screen that settled early and then started moving again", async () => {
|
|
currentFrame = () => undefined; // force the tree-only path
|
|
// Three still reads set the verdict — two agreeing intervals is what it
|
|
// takes — and everything after them moves, so the case needs a fourth read
|
|
// to show the movement. 6000ms serves about thirty when the machine is
|
|
// idle and still four when a batch run stretches every round; the
|
|
// assertion on `reads` below separates a run that never saw movement from
|
|
// a latch that failed to clear. It stops there because a longer wait here
|
|
// starves the budget-tail case below, which needs its own reads inside a
|
|
// 2000ms window.
|
|
let reads = 0;
|
|
currentTree = () => {
|
|
reads += 1;
|
|
return reads <= 3 ? screenWith("Home") : screenWith(`churn ${reads}`);
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 6000, stableFor: 0 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(reads, "reads served — under four, nothing moved to be judged").toBeGreaterThanOrEqual(
|
|
4
|
|
);
|
|
expect(r.steps.at(-1)!.warning).toContain("never held still");
|
|
}, 25_000);
|
|
|
|
// H2: the same latch let a screen that had gone BLANK by the deadline report
|
|
// ready. A blank tree is an observation, not a gap — it clears the verdict.
|
|
it("does not pass on a screen that settled early and then went blank", async () => {
|
|
currentFrame = () => undefined;
|
|
// Three still reads, then blank, sized and guarded the same way as the
|
|
// case above.
|
|
let reads = 0;
|
|
currentTree = () => {
|
|
reads += 1;
|
|
return reads <= 3 ? screenWith("Home") : n({ role: "AXWindow", frame: FULL, children: [] });
|
|
};
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 6000, stableFor: 0 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(
|
|
reads,
|
|
"reads served — under four, nothing went blank to be judged"
|
|
).toBeGreaterThanOrEqual(4);
|
|
expect(r.steps.at(-1)!.warning).toContain("never held still");
|
|
}, 25_000);
|
|
|
|
// H3: bounding the tree read by the remaining budget made the LAST read
|
|
// time out on every run, which turned every honest timeout into an
|
|
// environment `error` — deleting the hard-fail that justifies this step over
|
|
// the soft `await-screen-idle` tool. A round is not started without a budget
|
|
// to observe it with, and a read that ran out of step budget is the step
|
|
// ending, not the source failing.
|
|
it("still warns, rather than erroring, when a slow tree source keeps changing", async () => {
|
|
// 300ms per read against a 200ms tail budget: the LAST read runs out of
|
|
// step budget, which is the step ending, not the source failing. Earlier
|
|
// reads landed and saw a moving screen, so the verdict is theirs — and the
|
|
// budget the last read was given is what separates this from a source that
|
|
// wedged (see the case above).
|
|
treeDelayMs = 300;
|
|
let tick = 0;
|
|
currentTree = () => screenWith(`frame ${tick++}`);
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 2000, stableFor: 0 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.steps.at(-1)!.status).toBe("pass");
|
|
expect(r.steps.at(-1)!.warning).toContain("never held still");
|
|
});
|
|
|
|
// A step whose budget is spent mid-settle must not invent a verdict out of
|
|
// what it did not manage to observe. It has three ways to do that — blaming
|
|
// the capture, blaming motion, or claiming a settle — so this pins the
|
|
// outcome exactly rather than ruling one wording out.
|
|
it("reaches a real settle rather than a verdict about the budget that ran out", async () => {
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 1600, stableFor: 900 }
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
const step = r.steps.at(-1)!;
|
|
// A still screen and a working capture path: the only honest outcome is a
|
|
// clean settle, reached before the hold could exhaust the wait.
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toBeUndefined();
|
|
});
|
|
|
|
// A tree that reads back fine and is empty is an observation about the app,
|
|
// not a window that could not be read — so it warns like any other screen
|
|
// that did not settle, and the flow goes on to the checks that carry its
|
|
// verdict. Stopping there used to take every later step with it, including
|
|
// the element check that would have named what was actually wrong.
|
|
it("distinguishes a screen that never rendered from one that never settled", async () => {
|
|
currentTree = () => n({ role: "AXWindow", frame: FULL, children: [] });
|
|
await writeFlow(
|
|
"ready",
|
|
`executionPrerequisite: ""
|
|
steps:
|
|
- await: { idle: true, timeout: 900 }
|
|
- echo: reached
|
|
`
|
|
);
|
|
const r = await run("ready");
|
|
expect(r.ok).toBe(true);
|
|
expect(r.errored).toBe(0);
|
|
const step = r.steps.find((s) => s.kind === "idle")!;
|
|
expect(step.status).toBe("pass");
|
|
expect(step.warning).toContain("never rendered content");
|
|
expect(step.warning).not.toContain("never held still");
|
|
expect(r.steps.at(-1)).toMatchObject({ kind: "echo", status: "pass" });
|
|
});
|
|
});
|