Files
software-mansion__argent/packages/tool-server/test/flows/flow-deviceless.test.ts
Jakub Piasecki abe5ff3c56 refactor(flow): generalize when's block machinery so a second block directive can't miss a site (#748)
`when` is the only directive with a sibling `steps:` list, and four
runner sites asked `kind === "when"` directly to decide whether a step
has authored children to expand as skip lines. A second block directive
would have had to remember all four, and a forgotten one silently drops
a whole block from the report — exactly the invariant
`reportBlockSkipped` exists to hold.

This introduces `blockSteps(step)` as the single runtime predicate and
`BLOCK_DIRECTIVE_KEYS` as its parse-time counterpart, and extracts the
parse-side block plumbing (`assertBlockDepth`, `parseBlockSteps`) that a
second directive must share — notably the depth cap, renamed
`MAX_BLOCK_DEPTH`, which has to be ONE counter across directives or an
alternating nesting chain evades it and reopens the cyclic-YAML-alias
hole it was added to close.

No behavior change, with one deliberate exception: the depth-cap message
now reads `block directives nest deeper than 20 levels` where it said
`when blocks nest deeper than 20 levels` — the cap stopped being
`when`'s the moment it became shared, and a message naming only `when`
would misdirect an author who hit it through a different directive.
Every other error message, their ordering, and the report shapes are
untouched.

559 flow tests pass unchanged.

---

**Stack**

1. **#748 — you are here** · `refactor(flow)`: generalize when's block
machinery → `main`
2. #749 · `feat(flow)`: add `repeat:` blocks → #748
2026-08-13 21:51:23 +02:00

561 lines
22 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 } from "@argent/registry";
import { zodObjectToJsonSchema } from "@argent/registry";
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
import { serializeFlow, type FlowStep } from "../../src/tools/flows/flow-utils";
import {
flowRequiresDevice,
flowScopesDevice,
stepRequiresDevice,
} from "../../src/tools/flows/flow-device";
import { createStopAllSimulatorServersTool } from "../../src/tools/simulator/stop-all-simulator-servers";
const DEVICE = "00000000-0000-0000-0000-0000000000ab";
let tmpDir: string;
/**
* Tools keyed by the device argument they declare — what decides whether a step
* acts on a device. `undefined` models a tool the registry does not know.
*/
const TOOLS: Record<string, { inputSchema?: unknown } | undefined> = {
"tap": { inputSchema: { properties: { udid: {}, x: {}, y: {} } } },
"stop-metro": { inputSchema: { properties: { port: {} } } },
// Declares a device LIST rather than a single id — the shape the runner has
// to rebind to the run device, and therefore one that makes a step need one.
"stop-all-simulator-servers": { inputSchema: { properties: { devices: {} } } },
// A tool that declares no input at all.
"gather-workspace-data": {},
// Takes a device without receiving the run's own.
"flow-execute": { inputSchema: { properties: { name: {}, device: {} } } },
};
function mockRegistry(opts: { booted?: string[] } = {}) {
const invokeTool = vi.fn(async (id: string) => {
if (id === "list-devices") {
return {
devices: (opts.booted ?? []).map((udid) => ({ platform: "ios", udid, state: "Booted" })),
};
}
return { ok: true };
});
const registry = {
invokeTool,
getTool: vi.fn((name: string) => TOOLS[name]),
resolveService: vi.fn(async () => ({
isConnected: () => true,
listConnectedBundleIds: () => [],
})),
} as unknown as Registry;
return { registry, invokeTool };
}
async function writeFlow(name: string, steps: FlowStep[]): 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 }),
"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;
}
/** Run without an explicit device, the case where auto-detection would kick in. */
async function runAuto(registry: Registry, name: string) {
const runFlow = createRunFlowTool(registry);
return runFlow.execute({}, { name, project_root: tmpDir });
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "flow-deviceless-"));
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
describe("a flow that touches no device", () => {
it("runs with nothing booted, and never looks for a device", async () => {
await writeFlow("echo-only", [
{ kind: "echo", message: "first step" },
{ kind: "echo", message: "second step" },
]);
const { registry, invokeTool } = mockRegistry({ booted: [] });
const result = asRun(await runAuto(registry, "echo-only"));
expect(result.ok).toBe(true);
expect(result.device).toBe("");
expect(result.steps.map((s) => [s.kind, s.status])).toEqual([
["echo", "pass"],
["echo", "pass"],
]);
expect(invokeTool).not.toHaveBeenCalledWith(
"list-devices",
expect.anything(),
expect.anything()
);
});
it("is not attributed to a device that merely happens to be booted", async () => {
// Otherwise an identical flow reports differently on a laptop with a
// simulator open than in CI with none.
await writeFlow("echo-only", [{ kind: "echo", message: "hi" }]);
const { registry, invokeTool } = mockRegistry({ booted: [DEVICE] });
const result = asRun(await runAuto(registry, "echo-only"));
expect(result.device).toBe("");
expect(invokeTool).not.toHaveBeenCalledWith(
"list-devices",
expect.anything(),
expect.anything()
);
});
it("still names the device when one was asked for explicitly", async () => {
await writeFlow("echo-only", [{ kind: "echo", message: "hi" }]);
const { registry } = mockRegistry({ booted: [DEVICE] });
const runFlow = createRunFlowTool(registry);
const result = asRun(
await runFlow.execute({}, { name: "echo-only", project_root: tmpDir, device: DEVICE })
);
expect(result.device).toBe(DEVICE);
});
it("runs a wait-only flow", async () => {
await writeFlow("waiting", [{ kind: "wait", ms: 1 }]);
const { registry } = mockRegistry({ booted: [] });
expect(asRun(await runAuto(registry, "waiting")).ok).toBe(true);
});
it("runs a tool step whose tool takes no device", async () => {
await writeFlow("stop", [{ kind: "tool", name: "stop-metro", args: { port: 8081 } }]);
const { registry } = mockRegistry({ booted: [] });
const result = asRun(await runAuto(registry, "stop"));
expect(result.ok).toBe(true);
expect(result.device).toBe("");
});
it("runs a tool step whose tool declares no input at all", async () => {
// A tool with no schema must not be mistaken for one that needs a device,
// and reading its absent schema must not throw.
await writeFlow("no-schema", [{ kind: "tool", name: "gather-workspace-data", args: {} }]);
const { registry } = mockRegistry({ booted: [] });
expect(asRun(await runAuto(registry, "no-schema")).ok).toBe(true);
});
it("runs an empty flow", async () => {
await writeFlow("nothing", []);
const { registry } = mockRegistry({ booted: [] });
const result = asRun(await runAuto(registry, "nothing"));
expect(result.ok).toBe(true);
expect(result.device).toBe("");
expect(result.steps).toEqual([]);
});
});
describe("a flow that does touch a device still demands one", () => {
const expectDemandsDevice = async (name: string) => {
const { registry } = mockRegistry({ booted: [] });
await expect(runAuto(registry, name)).rejects.toThrow(/No booted device found/);
};
it("when a directive step is mixed in with narration", async () => {
await writeFlow("mixed", [
{ kind: "echo", message: "about to tap" },
{ kind: "tap", x: 0.5, y: 0.5 },
]);
await expectDemandsDevice("mixed");
});
it("when the only device step is inside a when block", async () => {
await writeFlow("guarded", [
{ kind: "echo", message: "checking" },
{
kind: "when",
condition: { kind: "ui", condition: "visible", selector: { text: "Settings" } },
steps: [{ kind: "tap", x: 0.5, y: 0.5 }],
},
]);
await expectDemandsDevice("guarded");
});
it("when a when block guards on platform and contains only narration", async () => {
// The guard reads the device's platform, so the block's contents are beside
// the point.
await writeFlow("platform-guarded", [
{
kind: "when",
condition: { kind: "platform", platform: "ios" },
steps: [{ kind: "echo", message: "on ios" }],
},
]);
await expectDemandsDevice("platform-guarded");
});
it("when it launches an app", async () => {
await writeFlow("launcher", [{ kind: "launch", app: { ios: "com.example.app" } }]);
await expectDemandsDevice("launcher");
});
it("when a tool step's tool declares a device argument", async () => {
await writeFlow("tapping", [{ kind: "tool", name: "tap", args: { x: 0.5 } }]);
await expectDemandsDevice("tapping");
});
it("when a tool step's tool takes a device without being given the run's own", async () => {
// A nested flow drives a device even though the runner does not hand it one.
await writeFlow("nested", [{ kind: "tool", name: "flow-execute", args: { name: "inner" } }]);
await expectDemandsDevice("nested");
});
it("when the tool is unknown to the registry", async () => {
await writeFlow("mystery", [{ kind: "tool", name: "not-a-tool", args: {} }]);
await expectDemandsDevice("mystery");
});
it("when it composes another flow, even a narration-only one", async () => {
// The fragment is resolved at run time, so composition is taken to need a
// device rather than resolved twice and risking disagreement.
await writeFlow("child", [{ kind: "echo", message: "quiet" }]);
await writeFlow("parent", [
{ kind: "echo", message: "calling child" },
{ kind: "run", flow: "child.yaml" },
]);
await expectDemandsDevice("parent");
});
});
describe("stepRequiresDevice", () => {
it("classifies every step kind", () => {
// Keyed on the union, so a new step kind fails to compile until it is
// classified here as well as in the implementation.
const expected: Record<FlowStep["kind"], boolean> = {
"echo": false,
"wait": false,
"tool": true,
"run": true,
"when": true,
"launch": true,
"tap": true,
"long-press": true,
"type": true,
"await": true,
"assert": true,
"idle": true,
"scroll-to": true,
"pinch": true,
"rotate": true,
"snapshot": true,
};
const samples: Record<FlowStep["kind"], FlowStep> = {
"echo": { kind: "echo", message: "x" },
"wait": { kind: "wait", ms: 1 },
"tool": { kind: "tool", name: "tap", args: {} },
"run": { kind: "run", flow: "other" },
"when": { kind: "when", condition: { kind: "platform", platform: "ios" }, steps: [] },
"launch": { kind: "launch", app: { ios: "com.example" } },
"tap": { kind: "tap", x: 0, y: 0 },
"long-press": { kind: "long-press", x: 0, y: 0 },
"type": { kind: "type", into: { text: "f" }, text: "hi" },
"await": { kind: "await", condition: "visible", selector: { text: "f" } },
"assert": { kind: "assert", condition: "visible", selector: { text: "f" } },
"idle": { kind: "idle" },
"scroll-to": { kind: "scroll-to", target: { text: "f" }, direction: "down" },
"pinch": { kind: "pinch", scale: 2 },
"rotate": { kind: "rotate", by: 90 },
"snapshot": { kind: "snapshot", name: "s" },
};
const { registry } = mockRegistry();
for (const kind of Object.keys(expected) as FlowStep["kind"][]) {
expect(stepRequiresDevice(registry, samples[kind]), `kind: ${kind}`).toBe(expected[kind]);
}
});
it("distinguishes tool steps by the device argument their tool declares", () => {
const { registry } = mockRegistry();
const toolStep = (name: string): FlowStep => ({ kind: "tool", name, args: {} });
expect(stepRequiresDevice(registry, toolStep("tap"))).toBe(true);
expect(stepRequiresDevice(registry, toolStep("flow-execute"))).toBe(true);
expect(stepRequiresDevice(registry, toolStep("stop-metro"))).toBe(false);
expect(stepRequiresDevice(registry, toolStep("gather-workspace-data"))).toBe(false);
expect(stepRequiresDevice(registry, toolStep("not-a-tool"))).toBe(true);
});
it("does NOT count the REAL stop-all-simulator-servers schema as needing a device", () => {
// Against the derived JSON schema, not the mock above: the mock is only as
// good as its agreement with the tool, and the failure this guards is
// exactly a drift between the two. Catches a rename of `devices` too.
//
// `devices` is a SCOPE, not a target: the unscoped call is a complete,
// meaningful machine-wide sweep, so a flow whose only step is this one
// needs no device. Counting it made such a flow demand one — see the
// cleanup-flow cases below, which are the two situations it actually runs
// in.
const schema = zodObjectToJsonSchema(
createStopAllSimulatorServersTool({} as unknown as Registry).zodSchema!
);
expect(Object.keys((schema as { properties: Record<string, unknown> }).properties)).toContain(
"devices"
);
const registry = { getTool: () => ({ inputSchema: schema }) } as unknown as Registry;
expect(
stepRequiresDevice(registry, { kind: "tool", name: "stop-all-simulator-servers", args: {} })
).toBe(false);
});
it("counts a device TARGET argument, but not a device LIST scope", () => {
// The distinction is what a missing device does to the step: `screenshot`
// with no `udid` has nothing to point at, while the teardown with no
// `devices` is the sweep itself.
const { registry } = mockRegistry();
expect(
stepRequiresDevice(registry, { kind: "tool", name: "stop-all-simulator-servers", args: {} })
).toBe(false);
expect(stepRequiresDevice(registry, { kind: "tool", name: "tap", args: {} })).toBe(true);
});
});
describe("flowRequiresDevice", () => {
// These pin the function's ANSWERS. With `when` the only block kind, its
// header decides every case - no FlowStep value can exercise the child walk.
const whenOver = (steps: FlowStep[]): FlowStep => ({
kind: "when",
condition: { kind: "platform", platform: "ios" },
steps,
});
it("answers false for an empty flow", () => {
const { registry } = mockRegistry();
expect(flowRequiresDevice(registry, [])).toBe(false);
});
it("answers false for narration and waits alone", () => {
const { registry } = mockRegistry();
expect(
flowRequiresDevice(registry, [
{ kind: "echo", message: "hi" },
{ kind: "wait", ms: 1 },
])
).toBe(false);
});
it("answers false for a tool step whose tool takes no device", () => {
const { registry } = mockRegistry();
expect(
flowRequiresDevice(registry, [{ kind: "tool", name: "stop-metro", args: { port: 8081 } }])
).toBe(false);
});
it("answers true for a tool step whose tool declares a device argument", () => {
const { registry } = mockRegistry();
expect(flowRequiresDevice(registry, [{ kind: "tool", name: "tap", args: {} }])).toBe(true);
});
it("answers true for a when over a device-free body - the guard reads the device", () => {
const { registry } = mockRegistry();
expect(flowRequiresDevice(registry, [whenOver([{ kind: "echo", message: "quiet" }])])).toBe(
true
);
});
it("answers true for a when nested three deep", () => {
const { registry } = mockRegistry();
expect(flowRequiresDevice(registry, [whenOver([whenOver([whenOver([])])])])).toBe(true);
});
it("answers true when only a later step in a mixed list needs a device", () => {
const { registry } = mockRegistry();
expect(
flowRequiresDevice(registry, [
{ kind: "echo", message: "about to tap" },
{ kind: "tool", name: "stop-metro", args: { port: 8081 } },
{ kind: "tap", x: 0.5, y: 0.5 },
])
).toBe(true);
});
});
describe("flowScopesDevice", () => {
// These pin the function's ANSWERS. The child walk shows in them - unlike
// `flowRequiresDevice`, a `when` header scopes nothing itself - but only
// under a direct call: a run reaches this question only once
// `flowRequiresDevice` said no, and `when`, the only block kind, answers that
// one yes for any flow holding a block.
const whenOver = (steps: FlowStep[]): FlowStep => ({
kind: "when",
condition: { kind: "platform", platform: "ios" },
steps,
});
const teardown: FlowStep = { kind: "tool", name: "stop-all-simulator-servers", args: {} };
it("answers false for an empty flow", () => {
const { registry } = mockRegistry();
expect(flowScopesDevice(registry, [])).toBe(false);
});
it("answers false for a tool step whose tool declares no device scope", () => {
const { registry } = mockRegistry();
expect(
flowScopesDevice(registry, [
{ kind: "echo", message: "hi" },
{ kind: "tool", name: "stop-metro", args: { port: 8081 } },
])
).toBe(false);
});
it("answers false for a device TARGET argument - a target is not a scope", () => {
const { registry } = mockRegistry();
expect(flowScopesDevice(registry, [{ kind: "tool", name: "tap", args: {} }])).toBe(false);
});
it("answers true for a tool step whose tool declares a device LIST scope", () => {
const { registry } = mockRegistry();
expect(flowScopesDevice(registry, [teardown])).toBe(true);
});
it("answers false for a when over a body that scopes nothing", () => {
const { registry } = mockRegistry();
expect(flowScopesDevice(registry, [whenOver([{ kind: "echo", message: "quiet" }])])).toBe(
false
);
});
it("answers true for a scope inside a when, nested three deep", () => {
const { registry } = mockRegistry();
expect(flowScopesDevice(registry, [whenOver([whenOver([whenOver([teardown])])])])).toBe(true);
});
});
describe("a cleanup flow whose only step is stop-all-simulator-servers", () => {
const teardownOnly: FlowStep[] = [
// What the recorder writes for an UNSCOPED `stop-all-simulator-servers`.
// A scoped one keeps its `devices` in the YAML — `stripDeviceKeys` touches
// only the target keys, and `flow-tools.test.ts`'s "keeps the devices list
// when recording a scoped teardown" pins that — so the empty args here are
// the recording of the machine-wide sweep, which replay then NARROWS onto
// the run device.
{ kind: "tool", name: "stop-all-simulator-servers", args: {} },
];
it("replays against the run device when exactly one is booted", async () => {
await writeFlow("teardownonly", teardownOnly);
const { registry, invokeTool } = mockRegistry({ booted: [DEVICE] });
const run = asRun(await runAuto(registry, "teardownonly"));
expect(run.device).toBe(DEVICE);
expect(run.ok).toBe(true);
expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", { devices: [DEVICE] });
});
it("runs as the machine-wide sweep with NOTHING booted", async () => {
// One of the two situations a cleanup flow actually runs in. Requiring a
// device here failed it with "No booted device found" — on a flow whose
// entire purpose is to run when the machine needs clearing.
await writeFlow("teardownonly", teardownOnly);
const { registry, invokeTool } = mockRegistry({ booted: [] });
const run = asRun(await runAuto(registry, "teardownonly"));
expect(run.ok).toBe(true);
expect(run.passed).toBe(1);
// No scope, and emphatically not `[""]` — an id that owns nothing would
// reap nothing and still pass.
expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", {});
});
it("runs as the machine-wide sweep with SEVERAL booted, without disambiguation", async () => {
// The other one. Requiring a device here failed with "2 booted devices
// matched — pass --device or --platform", which is not a question a sweep
// has an answer to.
await writeFlow("teardownonly", teardownOnly);
const other = "11111111-1111-1111-1111-111111111111";
const { registry, invokeTool } = mockRegistry({ booted: [DEVICE, other] });
const run = asRun(await runAuto(registry, "teardownonly"));
expect(run.ok).toBe(true);
expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", {});
});
it("scopes to an explicitly passed device", async () => {
// The narrowing is deliberate where the run has an answer: a replayed
// teardown must not reap devices another agent is mid-session on.
await writeFlow("teardownonly", teardownOnly);
const other = "11111111-1111-1111-1111-111111111111";
const { registry, invokeTool } = mockRegistry({ booted: [DEVICE, other] });
const runFlow = createRunFlowTool(registry);
const run = asRun(
await runFlow.execute({}, { name: "teardownonly", project_root: tmpDir, device: DEVICE })
);
expect(run.ok).toBe(true);
expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", { devices: [DEVICE] });
});
it("falls back to the sweep when a passed platform still matches several", async () => {
// A platform that does not narrow to one device is not an answer either,
// and the flow must still run rather than demanding --device.
await writeFlow("teardownonly", teardownOnly);
const other = "11111111-1111-1111-1111-111111111111";
const { registry, invokeTool } = mockRegistry({ booted: [DEVICE, other] });
const runFlow = createRunFlowTool(registry);
const run = asRun(
await runFlow.execute({}, { name: "teardownonly", project_root: tmpDir, platform: "ios" })
);
expect(run.ok).toBe(true);
expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", {});
});
it("fails the run when list-devices itself breaks, rather than sweeping the machine", async () => {
// The opportunistic resolve swallows one answer — "nothing booted, or
// several" — and used to swallow every other failure with it: an
// adb/simctl error, a dead sub-tool, an abort. The teardown then ran
// UNSCOPED and reported pass, which is the machine-wide sweep this path
// exists to avoid, on a machine whose device list nobody could even read.
await writeFlow("teardownonly", teardownOnly);
const { registry, invokeTool } = mockRegistry({ booted: [DEVICE] });
vi.mocked(registry.invokeTool).mockImplementation(async (id: string) => {
if (id === "list-devices") throw new Error("adb: device offline");
return { ok: true };
});
await expect(runAuto(registry, "teardownonly")).rejects.toThrow(/adb: device offline/);
expect(invokeTool).not.toHaveBeenCalledWith(
"stop-all-simulator-servers",
expect.anything(),
expect.anything()
);
});
it("still scopes the teardown when the flow ALSO has a device step", async () => {
// A flow with a real device step resolves one as it always did, and the
// teardown is scoped to it — the cross-agent protection the scope exists
// for is unaffected by any of the above.
await writeFlow("teardownmixed", [
{ kind: "tool", name: "tap", args: { x: 1, y: 2 } },
...teardownOnly,
]);
const { registry, invokeTool } = mockRegistry({ booted: [DEVICE] });
const run = asRun(await runAuto(registry, "teardownmixed"));
expect(run.device).toBe(DEVICE);
expect(run.ok).toBe(true);
expect(invokeTool).toHaveBeenCalledWith("stop-all-simulator-servers", { devices: [DEVICE] });
});
});