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).
321 lines
11 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|