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).
124 lines
4.3 KiB
TypeScript
124 lines
4.3 KiB
TypeScript
/**
|
|
* End-to-end regression for the feature-flag-gate bypass (PR #271 FIX 1).
|
|
*
|
|
* `flow-execute` dispatches arbitrary tool ids through `registry.invokeTool`.
|
|
* Before the fix, the `argent-lens` gate lived ONLY at the HTTP edge
|
|
* (http.ts), so a flow could invoke a flag-gated tool (e.g. `propose_variant`)
|
|
* even while the flag was OFF — the registry dispatch had no gate. This test
|
|
* drives a REAL `Registry` (not the mock used elsewhere) so the gate it
|
|
* exercises is the production one, and proves:
|
|
*
|
|
* - flag OFF → the step errors with "not found" AND the gated tool's side
|
|
* effect never happens (store not mutated);
|
|
* - flag ON → the same step runs and the side effect lands.
|
|
*
|
|
* Run `run_in_band`-style serially because it relies on a shared active project
|
|
* root (the flow harness's module state), like the sibling flow tests.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import { z } from "zod";
|
|
import { Registry } from "@argent/registry";
|
|
|
|
import { createRunFlowTool } from "../../src/tools/flows/flow-run";
|
|
import {
|
|
clearActiveProjectRoot,
|
|
setActiveProjectRoot,
|
|
serializeFlow,
|
|
} from "../../src/tools/flows/flow-utils";
|
|
|
|
let tmpDir: string;
|
|
|
|
/**
|
|
* Builds a real Registry whose flag check we control, with a flag-gated tool
|
|
* whose `execute` flips `sideEffect.ran` — our observable "store mutation".
|
|
*/
|
|
function buildRegistry(flagEnabled: boolean): { registry: Registry; sideEffect: { ran: boolean } } {
|
|
const sideEffect = { ran: false };
|
|
const registry = new Registry({ isFlagEnabled: () => flagEnabled });
|
|
registry.registerTool({
|
|
id: "propose_variant",
|
|
featureFlag: "argent-lens",
|
|
zodSchema: z.object({}),
|
|
services: () => ({}),
|
|
async execute() {
|
|
sideEffect.ran = true;
|
|
return { ok: true };
|
|
},
|
|
});
|
|
return { registry, sideEffect };
|
|
}
|
|
|
|
async function writeFlow(name: string): Promise<void> {
|
|
const dir = path.join(tmpDir, ".argent", "flows");
|
|
await fs.mkdir(dir, { recursive: true });
|
|
await fs.writeFile(
|
|
path.join(dir, `${name}.yaml`),
|
|
serializeFlow({
|
|
executionPrerequisite: "",
|
|
steps: [{ kind: "tool", name: "propose_variant", args: {} }],
|
|
})
|
|
);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-flag-gate-"));
|
|
setActiveProjectRoot(tmpDir);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
clearActiveProjectRoot();
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("flow-execute honors the registry feature-flag gate", () => {
|
|
it("flag OFF: the gated step errors (not found) and the tool's side effect never runs", async () => {
|
|
const { registry, sideEffect } = buildRegistry(false);
|
|
const runFlow = createRunFlowTool(registry);
|
|
await writeFlow("gated-off");
|
|
|
|
const result = await runFlow.execute(
|
|
{},
|
|
{ name: "gated-off", project_root: tmpDir, device: "00000000-0000-0000-0000-0000000000ab" }
|
|
);
|
|
|
|
expect(result).toHaveProperty("steps");
|
|
const steps = (
|
|
result as { steps: Array<{ kind: string; status: string; tool?: string; reason?: string }> }
|
|
).steps;
|
|
expect(steps).toHaveLength(1);
|
|
expect(steps[0]).toMatchObject({ kind: "tool", status: "error", tool: "propose_variant" });
|
|
// "not found" surfaces because the disabled flag-gated tool is treated as
|
|
// unregistered by the registry (mirrors the HTTP 404).
|
|
expect(steps[0]!.reason).toMatch(/not found/i);
|
|
// The bypass is closed: the gated tool's body never ran.
|
|
expect(sideEffect.ran).toBe(false);
|
|
});
|
|
|
|
it("flag ON: the same gated step runs and the side effect lands", async () => {
|
|
const { registry, sideEffect } = buildRegistry(true);
|
|
const runFlow = createRunFlowTool(registry);
|
|
await writeFlow("gated-on");
|
|
|
|
const result = await runFlow.execute(
|
|
{},
|
|
{ name: "gated-on", project_root: tmpDir, device: "00000000-0000-0000-0000-0000000000ab" }
|
|
);
|
|
|
|
const steps = (
|
|
result as { steps: Array<{ kind: string; status: string; tool?: string; result?: unknown }> }
|
|
).steps;
|
|
expect(steps).toHaveLength(1);
|
|
expect(steps[0]).toMatchObject({
|
|
kind: "tool",
|
|
status: "pass",
|
|
tool: "propose_variant",
|
|
result: { ok: true },
|
|
});
|
|
expect(steps[0]).not.toHaveProperty("reason");
|
|
expect(sideEffect.ran).toBe(true);
|
|
});
|
|
});
|