From b3ec28e59b3b3cfc502ec316c7e9c1f978b51476 Mon Sep 17 00:00:00 2001 From: DjDeveloperr Date: Thu, 7 May 2026 00:50:51 -0400 Subject: [PATCH] Add device-bound test API and Maestro YAML runner --- README.md | 14 +- docs/api/rest.md | 49 +++ docs/cli/commands.md | 10 + docs/guide/testing.md | 59 +-- packages/simdeck-test/dist/index.d.ts | 131 ++++-- packages/simdeck-test/dist/index.js | 414 +++++++++++-------- packages/simdeck-test/src/index.ts | 546 +++++++++++++++++--------- server/Cargo.lock | 21 + server/Cargo.toml | 2 + server/src/api/routes.rs | 364 +++++++++++++++-- server/src/main.rs | 531 ++++++++++++++++++++++++- skills/simdeck/SKILL.md | 20 +- 12 files changed, 1697 insertions(+), 464 deletions(-) diff --git a/README.md b/README.md index 4273860..60f9f1d 100644 --- a/README.md +++ b/README.md @@ -187,11 +187,11 @@ coordinates directly. ```ts import { connect } from "simdeck/test"; -const sim = await connect(); +const sim = await connect({ udid: "" }); try { - await sim.tap("", 0.5, 0.5); - await sim.waitFor("", { label: "Continue" }); - await sim.screenshot(""); + await sim.tap(0.5, 0.5); + await sim.waitFor({ label: "Continue" }); + await sim.screenshot(); } finally { sim.close(); } @@ -200,6 +200,12 @@ try { `connect()` starts the project daemon when needed, reuses it when it is already healthy, and only stops daemons it started itself. +Run common Maestro YAML flows against the same daemon-backed iOS Simulator API: + +```sh +simdeck maestro test flow.yaml --artifacts-dir artifacts/maestro +``` + ## NativeScript Inspector NativeScript apps can connect directly to the running server from JS and expose diff --git a/docs/api/rest.md b/docs/api/rest.md index f17dd73..12adb46 100644 --- a/docs/api/rest.md +++ b/docs/api/rest.md @@ -375,6 +375,55 @@ The response always includes: 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. +### `POST /api/simulators/{udid}/query` + +Returns compact accessibility matches for a selector: + +```json +{ + "selector": { + "text": "Continue", + "id": "continue-button", + "elementType": "Button", + "enabled": true, + "regex": false + }, + "source": "auto", + "maxDepth": 8, + "limit": 20 +} +``` + +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. + +### `POST /api/simulators/{udid}/wait-for` + +Polls until a selector appears. `assert` is an alias with the same payload shape: + +```json +{ + "selector": { "text": "Welcome", "regex": true }, + "timeoutMs": 5000, + "pollMs": 100 +} +``` + +Use `POST /api/simulators/{udid}/wait-for-not` or `/assert-not` for negative assertions. + +### `POST /api/simulators/{udid}/scroll-until-visible` + +Scrolls and polls until the selector appears: + +```json +{ + "selector": { "text": "Settings" }, + "direction": "down", + "timeoutMs": 10000 +} +``` + +`direction` accepts `up`, `down`, `left`, and `right`. + ## Inspector proxy ### `POST /api/simulators/{udid}/inspector/request` diff --git a/docs/cli/commands.md b/docs/cli/commands.md index f67a127..6cf66fc 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -251,6 +251,16 @@ simdeck batch \ 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. +## Maestro YAML + +Run common Maestro flows through SimDeck's daemon-backed iOS Simulator API: + +```sh +simdeck maestro test flow.yaml --artifacts-dir artifacts/maestro +``` + +The compatibility runner supports the core local commands: `launchApp`, `openLink`, `tapOn`, `inputText`, `eraseText`, `pressKey`, `assertVisible`, `assertNotVisible`, `scrollUntilVisible`, `swipe`, `takeScreenshot`, and `waitForAnimationToEnd`. + ## Evidence ```sh diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 6a5f3a7..ad95262 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -9,42 +9,55 @@ SimDeck supports two test layers: a small JS/TS client package for app tests, an ```ts import { connect } from "simdeck/test"; -const sim = await connect(); +const sim = await connect({ udid: "" }); try { const devices = await sim.list(); - await sim.launch("", "com.example.App"); - await sim.tap("", 0.5, 0.5); - await sim.waitFor("", { label: "Continue" }); - const png = await sim.screenshot(""); + await sim.launch("com.example.App"); + await sim.tap(0.5, 0.5); + await sim.waitFor({ label: "Continue" }); + const png = await sim.screenshot(); } finally { sim.close(); } ``` -`connect()` starts the daemon when needed, reuses it when healthy, and only stops daemons it started itself unless `keepDaemon` is set. +`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("")` returns a session bound to another simulator. ## Session API The current session object exposes: -| Method | Purpose | -| -------------- | ----------------------------------------------------------------- | -| `list()` | Fetch simulator inventory from `GET /api/simulators`. | -| `launch()` | Launch an installed bundle ID. | -| `openUrl()` | Open a URL or deep link. | -| `tap()` | Tap normalized screen coordinates. | -| `key()` | Send one HID key code. | -| `button()` | Press a hardware button. | -| `tree()` | Fetch an accessibility hierarchy. | -| `query()` | Return compact matches for a selector. | -| `waitFor()` | Poll until a selector appears. | -| `assert()` | Assert a selector is present. | -| `batch()` | Run multiple REST actions through `/api/simulators/{udid}/batch`. | -| `screenshot()` | Return a PNG buffer. | -| `close()` | Stop the daemon if this session started it. | - -Selectors can match `id`, `label`, `value`, or `type`. Query options accept `source`, `maxDepth`, and `includeHidden`. +| Method | Purpose | +| ---------------------- | ----------------------------------------------------------------- | +| `list()` | Fetch simulator inventory from `GET /api/simulators`. | +| `launch()` | Launch an installed bundle ID. | +| `openUrl()` | Open a URL or deep link. | +| `tap()` | Tap normalized screen coordinates. | +| `key()` | Send one HID key code. | +| `button()` | Press a hardware button. | +| `tree()` | Fetch an accessibility hierarchy. | +| `query()` | Return compact matches for a selector. | +| `waitFor()` | Poll until a selector appears. | +| `waitForNot()` | Poll until a selector disappears. | +| `assert()` | Assert a selector is present. | +| `assertNot()` | Assert a selector is absent. | +| `scrollUntilVisible()` | Scroll until a selector appears or the timeout expires. | +| `batch()` | Run multiple REST actions through `/api/simulators/{udid}/batch`. | +| `screenshot()` | Return a PNG buffer. | +| `close()` | Stop the daemon if this session started it. | + +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`. + +## Maestro-Compatible YAML + +The CLI includes a compatibility runner for common Maestro YAML flows: + +```sh +simdeck maestro test flow.yaml --artifacts-dir artifacts/maestro +``` + +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. ## Repository Integration Suite diff --git a/packages/simdeck-test/dist/index.d.ts b/packages/simdeck-test/dist/index.d.ts index b0a02ff..11dd73f 100644 --- a/packages/simdeck-test/dist/index.d.ts +++ b/packages/simdeck-test/dist/index.d.ts @@ -1,75 +1,122 @@ export type SimDeckLaunchOptions = { cliPath?: string; projectRoot?: string; + udid?: string; keepDaemon?: boolean; isolated?: boolean; port?: number; videoCodec?: "auto" | "hardware" | "software" | "h264-software"; }; export type QueryOptions = { - source?: "auto" | "nativescript" | "uikit" | "native-ax"; + source?: + | "auto" + | "nativescript" + | "react-native" + | "swiftui" + | "uikit" + | "native-ax"; maxDepth?: number; includeHidden?: boolean; }; export type ElementSelector = { + text?: string; id?: string; label?: string; value?: string; type?: string; + index?: number; + enabled?: boolean; + checked?: boolean; + focused?: boolean; + selected?: boolean; + regex?: boolean; }; export type TapOptions = QueryOptions & { durationMs?: number; waitTimeoutMs?: number; pollMs?: number; }; +type DeviceMethod = { + (...args: TArgs): Promise; + (udid: string, ...args: TArgs): Promise; +}; +type DeviceNoArgMethod = { + (): Promise; + (udid: string): Promise; +}; export type SimDeckSession = { endpoint: string; pid: number; projectRoot: string; + udid?: string; + device(udid: string): SimDeckSession; list(): Promise; - install(udid: string, appPath: string): Promise; - uninstall(udid: string, bundleId: string): Promise; - launch(udid: string, bundleId: string): Promise; - openUrl(udid: string, url: string): Promise; - tap(udid: string, x: number, y: number): Promise; - tapElement( - udid: string, - selector: ElementSelector, - options?: TapOptions, - ): Promise; - touch(udid: string, x: number, y: number, phase: string): Promise; - key(udid: string, keyCode: number, modifiers?: number): Promise; - button(udid: string, button: string, durationMs?: number): Promise; - pasteboardSet(udid: string, text: string): Promise; - pasteboardGet(udid: string): Promise; - chromeProfile(udid: string): Promise; - tree(udid: string, options?: QueryOptions): Promise; - query( - udid: string, - selector: ElementSelector, - options?: QueryOptions, - ): Promise; - assert( - udid: string, - selector: ElementSelector, - options?: QueryOptions, - ): Promise; - waitFor( - udid: string, - selector: ElementSelector, - options?: QueryOptions & { - timeoutMs?: number; - pollMs?: number; - }, - ): Promise; - batch( - udid: string, - steps: unknown[], - continueOnError?: boolean, - ): Promise; - screenshot(udid: string): Promise; + install: DeviceMethod<[appPath: string], void>; + uninstall: DeviceMethod<[bundleId: string], void>; + launch: DeviceMethod<[bundleId: string], void>; + openUrl: DeviceMethod<[url: string], void>; + tap: DeviceMethod<[x: number, y: number], void>; + tapElement: DeviceMethod< + [selector: ElementSelector, options?: TapOptions], + void + >; + touch: DeviceMethod<[x: number, y: number, phase: string], void>; + key: DeviceMethod<[keyCode: number, modifiers?: number], void>; + button: DeviceMethod<[button: string, durationMs?: number], void>; + pasteboardSet: DeviceMethod<[text: string], void>; + pasteboardGet: DeviceNoArgMethod; + chromeProfile: DeviceNoArgMethod; + tree: DeviceMethod<[options?: QueryOptions], unknown>; + query: DeviceMethod< + [selector: ElementSelector, options?: QueryOptions], + unknown[] + >; + assert: DeviceMethod< + [selector: ElementSelector, options?: QueryOptions], + unknown + >; + assertNot: DeviceMethod< + [selector: ElementSelector, options?: QueryOptions], + unknown + >; + waitFor: DeviceMethod< + [ + selector: ElementSelector, + options?: QueryOptions & { + timeoutMs?: number; + pollMs?: number; + }, + ], + unknown + >; + waitForNot: DeviceMethod< + [ + selector: ElementSelector, + options?: QueryOptions & { + timeoutMs?: number; + pollMs?: number; + }, + ], + unknown + >; + scrollUntilVisible: DeviceMethod< + [ + selector: ElementSelector, + options?: QueryOptions & { + timeoutMs?: number; + pollMs?: number; + direction?: "up" | "down" | "left" | "right"; + durationMs?: number; + steps?: number; + }, + ], + unknown + >; + batch: DeviceMethod<[steps: unknown[], continueOnError?: boolean], unknown>; + screenshot: DeviceNoArgMethod; close(): void; }; export declare function connect( options?: SimDeckLaunchOptions, ): Promise; +export {}; diff --git a/packages/simdeck-test/dist/index.js b/packages/simdeck-test/dist/index.js index 0259c11..e122e53 100644 --- a/packages/simdeck-test/dist/index.js +++ b/packages/simdeck-test/dist/index.js @@ -13,166 +13,261 @@ export async function connect(options = {}) { cwd: options.projectRoot, }); const endpoint = result.url; - const session = { - endpoint, - pid: result.pid, - projectRoot: result.projectRoot, - list: () => requestJson(endpoint, "GET", "/api/simulators"), - install: (udid, appPath) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/install`, - { - appPath, - }, - ), - uninstall: (udid, bundleId) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/uninstall`, - { - bundleId, - }, - ), - launch: (udid, bundleId) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/launch`, - { - bundleId, - }, - ), - openUrl: (udid, url) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/open-url`, - { - url, - }, - ), - tap: (udid, x, y) => - requestOk(endpoint, `/api/simulators/${encodeURIComponent(udid)}/tap`, { - x, - y, - normalized: true, - }), - tapElement: (udid, selector, tapOptions) => - requestOk(endpoint, `/api/simulators/${encodeURIComponent(udid)}/tap`, { - selector: selectorPayload(selector), - ...tapOptions, - }), - touch: (udid, x, y, phase) => - requestOk(endpoint, `/api/simulators/${encodeURIComponent(udid)}/touch`, { - x, - y, - phase, - }), - key: (udid, keyCode, modifiers = 0) => - requestOk(endpoint, `/api/simulators/${encodeURIComponent(udid)}/key`, { - keyCode, - modifiers, - }), - button: (udid, button, durationMs = 0) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/button`, - { - button, - durationMs, - }, - ), - pasteboardSet: (udid, text) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/pasteboard`, - { - text, - }, - ), - pasteboardGet: async (udid) => { - const result = await requestJson( - endpoint, - "GET", - `/api/simulators/${encodeURIComponent(udid)}/pasteboard`, - ); - return result.text ?? ""; - }, - chromeProfile: (udid) => - requestJson( - endpoint, - "GET", - `/api/simulators/${encodeURIComponent(udid)}/chrome-profile`, - ), - tree: (udid, treeOptions) => - requestJson( - endpoint, - "GET", - `/api/simulators/${encodeURIComponent(udid)}/accessibility-tree?${treeQuery(treeOptions)}`, - ), - query: async (udid, selector, treeOptions) => { - const result = await requestJson( - endpoint, - "POST", - `/api/simulators/${encodeURIComponent(udid)}/query`, - { - selector: selectorPayload(selector), - ...treeOptions, - }, + const createSession = (boundUdid) => { + const requireUdid = (method) => { + if (boundUdid) return boundUdid; + throw new Error( + `${method} requires a UDID. Pass connect({ udid }) or call ${method}(udid, ...).`, ); - return result.matches; - }, - assert: (udid, selector, assertOptions) => - requestJson( - endpoint, - "POST", - `/api/simulators/${encodeURIComponent(udid)}/assert`, - { - selector: selectorPayload(selector), - ...assertOptions, - }, - ), - waitFor: (udid, selector, waitOptions) => - requestJson( - endpoint, - "POST", - `/api/simulators/${encodeURIComponent(udid)}/wait-for`, - { - selector: selectorPayload(selector), - ...waitOptions, - }, - ), - batch: (udid, steps, continueOnError = false) => - requestJson( - endpoint, - "POST", - `/api/simulators/${encodeURIComponent(udid)}/batch`, - { - steps, - continueOnError, - }, - ), - screenshot: (udid) => - requestBuffer( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/screenshot.png`, - ), - close: () => { - if (options.keepDaemon) { - return; + }; + const splitDeviceArgs = (method, args, boundArity) => { + if (typeof args[0] === "string" && args.length > boundArity) { + return [args[0], args.slice(1)]; } - if (result.child) { - result.child.kill(); - if (result.isolatedRoot) { - fs.rmSync(result.isolatedRoot, { recursive: true, force: true }); - } - return; + return [requireUdid(method), args]; + }; + const splitDeviceNoArg = (method, args) => { + if (typeof args[0] === "string") { + return [args[0], args.slice(1)]; } - if (result.started) { - spawnSync(cliPath, ["daemon", "stop"], { cwd: options.projectRoot }); - } - }, + return [requireUdid(method), args]; + }; + return { + endpoint, + pid: result.pid, + projectRoot: result.projectRoot, + udid: boundUdid, + device: (udid) => createSession(udid), + list: () => requestJson(endpoint, "GET", "/api/simulators"), + install: (...args) => { + const [udid, rest] = splitDeviceArgs("install", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/install`, + { appPath: rest[0] }, + ); + }, + uninstall: (...args) => { + const [udid, rest] = splitDeviceArgs("uninstall", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/uninstall`, + { bundleId: rest[0] }, + ); + }, + launch: (...args) => { + const [udid, rest] = splitDeviceArgs("launch", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/launch`, + { bundleId: rest[0] }, + ); + }, + openUrl: (...args) => { + const [udid, rest] = splitDeviceArgs("openUrl", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/open-url`, + { url: rest[0] }, + ); + }, + tap: (...args) => { + const [udid, rest] = splitDeviceArgs("tap", args, 2); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/tap`, + { + x: rest[0], + y: rest[1], + normalized: true, + }, + ); + }, + tapElement: (...args) => { + const [udid, rest] = splitDeviceArgs("tapElement", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/tap`, + { + selector: selectorPayload(rest[0]), + ...rest[1], + }, + ); + }, + touch: (...args) => { + const [udid, rest] = splitDeviceArgs("touch", args, 3); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/touch`, + { + x: rest[0], + y: rest[1], + phase: rest[2], + }, + ); + }, + key: (...args) => { + const [udid, rest] = splitDeviceArgs("key", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/key`, + { + keyCode: rest[0], + modifiers: rest[1] ?? 0, + }, + ); + }, + button: (...args) => { + const [udid, rest] = splitDeviceArgs("button", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/button`, + { button: rest[0], durationMs: rest[1] ?? 0 }, + ); + }, + pasteboardSet: (...args) => { + const [udid, rest] = splitDeviceArgs("pasteboardSet", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/pasteboard`, + { text: rest[0] }, + ); + }, + pasteboardGet: async (...args) => { + const [udid] = splitDeviceNoArg("pasteboardGet", args); + const result = await requestJson( + endpoint, + "GET", + `/api/simulators/${encodeURIComponent(udid)}/pasteboard`, + ); + return result.text ?? ""; + }, + chromeProfile: (...args) => { + const [udid] = splitDeviceNoArg("chromeProfile", args); + return requestJson( + endpoint, + "GET", + `/api/simulators/${encodeURIComponent(udid)}/chrome-profile`, + ); + }, + tree: (...args) => { + const [udid, rest] = splitDeviceArgs("tree", args, 0); + return requestJson( + endpoint, + "GET", + `/api/simulators/${encodeURIComponent(udid)}/accessibility-tree?${treeQuery(rest[0])}`, + ); + }, + query: async (...args) => { + const [udid, rest] = splitDeviceArgs("query", args, 1); + const result = await requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/query`, + { + selector: selectorPayload(rest[0]), + ...rest[1], + }, + ); + return result.matches; + }, + assert: (...args) => { + const [udid, rest] = splitDeviceArgs("assert", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/assert`, + { + selector: selectorPayload(rest[0]), + ...rest[1], + }, + ); + }, + assertNot: (...args) => { + const [udid, rest] = splitDeviceArgs("assertNot", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/assert-not`, + { + selector: selectorPayload(rest[0]), + ...rest[1], + }, + ); + }, + waitFor: (...args) => { + const [udid, rest] = splitDeviceArgs("waitFor", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/wait-for`, + { + selector: selectorPayload(rest[0]), + ...rest[1], + }, + ); + }, + waitForNot: (...args) => { + const [udid, rest] = splitDeviceArgs("waitForNot", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/wait-for-not`, + { + selector: selectorPayload(rest[0]), + ...rest[1], + }, + ); + }, + scrollUntilVisible: (...args) => { + const [udid, rest] = splitDeviceArgs("scrollUntilVisible", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/scroll-until-visible`, + { + selector: selectorPayload(rest[0]), + ...rest[1], + }, + ); + }, + batch: (...args) => { + const [udid, rest] = splitDeviceArgs("batch", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/batch`, + { + steps: rest[0], + continueOnError: rest[1] ?? false, + }, + ); + }, + screenshot: (...args) => { + const [udid] = splitDeviceNoArg("screenshot", args); + return requestBuffer( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/screenshot.png`, + ); + }, + close: () => { + if (options.keepDaemon) { + return; + } + if (result.child) { + result.child.kill(); + if (result.isolatedRoot) { + fs.rmSync(result.isolatedRoot, { recursive: true, force: true }); + } + return; + } + if (result.started) { + spawnSync(cliPath, ["daemon", "stop"], { cwd: options.projectRoot }); + } + }, + }; }; - return session; + return createSession(options.udid); } async function startIsolatedDaemon(cliPath, options) { const port = options.port ?? (await freePortPair()); @@ -368,9 +463,16 @@ function treeQuery(options = {}) { } function selectorPayload(selector) { return { + text: selector.text, id: selector.id, label: selector.label, value: selector.value, elementType: selector.type, + index: selector.index, + enabled: selector.enabled, + checked: selector.checked, + focused: selector.focused, + selected: selector.selected, + regex: selector.regex, }; } diff --git a/packages/simdeck-test/src/index.ts b/packages/simdeck-test/src/index.ts index 3e786b3..2ab8212 100644 --- a/packages/simdeck-test/src/index.ts +++ b/packages/simdeck-test/src/index.ts @@ -9,6 +9,7 @@ import path from "node:path"; export type SimDeckLaunchOptions = { cliPath?: string; projectRoot?: string; + udid?: string; keepDaemon?: boolean; isolated?: boolean; port?: number; @@ -16,16 +17,29 @@ export type SimDeckLaunchOptions = { }; export type QueryOptions = { - source?: "auto" | "nativescript" | "uikit" | "native-ax"; + source?: + | "auto" + | "nativescript" + | "react-native" + | "swiftui" + | "uikit" + | "native-ax"; maxDepth?: number; includeHidden?: boolean; }; export type ElementSelector = { + text?: string; id?: string; label?: string; value?: string; type?: string; + index?: number; + enabled?: boolean; + checked?: boolean; + focused?: boolean; + selected?: boolean; + regex?: boolean; }; export type TapOptions = QueryOptions & { @@ -34,49 +48,80 @@ export type TapOptions = QueryOptions & { pollMs?: number; }; +type DeviceMethod = { + (...args: TArgs): Promise; + (udid: string, ...args: TArgs): Promise; +}; + +type DeviceNoArgMethod = { + (): Promise; + (udid: string): Promise; +}; + export type SimDeckSession = { endpoint: string; pid: number; projectRoot: string; + udid?: string; + device(udid: string): SimDeckSession; list(): Promise; - install(udid: string, appPath: string): Promise; - uninstall(udid: string, bundleId: string): Promise; - launch(udid: string, bundleId: string): Promise; - openUrl(udid: string, url: string): Promise; - tap(udid: string, x: number, y: number): Promise; - tapElement( - udid: string, - selector: ElementSelector, - options?: TapOptions, - ): Promise; - touch(udid: string, x: number, y: number, phase: string): Promise; - key(udid: string, keyCode: number, modifiers?: number): Promise; - button(udid: string, button: string, durationMs?: number): Promise; - pasteboardSet(udid: string, text: string): Promise; - pasteboardGet(udid: string): Promise; - chromeProfile(udid: string): Promise; - tree(udid: string, options?: QueryOptions): Promise; - query( - udid: string, - selector: ElementSelector, - options?: QueryOptions, - ): Promise; - assert( - udid: string, - selector: ElementSelector, - options?: QueryOptions, - ): Promise; - waitFor( - udid: string, - selector: ElementSelector, - options?: QueryOptions & { timeoutMs?: number; pollMs?: number }, - ): Promise; - batch( - udid: string, - steps: unknown[], - continueOnError?: boolean, - ): Promise; - screenshot(udid: string): Promise; + install: DeviceMethod<[appPath: string], void>; + uninstall: DeviceMethod<[bundleId: string], void>; + launch: DeviceMethod<[bundleId: string], void>; + openUrl: DeviceMethod<[url: string], void>; + tap: DeviceMethod<[x: number, y: number], void>; + tapElement: DeviceMethod< + [selector: ElementSelector, options?: TapOptions], + void + >; + touch: DeviceMethod<[x: number, y: number, phase: string], void>; + key: DeviceMethod<[keyCode: number, modifiers?: number], void>; + button: DeviceMethod<[button: string, durationMs?: number], void>; + pasteboardSet: DeviceMethod<[text: string], void>; + pasteboardGet: DeviceNoArgMethod; + chromeProfile: DeviceNoArgMethod; + tree: DeviceMethod<[options?: QueryOptions], unknown>; + query: DeviceMethod< + [selector: ElementSelector, options?: QueryOptions], + unknown[] + >; + assert: DeviceMethod< + [selector: ElementSelector, options?: QueryOptions], + unknown + >; + assertNot: DeviceMethod< + [selector: ElementSelector, options?: QueryOptions], + unknown + >; + waitFor: DeviceMethod< + [ + selector: ElementSelector, + options?: QueryOptions & { timeoutMs?: number; pollMs?: number }, + ], + unknown + >; + waitForNot: DeviceMethod< + [ + selector: ElementSelector, + options?: QueryOptions & { timeoutMs?: number; pollMs?: number }, + ], + unknown + >; + scrollUntilVisible: DeviceMethod< + [ + selector: ElementSelector, + options?: QueryOptions & { + timeoutMs?: number; + pollMs?: number; + direction?: "up" | "down" | "left" | "right"; + durationMs?: number; + steps?: number; + }, + ], + unknown + >; + batch: DeviceMethod<[steps: unknown[], continueOnError?: boolean], unknown>; + screenshot: DeviceNoArgMethod; close(): void; }; @@ -103,166 +148,268 @@ export async function connect( cwd: options.projectRoot, }); const endpoint = result.url; - const session: SimDeckSession = { - endpoint, - pid: result.pid, - projectRoot: result.projectRoot, - list: () => requestJson(endpoint, "GET", "/api/simulators"), - install: (udid, appPath) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/install`, - { - appPath, - }, - ), - uninstall: (udid, bundleId) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/uninstall`, - { - bundleId, - }, - ), - launch: (udid, bundleId) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/launch`, - { - bundleId, - }, - ), - openUrl: (udid, url) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/open-url`, - { - url, - }, - ), - tap: (udid, x, y) => - requestOk(endpoint, `/api/simulators/${encodeURIComponent(udid)}/tap`, { - x, - y, - normalized: true, - }), - tapElement: (udid, selector, tapOptions) => - requestOk(endpoint, `/api/simulators/${encodeURIComponent(udid)}/tap`, { - selector: selectorPayload(selector), - ...tapOptions, - }), - touch: (udid, x, y, phase) => - requestOk(endpoint, `/api/simulators/${encodeURIComponent(udid)}/touch`, { - x, - y, - phase, - }), - key: (udid, keyCode, modifiers = 0) => - requestOk(endpoint, `/api/simulators/${encodeURIComponent(udid)}/key`, { - keyCode, - modifiers, - }), - button: (udid, button, durationMs = 0) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/button`, - { - button, - durationMs, - }, - ), - pasteboardSet: (udid, text) => - requestOk( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/pasteboard`, - { - text, - }, - ), - pasteboardGet: async (udid) => { - const result = await requestJson<{ text?: string }>( - endpoint, - "GET", - `/api/simulators/${encodeURIComponent(udid)}/pasteboard`, - ); - return result.text ?? ""; - }, - chromeProfile: (udid) => - requestJson( - endpoint, - "GET", - `/api/simulators/${encodeURIComponent(udid)}/chrome-profile`, - ), - tree: (udid, treeOptions) => - requestJson( - endpoint, - "GET", - `/api/simulators/${encodeURIComponent(udid)}/accessibility-tree?${treeQuery(treeOptions)}`, - ), - query: async (udid, selector, treeOptions) => { - const result = await requestJson<{ matches: unknown[] }>( - endpoint, - "POST", - `/api/simulators/${encodeURIComponent(udid)}/query`, - { - selector: selectorPayload(selector), - ...treeOptions, - }, + const createSession = (boundUdid?: string): SimDeckSession => { + const requireUdid = (method: string): string => { + if (boundUdid) return boundUdid; + throw new Error( + `${method} requires a UDID. Pass connect({ udid }) or call ${method}(udid, ...).`, ); - return result.matches; - }, - assert: (udid, selector, assertOptions) => - requestJson( - endpoint, - "POST", - `/api/simulators/${encodeURIComponent(udid)}/assert`, - { - selector: selectorPayload(selector), - ...assertOptions, - }, - ), - waitFor: (udid, selector, waitOptions) => - requestJson( - endpoint, - "POST", - `/api/simulators/${encodeURIComponent(udid)}/wait-for`, - { - selector: selectorPayload(selector), - ...waitOptions, - }, - ), - batch: (udid, steps, continueOnError = false) => - requestJson( - endpoint, - "POST", - `/api/simulators/${encodeURIComponent(udid)}/batch`, - { - steps, - continueOnError, - }, - ), - screenshot: (udid) => - requestBuffer( - endpoint, - `/api/simulators/${encodeURIComponent(udid)}/screenshot.png`, - ), - close: () => { - if (options.keepDaemon) { - return; + }; + const splitDeviceArgs = ( + method: string, + args: unknown[], + boundArity: number, + ): [string, unknown[]] => { + if (typeof args[0] === "string" && args.length > boundArity) { + return [args[0], args.slice(1)]; } - if (result.child) { - result.child.kill(); - if (result.isolatedRoot) { - fs.rmSync(result.isolatedRoot, { recursive: true, force: true }); - } - return; + return [requireUdid(method), args]; + }; + const splitDeviceNoArg = ( + method: string, + args: unknown[], + ): [string, unknown[]] => { + if (typeof args[0] === "string") { + return [args[0], args.slice(1)]; } - if (result.started) { - spawnSync(cliPath, ["daemon", "stop"], { cwd: options.projectRoot }); - } - }, + return [requireUdid(method), args]; + }; + return { + endpoint, + pid: result.pid, + projectRoot: result.projectRoot, + udid: boundUdid, + device: (udid: string) => createSession(udid), + list: () => requestJson(endpoint, "GET", "/api/simulators"), + install: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("install", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/install`, + { appPath: rest[0] }, + ); + }, + uninstall: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("uninstall", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/uninstall`, + { bundleId: rest[0] }, + ); + }, + launch: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("launch", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/launch`, + { bundleId: rest[0] }, + ); + }, + openUrl: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("openUrl", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/open-url`, + { url: rest[0] }, + ); + }, + tap: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("tap", args, 2); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/tap`, + { + x: rest[0], + y: rest[1], + normalized: true, + }, + ); + }, + tapElement: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("tapElement", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/tap`, + { + selector: selectorPayload(rest[0] as ElementSelector), + ...(rest[1] as TapOptions | undefined), + }, + ); + }, + touch: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("touch", args, 3); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/touch`, + { + x: rest[0], + y: rest[1], + phase: rest[2], + }, + ); + }, + key: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("key", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/key`, + { + keyCode: rest[0], + modifiers: rest[1] ?? 0, + }, + ); + }, + button: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("button", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/button`, + { button: rest[0], durationMs: rest[1] ?? 0 }, + ); + }, + pasteboardSet: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("pasteboardSet", args, 1); + return requestOk( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/pasteboard`, + { text: rest[0] }, + ); + }, + pasteboardGet: async (...args: unknown[]) => { + const [udid] = splitDeviceNoArg("pasteboardGet", args); + const result = await requestJson<{ text?: string }>( + endpoint, + "GET", + `/api/simulators/${encodeURIComponent(udid)}/pasteboard`, + ); + return result.text ?? ""; + }, + chromeProfile: (...args: unknown[]) => { + const [udid] = splitDeviceNoArg("chromeProfile", args); + return requestJson( + endpoint, + "GET", + `/api/simulators/${encodeURIComponent(udid)}/chrome-profile`, + ); + }, + tree: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("tree", args, 0); + return requestJson( + endpoint, + "GET", + `/api/simulators/${encodeURIComponent(udid)}/accessibility-tree?${treeQuery(rest[0] as QueryOptions | undefined)}`, + ); + }, + query: async (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("query", args, 1); + const result = await requestJson<{ matches: unknown[] }>( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/query`, + { + selector: selectorPayload(rest[0] as ElementSelector), + ...(rest[1] as QueryOptions | undefined), + }, + ); + return result.matches; + }, + assert: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("assert", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/assert`, + { + selector: selectorPayload(rest[0] as ElementSelector), + ...(rest[1] as QueryOptions | undefined), + }, + ); + }, + assertNot: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("assertNot", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/assert-not`, + { + selector: selectorPayload(rest[0] as ElementSelector), + ...(rest[1] as QueryOptions | undefined), + }, + ); + }, + waitFor: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("waitFor", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/wait-for`, + { + selector: selectorPayload(rest[0] as ElementSelector), + ...(rest[1] as QueryOptions | undefined), + }, + ); + }, + waitForNot: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("waitForNot", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/wait-for-not`, + { + selector: selectorPayload(rest[0] as ElementSelector), + ...(rest[1] as QueryOptions | undefined), + }, + ); + }, + scrollUntilVisible: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("scrollUntilVisible", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/scroll-until-visible`, + { + selector: selectorPayload(rest[0] as ElementSelector), + ...(rest[1] as QueryOptions | undefined), + }, + ); + }, + batch: (...args: unknown[]) => { + const [udid, rest] = splitDeviceArgs("batch", args, 1); + return requestJson( + endpoint, + "POST", + `/api/simulators/${encodeURIComponent(udid)}/batch`, + { + steps: rest[0], + continueOnError: rest[1] ?? false, + }, + ); + }, + screenshot: (...args: unknown[]) => { + const [udid] = splitDeviceNoArg("screenshot", args); + return requestBuffer( + endpoint, + `/api/simulators/${encodeURIComponent(udid)}/screenshot.png`, + ); + }, + close: () => { + if (options.keepDaemon) { + return; + } + if (result.child) { + result.child.kill(); + if (result.isolatedRoot) { + fs.rmSync(result.isolatedRoot, { recursive: true, force: true }); + } + return; + } + if (result.started) { + spawnSync(cliPath, ["daemon", "stop"], { cwd: options.projectRoot }); + } + }, + } as SimDeckSession; }; - return session; + return createSession(options.udid); } async function startIsolatedDaemon( @@ -499,11 +646,18 @@ function treeQuery(options: QueryOptions = {}): string { function selectorPayload( selector: ElementSelector, -): Record { +): Record { return { + text: selector.text, id: selector.id, label: selector.label, value: selector.value, elementType: selector.type, + index: selector.index, + enabled: selector.enabled, + checked: selector.checked, + focused: selector.focused, + selected: selector.selected, + regex: selector.regex, }; } diff --git a/server/Cargo.lock b/server/Cargo.lock index 3b8e980..2e5aad1 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1820,6 +1820,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.10.6" @@ -1891,8 +1904,10 @@ dependencies = [ "hex", "http", "libc", + "regex", "serde", "serde_json", + "serde_yaml", "sha2", "thiserror 2.0.18", "tokio", @@ -2369,6 +2384,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/server/Cargo.toml b/server/Cargo.toml index 37fd0c4..e1c2d51 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -14,8 +14,10 @@ futures = "0.3" hex = "0.4" http = "1.1" libc = "0.2" +regex = "1.11" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_yaml = "0.9" sha2 = "0.10" thiserror = "2.0" tokio = { version = "1.42", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] } diff --git a/server/src/api/routes.rs b/server/src/api/routes.rs index 185a710..141ecb4 100644 --- a/server/src/api/routes.rs +++ b/server/src/api/routes.rs @@ -17,6 +17,7 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use futures::{SinkExt, StreamExt}; +use regex::Regex; use serde::Deserialize; use serde_json::Map; use serde_json::{json as json_value, Value}; @@ -266,11 +267,18 @@ struct ButtonPayload { #[derive(Deserialize, Clone, Default)] #[serde(rename_all = "camelCase")] struct ElementSelectorPayload { + text: Option, id: Option, label: Option, value: Option, #[serde(alias = "type")] element_type: Option, + index: Option, + enabled: Option, + checked: Option, + focused: Option, + selected: Option, + regex: Option, } #[derive(Deserialize, Clone)] @@ -296,6 +304,21 @@ struct WaitForPayload { poll_ms: Option, } +#[derive(Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +struct ScrollUntilVisiblePayload { + #[serde(default)] + selector: ElementSelectorPayload, + source: Option, + max_depth: Option, + include_hidden: Option, + timeout_ms: Option, + poll_ms: Option, + direction: Option, + duration_ms: Option, + steps: Option, +} + #[derive(Deserialize, Clone)] #[serde(rename_all = "camelCase")] struct TapElementPayload { @@ -333,6 +356,8 @@ enum BatchStep { Tap(TapElementPayload), WaitFor(WaitForPayload), Assert(WaitForPayload), + AssertNot(WaitForPayload), + ScrollUntilVisible(ScrollUntilVisiblePayload), Key { key_code: u16, modifiers: Option, @@ -490,6 +515,18 @@ pub fn router(state: AppState) -> Router { .route("/api/simulators/{udid}/query", post(accessibility_query)) .route("/api/simulators/{udid}/wait-for", post(wait_for_element)) .route("/api/simulators/{udid}/assert", post(assert_element)) + .route( + "/api/simulators/{udid}/wait-for-not", + post(wait_for_not_element), + ) + .route( + "/api/simulators/{udid}/assert-not", + post(assert_not_element), + ) + .route( + "/api/simulators/{udid}/scroll-until-visible", + post(scroll_until_visible), + ) .route("/api/simulators/{udid}/batch", post(run_batch)) .route("/api/simulators/{udid}/touch", post(send_touch)) .route("/api/simulators/{udid}/control", get(control_socket)) @@ -1155,6 +1192,30 @@ async fn assert_element( wait_for_element_payload(state, udid, payload).await } +async fn wait_for_not_element( + State(state): State, + Path(udid): Path, + Json(payload): Json, +) -> Result, AppError> { + wait_for_absent_element_payload(state, udid, payload).await +} + +async fn assert_not_element( + State(state): State, + Path(udid): Path, + Json(payload): Json, +) -> Result, AppError> { + wait_for_absent_element_payload(state, udid, payload).await +} + +async fn scroll_until_visible( + State(state): State, + Path(udid): Path, + Json(payload): Json, +) -> Result, AppError> { + scroll_until_visible_payload(state, udid, payload).await +} + async fn run_batch( State(state): State, Path(udid): Path, @@ -1776,6 +1837,39 @@ async fn wait_for_element_payload( }))) } +async fn wait_for_absent_element_payload( + state: AppState, + udid: String, + payload: WaitForPayload, +) -> Result, AppError> { + let started = Instant::now(); + let timeout_ms = payload.timeout_ms.unwrap_or(5_000); + let poll_ms = payload.poll_ms.unwrap_or(100).max(10); + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + loop { + let snapshot = accessibility_tree_value( + state.clone(), + udid.clone(), + payload.source.as_deref(), + payload.max_depth, + payload.include_hidden.unwrap_or(false), + ) + .await?; + if first_matching_element(&snapshot, &payload.selector).is_none() { + return Ok(json(json_value!({ + "ok": true, + "elapsedMs": started.elapsed().as_millis() as u64, + }))); + } + if timeout_ms == 0 || Instant::now() >= deadline { + return Err(AppError::bad_request( + "Accessibility element still matched the selector.", + )); + } + tokio::time::sleep(Duration::from_millis(poll_ms)).await; + } +} + async fn wait_for_snapshot_match( state: AppState, udid: String, @@ -1803,6 +1897,76 @@ async fn wait_for_snapshot_match( } } +async fn scroll_until_visible_payload( + state: AppState, + udid: String, + payload: ScrollUntilVisiblePayload, +) -> Result, AppError> { + let started = Instant::now(); + let timeout_ms = payload.timeout_ms.unwrap_or(10_000); + let poll_ms = payload.poll_ms.unwrap_or(100).max(10); + let deadline = Instant::now() + Duration::from_millis(timeout_ms); + let mut scroll_count = 0usize; + loop { + let snapshot = accessibility_tree_value( + state.clone(), + udid.clone(), + payload.source.as_deref(), + payload.max_depth, + payload.include_hidden.unwrap_or(false), + ) + .await?; + if let Some(found) = first_matching_element(&snapshot, &payload.selector) { + return Ok(json(json_value!({ + "ok": true, + "elapsedMs": started.elapsed().as_millis() as u64, + "scrollCount": scroll_count, + "match": compact_accessibility_node(&found), + }))); + } + if timeout_ms == 0 || Instant::now() >= deadline { + return Err(AppError::not_found("No accessibility element matched.")); + } + let (start_x, start_y, end_x, end_y) = + normalized_scroll_coordinates(payload.direction.as_deref())?; + let duration_ms = payload.duration_ms.unwrap_or(350); + let steps = payload.steps.unwrap_or(12).max(1); + let action_udid = udid.clone(); + run_bridge_action(state.clone(), move |bridge| { + let input = bridge.create_input_session(&action_udid)?; + let delay = Duration::from_millis(duration_ms / u64::from(steps)); + input.send_touch(start_x, start_y, "began")?; + for step in 1..steps { + let t = f64::from(step) / f64::from(steps); + input.send_touch( + start_x + (end_x - start_x) * t, + start_y + (end_y - start_y) * t, + "moved", + )?; + std::thread::sleep(delay); + } + input.send_touch(end_x, end_y, "ended") + }) + .await?; + scroll_count += 1; + tokio::time::sleep(Duration::from_millis(poll_ms)).await; + } +} + +fn normalized_scroll_coordinates( + direction: Option<&str>, +) -> Result<(f64, f64, f64, f64), AppError> { + match direction.unwrap_or("down").to_ascii_lowercase().as_str() { + "down" => Ok((0.5, 0.78, 0.5, 0.22)), + "up" => Ok((0.5, 0.22, 0.5, 0.78)), + "left" => Ok((0.78, 0.5, 0.22, 0.5)), + "right" => Ok((0.22, 0.5, 0.78, 0.5)), + other => Err(AppError::bad_request(format!( + "Unsupported scroll direction `{other}`." + ))), + } +} + async fn run_batch_step(state: AppState, udid: String, step: BatchStep) -> Result { match step { BatchStep::Sleep { ms, seconds } => { @@ -1827,6 +1991,18 @@ async fn run_batch_step(state: AppState, udid: String, step: BatchStep) -> Resul .ok_or_else(|| AppError::not_found("No accessibility element matched."))?; Ok(json_value!({ "action": "assert", "match": compact_accessibility_node(&found) })) } + BatchStep::AssertNot(payload) => { + let Json(_) = wait_for_absent_element_payload(state, udid, payload).await?; + Ok(json_value!({ "action": "assertNot" })) + } + BatchStep::ScrollUntilVisible(payload) => { + let Json(result) = scroll_until_visible_payload(state, udid, payload).await?; + Ok(json_value!({ + "action": "scrollUntilVisible", + "match": result.get("match").cloned().unwrap_or(Value::Null), + "scrollCount": result.get("scrollCount").cloned().unwrap_or(Value::Null), + })) + } BatchStep::Key { key_code, modifiers, @@ -2090,12 +2266,16 @@ fn query_compact_elements( let mut matches = Vec::new(); if let Some(roots) = snapshot.get("roots").and_then(Value::as_array) { for root in roots { - collect_query_matches(root, selector, limit, &mut matches); - if matches.len() >= limit { + let target_limit = selector.index.map(|index| index + 1).unwrap_or(limit); + collect_query_matches(root, selector, target_limit, &mut matches); + if matches.len() >= target_limit { break; } } } + if let Some(index) = selector.index { + return matches.into_iter().nth(index).into_iter().collect(); + } matches } @@ -2123,6 +2303,16 @@ fn collect_query_matches( fn first_matching_element(snapshot: &Value, selector: &ElementSelectorPayload) -> Option { let roots = snapshot.get("roots")?.as_array()?; + if let Some(index) = selector.index { + let mut matches = Vec::new(); + for root in roots { + collect_query_matches(root, selector, index + 1, &mut matches); + if matches.len() > index { + break; + } + } + return matches.into_iter().nth(index); + } for root in roots { if let Some(found) = first_matching_node(root, selector) { return Some(found.clone()); @@ -2155,48 +2345,122 @@ fn element_matches_selector(node: &Value, selector: &ElementSelectorPayload) -> if selector_is_empty(selector) { return true; } - selector - .element_type - .as_ref() - .is_none_or(|expected| string_fields_match(node, expected, &["type", "role", "className"])) - && selector.id.as_ref().is_none_or(|expected| { - string_fields_match( - node, - expected, - &[ - "AXIdentifier", - "AXUniqueId", - "inspectorId", - "id", - "identifier", - ], - ) - }) - && selector.label.as_ref().is_none_or(|expected| { - string_fields_match( - node, - expected, - &["AXLabel", "label", "title", "text", "name"], - ) - }) - && selector - .value - .as_ref() - .is_none_or(|expected| string_fields_match(node, expected, &["AXValue", "value"])) + let use_regex = selector.regex.unwrap_or(false); + selector.element_type.as_ref().is_none_or(|expected| { + string_fields_match(node, expected, use_regex, &["type", "role", "className"]) + }) && selector.id.as_ref().is_none_or(|expected| { + string_fields_match( + node, + expected, + use_regex, + &[ + "AXIdentifier", + "AXUniqueId", + "inspectorId", + "id", + "identifier", + ], + ) + }) && selector.text.as_ref().is_none_or(|expected| { + string_fields_match( + node, + expected, + use_regex, + &["AXLabel", "label", "title", "text", "name"], + ) + }) && selector.label.as_ref().is_none_or(|expected| { + string_fields_match( + node, + expected, + use_regex, + &["AXLabel", "label", "title", "text", "name"], + ) + }) && selector.value.as_ref().is_none_or(|expected| { + string_fields_match(node, expected, use_regex, &["AXValue", "value"]) + }) && selector.enabled.is_none_or(|expected| { + bool_fields_match( + node, + expected, + &[ + "enabled", + "AXEnabled", + "isEnabled", + "isUserInteractionEnabled", + ], + ) + }) && selector.checked.is_none_or(|expected| { + bool_or_state_fields_match( + node, + expected, + &["checked", "isChecked", "AXChecked"], + &["AXValue", "value"], + &["1", "true", "yes", "on", "checked", "selected"], + ) + }) && selector.focused.is_none_or(|expected| { + bool_fields_match(node, expected, &["focused", "isFocused", "AXFocused"]) + }) && selector.selected.is_none_or(|expected| { + bool_or_state_fields_match( + node, + expected, + &["selected", "isSelected", "AXSelected"], + &["AXValue", "value"], + &["selected", "1", "true", "yes", "on"], + ) + }) } fn selector_is_empty(selector: &ElementSelectorPayload) -> bool { - selector.id.is_none() + selector.text.is_none() + && selector.id.is_none() && selector.label.is_none() && selector.value.is_none() && selector.element_type.is_none() + && selector.enabled.is_none() + && selector.checked.is_none() + && selector.focused.is_none() + && selector.selected.is_none() } -fn string_fields_match(node: &Value, expected: &str, fields: &[&str]) -> bool { +fn string_fields_match(node: &Value, expected: &str, use_regex: bool, fields: &[&str]) -> bool { + let regex = use_regex.then(|| Regex::new(expected).ok()).flatten(); fields .iter() .filter_map(|field| node.get(*field).and_then(Value::as_str)) - .any(|value| value == expected) + .any(|value| { + if let Some(regex) = regex.as_ref() { + regex.is_match(value) + } else { + value == expected + } + }) +} + +fn bool_fields_match(node: &Value, expected: bool, fields: &[&str]) -> bool { + fields + .iter() + .find_map(|field| node.get(*field).and_then(Value::as_bool)) + .is_some_and(|value| value == expected) +} + +fn bool_or_state_fields_match( + node: &Value, + expected: bool, + bool_fields: &[&str], + string_fields: &[&str], + truthy_values: &[&str], +) -> bool { + if bool_fields_match(node, expected, bool_fields) { + return true; + } + string_fields + .iter() + .filter_map(|field| node.get(*field).and_then(Value::as_str)) + .any(|value| { + let truthy = truthy_values + .iter() + .any(|truthy| value.eq_ignore_ascii_case(truthy)); + truthy == expected + }) } fn tap_point_from_snapshot( @@ -2207,6 +2471,7 @@ fn tap_point_from_snapshot( .get("roots") .and_then(Value::as_array) .ok_or_else(|| AppError::not_found("Accessibility snapshot does not contain roots."))?; + let mut seen_matches = 0usize; for root in roots { let root_frame = root .get("frame") @@ -2214,7 +2479,7 @@ fn tap_point_from_snapshot( .ok_or_else(|| AppError::not_found("Accessibility root does not expose a frame."))?; let root_width = number_field(root_frame, "width")?; let root_height = number_field(root_frame, "height")?; - if let Some(node) = first_matching_node(root, selector) { + if let Some(node) = indexed_matching_node(root, selector, &mut seen_matches) { let frame = node .get("frame") .or_else(|| node.get("frameInScreen")) @@ -2230,6 +2495,30 @@ fn tap_point_from_snapshot( Err(AppError::not_found("No accessibility element matched.")) } +fn indexed_matching_node<'a>( + node: &'a Value, + selector: &ElementSelectorPayload, + seen_matches: &mut usize, +) -> Option<&'a Value> { + if element_matches_selector(node, selector) { + if selector.index.unwrap_or(0) == *seen_matches { + return Some(node); + } + *seen_matches += 1; + } + for child in node + .get("children") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + if let Some(found) = indexed_matching_node(child, selector, seen_matches) { + return Some(found); + } + } + None +} + fn normalize_screen_point_from_snapshot( snapshot: &Value, x: f64, @@ -3508,10 +3797,17 @@ mod tests { fn selector() -> ElementSelectorPayload { ElementSelectorPayload { + text: None, id: Some("continue-button".to_owned()), label: Some("Continue".to_owned()), value: None, element_type: Some("Button".to_owned()), + index: None, + enabled: None, + checked: None, + focused: None, + selected: None, + regex: None, } } diff --git a/server/src/main.rs b/server/src/main.rs index 6234a16..6529fc7 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -25,6 +25,7 @@ use native::bridge::{NativeBridge, NativeInputSession}; use native::ffi; use serde::{Deserialize, Serialize}; use serde_json::Value; +use serde_yaml::Value as YamlValue; use simulators::registry::SessionRegistry; use std::collections::hash_map::DefaultHasher; use std::env; @@ -103,6 +104,10 @@ enum Command { #[command(subcommand)] command: ProviderCommand, }, + Maestro { + #[command(subcommand)] + command: MaestroCommand, + }, #[command(hide = true)] Serve { #[arg(long, default_value_t = 4310)] @@ -514,6 +519,18 @@ enum ProviderCommand { }, } +#[derive(Subcommand)] +enum MaestroCommand { + Test { + udid: String, + flow: PathBuf, + #[arg(long)] + artifacts_dir: Option, + #[arg(long)] + continue_on_error: bool, + }, +} + #[derive(Subcommand)] enum ServiceCommand { On { @@ -1251,6 +1268,7 @@ fn is_known_command(value: &str) -> bool { matches!( value, "ui" | "daemon" + | "maestro" | "service" | "core-simulator" | "simctl-service" @@ -1943,6 +1961,20 @@ fn main() -> anyhow::Result<()> { }), }, Command::Provider { command } => run_provider_command(command), + Command::Maestro { command } => match command { + MaestroCommand::Test { + udid, + flow, + artifacts_dir, + continue_on_error, + } => { + let service_url = command_service_url(explicit_server_url.clone())?; + let report = + run_maestro_flow(&service_url, &udid, &flow, artifacts_dir, continue_on_error)?; + println_json(&report)?; + Ok(()) + } + }, Command::Serve { port, bind, @@ -4252,6 +4284,413 @@ fn parse_modifier_mask(value: &str) -> Result { Ok(mask) } +fn run_maestro_flow( + server_url: &str, + udid: &str, + flow: &Path, + artifacts_dir: Option, + continue_on_error: bool, +) -> anyhow::Result { + let raw = fs::read_to_string(flow) + .with_context(|| format!("read Maestro flow {}", flow.display()))?; + let yaml = parse_maestro_flow_yaml(&raw) + .with_context(|| format!("parse Maestro flow {}", flow.display()))?; + let commands = maestro_commands_from_flow(&yaml)?; + let artifact_root = artifacts_dir.unwrap_or_else(|| { + PathBuf::from("simdeck-artifacts").join( + flow.file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("maestro-flow"), + ) + }); + fs::create_dir_all(&artifact_root)?; + + let mut steps = Vec::new(); + let mut failures = Vec::new(); + for (index, command) in commands.iter().enumerate() { + let started = Instant::now(); + let result = run_maestro_command(server_url, udid, command, &artifact_root); + match result { + Ok(detail) => steps.push(serde_json::json!({ + "index": index, + "ok": true, + "command": maestro_command_name(command), + "elapsedMs": started.elapsed().as_millis() as u64, + "detail": detail, + })), + Err(error) => { + let message = error.to_string(); + let screenshot = + capture_maestro_failure_screenshot(server_url, udid, &artifact_root, index + 1) + .ok(); + steps.push(serde_json::json!({ + "index": index, + "ok": false, + "command": maestro_command_name(command), + "elapsedMs": started.elapsed().as_millis() as u64, + "error": message, + "screenshot": screenshot, + })); + failures.push(message); + if !continue_on_error { + break; + } + } + } + } + + Ok(serde_json::json!({ + "ok": failures.is_empty(), + "flow": flow, + "udid": udid, + "steps": steps, + "failureCount": failures.len(), + "artifactsDir": artifact_root, + })) +} + +fn parse_maestro_flow_yaml(raw: &str) -> anyhow::Result { + let mut documents = Vec::new(); + for document in serde_yaml::Deserializer::from_str(raw) { + documents.push(YamlValue::deserialize(document)?); + } + match documents.len() { + 0 => Err(anyhow::anyhow!("Maestro flow is empty.")), + 1 => Ok(documents.remove(0)), + _ => { + let app_id = documents + .first() + .and_then(|value| yaml_string_or_field(value, "appId")); + let mut commands = documents + .pop() + .ok_or_else(|| anyhow::anyhow!("Maestro flow is empty."))?; + if let Some(app_id) = app_id { + fill_empty_launch_app_commands(&mut commands, &app_id); + } + Ok(commands) + } + } +} + +fn fill_empty_launch_app_commands(commands: &mut YamlValue, app_id: &str) { + let Some(commands) = commands.as_sequence_mut() else { + return; + }; + for command in commands { + if command.as_str() == Some("launchApp") { + let mut mapping = serde_yaml::Mapping::new(); + mapping.insert( + YamlValue::String("launchApp".to_owned()), + YamlValue::String(app_id.to_owned()), + ); + *command = YamlValue::Mapping(mapping); + continue; + } + let Some(mapping) = command.as_mapping_mut() else { + continue; + }; + let key = YamlValue::String("launchApp".to_owned()); + let Some(value) = mapping.get_mut(&key) else { + continue; + }; + if value.is_null() || value.as_mapping().is_some_and(|mapping| mapping.is_empty()) { + *value = YamlValue::String(app_id.to_owned()); + } + } +} + +fn maestro_commands_from_flow(flow: &YamlValue) -> anyhow::Result> { + match flow { + YamlValue::Sequence(commands) => Ok(commands.clone()), + YamlValue::Mapping(mapping) => mapping + .get(YamlValue::String("commands".to_owned())) + .and_then(YamlValue::as_sequence) + .cloned() + .ok_or_else(|| { + anyhow::anyhow!("Maestro flow must be a command list or contain `commands`.") + }), + _ => Err(anyhow::anyhow!( + "Maestro flow must be a command list or contain `commands`." + )), + } +} + +fn run_maestro_command( + server_url: &str, + udid: &str, + command: &YamlValue, + artifacts_dir: &Path, +) -> anyhow::Result { + let null_value = YamlValue::Null; + let (name, value) = if let Some(name) = command.as_str() { + (name, &null_value) + } else { + let Some(mapping) = command.as_mapping() else { + anyhow::bail!("Maestro command must be a string or mapping."); + }; + if mapping.len() != 1 { + anyhow::bail!("Maestro command must contain exactly one action."); + } + let (name, value) = mapping.iter().next().unwrap(); + ( + name.as_str() + .ok_or_else(|| anyhow::anyhow!("Maestro command name must be a string."))?, + value, + ) + }; + match name { + "launchApp" => { + let bundle_id = maestro_bundle_id(value)?; + service_launch(server_url, udid, &bundle_id)?; + Ok(serde_json::json!({ "bundleId": bundle_id })) + } + "openLink" => { + let url = yaml_string_or_field(value, "link") + .or_else(|| yaml_string_or_field(value, "url")) + .ok_or_else(|| anyhow::anyhow!("openLink requires a URL."))?; + service_open_url(server_url, udid, &url)?; + Ok(serde_json::json!({ "url": url })) + } + "tapOn" => { + let body = maestro_tap_body(value)?; + service_tap_element(server_url, udid, body)?; + Ok(Value::Null) + } + "inputText" => { + let text = yaml_string_or_field(value, "text") + .ok_or_else(|| anyhow::anyhow!("inputText requires text."))?; + service_batch( + server_url, + udid, + vec![serde_json::json!({ "action": "type", "text": text })], + false, + )?; + Ok(Value::Null) + } + "eraseText" => { + let count = yaml_u64_or_field(value, "charactersToErase").unwrap_or(64); + let keys = vec![42u16; count as usize]; + service_key_sequence(server_url, udid, &keys, 5)?; + Ok(serde_json::json!({ "charactersToErase": count })) + } + "pressKey" => { + let key = yaml_string_or_field(value, "key") + .ok_or_else(|| anyhow::anyhow!("pressKey requires a key."))?; + service_key(server_url, udid, parse_hid_key(&key)?, 0)?; + Ok(serde_json::json!({ "key": key })) + } + "assertVisible" => { + let selector = maestro_selector(value)?; + service_wait_for(server_url, udid, "assert", selector, 5_000)?; + Ok(Value::Null) + } + "assertNotVisible" => { + let selector = maestro_selector(value)?; + service_wait_for(server_url, udid, "assert-not", selector, 5_000)?; + Ok(Value::Null) + } + "scrollUntilVisible" => { + let selector_value = yaml_field(value, "element").unwrap_or(value); + let selector = maestro_selector(selector_value)?; + let direction = + yaml_string_or_field(value, "direction").unwrap_or_else(|| "down".to_owned()); + service_post_ok( + server_url, + udid, + "scroll-until-visible", + &serde_json::json!({ + "selector": selector, + "direction": direction, + "timeoutMs": yaml_u64_or_field(value, "timeout").unwrap_or(10_000), + }), + )?; + Ok(Value::Null) + } + "swipe" => { + let direction = + yaml_string_or_field(value, "direction").unwrap_or_else(|| "up".to_owned()); + let preset = match direction.as_str() { + "up" => "scroll-up", + "down" => "scroll-down", + "left" => "scroll-left", + "right" => "scroll-right", + _ => "scroll-up", + }; + service_batch( + server_url, + udid, + vec![serde_json::json!({ "action": "gesture", "preset": preset })], + false, + )?; + Ok(serde_json::json!({ "direction": direction })) + } + "takeScreenshot" => { + let name = yaml_string_or_field(value, "path") + .or_else(|| yaml_string_or_field(value, "name")) + .unwrap_or_else(|| "screenshot".to_owned()); + let path = artifacts_dir.join(format!("{}.png", name.trim_end_matches(".png"))); + let png = service_get_bytes( + server_url, + &format!( + "/api/simulators/{}/screenshot.png", + url_path_component(udid) + ), + )?; + fs::write(&path, png)?; + Ok(serde_json::json!({ "path": path })) + } + "waitForAnimationToEnd" | "waitForAnimationToEnd:" => { + sleep_ms( + yaml_u64_or_field(value, "timeout") + .unwrap_or(1_000) + .min(10_000), + ); + Ok(Value::Null) + } + other => Err(anyhow::anyhow!( + "Unsupported Maestro command `{other}` in this compatibility runner." + )), + } +} + +fn maestro_command_name(command: &YamlValue) -> String { + if let Some(name) = command.as_str() { + return name.to_owned(); + } + command + .as_mapping() + .and_then(|mapping| mapping.keys().next()) + .and_then(YamlValue::as_str) + .unwrap_or("unknown") + .to_owned() +} + +fn maestro_bundle_id(value: &YamlValue) -> anyhow::Result { + yaml_string_or_field(value, "appId") + .or_else(|| yaml_string_or_field(value, "bundleId")) + .ok_or_else(|| anyhow::anyhow!("launchApp requires `appId` or `bundleId`.")) +} + +fn maestro_tap_body(value: &YamlValue) -> anyhow::Result { + if let Some(point) = yaml_string_or_field(value, "point") { + let (x, y) = parse_maestro_point(&point)?; + return Ok(serde_json::json!({ "x": x, "y": y, "normalized": true })); + } + Ok(serde_json::json!({ + "selector": maestro_selector(value)?, + "waitTimeoutMs": yaml_u64_or_field(value, "timeout").unwrap_or(5_000), + })) +} + +fn maestro_selector(value: &YamlValue) -> anyhow::Result { + if let Some(text) = value.as_str() { + return Ok(serde_json::json!({ "text": text, "regex": true })); + } + let Some(mapping) = value.as_mapping() else { + anyhow::bail!("Selector must be a string or mapping."); + }; + let text = yaml_string_field(mapping, "text"); + let id = yaml_string_field(mapping, "id"); + Ok(serde_json::json!({ + "text": text, + "id": id, + "label": yaml_string_field(mapping, "label"), + "value": yaml_string_field(mapping, "value"), + "elementType": yaml_string_field(mapping, "type"), + "index": yaml_u64_field(mapping, "index"), + "enabled": yaml_bool_field(mapping, "enabled"), + "checked": yaml_bool_field(mapping, "checked"), + "focused": yaml_bool_field(mapping, "focused"), + "selected": yaml_bool_field(mapping, "selected"), + "regex": text.is_some() || id.is_some(), + })) +} + +fn service_wait_for( + server_url: &str, + udid: &str, + action: &str, + selector: Value, + timeout_ms: u64, +) -> anyhow::Result<()> { + service_post_ok( + server_url, + udid, + action, + &serde_json::json!({ "selector": selector, "timeoutMs": timeout_ms }), + ) +} + +fn parse_maestro_point(point: &str) -> anyhow::Result<(f64, f64)> { + let (x, y) = point + .split_once(',') + .ok_or_else(|| anyhow::anyhow!("point must be `x,y`."))?; + let parse = |value: &str| -> anyhow::Result { + let value = value.trim(); + if let Some(percent) = value.strip_suffix('%') { + Ok(percent.parse::()? / 100.0) + } else { + Ok(value.parse::()?) + } + }; + Ok((parse(x)?, parse(y)?)) +} + +fn capture_maestro_failure_screenshot( + server_url: &str, + udid: &str, + artifacts_dir: &Path, + step: usize, +) -> anyhow::Result { + let path = artifacts_dir.join(format!("failure-step-{step}.png")); + let png = service_get_bytes( + server_url, + &format!( + "/api/simulators/{}/screenshot.png", + url_path_component(udid) + ), + )?; + fs::write(&path, png)?; + Ok(path) +} + +fn yaml_field<'a>(value: &'a YamlValue, field: &str) -> Option<&'a YamlValue> { + value.as_mapping()?.get(YamlValue::String(field.to_owned())) +} + +fn yaml_string_or_field(value: &YamlValue, field: &str) -> Option { + value.as_str().map(str::to_owned).or_else(|| { + yaml_field(value, field) + .and_then(YamlValue::as_str) + .map(str::to_owned) + }) +} + +fn yaml_u64_or_field(value: &YamlValue, field: &str) -> Option { + value + .as_u64() + .or_else(|| yaml_field(value, field).and_then(YamlValue::as_u64)) +} + +fn yaml_string_field(mapping: &serde_yaml::Mapping, field: &str) -> Option { + mapping + .get(YamlValue::String(field.to_owned())) + .and_then(YamlValue::as_str) + .map(str::to_owned) +} + +fn yaml_u64_field(mapping: &serde_yaml::Mapping, field: &str) -> Option { + mapping + .get(YamlValue::String(field.to_owned())) + .and_then(YamlValue::as_u64) +} + +fn yaml_bool_field(mapping: &serde_yaml::Mapping, field: &str) -> Option { + mapping + .get(YamlValue::String(field.to_owned())) + .and_then(YamlValue::as_bool) +} + fn run_batch( bridge: &NativeBridge, udid: &str, @@ -4367,6 +4806,25 @@ fn batch_line_to_json_step(line: &str) -> anyhow::Result { "timeoutMs": args.value("timeout-ms").or_else(|| args.value("wait-timeout-ms")).and_then(|value| value.parse::().ok()).unwrap_or(5_000), "pollMs": args.value("poll-interval-ms").and_then(|value| value.parse::().ok()).unwrap_or(100), }), + "assert-not" | "assertNot" | "wait-for-not" | "waitForNot" => serde_json::json!({ + "action": "assertNot", + "selector": batch_selector_json(&args), + "source": args.value("source"), + "maxDepth": args.value("max-depth").and_then(|value| value.parse::().ok()), + "includeHidden": args.flag("include-hidden"), + "timeoutMs": args.value("timeout-ms").or_else(|| args.value("wait-timeout-ms")).and_then(|value| value.parse::().ok()).unwrap_or(5_000), + "pollMs": args.value("poll-interval-ms").and_then(|value| value.parse::().ok()).unwrap_or(100), + }), + "scroll-until-visible" | "scrollUntilVisible" => serde_json::json!({ + "action": "scrollUntilVisible", + "selector": batch_selector_json(&args), + "source": args.value("source"), + "maxDepth": args.value("max-depth").and_then(|value| value.parse::().ok()), + "includeHidden": args.flag("include-hidden"), + "timeoutMs": args.value("timeout-ms").or_else(|| args.value("wait-timeout-ms")).and_then(|value| value.parse::().ok()).unwrap_or(10_000), + "pollMs": args.value("poll-interval-ms").and_then(|value| value.parse::().ok()).unwrap_or(100), + "direction": args.value("direction").unwrap_or("down"), + }), "key" => serde_json::json!({ "action": "key", "keyCode": parse_hid_key(tokens.get(1).map(String::as_str).unwrap_or(""))?, @@ -4790,10 +5248,17 @@ fn required_f64(args: &StepOptions, key: &str) -> Result Value { serde_json::json!({ + "text": args.value("text"), "id": args.value("id"), "label": args.value("label"), "value": args.value("value"), "elementType": args.value("element-type"), + "index": args.value("index").and_then(|value| value.parse::().ok()), + "enabled": args.value("enabled").and_then(parse_bool_value), + "checked": args.value("checked").and_then(parse_bool_value), + "focused": args.value("focused").and_then(parse_bool_value), + "selected": args.value("selected").and_then(parse_bool_value), + "regex": args.flag("regex"), }) } @@ -4806,6 +5271,14 @@ fn batch_selector_from_args(args: &StepOptions) -> ElementSelector { } } +fn parse_bool_value(value: &str) -> Option { + match value.to_ascii_lowercase().as_str() { + "true" | "1" | "yes" | "on" => Some(true), + "false" | "0" | "no" | "off" => Some(false), + _ => None, + } +} + fn wait_for_batch_selector( bridge: &NativeBridge, udid: &str, @@ -5320,10 +5793,11 @@ fn default_client_root() -> anyhow::Result { #[cfg(test)] mod tests { use super::{ - batch_line_to_json_step, normalize_accessibility_point_for_display, + batch_line_to_json_step, maestro_commands_from_flow, maestro_selector, + normalize_accessibility_point_for_display, parse_maestro_flow_yaml, parse_maestro_point, server_health_watchdog_should_restart, service_post_error_is_retryable, studio_daemon_restart_args, Cli, Command, DaemonCommand, StreamQualityProfileArg, - StudioExposeOptions, VideoCodecMode, SERVER_HEALTH_WATCHDOG_FAILURE_THRESHOLD, + StudioExposeOptions, VideoCodecMode, YamlValue, SERVER_HEALTH_WATCHDOG_FAILURE_THRESHOLD, SERVER_HEALTH_WATCHDOG_HTTP_FAILURE_THRESHOLD, }; use clap::Parser; @@ -5460,6 +5934,59 @@ mod tests { assert_eq!(step["timeoutMs"], 5000); } + #[test] + fn batch_assert_not_and_scroll_map_to_daemon_actions() { + let assert_not = batch_line_to_json_step("assert-not --text Loading --regex").unwrap(); + assert_eq!(assert_not["action"], "assertNot"); + assert_eq!(assert_not["selector"]["text"], "Loading"); + assert_eq!(assert_not["selector"]["regex"], true); + + let scroll = + batch_line_to_json_step("scroll-until-visible --text Settings --direction down") + .unwrap(); + assert_eq!(scroll["action"], "scrollUntilVisible"); + assert_eq!(scroll["selector"]["text"], "Settings"); + assert_eq!(scroll["direction"], "down"); + } + + #[test] + fn maestro_flow_accepts_config_with_commands() { + let yaml = parse_maestro_flow_yaml( + r#" +appId: com.example.App +--- +- launchApp +- tapOn: Continue +"#, + ) + .unwrap(); + let commands = maestro_commands_from_flow(&yaml).unwrap(); + assert_eq!(commands.len(), 2); + assert_eq!(commands[0]["launchApp"].as_str(), Some("com.example.App")); + } + + #[test] + fn maestro_selector_maps_text_and_state() { + let yaml: YamlValue = serde_yaml::from_str( + r#" +text: Continue.* +enabled: true +index: 1 +"#, + ) + .unwrap(); + let selector = maestro_selector(&yaml).unwrap(); + assert_eq!(selector["text"], "Continue.*"); + assert_eq!(selector["enabled"], true); + assert_eq!(selector["index"], 1); + assert_eq!(selector["regex"], true); + } + + #[test] + fn maestro_percent_points_become_normalized_coordinates() { + assert_eq!(parse_maestro_point("50%,75%").unwrap(), (0.5, 0.75)); + } + #[test] fn server_health_watchdog_restarts_when_http_listener_is_unhealthy() { assert!(server_health_watchdog_should_restart( diff --git a/skills/simdeck/SKILL.md b/skills/simdeck/SKILL.md index 268e6b2..3492ff8 100644 --- a/skills/simdeck/SKILL.md +++ b/skills/simdeck/SKILL.md @@ -84,13 +84,13 @@ For persistent app integration tests, use `simdeck/test` instead of shelling out ```ts import { connect } from "simdeck/test"; -const simdeck = await connect(); +const simdeck = await connect({ udid }); try { - await simdeck.launch(udid, "com.example.App"); - await simdeck.waitFor(udid, { id: "login.button" }, { maxDepth: 8 }); - await simdeck.tap(udid, 0.5, 0.5); - await simdeck.assert(udid, { label: "Welcome" }, { maxDepth: 8 }); - const matches = await simdeck.query(udid, { id: "account.name" }); + await simdeck.launch("com.example.App"); + await simdeck.waitFor({ id: "login.button" }, { maxDepth: 8 }); + await simdeck.tap(0.5, 0.5); + await simdeck.assert({ label: "Welcome" }, { maxDepth: 8 }); + const matches = await simdeck.query({ id: "account.name" }); console.log(matches); } finally { simdeck.close(); @@ -166,7 +166,7 @@ Batch rules: one source (`--step`, `--file`, or `--stdin`); keep `` at bat For JS tests, batch can combine action and verification without extra CLI process startup: ```ts -await simdeck.batch(udid, [ +await simdeck.batch([ { action: "tap", selector: { label: "Continue" }, waitTimeoutMs: 5000 }, { action: "waitFor", @@ -177,6 +177,12 @@ await simdeck.batch(udid, [ ]); ``` +For app-style flows, SimDeck can run a practical subset of Maestro YAML: + +```bash +simdeck maestro test flow.yaml --artifacts-dir artifacts/maestro +``` + ## Evidence ```bash