Skip to content

Commit b3ec28e

Browse files
committed
Add device-bound test API and Maestro YAML runner
1 parent f24c075 commit b3ec28e

12 files changed

Lines changed: 1697 additions & 464 deletions

File tree

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -187,11 +187,11 @@ coordinates directly.
187187
```ts
188188
import { connect } from "simdeck/test";
189189

190-
const sim = await connect();
190+
const sim = await connect({ udid: "<udid>" });
191191
try {
192-
await sim.tap("<udid>", 0.5, 0.5);
193-
await sim.waitFor("<udid>", { label: "Continue" });
194-
await sim.screenshot("<udid>");
192+
await sim.tap(0.5, 0.5);
193+
await sim.waitFor({ label: "Continue" });
194+
await sim.screenshot();
195195
} finally {
196196
sim.close();
197197
}
@@ -200,6 +200,12 @@ try {
200200
`connect()` starts the project daemon when needed, reuses it when it is already
201201
healthy, and only stops daemons it started itself.
202202

203+
Run common Maestro YAML flows against the same daemon-backed iOS Simulator API:
204+
205+
```sh
206+
simdeck maestro test <udid> flow.yaml --artifacts-dir artifacts/maestro
207+
```
208+
203209
## NativeScript Inspector
204210

205211
NativeScript apps can connect directly to the running server from JS and expose

docs/api/rest.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,55 @@ The response always includes:
375375

376376
Returns the AX-style accessibility description of the topmost element at a screen point. `x` and `y` are in UIKit screen points and must be finite, non-negative numbers.
377377

378+
### `POST /api/simulators/{udid}/query`
379+
380+
Returns compact accessibility matches for a selector:
381+
382+
```json
383+
{
384+
"selector": {
385+
"text": "Continue",
386+
"id": "continue-button",
387+
"elementType": "Button",
388+
"enabled": true,
389+
"regex": false
390+
},
391+
"source": "auto",
392+
"maxDepth": 8,
393+
"limit": 20
394+
}
395+
```
396+
397+
Selectors can match `text`, `id`, `label`, `value`, `elementType`, `index`, `enabled`, `checked`, `focused`, and `selected`. Set `regex: true` to use regular expression matching for string fields.
398+
399+
### `POST /api/simulators/{udid}/wait-for`
400+
401+
Polls until a selector appears. `assert` is an alias with the same payload shape:
402+
403+
```json
404+
{
405+
"selector": { "text": "Welcome", "regex": true },
406+
"timeoutMs": 5000,
407+
"pollMs": 100
408+
}
409+
```
410+
411+
Use `POST /api/simulators/{udid}/wait-for-not` or `/assert-not` for negative assertions.
412+
413+
### `POST /api/simulators/{udid}/scroll-until-visible`
414+
415+
Scrolls and polls until the selector appears:
416+
417+
```json
418+
{
419+
"selector": { "text": "Settings" },
420+
"direction": "down",
421+
"timeoutMs": 10000
422+
}
423+
```
424+
425+
`direction` accepts `up`, `down`, `left`, and `right`.
426+
378427
## Inspector proxy
379428

380429
### `POST /api/simulators/{udid}/inspector/request`

docs/cli/commands.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,16 @@ simdeck batch <udid> \
251251

252252
Batch input can come from `--step`, `--file`, or `--stdin`. Use `wait-for` or `assert` with selector flags (`--id`, `--label`, `--value`, `--element-type`) to wait for UI state instead of fixed delays. `sleep 500` waits 500 ms; suffix seconds explicitly with `s`, as in `sleep 0.5s`. It fails fast by default; pass `--continue-on-error` for best-effort execution.
253253

254+
## Maestro YAML
255+
256+
Run common Maestro flows through SimDeck's daemon-backed iOS Simulator API:
257+
258+
```sh
259+
simdeck maestro test <udid> flow.yaml --artifacts-dir artifacts/maestro
260+
```
261+
262+
The compatibility runner supports the core local commands: `launchApp`, `openLink`, `tapOn`, `inputText`, `eraseText`, `pressKey`, `assertVisible`, `assertNotVisible`, `scrollUntilVisible`, `swipe`, `takeScreenshot`, and `waitForAnimationToEnd`.
263+
254264
## Evidence
255265

256266
```sh

docs/guide/testing.md

Lines changed: 36 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,42 +9,55 @@ SimDeck supports two test layers: a small JS/TS client package for app tests, an
99
```ts
1010
import { connect } from "simdeck/test";
1111

12-
const sim = await connect();
12+
const sim = await connect({ udid: "<udid>" });
1313

1414
try {
1515
const devices = await sim.list();
16-
await sim.launch("<udid>", "com.example.App");
17-
await sim.tap("<udid>", 0.5, 0.5);
18-
await sim.waitFor("<udid>", { label: "Continue" });
19-
const png = await sim.screenshot("<udid>");
16+
await sim.launch("com.example.App");
17+
await sim.tap(0.5, 0.5);
18+
await sim.waitFor({ label: "Continue" });
19+
const png = await sim.screenshot();
2020
} finally {
2121
sim.close();
2222
}
2323
```
2424

25-
`connect()` starts the daemon when needed, reuses it when healthy, and only stops daemons it started itself unless `keepDaemon` is set.
25+
`connect()` starts the daemon when needed, reuses it when healthy, and only stops daemons it started itself unless `keepDaemon` is set. Pass `udid` to bind the session to one simulator; existing calls that pass `udid` as the first method argument still work, and `sim.device("<other-udid>")` returns a session bound to another simulator.
2626

2727
## Session API
2828

2929
The current session object exposes:
3030

31-
| Method | Purpose |
32-
| -------------- | ----------------------------------------------------------------- |
33-
| `list()` | Fetch simulator inventory from `GET /api/simulators`. |
34-
| `launch()` | Launch an installed bundle ID. |
35-
| `openUrl()` | Open a URL or deep link. |
36-
| `tap()` | Tap normalized screen coordinates. |
37-
| `key()` | Send one HID key code. |
38-
| `button()` | Press a hardware button. |
39-
| `tree()` | Fetch an accessibility hierarchy. |
40-
| `query()` | Return compact matches for a selector. |
41-
| `waitFor()` | Poll until a selector appears. |
42-
| `assert()` | Assert a selector is present. |
43-
| `batch()` | Run multiple REST actions through `/api/simulators/{udid}/batch`. |
44-
| `screenshot()` | Return a PNG buffer. |
45-
| `close()` | Stop the daemon if this session started it. |
46-
47-
Selectors can match `id`, `label`, `value`, or `type`. Query options accept `source`, `maxDepth`, and `includeHidden`.
31+
| Method | Purpose |
32+
| ---------------------- | ----------------------------------------------------------------- |
33+
| `list()` | Fetch simulator inventory from `GET /api/simulators`. |
34+
| `launch()` | Launch an installed bundle ID. |
35+
| `openUrl()` | Open a URL or deep link. |
36+
| `tap()` | Tap normalized screen coordinates. |
37+
| `key()` | Send one HID key code. |
38+
| `button()` | Press a hardware button. |
39+
| `tree()` | Fetch an accessibility hierarchy. |
40+
| `query()` | Return compact matches for a selector. |
41+
| `waitFor()` | Poll until a selector appears. |
42+
| `waitForNot()` | Poll until a selector disappears. |
43+
| `assert()` | Assert a selector is present. |
44+
| `assertNot()` | Assert a selector is absent. |
45+
| `scrollUntilVisible()` | Scroll until a selector appears or the timeout expires. |
46+
| `batch()` | Run multiple REST actions through `/api/simulators/{udid}/batch`. |
47+
| `screenshot()` | Return a PNG buffer. |
48+
| `close()` | Stop the daemon if this session started it. |
49+
50+
Selectors can match `text`, `id`, `label`, `value`, `type`, `index`, `enabled`, `checked`, `focused`, and `selected`. Set `regex: true` to treat string selector fields as regular expressions. Query options accept `source`, `maxDepth`, and `includeHidden`.
51+
52+
## Maestro-Compatible YAML
53+
54+
The CLI includes a compatibility runner for common Maestro YAML flows:
55+
56+
```sh
57+
simdeck maestro test <udid> flow.yaml --artifacts-dir artifacts/maestro
58+
```
59+
60+
Supported commands include `launchApp`, `openLink`, `tapOn`, `inputText`, `eraseText`, `pressKey`, `assertVisible`, `assertNotVisible`, `scrollUntilVisible`, `swipe`, `takeScreenshot`, and `waitForAnimationToEnd`. Unsupported Maestro commands fail clearly so the flow can be adjusted or the compatibility layer can be expanded.
4861

4962
## Repository Integration Suite
5063

Lines changed: 89 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,122 @@
11
export type SimDeckLaunchOptions = {
22
cliPath?: string;
33
projectRoot?: string;
4+
udid?: string;
45
keepDaemon?: boolean;
56
isolated?: boolean;
67
port?: number;
78
videoCodec?: "auto" | "hardware" | "software" | "h264-software";
89
};
910
export type QueryOptions = {
10-
source?: "auto" | "nativescript" | "uikit" | "native-ax";
11+
source?:
12+
| "auto"
13+
| "nativescript"
14+
| "react-native"
15+
| "swiftui"
16+
| "uikit"
17+
| "native-ax";
1118
maxDepth?: number;
1219
includeHidden?: boolean;
1320
};
1421
export type ElementSelector = {
22+
text?: string;
1523
id?: string;
1624
label?: string;
1725
value?: string;
1826
type?: string;
27+
index?: number;
28+
enabled?: boolean;
29+
checked?: boolean;
30+
focused?: boolean;
31+
selected?: boolean;
32+
regex?: boolean;
1933
};
2034
export type TapOptions = QueryOptions & {
2135
durationMs?: number;
2236
waitTimeoutMs?: number;
2337
pollMs?: number;
2438
};
39+
type DeviceMethod<TArgs extends unknown[], TResult> = {
40+
(...args: TArgs): Promise<TResult>;
41+
(udid: string, ...args: TArgs): Promise<TResult>;
42+
};
43+
type DeviceNoArgMethod<TResult> = {
44+
(): Promise<TResult>;
45+
(udid: string): Promise<TResult>;
46+
};
2547
export type SimDeckSession = {
2648
endpoint: string;
2749
pid: number;
2850
projectRoot: string;
51+
udid?: string;
52+
device(udid: string): SimDeckSession;
2953
list(): Promise<unknown>;
30-
install(udid: string, appPath: string): Promise<void>;
31-
uninstall(udid: string, bundleId: string): Promise<void>;
32-
launch(udid: string, bundleId: string): Promise<void>;
33-
openUrl(udid: string, url: string): Promise<void>;
34-
tap(udid: string, x: number, y: number): Promise<void>;
35-
tapElement(
36-
udid: string,
37-
selector: ElementSelector,
38-
options?: TapOptions,
39-
): Promise<void>;
40-
touch(udid: string, x: number, y: number, phase: string): Promise<void>;
41-
key(udid: string, keyCode: number, modifiers?: number): Promise<void>;
42-
button(udid: string, button: string, durationMs?: number): Promise<void>;
43-
pasteboardSet(udid: string, text: string): Promise<void>;
44-
pasteboardGet(udid: string): Promise<string>;
45-
chromeProfile(udid: string): Promise<unknown>;
46-
tree(udid: string, options?: QueryOptions): Promise<unknown>;
47-
query(
48-
udid: string,
49-
selector: ElementSelector,
50-
options?: QueryOptions,
51-
): Promise<unknown[]>;
52-
assert(
53-
udid: string,
54-
selector: ElementSelector,
55-
options?: QueryOptions,
56-
): Promise<unknown>;
57-
waitFor(
58-
udid: string,
59-
selector: ElementSelector,
60-
options?: QueryOptions & {
61-
timeoutMs?: number;
62-
pollMs?: number;
63-
},
64-
): Promise<unknown>;
65-
batch(
66-
udid: string,
67-
steps: unknown[],
68-
continueOnError?: boolean,
69-
): Promise<unknown>;
70-
screenshot(udid: string): Promise<Buffer>;
54+
install: DeviceMethod<[appPath: string], void>;
55+
uninstall: DeviceMethod<[bundleId: string], void>;
56+
launch: DeviceMethod<[bundleId: string], void>;
57+
openUrl: DeviceMethod<[url: string], void>;
58+
tap: DeviceMethod<[x: number, y: number], void>;
59+
tapElement: DeviceMethod<
60+
[selector: ElementSelector, options?: TapOptions],
61+
void
62+
>;
63+
touch: DeviceMethod<[x: number, y: number, phase: string], void>;
64+
key: DeviceMethod<[keyCode: number, modifiers?: number], void>;
65+
button: DeviceMethod<[button: string, durationMs?: number], void>;
66+
pasteboardSet: DeviceMethod<[text: string], void>;
67+
pasteboardGet: DeviceNoArgMethod<string>;
68+
chromeProfile: DeviceNoArgMethod<unknown>;
69+
tree: DeviceMethod<[options?: QueryOptions], unknown>;
70+
query: DeviceMethod<
71+
[selector: ElementSelector, options?: QueryOptions],
72+
unknown[]
73+
>;
74+
assert: DeviceMethod<
75+
[selector: ElementSelector, options?: QueryOptions],
76+
unknown
77+
>;
78+
assertNot: DeviceMethod<
79+
[selector: ElementSelector, options?: QueryOptions],
80+
unknown
81+
>;
82+
waitFor: DeviceMethod<
83+
[
84+
selector: ElementSelector,
85+
options?: QueryOptions & {
86+
timeoutMs?: number;
87+
pollMs?: number;
88+
},
89+
],
90+
unknown
91+
>;
92+
waitForNot: DeviceMethod<
93+
[
94+
selector: ElementSelector,
95+
options?: QueryOptions & {
96+
timeoutMs?: number;
97+
pollMs?: number;
98+
},
99+
],
100+
unknown
101+
>;
102+
scrollUntilVisible: DeviceMethod<
103+
[
104+
selector: ElementSelector,
105+
options?: QueryOptions & {
106+
timeoutMs?: number;
107+
pollMs?: number;
108+
direction?: "up" | "down" | "left" | "right";
109+
durationMs?: number;
110+
steps?: number;
111+
},
112+
],
113+
unknown
114+
>;
115+
batch: DeviceMethod<[steps: unknown[], continueOnError?: boolean], unknown>;
116+
screenshot: DeviceNoArgMethod<Buffer>;
71117
close(): void;
72118
};
73119
export declare function connect(
74120
options?: SimDeckLaunchOptions,
75121
): Promise<SimDeckSession>;
122+
export {};

0 commit comments

Comments
 (0)