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>
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
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"
|
||
);
|
||
});
|
||
});
|