mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
e09e200eb5
## What
A booted remote iOS simulator is now a device a flow run can be pointed
at - both by auto-detection and by `--platform ios-remote`.
## Why
It was invisible to flow device resolution. With a remote simulator
booted alongside local ones:
```
$ argent flow run checkout
7 booted devices matched - pass --device or --platform to disambiguate.
Available devices: 79EE0744 (ios, Booted), A1E0DF35 (ios, Booted), emulator-5554 (android, device), ...
```
Three local iPhones and four Android emulators, and the remote simulator
absent entirely. Narrowing to it did not work either - `--platform
ios-remote` was rejected by the parameter's enum. The only way onto a
remote simulator was typing the full `remote:<uuid>` id.
The cause is one type. `RawDevice.platform` was typed from the tuple of
platforms an author can *write* in a flow file, which has no
`ios-remote`, so nothing forced the case to be handled and two functions
quietly fell through:
- `isBooted` hit its `default: return false`, so a remote row never
counted as booted.
- `deviceEntryId` fell through to `serial`, a field a remote row does
not carry (it uses `udid`, prefixed `remote:`). Even once counted, it
had no id - and a resolution error printed it as `? (ios-remote,
Booted)`, hiding the very id the caller needed.
## How
The tuple served two different jobs at once - what an author may write,
and what a run may be pointed at - so it is now two:
- `LAUNCH_PLATFORMS` - launch-map keys and `when:` guards. Unchanged.
- `SELECTABLE_PLATFORMS` - device selection. Adds `ios-remote`.
`ios-remote` is selectable but deliberately not writable. A flow says
what it drives, not which machine hosts the simulator, so `when: {
platform: ios-remote }` stays a parse error.
For the same reason `ios` and `ios-remote` do not overlap: `ios` selects
local simulators only. The flow file is identical either way, so which
host runs it is an explicit choice rather than something that changes
with whatever happens to be booted.
## Scope
Selection only. A remote flow run is still coordinate-only - selector
steps, `await: { idle: true }` and `snapshot: { cropOn }` need a UI tree
source that `ios-remote` does not have yet, and `launch:` maps still
have no `ios-remote` key. Those are separate changes.
## Verification
End-to-end against a booted cloud simulator (`remote:73A22194-…`, iPhone
17 Pro / iOS 26.5) driven through the branch tool-server, with three
local simulators and four Android emulators also up:
| Run | Result |
| --- | --- |
| `platform: "ios-remote"` | resolves `remote:73A22194-…`, flow passes |
| no platform | 8 booted devices listed, including `remote:73A22194-…
(ios-remote, Booted)` - named by its id, not `?` |
| `platform: "ios"` | 3 local simulators only, the remote one absent |
`npm run build` and the tool-server suite pass.
## Docs
`flow-yaml.mdx` and the `argent flow run --help` text both list
`ios-remote` and note that `ios` never picks a remote simulator.
<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit
* **New Features**
* Added support for selecting remote iOS simulators with `--platform
ios-remote`.
* Remote simulators are automatically detected when booted and
identified by their device ID.
* Local `ios` selection continues to target only local simulators.
* **Documentation**
* Updated command-line help and flow documentation to describe remote
iOS simulator support and platform selection behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
Co-authored-by: Hubert Gancarczyk <claude-hubert.gancarczyk@swmansion.com>
737 lines
29 KiB
TypeScript
737 lines
29 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[]; devices?: object[] } = {}) {
|
|
const invokeTool = vi.fn(async (id: string) => {
|
|
if (id === "list-devices") {
|
|
return {
|
|
devices: [
|
|
...(opts.booted ?? []).map((udid) => ({ platform: "ios", udid, state: "Booted" })),
|
|
...(opts.devices ?? []),
|
|
],
|
|
};
|
|
}
|
|
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 the only device is a paired (unreachable) physical iPhone", async () => {
|
|
// list-devices keeps a paired phone listed for days after it was last
|
|
// seen, with state "paired". Auto-bind never counts a physical device as
|
|
// booted, so the run fails up front with the resolution error
|
|
// (enumerating the phone) instead of binding an unreachable device and
|
|
// failing opaquely mid-flow.
|
|
await writeFlow("stale-phone", [{ kind: "tap", x: 0.5, y: 0.5 }]);
|
|
const { registry } = mockRegistry({
|
|
devices: [
|
|
{ platform: "ios", kind: "device", udid: "00008120-000A44443333801E", state: "paired" },
|
|
],
|
|
});
|
|
await expect(runAuto(registry, "stale-phone")).rejects.toThrow(
|
|
/No booted device found.*\(ios, paired\)/
|
|
);
|
|
});
|
|
|
|
it("when the only device is a connected physical iPhone, which is named as the way out", async () => {
|
|
// A cabled phone is reachable, but a flow must never land on real
|
|
// hardware because nothing else was booted. The error tells the caller
|
|
// how to run on it deliberately.
|
|
await writeFlow("phone-only", [{ kind: "tap", x: 0.5, y: 0.5 }]);
|
|
const { registry } = mockRegistry({
|
|
devices: [
|
|
{ platform: "ios", kind: "device", udid: "00008120-000A44443333801E", state: "connected" },
|
|
],
|
|
});
|
|
await expect(runAuto(registry, "phone-only")).rejects.toThrow(
|
|
/No booted device found.*A physical iPhone is connected \(00008120-000A44443333801E\); hardware is never picked automatically, pass device <udid> \(--device on the CLI\) to run on it\..*\(ios, connected\)/
|
|
);
|
|
});
|
|
|
|
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,
|
|
"swipe": true,
|
|
"type": true,
|
|
"await": true,
|
|
"assert": true,
|
|
"idle": true,
|
|
"scroll-to": true,
|
|
"pinch": true,
|
|
"rotate": true,
|
|
"snapshot": true,
|
|
"script": false,
|
|
};
|
|
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 },
|
|
"swipe": { kind: "swipe", direction: "left" },
|
|
"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" },
|
|
"script": { kind: "script", path: "seed.mjs" },
|
|
};
|
|
|
|
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] });
|
|
});
|
|
});
|
|
|
|
describe("a connected physical iPhone never competes with simulators", () => {
|
|
const PHONE = "00008120-000A44443333801E";
|
|
const phone = { platform: "ios", kind: "device", udid: PHONE, state: "connected" };
|
|
|
|
it("binds the one booted simulator when a phone is also on the cable", async () => {
|
|
// Before this guard a cabled phone counted as booted, so the lone
|
|
// simulator became a "2 booted devices matched" error that --platform ios
|
|
// could not resolve.
|
|
await writeFlow("tapping", [{ kind: "tap", x: 0.5, y: 0.5 }]);
|
|
const { registry } = mockRegistry({ booted: [DEVICE], devices: [phone] });
|
|
|
|
const result = asRun(await runAuto(registry, "tapping"));
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.device).toBe(DEVICE);
|
|
});
|
|
|
|
it("binds the one booted simulator under --platform ios with a phone on the cable", async () => {
|
|
await writeFlow("tapping", [{ kind: "tap", x: 0.5, y: 0.5 }]);
|
|
const { registry } = mockRegistry({ booted: [DEVICE], devices: [phone] });
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const result = asRun(
|
|
await runFlow.execute({}, { name: "tapping", project_root: tmpDir, platform: "ios" })
|
|
);
|
|
|
|
expect(result.device).toBe(DEVICE);
|
|
});
|
|
|
|
it("does not name the phone when the run was scoped to another platform", async () => {
|
|
await writeFlow("tapping", [{ kind: "tap", x: 0.5, y: 0.5 }]);
|
|
const { registry } = mockRegistry({ devices: [phone] });
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
await expect(
|
|
runFlow.execute({}, { name: "tapping", project_root: tmpDir, platform: "android" })
|
|
).rejects.toThrow(
|
|
/No booted android device found\. Pass a device id or platform explicitly\. Available devices/
|
|
);
|
|
});
|
|
|
|
it("runs on the phone when it is named explicitly", async () => {
|
|
await writeFlow("tapping", [{ kind: "tap", x: 0.5, y: 0.5 }]);
|
|
const { registry } = mockRegistry({ booted: [DEVICE], devices: [phone] });
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const result = asRun(
|
|
await runFlow.execute({}, { name: "tapping", project_root: tmpDir, device: PHONE })
|
|
);
|
|
|
|
expect(result.device).toBe(PHONE);
|
|
});
|
|
});
|
|
|
|
describe("a booted remote simulator", () => {
|
|
const REMOTE = "remote:73A22194-1D9E-4C0E-9D75-6C2A1F0B4E51";
|
|
const remoteSim = {
|
|
platform: "ios-remote",
|
|
udid: REMOTE,
|
|
state: "Booted",
|
|
name: "iPhone 17 Pro",
|
|
runtime: "iOS 26.5",
|
|
};
|
|
|
|
beforeEach(async () => {
|
|
await writeFlow("tapping", [{ kind: "tap", x: 0.5, y: 0.5 }]);
|
|
});
|
|
|
|
it("is auto-detected when it is the only booted device", async () => {
|
|
// A remote row carries `udid`, not `serial`, and reports simctl's own
|
|
// "Booted" — before this it was neither counted as booted nor identifiable,
|
|
// so a lone remote simulator resolved to "No booted device found".
|
|
const { registry } = mockRegistry({ devices: [remoteSim] });
|
|
|
|
const result = asRun(await runAuto(registry, "tapping"));
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.device).toBe(REMOTE);
|
|
});
|
|
|
|
it("is selected by platform ios-remote when local simulators are also booted", async () => {
|
|
const { registry } = mockRegistry({ booted: [DEVICE], devices: [remoteSim] });
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const result = asRun(
|
|
await runFlow.execute({}, { name: "tapping", project_root: tmpDir, platform: "ios-remote" })
|
|
);
|
|
|
|
expect(result.device).toBe(REMOTE);
|
|
});
|
|
|
|
it("is NOT matched by platform ios, which stays local-only", async () => {
|
|
// The two do not overlap on purpose: the flow file is identical either
|
|
// way, so which host runs it must be an explicit choice rather than
|
|
// whatever happens to be booted.
|
|
const { registry } = mockRegistry({ booted: [DEVICE], devices: [remoteSim] });
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
const result = asRun(
|
|
await runFlow.execute({}, { name: "tapping", project_root: tmpDir, platform: "ios" })
|
|
);
|
|
|
|
expect(result.device).toBe(DEVICE);
|
|
});
|
|
|
|
it("leaves platform ios with nothing to bind when only a remote sim is up", async () => {
|
|
const { registry } = mockRegistry({ devices: [remoteSim] });
|
|
const runFlow = createRunFlowTool(registry);
|
|
|
|
await expect(
|
|
runFlow.execute({}, { name: "tapping", project_root: tmpDir, platform: "ios" })
|
|
).rejects.toThrow(/No booted ios device found/);
|
|
});
|
|
|
|
it("is named by its id in a resolution error, never as `?`", async () => {
|
|
// `deviceEntryId` fell through to `serial`, which a remote row does not
|
|
// carry, so an ambiguity listed the one device the caller most needed the
|
|
// id of as "? (ios-remote, Booted)".
|
|
const other = "11111111-1111-1111-1111-111111111111";
|
|
const { registry } = mockRegistry({ booted: [DEVICE, other], devices: [remoteSim] });
|
|
|
|
await expect(runAuto(registry, "tapping")).rejects.toThrow(
|
|
new RegExp(`3 booted devices matched.*${REMOTE} \\(ios-remote, Booted\\)`)
|
|
);
|
|
});
|
|
|
|
it("is not treated as booted while it is shut down", async () => {
|
|
const { registry } = mockRegistry({
|
|
devices: [{ ...remoteSim, state: "Shutdown" }],
|
|
});
|
|
|
|
await expect(runAuto(registry, "tapping")).rejects.toThrow(
|
|
/No booted device found.*\(ios-remote, Shutdown\)/
|
|
);
|
|
});
|
|
});
|