Files
software-mansion__argent/packages/tool-server/test/flows/flow-progress.test.ts
Jakub Piasecki cc2f907ce9 feat(flows): resolve run: directives from explicit YAML paths (#567)
A `run:` value is now a YAML path resolved against the directory of the
flow file that contains the step, matching the CLI cutover:

```yaml
- run: login.yaml
- run: ../shared/login.yaml   # cross-directory composition now works
```

Bare names fail at parse with a migration hint (`run: login` → `run:
login.yaml`). The filename stem stays the fragment's report attribution;
reports also carry the as-written path as `target`. Cycle detection
compares realpath-canonical paths (same stem in two dirs ≠ cycle; same
file via symlink/spelling = cycle). Snapshot baselines are unchanged —
keyed by and stored beside the root flow.

An uploaded root flow with `run:` steps is now rejected up front with a
contract error instead of a mid-run temp-dir ENOENT (its referenced
files stayed on the client, and relative resolution in the temp dir
could reach host files); same for a relative chromium app path. The
recorder emits the sibling form (`run: <name>.yaml`).

**Breaking:** existing `run: <name>` directives fail at parse until
renamed to `run: <name>.yaml`. Snapshot baselines for a saved flow whose
YAML is a symlink now anchor beside the real file (the symlink target's
directory) — move any committed `.argent/flows/__baselines__/<flow>/`
set beside the real file once, or the next run fails with a missing
baseline. A `run:` target must also resolve inside the project root or
beside/below the flow file that references it (arbitrary host YAML is no
longer reachable), and an uploaded flow with a `snapshot` step is
rejected up front (its baselines cannot anchor to the per-call temp
dir).
2026-08-05 14:51:42 +02:00

213 lines
7.0 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 { Registry, ToolContext } from "@argent/registry";
import {
createRunFlowTool,
type FlowRunResult,
type StepReport,
} from "../../src/tools/flows/flow-run";
import { serializeFlow } from "../../src/tools/flows/flow-utils";
const DEVICE = "00000000-0000-0000-0000-0000000000ab";
let tmpDir: string;
function mockRegistry(): Registry {
return {
invokeTool: vi.fn(async (id: string) => {
if (id === "list-devices") return { devices: [] };
if (id === "boom") throw new Error("kaput");
return { ok: true };
}),
getTool: vi.fn(() => undefined),
// iOS flows gate on a native-devtools connection: report connected so the
// run proceeds, but expose no target app so the tree fetch falls back to
// the AX tree (these tests don't drive the native hierarchy).
resolveService: vi.fn(async () => ({
isConnected: () => true,
listConnectedBundleIds: () => [],
})),
} as unknown as Registry;
}
async function writeFlow(name: string, yaml: Parameters<typeof serializeFlow>[0]): Promise<void> {
const dir = path.join(tmpDir, ".argent", "flows");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, `${name}.yaml`), serializeFlow(yaml), "utf8");
}
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
if (!("steps" in r)) throw new Error(`expected a run result, got notice: ${r.notice}`);
return r;
}
/** ToolContext carrying only the progress hook — the flows under test touch nothing else. */
function progressCtx(events: StepReport[]): ToolContext {
return { emitProgress: (e: unknown) => events.push(e as StepReport) } as unknown as ToolContext;
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-progress-"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("flow progress streaming (ctx.emitProgress)", () => {
it("emits every report in execution order, identical to the final result", async () => {
await writeFlow("main", {
executionPrerequisite: "",
steps: [
{ kind: "echo", message: "starting" },
{ kind: "tool", name: "tap", args: { x: 0.5 } },
{ kind: "tool", name: "swipe", args: {} },
],
});
const events: StepReport[] = [];
const runFlow = createRunFlowTool(mockRegistry());
const result = asRun(
await runFlow.execute(
{},
{ name: "main", project_root: tmpDir, device: DEVICE },
progressCtx(events)
)
);
// Every appended report streamed, in order, as the same objects.
expect(events).toEqual(result.steps);
expect(events.map((e) => `${e.kind}:${e.status}`)).toEqual([
"echo:pass",
"tool:pass",
"tool:pass",
]);
// Echo is narration — the counters cover only the two real steps.
expect(result.passed).toBe(2);
expect(result.ok).toBe(true);
});
it("streams nested fragment steps (run:) with their source-flow attribution", async () => {
await writeFlow("login", {
executionPrerequisite: "On login screen",
steps: [{ kind: "tool", name: "tap", args: { x: 0.5 } }],
});
await writeFlow("main", {
executionPrerequisite: "",
steps: [
{ kind: "run", flow: "login.yaml" },
{ kind: "echo", message: "done" },
],
});
const events: StepReport[] = [];
const runFlow = createRunFlowTool(mockRegistry());
const result = asRun(
await runFlow.execute(
{},
{ name: "main", project_root: tmpDir, device: DEVICE },
progressCtx(events)
)
);
expect(events).toEqual(result.steps);
// The run marker and the fragment's expanded step both stream, attributed
// to the fragment.
expect(events.map((e) => `${e.kind}:${e.flow}`)).toEqual([
"run:login",
"tool:login",
"echo:main",
]);
});
it("streams the failing step and the skip-flood that follows it", async () => {
await writeFlow("main", {
executionPrerequisite: "",
steps: [
{ kind: "tool", name: "tap", args: {} },
{ kind: "tool", name: "boom", args: {} },
{ kind: "tool", name: "swipe", args: {} },
{ kind: "echo", message: "never reached" },
],
});
const events: StepReport[] = [];
const runFlow = createRunFlowTool(mockRegistry());
const result = asRun(
await runFlow.execute(
{},
{ name: "main", project_root: tmpDir, device: DEVICE },
progressCtx(events)
)
);
expect(events).toEqual(result.steps);
expect(events.map((e) => e.status)).toEqual(["pass", "error", "skip", "skip"]);
expect(result.ok).toBe(false);
// The skipped echo stays out of the counters like a passed one would.
expect(result.passed).toBe(1);
expect(result.errored).toBe(1);
expect(result.skipped).toBe(1);
});
it("labels directive steps with their target selector", async () => {
// A failing first step hard-stops the run, so every directive below is
// reported as a skip — which must still carry its target label.
await writeFlow("main", {
executionPrerequisite: "",
steps: [
{ kind: "tool", name: "boom", args: {} },
{ kind: "tap", selector: { text: "Clear logs", loose: true } },
{ kind: "tap", x: 0.5, y: 0.25 },
{ kind: "long-press", selector: { text: "Row 3", loose: true }, duration: 1200 },
{
kind: "assert",
condition: "hidden",
selector: { text: "] outer Touchable", loose: true },
},
{
kind: "await",
condition: "text",
selector: { identifier: "total" },
expectedText: "Total",
textMatch: "contains",
},
{ kind: "scroll-to", target: { text: "Nested touchables", loose: true }, direction: "up" },
{ kind: "type", into: { identifier: "email" }, text: "a@b.c" },
{ kind: "snapshot", name: "home" },
],
});
const runFlow = createRunFlowTool(mockRegistry());
const result = asRun(
await runFlow.execute({}, { name: "main", project_root: tmpDir, device: DEVICE })
);
expect(result.steps.map((s) => s.target)).toEqual([
undefined,
'"Clear logs"',
"(0.5, 0.25)",
'"Row 3"',
'hidden "] outer Touchable"',
'id=total contains "Total"',
'"Nested touchables" (up)',
"into id=email",
'"home"',
]);
});
it("runs identically when no progress consumer is attached", async () => {
await writeFlow("main", {
executionPrerequisite: "",
steps: [{ kind: "tool", name: "tap", args: {} }],
});
const runFlow = createRunFlowTool(mockRegistry());
const result = asRun(
await runFlow.execute({}, { name: "main", project_root: tmpDir, device: DEVICE })
);
expect(result.ok).toBe(true);
expect(result.steps).toHaveLength(1);
});
});