Files
software-mansion__argent/packages/argent-cli/test/flow-output.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

321 lines
11 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { MaterializeContext } from "@argent/tools-client";
import { exportFailureArtifacts, type FlowReport, type StepReport } from "../src/flow.js";
let tmpDir: string;
let outDir: string;
// Legacy string-path artifacts contain no handles, so materialization walks
// them without touching the network — the URL never resolves.
const ctx: MaterializeContext = { toolsUrl: "http://tools.invalid" };
async function writeFile(name: string, content: string): Promise<string> {
const p = path.join(tmpDir, name);
await fs.writeFile(p, content);
return p;
}
/**
* A wire artifact handle whose hostPath is a real local file, sized/stamped so
* the materializer's co-location gate resolves it in place (no download).
*/
async function writeHandle(name: string, content: string): Promise<Record<string, unknown>> {
const p = await writeFile(name, content);
const st = await fs.stat(p);
return {
__argentArtifact: true,
id: `id-${name}`,
filename: name,
mimeType: "image/png",
size: st.size,
mtimeMs: st.mtimeMs,
hostPath: p,
};
}
function mkReport(steps: StepReport[]): FlowReport {
return {
flow: "checkout",
device: "UDID-1",
ok: false,
passed: 0,
failed: 1,
skipped: 0,
errored: 0,
steps,
};
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-output-"));
outDir = path.join(tmpDir, "out");
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("exportFailureArtifacts", () => {
it("copies every role of a failed snapshot to <output>/<flow>/<key>-<role>.png and rewrites the report", async () => {
const baseline = await writeFile("b.png", "baseline-bytes");
const current = await writeFile("c.png", "current-bytes");
const diff = await writeFile("d.png", "diff-bytes");
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { baseline, current, diff },
};
await exportFailureArtifacts(mkReport([step]), outDir, ctx);
const dir = path.join(outDir, "checkout");
for (const [role, content] of [
["baseline", "baseline-bytes"],
["current", "current-bytes"],
["diff", "diff-bytes"],
] as const) {
const dest = path.join(dir, `home__ios-390x844-${role}.png`);
expect(step.artifacts?.[role]).toBe(dest);
expect(await fs.readFile(dest, "utf8")).toBe(content);
}
});
it("leaves passed and baseline-seeded snapshots alone (failure-only)", async () => {
const baseline = await writeFile("b.png", "baseline-bytes");
const seeded: StepReport = {
index: 0,
kind: "snapshot",
status: "pass",
warning: "baseline created",
snapshotKey: "home__ios-390x844",
artifacts: { baseline },
};
await exportFailureArtifacts(mkReport([seeded]), outDir, ctx);
expect(seeded.artifacts?.baseline).toBe(baseline);
await expect(fs.access(outDir)).rejects.toThrow();
});
it("derives the key from the baseline path when the server sent no snapshotKey", async () => {
const baseline = await writeFile("home__android-1080x2400.png", "baseline-bytes");
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
artifacts: { baseline },
};
await exportFailureArtifacts(mkReport([step]), outDir, ctx);
expect(step.artifacts?.baseline).toBe(
path.join(outDir, "checkout", "home__android-1080x2400-baseline.png")
);
});
it("skips unmaterialized (null) roles and steps with no usable key", async () => {
const current = await writeFile("c.png", "current-bytes");
const withNull: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { baseline: null, current },
};
const keyless: StepReport = {
index: 1,
kind: "snapshot",
status: "fail",
artifacts: { current },
};
await exportFailureArtifacts(mkReport([withNull, keyless]), outDir, ctx);
expect(withNull.artifacts?.baseline).toBeNull();
expect(withNull.artifacts?.current).toBe(
path.join(outDir, "checkout", "home__ios-390x844-current.png")
);
// No snapshotKey and no baseline to derive one from — nothing written.
expect(keyless.artifacts?.current).toBe(current);
});
it("materializes a co-located snapshot's handles in place and copies them without fetching", async () => {
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: {
baseline: await writeHandle("b.png", "baseline-bytes"),
current: await writeHandle("c.png", "current-bytes"),
diff: await writeHandle("d.png", "diff-bytes"),
},
};
const fetchSpy = vi.fn(async () => {
throw new Error("unexpected network fetch");
});
await exportFailureArtifacts(mkReport([step]), outDir, {
toolsUrl: "http://tools.invalid",
fetchImpl: fetchSpy as unknown as typeof fetch,
});
// The handles' hostPaths are on this machine — resolved in place, no wire.
expect(fetchSpy).not.toHaveBeenCalled();
for (const [role, content] of [
["baseline", "baseline-bytes"],
["current", "current-bytes"],
["diff", "diff-bytes"],
] as const) {
const dest = path.join(outDir, "checkout", `home__ios-390x844-${role}.png`);
expect(step.artifacts?.[role]).toBe(dest);
expect(await fs.readFile(dest, "utf8")).toBe(content);
}
});
it("downloads a remote handle (no hostPath) and copies it under --output", async () => {
const prevCache = process.env.ARGENT_ARTIFACTS_DIR;
process.env.ARGENT_ARTIFACTS_DIR = path.join(tmpDir, "cache");
try {
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: {
diff: {
__argentArtifact: true,
id: "diff-1",
filename: "remote-diff.png",
mimeType: "image/png",
size: 10,
},
},
};
const fetchSpy = vi.fn(async () => new Response("diff-bytes"));
await exportFailureArtifacts(mkReport([step]), outDir, {
toolsUrl: "http://tools.invalid",
authToken: "tok",
fetchImpl: fetchSpy as unknown as typeof fetch,
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(fetchSpy).toHaveBeenCalledWith("http://tools.invalid/artifacts/diff-1", {
headers: { Authorization: "Bearer tok" },
});
const dest = path.join(outDir, "checkout", "home__ios-390x844-diff.png");
expect(step.artifacts?.diff).toBe(dest);
expect(await fs.readFile(dest, "utf8")).toBe("diff-bytes");
} finally {
if (prevCache === undefined) delete process.env.ARGENT_ARTIFACTS_DIR;
else process.env.ARGENT_ARTIFACTS_DIR = prevCache;
}
});
it("refuses a flow name with path traversal: warns, writes nothing, downloads nothing", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
// A remote handle (no hostPath) would force a download — proving the
// guard fires before materialization, not just before the copy.
const handle = {
__argentArtifact: true,
id: "diff-1",
filename: "remote-diff.png",
mimeType: "image/png",
size: 10,
};
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { diff: handle },
};
const fetchSpy = vi.fn(async () => new Response("diff-bytes"));
await exportFailureArtifacts({ ...mkReport([step]), flow: "../escape" }, outDir, {
toolsUrl: "http://tools.invalid",
fetchImpl: fetchSpy as unknown as typeof fetch,
});
expect(fetchSpy).not.toHaveBeenCalled();
expect(step.artifacts?.diff).toBe(handle);
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("unsafe flow name"));
// The would-be destination outside --output must not exist.
await expect(fs.access(path.join(tmpDir, "escape"))).rejects.toThrow();
await expect(fs.access(outDir)).rejects.toThrow();
} finally {
errSpy.mockRestore();
}
});
it("skips a step whose snapshotKey contains path traversal, still exporting safe steps", async () => {
const evil = await writeFile("evil.png", "evil-bytes");
const good = await writeFile("good.png", "good-bytes");
const badStep: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "../../pwned",
artifacts: { current: evil },
};
const goodStep: StepReport = {
index: 1,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { current: good },
};
await exportFailureArtifacts(mkReport([badStep, goodStep]), outDir, ctx);
// Untouched — the join would have resolved to <tmpDir>/pwned-current.png.
expect(badStep.artifacts?.current).toBe(evil);
await expect(fs.access(path.join(tmpDir, "pwned-current.png"))).rejects.toThrow();
expect(goodStep.artifacts?.current).toBe(
path.join(outDir, "checkout", "home__ios-390x844-current.png")
);
});
it("skips a step whose baseline-derived key reduces to '..'", async () => {
// path.basename("<dir>/..") is ".." — the fallback alone can't contain it.
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
artifacts: { baseline: `${tmpDir}${path.sep}..` },
};
await exportFailureArtifacts(mkReport([step]), outDir, ctx);
expect(step.artifacts?.baseline).toBe(`${tmpDir}${path.sep}..`);
await expect(fs.access(outDir)).rejects.toThrow();
});
it("warns and keeps the temp path when a source file is unreadable", async () => {
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const gone = path.join(tmpDir, "vanished.png");
const step: StepReport = {
index: 0,
kind: "snapshot",
status: "fail",
snapshotKey: "home__ios-390x844",
artifacts: { diff: gone },
};
await exportFailureArtifacts(mkReport([step]), outDir, ctx);
expect(step.artifacts?.diff).toBe(gone);
expect(errSpy).toHaveBeenCalledWith(expect.stringContaining("warning: could not write"));
} finally {
errSpy.mockRestore();
}
});
});