Files
software-mansion__argent/packages/argent-cli/test/flow-render.test.ts
Hubert Gancarczyk 96ea94f87a feat(flow): add an idle condition that waits for the screen to stop moving (#728)
> 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>
2026-08-10 11:50:16 +02:00

327 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect } from "vitest";
import {
renderReport,
renderStepLine,
renderEchoLine,
renderSummary,
renderArtifactLines,
renderUnderStepLine,
renderFailedSteps,
renderBatchSummary,
type FlowReport,
type StepReport,
} from "../src/flow.js";
function mkReport(steps: StepReport[], overrides: Partial<FlowReport> = {}): FlowReport {
// Mirror the runner's summarize(): echo narration is not a counted step.
const counted = steps.filter((s) => s.kind !== "echo");
const passed = counted.filter((s) => s.status === "pass").length;
const failed = counted.filter((s) => s.status === "fail").length;
const skipped = counted.filter((s) => s.status === "skip").length;
const errored = counted.filter((s) => s.status === "error").length;
return {
flow: "checkout",
device: "UDID-1",
ok: failed === 0 && errored === 0,
passed,
failed,
skipped,
errored,
steps,
...overrides,
};
}
const STEPS: StepReport[] = [
{ index: 0, kind: "echo", status: "pass", message: "starting" },
{ index: 1, kind: "launch", status: "pass" },
{ index: 2, kind: "tap", status: "pass", flow: "login", target: '"Login"' },
{
index: 3,
kind: "snapshot",
status: "fail",
reason: "diff 2.10% > 1%",
target: '"home"',
artifacts: { baseline: "/tmp/b.png", diff: "/tmp/d.png" },
},
{ index: 4, kind: "await", status: "skip", target: 'visible "Done"' },
];
describe("flow report rendering", () => {
it("buffered renderReport keeps its historical shape", () => {
const out = renderReport(mkReport(STEPS));
expect(out).toBe(
[
'Flow "checkout" on UDID-1',
" › starting",
" ✓ 1 launch",
' ✓ 2 tap "Login" [login]',
' ✗ 3 snapshot "home" — diff 2.10% > 1%',
" baseline: /tmp/b.png",
" diff: /tmp/d.png",
' · 4 await visible "Done"',
"",
"FAIL — 2 passed, 1 failed, 0 errored, 1 skipped",
].join("\n")
);
});
it("live step lines match the buffered renderer's step lines", () => {
const report = mkReport(STEPS);
const buffered = renderReport(report).split("\n");
// Reproduce the live loop: number only non-echo steps, same top flow.
const live: string[] = [];
let n = 0;
for (const s of report.steps) {
if (s.kind === "echo") {
const line = renderEchoLine(s);
if (line) live.push(line);
continue;
}
n++;
live.push(renderStepLine(s, n, report.flow));
}
// Every live line appears verbatim in the buffered output (which adds the
// header, inline artifact paths, and summary around them).
for (const line of live) expect(buffered).toContain(line);
});
it("pass with a warning renders the warning glyph", () => {
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "pass",
warning: "baseline seeded",
};
expect(renderStepLine(step, 1, "checkout")).toBe(" ⚠ 1 snapshot");
});
it("renders a skipped echo distinctly from one that ran", () => {
// A `when:` block that didn't run reports its echo as skipped. It must not
// print identically to an echo that executed, or the report lies about
// what happened.
const ran: StepReport = { index: 0, kind: "echo", status: "pass", message: "entering block" };
const skipped: StepReport = {
index: 1,
kind: "echo",
status: "skip",
reason: "when block skipped",
message: "entering block",
};
expect(renderEchoLine(ran)).toBe(" › entering block");
expect(renderEchoLine(skipped)).toBe(" · › entering block — when block skipped");
// The two must be visually distinguishable.
expect(renderEchoLine(ran)).not.toBe(renderEchoLine(skipped));
});
it("a hard-stopped echo (skip, no reason) still renders instead of vanishing", () => {
const stopped: StepReport = { index: 5, kind: "echo", status: "skip", message: "cleanup note" };
expect(renderEchoLine(stopped)).toBe(" · › cleanup note");
});
it("an echo without a message renders nothing", () => {
expect(renderEchoLine({ index: 0, kind: "echo", status: "pass" })).toBeUndefined();
});
it("a skipped echo appears in the buffered report as a marked line", () => {
const out = renderReport(
mkReport([
{ index: 0, kind: "launch", status: "pass" },
{
index: 1,
kind: "when",
status: "skip",
reason: 'condition not met (visible "Promo") — block skipped (1 step)',
target: 'visible "Promo"',
},
{
index: 2,
kind: "echo",
status: "skip",
reason: "when block skipped",
message: "THIS MUST NOT RUN",
},
])
);
expect(out).toContain(" · › THIS MUST NOT RUN — when block skipped");
expect(out).not.toContain(" › THIS MUST NOT RUN");
});
it("indents step and echo labels by depth, keeping the glyph/number columns", () => {
const tap: StepReport = {
index: 2,
kind: "tap",
status: "pass",
target: '"Dismiss"',
depth: 1,
};
expect(renderStepLine(tap, 3, "checkout")).toBe(' ✓ 3 tap "Dismiss"');
expect(renderStepLine({ ...tap, depth: 2 }, 3, "checkout")).toBe(' ✓ 3 tap "Dismiss"');
// Absent depth (a pre-depth tool-server) and explicit 0 both render flat.
expect(renderStepLine({ ...tap, depth: undefined }, 3, "checkout")).toBe(
' ✓ 3 tap "Dismiss"'
);
expect(renderStepLine({ ...tap, depth: 0 }, 3, "checkout")).toBe(' ✓ 3 tap "Dismiss"');
const echo: StepReport = {
index: 3,
kind: "echo",
status: "pass",
message: "inside",
depth: 1,
};
expect(renderEchoLine(echo)).toBe(" › inside");
const skippedEcho: StepReport = {
...echo,
status: "skip",
reason: "when block skipped",
depth: 2,
};
expect(renderEchoLine(skippedEcho)).toBe(" · › inside — when block skipped");
});
it("clamps a hostile wire depth instead of throwing or exploding", () => {
// depth arrives over the wire: a negative value must not throw
// (String.repeat rejects it) and a huge one must not allocate a huge line.
const tap: StepReport = { index: 0, kind: "tap", status: "pass", target: '"A"', depth: -3 };
expect(renderStepLine(tap, 1, "f")).toBe(' ✓ 1 tap "A"');
expect(renderStepLine({ ...tap, depth: 1.5 }, 1, "f")).toBe(' ✓ 1 tap "A"');
// The cap clamps, it does not discard: legitimate depth can exceed it
// (the producer's run-chain and when-nesting limits accumulate), so a
// too-deep step keeps the maximum indent rather than snapping back flat.
const atCap = renderStepLine({ ...tap, depth: 20 }, 1, "f");
expect(atCap).toBe(` ✓ 1 ${" ".repeat(20)}tap "A"`);
expect(renderStepLine({ ...tap, depth: 21 }, 1, "f")).toBe(atCap);
expect(renderStepLine({ ...tap, depth: 1e9 }, 1, "f")).toBe(atCap);
});
it("buffered report shifts under-step lines (warnings, artifacts) with the step", () => {
const out = renderReport(
mkReport([
{
index: 0,
kind: "when",
status: "pass",
reason: 'condition met (visible "Promo")',
target: 'visible "Promo"',
},
{
index: 1,
kind: "snapshot",
status: "fail",
reason: "diff 2.10% > 1%",
target: '"home"',
depth: 1,
warning: "baseline seeded",
artifacts: { baseline: "/tmp/b.png" },
},
])
);
expect(out).toContain(' ✗ 2 snapshot "home" — diff 2.10% > 1%');
expect(out).toContain(" ⚠ baseline seeded");
expect(out).toContain(" baseline: /tmp/b.png");
});
it("the live tail's warning line (renderUnderStepLine) shifts with depth too", () => {
// The live path prints warnings through the same helper as the buffered
// renderer — pin the helper so the two can't drift apart.
const step: StepReport = { index: 0, kind: "snapshot", status: "pass", depth: 1 };
expect(renderUnderStepLine(step, 3, "⚠ baseline seeded")).toBe(" ⚠ baseline seeded");
expect(renderUnderStepLine({ ...step, depth: undefined }, 3, "⚠ w")).toBe(" ⚠ w");
});
it("under-step lines stay under the label when the step number grows past 99", () => {
// padStart(2) widens the number column at 100+; the under-step pad must
// widen with it, at any depth.
for (const n of [9, 99, 100, 1000]) {
for (const depth of [undefined, 1]) {
const step: StepReport = { index: 0, kind: "snapshot", status: "pass", depth };
const labelCol = renderStepLine(step, n, "f").indexOf("snapshot");
expect(renderUnderStepLine(step, n, "⚠ w").indexOf("⚠")).toBe(labelCol);
}
}
});
it("renderSummary carries the device only when asked (live tail)", () => {
const report = mkReport(STEPS);
expect(renderSummary(report)).toBe("FAIL — 2 passed, 1 failed, 0 errored, 1 skipped");
// "started on": a chromium run can move onto runner-booted instances, so
// the summary must not claim the whole run happened on the starting device.
expect(renderSummary(report, { withDevice: true })).toBe(
"FAIL (started on UDID-1) — 2 passed, 1 failed, 0 errored, 1 skipped"
);
});
it("renderArtifactLines labels paths by step number, skipping echo steps", () => {
const lines = renderArtifactLines(mkReport(STEPS));
// The snapshot is the 3rd numbered step (echo carries no number).
expect(lines).toEqual([
" snapshot (step 3):",
" baseline: /tmp/b.png",
" diff: /tmp/d.png",
]);
});
it("renderFailedSteps keeps full-report numbering and under-lines, echoes excluded", () => {
// Only the failing snapshot appears, numbered 3 as in the full report so
// the line matches a single-mode rerun; its artifact paths ride along.
expect(renderFailedSteps(mkReport(STEPS))).toEqual([
' ✗ 3 snapshot "home" — diff 2.10% > 1%',
" baseline: /tmp/b.png",
" diff: /tmp/d.png",
]);
});
it("renderFailedSteps includes errored steps and their warnings", () => {
expect(
renderFailedSteps(
mkReport([
{ index: 0, kind: "tap", status: "pass" },
{
index: 1,
kind: "tool",
tool: "screenshot",
status: "error",
reason: "device gone",
warning: "no baseline; adopted",
},
])
)
).toEqual([" ✗ 2 tool screenshot — device gone", " ⚠ no baseline; adopted"]);
});
it("renderFailedSteps is empty for a clean pass", () => {
expect(renderFailedSteps(mkReport([{ index: 0, kind: "tap", status: "pass" }]))).toEqual([]);
});
it("renderFailedSteps prints a passing step's warning, which renderSummary counts", () => {
// `await: { idle: true }` only ever warns on a step that PASSED, and the
// summary counts warnings whatever the status — so a directory run used to
// report "1 warning" with the text nowhere on screen.
const report = mkReport([
{ index: 0, kind: "tap", status: "pass" },
{ index: 1, kind: "idle", status: "pass", warning: "the screen never held still" },
]);
expect(renderFailedSteps(report)).toEqual([
" ⚠ 2 idle",
" ⚠ the screen never held still",
]);
expect(renderSummary(report)).toContain("1 warning");
});
it("renderBatchSummary mirrors the step summary's verdict shape", () => {
expect(renderBatchSummary({ total: 3, passed: 2, failed: 1, skipped: 0 })).toBe(
"FAIL — 3 flows: 2 passed, 1 failed, 0 skipped"
);
expect(renderBatchSummary({ total: 1, passed: 1, failed: 0, skipped: 0 })).toBe(
"PASS — 1 flow: 1 passed, 0 failed, 0 skipped"
);
// Skips only ever follow a failure, so they never turn the verdict alone.
expect(renderBatchSummary({ total: 2, passed: 1, failed: 0, skipped: 1 })).toBe(
"PASS — 2 flows: 1 passed, 0 failed, 1 skipped"
);
});
});