From b2fc5fbf47aee4fec1e0ba6a7f6afcd48fd4bbaf Mon Sep 17 00:00:00 2001 From: Filip131311 Date: Fri, 31 Jul 2026 21:54:55 +0200 Subject: [PATCH] fix(flow): don't demand a device for a flow that never touches one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flow of nothing but `echo` steps still went through device resolution, so it failed outright when nothing was booted — and picked whichever device happened to be running when something was, attributing a run to hardware it never touched. Whether a device is needed is now decided from the flow's own steps, before resolution. The classification is per step kind and defaults to needing one, so a kind added later inherits today's behaviour rather than quietly running against no device; the compiler rejects leaving a new kind unclassified. A `when` block needs a device whatever its body contains, since the guard reads one itself, and a `run` step counts as needing one without the fragment being read here — resolving it twice could disagree with the run-time resolution. `ExecState.device` becomes nullable so every site that acts on a device has to say so, and the executor re-checks each step against the same predicate: if the two decisions ever disagree the step reports it, rather than failing obscurely deeper in. A run with no device reports an empty `device` rather than borrowing one. The summary for such a run said `PASS — 0 passed, 0 failed, 0 errored, 0 skipped`, which reads as though nothing happened. Narration deliberately isn't counted — and should not be, since after a hard stop every remaining step including narration is reported skipped, so counting it would inflate that number on real failing runs. The count is right; what was missing was saying why it is zero. Both the CLI and the MCP renderer now add `(no test steps)`, and only on a pass, where the counts are what needs explaining. Fixes #644 --- packages/argent-cli/src/flow.ts | 15 +- .../test/flow-deviceless-render.test.ts | 63 ++++ packages/argent-mcp/src/content.ts | 7 +- .../src/tools/flows/flow-device.ts | 72 ++++- .../tool-server/src/tools/flows/flow-run.ts | 99 +++++- .../test/flows/flow-deviceless.test.ts | 288 ++++++++++++++++++ 6 files changed, 522 insertions(+), 22 deletions(-) create mode 100644 packages/argent-cli/test/flow-deviceless-render.test.ts create mode 100644 packages/tool-server/test/flows/flow-deviceless.test.ts diff --git a/packages/argent-cli/src/flow.ts b/packages/argent-cli/src/flow.ts index 4a1aa29d6..80b077231 100644 --- a/packages/argent-cli/src/flow.ts +++ b/packages/argent-cli/src/flow.ts @@ -215,9 +215,16 @@ export function renderSummary(report: FlowReport, opts: { withDevice?: boolean } const warnings = report.steps.filter((s) => s.warning).length; const warningsNote = warnings ? `, ${warnings} warning${warnings === 1 ? "" : "s"}` : ""; // The live renderer prints its header before the runner has resolved a - // device, so its summary carries the device instead. - const where = opts.withDevice ? ` on ${report.device}` : ""; - return `${report.ok ? "PASS" : "FAIL"}${where} — ${report.passed} passed, ${report.failed} failed, ${report.errored} errored, ${report.skipped} skipped${warningsNote}`; + // device, so its summary carries the device instead. Empty when the flow + // needed none. + const where = opts.withDevice && report.device ? ` on ${report.device}` : ""; + // Four zeros on a passing run read as though nothing happened. Say why: + // narration is not counted, so a flow of only narration counts nothing. + // Only on a pass — on a failure the counts are not what needs explaining. + const nothingCounted = + report.ok && report.passed + report.failed + report.errored + report.skipped === 0; + const note = nothingCounted ? " (no test steps)" : ""; + return `${report.ok ? "PASS" : "FAIL"}${where} — ${report.passed} passed, ${report.failed} failed, ${report.errored} errored, ${report.skipped} skipped${warningsNote}${note}`; } /** @@ -370,7 +377,7 @@ export function exitAfterFlush( export function renderReport(report: FlowReport): string { const lines: string[] = []; - lines.push(`Flow "${report.flow}" on ${report.device}`); + lines.push(`Flow "${report.flow}"${report.device ? ` on ${report.device}` : ""}`); // A fragment runs against the device's current state — remind the operator // what it assumes was already set up. if (report.executionPrerequisite) { diff --git a/packages/argent-cli/test/flow-deviceless-render.test.ts b/packages/argent-cli/test/flow-deviceless-render.test.ts new file mode 100644 index 000000000..793f22493 --- /dev/null +++ b/packages/argent-cli/test/flow-deviceless-render.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { renderReport, renderSummary, type FlowReport } from "../src/flow.js"; + +function report(overrides: Partial = {}): FlowReport { + return { + flow: "echo-only", + device: "", + ok: true, + passed: 0, + failed: 0, + skipped: 0, + errored: 0, + steps: [], + ...overrides, + } as FlowReport; +} + +describe("rendering a run that resolved no device", () => { + it("does not claim the run happened on a device", () => { + const line = renderSummary(report(), { withDevice: true }); + + expect(line).not.toContain(" on "); + expect(line).toBe("PASS — 0 passed, 0 failed, 0 errored, 0 skipped (no test steps)"); + }); + + it("omits the device from the report header too", () => { + const lines = renderReport(report()).split("\n"); + + expect(lines[0]).toBe('Flow "echo-only"'); + }); + + it("still names a device when the run had one", () => { + const line = renderSummary(report({ device: "UDID-1", passed: 2 }), { withDevice: true }); + + expect(line).toBe("PASS on UDID-1 — 2 passed, 0 failed, 0 errored, 0 skipped"); + }); +}); + +describe("the no-test-steps note", () => { + it("explains a passing run whose counters are all zero", () => { + expect(renderSummary(report())).toContain("(no test steps)"); + }); + + it("is absent whenever anything was counted", () => { + expect(renderSummary(report({ passed: 1 }))).not.toContain("(no test steps)"); + expect(renderSummary(report({ skipped: 1 }))).not.toContain("(no test steps)"); + }); + + it("is absent on a failure, where the counts are not what needs explaining", () => { + // A cancelled run can be a FAIL with every counter still zero; calling that + // "no test steps" would read as though the failure had no cause. + const line = renderSummary(report({ ok: false })); + + expect(line).toBe("FAIL — 0 passed, 0 failed, 0 errored, 0 skipped"); + expect(line).not.toContain("(no test steps)"); + }); + + it("leaves an ordinary summary byte-for-byte unchanged", () => { + const line = renderSummary(report({ ok: false, passed: 2, failed: 1, skipped: 1 })); + + expect(line).toBe("FAIL — 2 passed, 1 failed, 0 errored, 1 skipped"); + }); +}); diff --git a/packages/argent-mcp/src/content.ts b/packages/argent-mcp/src/content.ts index d621c6dd3..ed5002d9b 100644 --- a/packages/argent-mcp/src/content.ts +++ b/packages/argent-mcp/src/content.ts @@ -334,9 +334,14 @@ export async function flowRunToMcpContent( } if (result.ok !== undefined) { + // Narration steps are not counted, so a flow of only narration counts + // nothing — say so rather than reporting four zeros on a passing run. + const counted = + (result.passed ?? 0) + (result.failed ?? 0) + (result.errored ?? 0) + (result.skipped ?? 0); + const note = result.ok && counted === 0 ? " (no test steps)" : ""; blocks.push({ type: "text", - text: `${result.ok ? "PASS" : "FAIL"} — ${result.passed ?? 0} passed, ${result.failed ?? 0} failed, ${result.errored ?? 0} errored, ${result.skipped ?? 0} skipped`, + text: `${result.ok ? "PASS" : "FAIL"} — ${result.passed ?? 0} passed, ${result.failed ?? 0} failed, ${result.errored ?? 0} errored, ${result.skipped ?? 0} skipped${note}`, }); } else { blocks.push({ type: "text", text: `Flow "${result.flow}" complete.` }); diff --git a/packages/tool-server/src/tools/flows/flow-device.ts b/packages/tool-server/src/tools/flows/flow-device.ts index 40d7ae7cd..5aea4e19a 100644 --- a/packages/tool-server/src/tools/flows/flow-device.ts +++ b/packages/tool-server/src/tools/flows/flow-device.ts @@ -2,7 +2,7 @@ import type { DeviceInfo, Registry, ToolContext } from "@argent/registry"; import { FAILURE_CODES, FailureError } from "@argent/registry"; import { resolveDevice } from "../../utils/device-info"; import { invokeSubTool } from "../../utils/sub-invoke"; -import type { WhenPlatform } from "./flow-utils"; +import type { FlowStep, WhenPlatform } from "./flow-utils"; /** * Device resolution + binding for the flow runner. Flows store no device id @@ -17,6 +17,14 @@ export type FlowPlatform = WhenPlatform; const DEVICE_BIND_KEYS = ["udid", "device_id"] as const; +/** + * Keys that mean a tool acts on a device. A superset of the keys the runner + * injects: `device` names one without receiving the run's own (a nested flow + * takes it that way), and a step that drives a device must count as needing one + * even when the runner does not hand it over. + */ +const DEVICE_ARG_KEYS = [...DEVICE_BIND_KEYS, "device"] as const; + interface RawDevice { platform: FlowPlatform; state?: string; @@ -109,6 +117,68 @@ export function stripDeviceKeys(args: Record): Record stepRequiresDevice(registry, step)); +} + +function toolRequiresDevice(registry: Registry, toolName: string): boolean { + const toolDef = registry.getTool(toolName); + // An unknown tool is assumed to need a device: the step is going to fail + // either way, and it fails more usefully with one resolved. + if (!toolDef) return true; + const props = (toolDef.inputSchema as { properties?: Record } | undefined) + ?.properties; + // A tool with no declared input takes no device. + if (!props) return false; + return DEVICE_ARG_KEYS.some((k) => k in props); +} + export function bindDeviceArgs( registry: Registry, toolName: string, diff --git a/packages/tool-server/src/tools/flows/flow-run.ts b/packages/tool-server/src/tools/flows/flow-run.ts index 645f5c636..4ce009fcb 100644 --- a/packages/tool-server/src/tools/flows/flow-run.ts +++ b/packages/tool-server/src/tools/flows/flow-run.ts @@ -32,7 +32,13 @@ import type { TextMatchMode, WaitCondition } from "../../utils/ui-tree-match"; import { sleepOrAbort } from "../../utils/timing"; import { invokeSubTool } from "../../utils/sub-invoke"; import { isUnmetUiWaitResult } from "../await-ui-element"; -import { resolveFlowDevice, bindDeviceArgs, type FlowPlatform } from "./flow-device"; +import { + resolveFlowDevice, + bindDeviceArgs, + flowRequiresDevice, + stepRequiresDevice, + type FlowPlatform, +} from "./flow-device"; import { runDirective, invokeOnDevice, @@ -338,7 +344,8 @@ async function treeSourceGate( * already-launched app (see `state.chromiumLaunched`). */ async function runLaunch(state: ExecState, app: Launch): Promise { - const { registry, device, signal } = state; + const env = deviceEnv(state); + const { registry, device, signal } = env; if (device.platform === "chromium") { // Only the top-level flow's leading launch is honored: the runner boots that @@ -388,7 +395,7 @@ async function runLaunch(state: ExecState, app: Launch): Promise { + device: DeviceInfo | null; flowsDir: string; topFlowName: string; updateBaselines: boolean; @@ -424,6 +435,21 @@ interface ExecState extends ActionEnv { onStepReport?: (report: StepReport) => void; } +/** + * The run state as an environment that acts on a device. + * + * Only reached from a step classified as needing one, which is why the device is + * resolved at all. The throw is a contradiction guard, not an expected path: it + * fires only if the classification and the executor ever disagree, and says so + * rather than dereferencing null somewhere further in. + */ +function deviceEnv(state: ExecState): ActionEnv { + if (!state.device) { + throw new Error("internal: a step that acts on a device ran in a flow resolved as device-free"); + } + return { ...state, device: state.device }; +} + /** A chromium instance the runner booted and must tear down after the run. */ interface BootedChromium { deviceId: string; @@ -500,7 +526,6 @@ returns a notice with the prerequisite instead of running.`, // resolveRunDevice). Any instance it booted is torn down in the finally. const resolved = await resolveRunDevice(registry, ctx, flow, params, flowsDir); const device = resolved.device; - const env: ActionEnv = { registry, ctx, device, signal }; // Normalize the status bar (clock/battery/signal) for the whole run so it // never drives a snapshot diff and every screenshot is consistent. Pinned @@ -508,17 +533,20 @@ returns a notice with the prerequisite instead of running.`, // an e2e flow's leading launch step (relaunch + settle) doubles as // propagation headroom before anything is captured. No-op (returns false) // on chromium/vega; restored on teardown. - const statusBarPinned = await pinStatusBar(device); + const statusBarPinned = device !== null && (await pinStatusBar(device)); // The chromium equivalent of that normalization: front the page once so // a backgrounded window doesn't throttle rendering for the whole run — // wheel-event acks (scroll steps) stall on a throttled compositor. // Best-effort: bringToFront can focus a page but cannot unhide a // minimized window (gesture-scroll fails fast on that case itself). - if (device.platform === "chromium") await frontChromiumPage(registry, device); + if (device?.platform === "chromium") await frontChromiumPage(registry, device); const state: ExecState = { - ...env, + registry, + ctx, + device, + signal, flowsDir, topFlowName: params.name, updateBaselines: Boolean(params.updateBaselines), @@ -542,11 +570,19 @@ returns a notice with the prerequisite instead of running.`, // status-bar restore / chromium teardown lands after every step // already ran, and must not flip a finished run to FAIL. aborted = state.signal?.aborted === true; - if (state.pinned) await restoreStatusBar(device); + if (state.pinned && device) await restoreStatusBar(device); if (resolved.booted) await teardownBootedChromium(registry, resolved.booted); } - return summarize(params.name, device.id, flow.executionPrerequisite, state.reports, aborted); + // Empty when the flow needed no device — the run is not attributed to one + // it never touched. + return summarize( + params.name, + device?.id ?? "", + flow.executionPrerequisite, + state.reports, + aborted + ); }, }; } @@ -558,6 +594,11 @@ returns a notice with the prerequisite instead of running.`, * attaches to an already-booted device. An explicit `device` always attaches — * never boots or tears down. `flowDir` is the flow file's directory — the base * for a relative chromium app path. + * + * Returns null when no step in the flow acts on a device: such a run needs none, + * so demanding one would fail a flow that could have succeeded — and picking + * whichever device happens to be booted would make the report depend on what + * else is running on the machine. */ async function resolveRunDevice( registry: Registry, @@ -565,13 +606,18 @@ async function resolveRunDevice( flow: FlowFile, params: Params, flowDir: string -): Promise<{ device: DeviceInfo; booted: BootedChromium | null }> { +): Promise<{ device: DeviceInfo | null; booted: BootedChromium | null }> { if (!params.device) { const spec = chromiumBootSpec(flow, params.platform); if (spec) { const booted = await bootChromiumForFlow(spec, flowDir); return { device: resolveDevice(booted.deviceId), booted }; } + // Checked after the chromium boot path, which only applies to a flow led by + // a `launch` step — and a launch needs a device, so the two never compete. + if (!flowRequiresDevice(registry, flow.steps)) { + return { device: null, booted: null }; + } } const device = await resolveFlowDevice(registry, ctx, { device: params.device, @@ -842,6 +888,23 @@ async function execSteps(state: ExecState, steps: FlowStep[], scope: StepScope): if (step.kind === "when") reportBlockSkipped(state, step.steps, childScope(scope)); continue; } + // The flow was resolved as needing no device, yet a step that acts on one + // reached execution — the two decisions disagree. Report it as this step's + // error and stop, rather than letting it fail obscurely further in. + if (!state.device && stepRequiresDevice(state.registry, step)) { + state.stopped = true; + pushReport(state, { + index, + kind: step.kind, + status: "error", + flow: scope.flow, + target: stepTarget(step), + ...depthOf(scope), + reason: `step needs a device but the flow was resolved as device-free — pass an explicit device`, + }); + if (step.kind === "when") reportBlockSkipped(state, step.steps, childScope(scope)); + continue; + } if (state.signal?.aborted) { state.stopped = true; pushReport(state, { @@ -943,10 +1006,11 @@ async function execWhenStep( // platform guard it IS ios. The parser deliberately rejects "ios-remote" // as a guard spelling, so without this fold no guard could ever match on // a remote sim and iOS-only blocks would silently skip there. - const platform = state.device.platform === "ios-remote" ? "ios" : state.device.platform; + const guardEnv = deviceEnv(state); + const platform = guardEnv.device.platform === "ios-remote" ? "ios" : guardEnv.device.platform; met = platform === step.condition.platform; } else { - const probe = await probeWhenCondition(state, step.condition); + const probe = await probeWhenCondition(deviceEnv(state), step.condition); if (probe.aborted) { pushReport(state, { ...marker, status: "skip", reason: "run aborted" }); reportBlockSkipped(state, step.steps, inner, "run aborted"); @@ -1067,7 +1131,7 @@ async function execLeafStep( // touch gesture on a focus-driven TV target — must still land in the // structured report rather than abort the whole run unreported. try { - const r = await runDirective(state, step); + const r = await runDirective(deviceEnv(state), step); // A run cancelled mid-directive is a skip (matching the pre-step guard // and `wait`), never a step failure — the app did nothing wrong. if (r.aborted) return { ...base, status: "skip", reason: r.reason }; @@ -1086,7 +1150,7 @@ async function execLeafStep( case "snapshot": { try { - const r = await runSnapshot(state, { + const r = await runSnapshot(deviceEnv(state), { flowsDir: state.flowsDir, flowName: state.topFlowName, name: step.name, @@ -1107,7 +1171,10 @@ async function execLeafStep( } case "tool": { - const args = bindDeviceArgs(registry, step.name, device.id, step.args); + // With no device, the step reached here only because its tool declares no + // device argument, so there is nothing to inject — binding still strips + // any device key the recorded args carried. + const args = bindDeviceArgs(registry, step.name, device?.id ?? "", step.args); const outputHint = registry.getTool(step.name)?.outputHint; if (step.delayMs && !(await sleepOrAbort(step.delayMs, signal))) { return { ...base, status: "skip", tool: step.name, reason: "run aborted during delay" }; diff --git a/packages/tool-server/test/flows/flow-deviceless.test.ts b/packages/tool-server/test/flows/flow-deviceless.test.ts new file mode 100644 index 000000000..a28f84ccb --- /dev/null +++ b/packages/tool-server/test/flows/flow-deviceless.test.ts @@ -0,0 +1,288 @@ +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 { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run"; +import { serializeFlow, type FlowStep } from "../../src/tools/flows/flow-utils"; +import { stepRequiresDevice } from "../../src/tools/flows/flow-device"; + +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 = { + "tap": { inputSchema: { properties: { udid: {}, x: {}, y: {} } } }, + "stop-metro": { inputSchema: { properties: { port: {} } } }, + // A real tool that declares no input at all. + "stop-all-simulator-servers": {}, + // 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 { + 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("stop-all", [{ kind: "tool", name: "stop-all-simulator-servers", args: {} }]); + const { registry } = mockRegistry({ booted: [] }); + + expect(asRun(await runAuto(registry, "stop-all")).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" }, + ]); + 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 = { + "echo": false, + "wait": false, + "tool": true, + "run": true, + "when": true, + "launch": true, + "tap": true, + "long-press": true, + "type": true, + "await": true, + "assert": true, + "scroll-to": true, + "pinch": true, + "rotate": true, + "snapshot": true, + }; + const samples: Record = { + "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" } }, + "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("stop-all-simulator-servers"))).toBe(false); + expect(stepRequiresDevice(registry, toolStep("not-a-tool"))).toBe(true); + }); +});