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).
126 lines
4.8 KiB
TypeScript
126 lines
4.8 KiB
TypeScript
/**
|
|
* Server-side element-frame matcher used by `propose_variant` to auto-capture
|
|
* each variant's crop frame at propose time (so every variant crops to its own
|
|
* on-screen layout instead of inheriting the first variant's frozen frame —
|
|
* the "first thumbnail right, the rest mis-cropped" bug).
|
|
*
|
|
* `matchFrameInTree` must mirror the preview UI's `vpMatchNode`: normalize the
|
|
* same way, prefer the smallest sane on-screen box, ignore full-screen
|
|
* containers.
|
|
*/
|
|
import { describe, it, expect, vi } from "vitest";
|
|
import type { Registry } from "@argent/registry";
|
|
|
|
vi.mock("../src/tools/describe/platforms/ios", () => ({ describeIos: vi.fn() }));
|
|
vi.mock("../src/tools/describe/platforms/android", () => ({ describeAndroid: vi.fn() }));
|
|
vi.mock("../src/utils/device-info", () => ({
|
|
resolveDevice: (udid: string) => ({ id: udid, platform: "ios", kind: "simulator" }),
|
|
}));
|
|
// 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 { describeIos } from "../src/tools/describe/platforms/ios";
|
|
import { matchFrameInTree, captureElementFrame } from "../src/utils/match-element-frame";
|
|
|
|
const mockedIos = describeIos as unknown as ReturnType<typeof vi.fn>;
|
|
|
|
// A small tree: a full-screen root containing a header group, a title label,
|
|
// and a button. The title and button are the realistic match targets.
|
|
const TREE = {
|
|
role: "AXGroup",
|
|
frame: { x: 0, y: 0, width: 1, height: 1 },
|
|
children: [
|
|
{
|
|
role: "AXGroup",
|
|
frame: { x: 0, y: 0.05, width: 1, height: 0.12 }, // header container
|
|
children: [
|
|
{
|
|
role: "StaticText",
|
|
label: "Favourites",
|
|
frame: { x: 0.04, y: 0.07, width: 0.5, height: 0.04 },
|
|
children: [],
|
|
},
|
|
{
|
|
role: "Button",
|
|
label: "Export",
|
|
identifier: "export-btn",
|
|
frame: { x: 0.7, y: 0.07, width: 0.25, height: 0.05 },
|
|
children: [],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
};
|
|
|
|
describe("matchFrameInTree", () => {
|
|
it("text match returns the matched element's frame (not a container)", () => {
|
|
const f = matchFrameInTree(TREE as never, { by: "text", value: "Favourites" });
|
|
expect(f).toEqual({ x: 0.04, y: 0.07, width: 0.5, height: 0.04 });
|
|
});
|
|
|
|
it("identifier match is exact", () => {
|
|
const f = matchFrameInTree(TREE as never, { by: "identifier", value: "export-btn" });
|
|
expect(f).toEqual({ x: 0.7, y: 0.07, width: 0.25, height: 0.05 });
|
|
});
|
|
|
|
it("label match resolves the button by its accessibility label", () => {
|
|
const f = matchFrameInTree(TREE as never, { by: "label", value: "Export" });
|
|
expect(f).toEqual({ x: 0.7, y: 0.07, width: 0.25, height: 0.05 });
|
|
});
|
|
|
|
it("ignores near-full-screen container matches", () => {
|
|
// A tree whose only 'menu' match spans the whole screen → treated as a
|
|
// container, not the target → no frame.
|
|
const big = {
|
|
role: "AXGroup",
|
|
frame: { x: 0, y: 0, width: 1, height: 1 },
|
|
label: "menu",
|
|
children: [],
|
|
};
|
|
expect(matchFrameInTree(big as never, { by: "label", value: "menu" })).toBeNull();
|
|
});
|
|
|
|
it("returns null when nothing matches", () => {
|
|
expect(matchFrameInTree(TREE as never, { by: "text", value: "nope" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("captureElementFrame", () => {
|
|
const registry = {} as Registry;
|
|
|
|
it("describes the device and returns the matched frame", async () => {
|
|
mockedIos.mockResolvedValue({ tree: TREE, source: "ax-service" });
|
|
const f = await captureElementFrame(registry, "UDID-1", { by: "text", value: "Favourites" });
|
|
expect(f).toEqual({ x: 0.04, y: 0.07, width: 0.5, height: 0.04 });
|
|
});
|
|
|
|
it("returns null (best-effort) when describe throws", async () => {
|
|
mockedIos.mockRejectedValue(new Error("ax-service timed out"));
|
|
const f = await captureElementFrame(registry, "UDID-1", { by: "text", value: "Favourites" });
|
|
expect(f).toBeNull();
|
|
});
|
|
|
|
it("returns null when the describe tree has no match (e.g. empty AX tree)", async () => {
|
|
mockedIos.mockResolvedValue({
|
|
tree: { role: "AXGroup", frame: { x: 0, y: 0, width: 1, height: 1 }, children: [] },
|
|
source: "ax-service",
|
|
});
|
|
// attempts: 1 — skip the warm-up retry budget; here we only assert that a
|
|
// single describe with no match yields null.
|
|
const f = await captureElementFrame(
|
|
registry,
|
|
"UDID-1",
|
|
{ by: "text", value: "Favourites" },
|
|
{ attempts: 1 }
|
|
);
|
|
expect(f).toBeNull();
|
|
});
|
|
});
|