Files
software-mansion__argent/packages/tool-server/test/match-element-frame-cold-describe.test.ts
Jakub Piasecki 551c781fb0 feat(flow): support running end-to-end flows without agent (#429)
# 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).
2026-07-13 17:15:01 +02:00

193 lines
7.9 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, 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);
});
});