mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
551c781fb0
# Flow E2E
This PR turns flows from replayable tool-call recordings into a
declarative, portable e2e test system that runs headlessly via `argent
flow run <name>` — no LLM in the loop, non-zero exit on failure,
suitable for CI.
Agents can still replay flows via `flow-execute` and get the full output
from each step after it finishes.
## Flow format
Flows are YAML files in `.argent/flows/`, now built from **directives**
interpreted by the runner rather than raw tool calls:
- `launch` — terminate + relaunch the app under test (per-platform app
id map supported); its presence marks a flow as **e2e** (controls its
own start state) vs. a **fragment** (runs against current state,
declares an `executionPrerequisite`, composable via `run:` with
cycle/depth guards).
- `tap`, `type` (focus handshake + optional submit), `scroll-to`
(momentum-free, increments by half the container, `within:` for nested
scrollers), `await`/`assert` (`visible`/`exists`/`hidden`/`text`
conditions), `wait`, `snapshot`, `echo`, and raw `tool:` as escape hatch
— still fully supported, so previously recorded flows keep working
unchanged.
- **Selectors** are `{ text, identifier, role }`; a bare string is a
loose selector resolving identifier-first, then visible text. Any
directive failure hard-stops the flow; later steps report `skip`.
## Execution engine
- Flows store no device id — the runner binds a device at replay
(explicit `--device`/`--platform` or the single booted one).
- Selector resolution runs against **platform-unified UI trees**: iOS
native view hierarchy (full testID coverage, gated on native-devtools
connecting), Android full accessibility hierarchy (full resource-id
coverage), Chromium CDP DOM, and the Vega automation toolkit. A shared
flatten/text-hoisting pass lets `text` assertions read what an
identified container visibly shows. Vega is remote-driven, so touch
directives fail with guidance to use `tv-remote`/`keyboard`.
- Steps wait for the tree to **settle** (two consecutive identical
reads) before acting; `launch` additionally gates on platform
tree-source readiness instead of silently degrading.
## Visual snapshots
`snapshot` diffs a full-resolution screenshot against baselines in
`.argent/flows/__baselines__/<flow>/`, keyed by platform + resolution,
with configurable `maxMismatch` and `--update-baselines`. The status bar
is pinned for the whole run (iOS `simctl status_bar`, Android demo mode)
so clocks/battery don't drive diffs. Baseline/current/diff images are
returned as artifacts on failure (and the written baseline on first
capture/update); a clean pass carries none, keeping reports and agent
context lean.
## Recording
The record-live workflow (`flow-start-recording` → `flow-add-step` →
`flow-finish-recording`) now emits directives automatically: coordinate
taps are rewritten to portable selector `tap:` steps (selector captured
from the pre-tap tree), a leading `restart-app` becomes `launch:`,
`flow-execute` of a sibling fragment becomes `run:`, and device ids are
stripped. The skill instructs the agent to polish the saved YAML
afterwards (fold tap+keyboard into `type:`, swipes-to-reach into
`scroll-to:`, awaits into sugar form).
## Also included
- `argent flow run` / `argent flow list` CLI with per-step report
rendering, artifact materialization, and `--json` output.
- New structured failure codes for flow validation, device resolution,
composition, and devtools readiness.
- Extensive unit test coverage (tree adapters, selector matching,
scrolling, composition, type focus, snapshots, status bar).
193 lines
7.9 KiB
TypeScript
193 lines
7.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
import type { Registry } from "@argent/registry";
|
||
import type { DescribeNode } from "../src/tools/describe/contract";
|
||
|
||
vi.mock("../src/utils/device-info", () => ({
|
||
resolveDevice: vi.fn(() => ({
|
||
platform: "ios",
|
||
udid: "TEST-UDID",
|
||
name: "iPhone",
|
||
state: "Booted",
|
||
})),
|
||
}));
|
||
vi.mock("../src/tools/describe/platforms/ios", () => ({ describeIos: vi.fn() }));
|
||
vi.mock("../src/tools/describe/platforms/android", () => ({ describeAndroid: vi.fn() }));
|
||
// captureElementFrame probes isTvOsSimulator() — a real `xcrun simctl list`
|
||
// that never caches for a fake UDID, so it re-runs per call and takes seconds
|
||
// under the parallel suite load. Pin it; the rest of the module stays real.
|
||
vi.mock("../src/utils/ios-devices", async () => {
|
||
const actual = await vi.importActual<typeof import("../src/utils/ios-devices")>(
|
||
"../src/utils/ios-devices"
|
||
);
|
||
return { ...actual, isTvOsSimulator: async () => false };
|
||
});
|
||
|
||
import { captureElementFrame, findElementMatch } from "../src/utils/match-element-frame";
|
||
import { describeIos } from "../src/tools/describe/platforms/ios";
|
||
|
||
const describeIosMock = vi.mocked(describeIos);
|
||
const registry = {} as Registry;
|
||
|
||
const headerFrame = { x: 0.068, y: 0.092, width: 0.294, height: 0.033 };
|
||
const tabFrame = { x: 0, y: 0.923, width: 0.25, height: 0.077 };
|
||
const rowFrame = { x: 0, y: 0.215, width: 1, height: 0.13 };
|
||
const ROOT_FRAME = { x: 0, y: 0, width: 1, height: 1 };
|
||
const match = { by: "text" as const, value: "Favourites" };
|
||
// Zero retry delay so the warm-up loop runs instantly in tests.
|
||
const FAST = { attempts: 8, retryMs: 0 };
|
||
|
||
function node(
|
||
role: string,
|
||
label: string | undefined,
|
||
frame: { x: number; y: number; width: number; height: number },
|
||
children: DescribeNode[] = []
|
||
): DescribeNode {
|
||
return { role, label, frame, children };
|
||
}
|
||
|
||
// The empty/cold tree iOS returns for the first ~1s+ after a screen appears.
|
||
const COLD = { source: "ax-service" as const, tree: node("AXGroup", undefined, ROOT_FRAME, []) };
|
||
|
||
// A half-built tree: the persistent bottom tab ("Favourites (5)") has landed but
|
||
// the screen's own header has NOT yet rendered. Its label is a SUBSTRING of
|
||
// "Favourites" — the distractor that must not win.
|
||
const HALF_BUILT_TAB = {
|
||
source: "ax-service" as const,
|
||
tree: node("AXGroup", undefined, ROOT_FRAME, [
|
||
node("AXButton", ", , Favourites (5)", tabFrame),
|
||
node("AXButton", ", , Browse", { x: 0.25, y: 0.923, width: 0.25, height: 0.077 }),
|
||
node("AXButton", ", , Map", { x: 0.5, y: 0.923, width: 0.25, height: 0.077 }),
|
||
]),
|
||
};
|
||
|
||
// A fully-rendered Favourites screen: BOTH the header (exact "Favourites") and
|
||
// the tab ("Favourites (5)", substring) match — the exact header must win.
|
||
const WARM = {
|
||
source: "ax-service" as const,
|
||
tree: node("AXGroup", undefined, ROOT_FRAME, [
|
||
node("AXStaticText", "Favourites", headerFrame),
|
||
node("AXStaticText", "5 saved · 0 total stats", {
|
||
x: 0.068,
|
||
y: 0.127,
|
||
width: 0.294,
|
||
height: 0.015,
|
||
}),
|
||
node("AXButton", ", , Favourites (5)", tabFrame),
|
||
node("AXButton", ", , Browse", { x: 0.25, y: 0.923, width: 0.25, height: 0.077 }),
|
||
]),
|
||
};
|
||
|
||
// A fully-rendered DIFFERENT screen — no "Favourites" anywhere.
|
||
const WARM_OTHER = {
|
||
source: "ax-service" as const,
|
||
tree: node("AXGroup", undefined, ROOT_FRAME, [
|
||
node("AXStaticText", "Browse", { x: 0.068, y: 0.092, width: 0.2, height: 0.033 }),
|
||
node("AXButton", ", , Browse", { x: 0.25, y: 0.923, width: 0.25, height: 0.077 }),
|
||
node("AXButton", ", , Map", { x: 0.5, y: 0.923, width: 0.25, height: 0.077 }),
|
||
node("AXButton", ", , Compare", { x: 0.75, y: 0.923, width: 0.25, height: 0.077 }),
|
||
]),
|
||
};
|
||
|
||
// A list row whose label only CONTAINS the query — no exact hit possible.
|
||
const ROW = {
|
||
source: "ax-service" as const,
|
||
tree: node("AXGroup", undefined, ROOT_FRAME, [
|
||
node("AXGroup", "simisear, gluttony, blaze, ", rowFrame),
|
||
]),
|
||
};
|
||
|
||
describe("captureElementFrame — holds out for the exact element through warm-up", () => {
|
||
beforeEach(() => vi.clearAllMocks());
|
||
|
||
it("skips the half-built same-text tab and resolves the exact header once it renders", async () => {
|
||
describeIosMock
|
||
.mockResolvedValueOnce(COLD)
|
||
.mockResolvedValueOnce(HALF_BUILT_TAB)
|
||
.mockResolvedValueOnce(WARM);
|
||
const frame = await captureElementFrame(registry, "TEST-UDID", match, FAST);
|
||
expect(frame).toEqual(headerFrame); // header, NOT the bottom tab
|
||
expect(frame).not.toEqual(tabFrame);
|
||
expect(describeIosMock.mock.calls.length).toBe(3);
|
||
});
|
||
|
||
it("returns the exact header immediately when the screen is already warm", async () => {
|
||
describeIosMock.mockResolvedValue(WARM);
|
||
const frame = await captureElementFrame(registry, "TEST-UDID", match, FAST);
|
||
expect(frame).toEqual(headerFrame);
|
||
expect(describeIosMock.mock.calls.length).toBe(1);
|
||
});
|
||
|
||
it("falls back to a substring match after the budget when no exact hit exists", async () => {
|
||
describeIosMock.mockResolvedValue(ROW);
|
||
const frame = await captureElementFrame(
|
||
registry,
|
||
"TEST-UDID",
|
||
{ by: "text", value: "simisear" },
|
||
{ attempts: 3, retryMs: 0 }
|
||
);
|
||
expect(frame).toEqual(rowFrame);
|
||
expect(describeIosMock.mock.calls.length).toBe(3);
|
||
});
|
||
|
||
it("returns null when the element is absent within the budget", async () => {
|
||
describeIosMock.mockResolvedValue(WARM_OTHER);
|
||
const frame = await captureElementFrame(registry, "TEST-UDID", match, {
|
||
attempts: 3,
|
||
retryMs: 0,
|
||
});
|
||
expect(frame).toBeNull();
|
||
expect(describeIosMock.mock.calls.length).toBe(3);
|
||
});
|
||
|
||
it("keeps a substring partial when a LATER attempt re-empties (tree regressed mid-warm-up)", async () => {
|
||
// A substring hit lands first, then the AX tree re-empties (a transient the
|
||
// device can return while still settling). The held partial must survive —
|
||
// an "if the latest attempt is empty, return null" refactor would drop it.
|
||
describeIosMock
|
||
.mockResolvedValueOnce(ROW)
|
||
.mockResolvedValueOnce(COLD)
|
||
.mockResolvedValueOnce(COLD);
|
||
const frame = await captureElementFrame(
|
||
registry,
|
||
"TEST-UDID",
|
||
{ by: "text", value: "simisear" },
|
||
{ attempts: 3, retryMs: 0 }
|
||
);
|
||
expect(frame).toEqual(rowFrame); // the attempt-1 partial, not null
|
||
expect(describeIosMock.mock.calls.length).toBe(3); // no exact hit → full budget
|
||
});
|
||
|
||
it("stops well before the full attempt count once the wall-clock budget is spent (slow describe)", async () => {
|
||
// Each describe is slow, like an Android `uiautomator` dump. An instant
|
||
// describe would run all 8 attempts (see the absent-element test above); the
|
||
// time budget must cut the loop short so propose_variant can't block for
|
||
// 8 × a multi-second describe (the measured 18–23s Android stall).
|
||
describeIosMock.mockImplementation(async () => {
|
||
await new Promise((r) => setTimeout(r, 40));
|
||
return WARM_OTHER; // no "Favourites" → never an exact hit
|
||
});
|
||
const frame = await captureElementFrame(registry, "TEST-UDID", match, {
|
||
attempts: 8,
|
||
retryMs: 0,
|
||
budgetMs: 100,
|
||
});
|
||
expect(frame).toBeNull(); // absent → still best-effort null
|
||
// ~40ms/describe under a 100ms budget → a couple of calls, and crucially far
|
||
// fewer than the 8 attempts an instant describe would have run.
|
||
expect(describeIosMock.mock.calls.length).toBeLessThan(8);
|
||
expect(describeIosMock.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||
});
|
||
});
|
||
|
||
describe("findElementMatch — exact beats substring", () => {
|
||
it("prefers the exact 'Favourites' header over the 'Favourites (5)' tab", () => {
|
||
expect(findElementMatch(WARM.tree, match)).toEqual({ frame: headerFrame, exact: true });
|
||
});
|
||
|
||
it("reports a substring-only hit as inexact", () => {
|
||
const hit = findElementMatch(ROW.tree, { by: "text", value: "simisear" });
|
||
expect(hit?.exact).toBe(false);
|
||
expect(hit?.frame).toEqual(rowFrame);
|
||
});
|
||
});
|