Skip to content

Commit 6f1d453

Browse files
committed
Add Android integration coverage
1 parent e0914b7 commit 6f1d453

18 files changed

Lines changed: 1659 additions & 77 deletions

File tree

.github/workflows/ci.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,3 +232,51 @@ jobs:
232232

233233
- name: JS API simulator integration tests
234234
run: npm run test:integration:js-api
235+
236+
integration-android:
237+
name: Android emulator integration
238+
runs-on: ubuntu-latest
239+
timeout-minutes: 35
240+
needs:
241+
- client
242+
- packages
243+
244+
steps:
245+
- uses: actions/checkout@v4
246+
247+
- uses: actions/setup-node@v4
248+
with:
249+
node-version: 20
250+
cache: npm
251+
cache-dependency-path: package-lock.json
252+
253+
- uses: dtolnay/rust-toolchain@stable
254+
255+
- name: Enable KVM access
256+
run: |
257+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
258+
sudo udevadm control --reload-rules
259+
sudo udevadm trigger --name-match=kvm
260+
261+
- name: Install root dependencies
262+
run: npm ci --ignore-scripts --force
263+
264+
- name: Build Linux Android integration artifacts
265+
run: |
266+
npm run build:cli
267+
npm run build:simdeck-test
268+
269+
- name: Android emulator integration tests
270+
uses: reactivecircus/android-emulator-runner@v2
271+
with:
272+
api-level: 35
273+
target: google_apis
274+
arch: x86_64
275+
profile: pixel_6
276+
avd-name: SimDeck_Pixel_CI
277+
disable-animations: true
278+
script: npm run test:integration:android
279+
env:
280+
SIMDECK_INTEGRATION_ANDROID_AVD: SimDeck_Pixel_CI
281+
SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID: "1"
282+
SIMDECK_INTEGRATION_VERBOSE: "1"

docs/api/rest.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,7 @@ Returns the current accessibility tree. The server merges framework inspectors,
480480
| `swiftui` / `swift-ui` | Force the published SwiftUI logical tree if the Swift agent root publisher is installed in the app. |
481481
| `uikit` / `in-app-inspector` | Force the raw UIKit hierarchy from the in-app inspector agent (NativeScript or Swift). |
482482
| `native-ax` / `ax` | Always use the native accessibility snapshot. |
483+
| `android-uiautomator` | Force the Android emulator UIAutomator hierarchy. |
483484

484485
| Parameter | Default | Description |
485486
| --------------- | ------- | ----------------------------------------------------------------------------------------------- |

docs/cli/commands.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ simdeck describe <udid> --source react-native
207207
simdeck describe <udid> --source flutter
208208
simdeck describe <udid> --source uikit
209209
simdeck describe <udid> --source native-ax
210+
simdeck describe <udid> --source android-uiautomator
210211
simdeck describe <udid> --point 120,240
211212
simdeck describe <udid> --direct
212213
```

docs/cli/flags.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ The public commands generate an access token automatically. Use `simdeck daemon
4747

4848
## `describe`
4949

50-
| Flag | Default | Description |
51-
| ------------------ | ------------------------------ | --------------------------------------------------------------------------------------------- |
52-
| `--format` | `json` | Output format: `json`, `compact-json`, or `agent`. |
53-
| `--source` | `auto` | Hierarchy source: `auto`, `nativescript`, `react-native`, `flutter`, `uikit`, or `native-ax`. |
54-
| `--max-depth` | unlimited native / `80` daemon | Trim descendants after the requested depth. |
55-
| `--include-hidden` | `false` | Include hidden in-app inspector views when supported. |
56-
| `--direct` | `false` | Skip the daemon and use the private native accessibility bridge directly. |
57-
| `--point <x>,<y>` | unset | Return the native element at a screen point. |
50+
| Flag | Default | Description |
51+
| ------------------ | ------------------------------ | -------------------------------------------------------------------------------------------------------------------- |
52+
| `--format` | `json` | Output format: `json`, `compact-json`, or `agent`. |
53+
| `--source` | `auto` | Hierarchy source: `auto`, `nativescript`, `react-native`, `flutter`, `uikit`, `native-ax`, or `android-uiautomator`. |
54+
| `--max-depth` | unlimited native / `80` daemon | Trim descendants after the requested depth. |
55+
| `--include-hidden` | `false` | Include hidden in-app inspector views when supported. |
56+
| `--direct` | `false` | Skip the daemon and use the private native accessibility bridge directly. |
57+
| `--point <x>,<y>` | unset | Return the native element at a screen point. |
5858

5959
## Input Flags
6060

docs/contributing.md

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,15 @@ Set `SIMDECK_INTEGRATION_TRACE_HTTP=1` if you also need raw HTTP request logs.
145145
Set `SIMDECK_INTEGRATION_KEEP_SIMULATOR=1` with the verbose command if you want
146146
the temporary simulator left around for inspection after the suite exits.
147147

148-
GitHub Actions runs this suite on macOS after the normal build/test pipeline.
149-
The integration suite does not require the live video display bridge; REST input
150-
routes use the non-display native input path, and the video stream is covered by
148+
GitHub Actions runs the iOS suite on macOS after the normal build/test pipeline.
149+
It also runs the Android integration suite on an Ubuntu runner with a real
150+
Android emulator. Linux builds use a native iOS stub so the Android bridge,
151+
daemon, CLI, and `simdeck/test` API can be exercised without macOS frameworks.
152+
The Android CI job preboots the AVD with `android-emulator-runner` before
153+
starting SimDeck; local Android integration runs skip cleanly unless an emulator
154+
is already booted or `SIMDECK_INTEGRATION_BOOT_ANDROID=1` is set.
155+
The integration suites do not require the live video display bridge; REST input
156+
routes use the non-display input path, and the video stream is covered by
151157
lower-level protocol tests.
152158

153159
## Full CI pipeline
@@ -164,8 +170,10 @@ This is the normal local CI script:
164170
4. `npm run package:vscode-extension` — VS Code `.vsix`.
165171

166172
GitHub Actions runs `npm run ci`, then `npm run test:integration:cli` for the
167-
temp-simulator CLI and REST control sweep. A clean `npm run ci` and integration
168-
run are required for any PR that changes simulator control behavior.
173+
temp-simulator CLI and REST control sweep, plus
174+
`npm run test:integration:android` on Ubuntu for Android emulator coverage. A
175+
clean `npm run ci` and integration run are required for any PR that changes
176+
simulator control behavior.
169177

170178
## Documentation
171179

docs/guide/testing.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ The current session object exposes:
3131
| Method | Purpose |
3232
| -------------------------------------- | ----------------------------------------------------------------- |
3333
| `list()` | Fetch simulator inventory from `GET /api/simulators`. |
34+
| `boot()`, `shutdown()`, `erase()` | Manage simulator or Android emulator lifecycle. |
3435
| `install()`, `uninstall()` | Install or remove an app. |
3536
| `launch()` | Launch an installed bundle ID or Android package. |
3637
| `openUrl()` | Open a URL or deep link. |
@@ -44,6 +45,7 @@ The current session object exposes:
4445
| `toggleAppearance()` | Toggle light/dark appearance. |
4546
| `pasteboardSet()`, `pasteboardGet()` | Read or write pasteboard text. |
4647
| `chromeProfile()` | Fetch screen/chrome geometry. |
48+
| `logs()` | Fetch recent simulator or Android log entries. |
4749
| `tree()` | Fetch an accessibility hierarchy. |
4850
| `query()` | Return compact matches for a selector. |
4951
| `waitFor()` | Poll until a selector appears. |
@@ -56,7 +58,9 @@ Selectors can match `id`, `label`, `value`, or `type`. Query options accept `sou
5658

5759
## Repository Integration Suite
5860

59-
The repo includes a macOS-only integration runner that creates a temporary simulator, builds and installs a small UIKit fixture app, then sweeps the CLI and REST control surface.
61+
The repo includes simulator-backed integration runners. The iOS runner is
62+
macOS-only; it creates a temporary simulator, builds and installs a small UIKit
63+
fixture app, then sweeps the CLI and REST control surface.
6064

6165
```sh
6266
npm run build:cli
@@ -93,6 +97,31 @@ does not exceed the active `iphonesimulator` SDK version, falling back to the
9397
same major version when needed. This keeps CI off newer installed runtimes that
9498
do not match the selected Xcode toolchain.
9599

100+
Android coverage is opt-in because it requires a locally installed Android SDK
101+
and at least one existing AVD. It runs on macOS or Linux. On Linux, SimDeck
102+
builds the daemon with a native iOS stub and leaves the Android bridge active.
103+
The runner starts an isolated SimDeck daemon and sweeps the Android CLI and
104+
`simdeck/test` surface for lifecycle, tree, screenshot, pasteboard behavior,
105+
app launch, URL opening, touch/swipe/gesture, keyboard, system buttons,
106+
rotation, appearance, logs, and batch controls:
107+
108+
```sh
109+
npm run build:cli
110+
npm run build:simdeck-test
111+
npm run test:integration:android
112+
```
113+
114+
Set `SIMDECK_INTEGRATION_ANDROID_AVD=<avd-name>` to pick a specific AVD. The
115+
runner expects that emulator to already be booted, which is how the Linux CI job
116+
uses `reactivecircus/android-emulator-runner`. If no AVD is configured, or a
117+
local AVD exists but is not running, the Android runner prints a skip message
118+
and exits successfully. Set `SIMDECK_INTEGRATION_REQUIRE_RUNNING_ANDROID=1` to
119+
turn that skip into a failure. Set `SIMDECK_INTEGRATION_BOOT_ANDROID=1` to let
120+
SimDeck cold-boot a local AVD before the suite. Set
121+
`SIMDECK_INTEGRATION_KEEP_ANDROID=1` to leave an emulator booted when the runner
122+
started it. Set `SIMDECK_INTEGRATION_STEP_TIMEOUT_MS` to override the per-step
123+
timeout.
124+
96125
## Stress and Leak Checks
97126

98127
Use the stress runner against an already-running daemon when you want to shake out

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@
7070
"test:integration:fixture": "node scripts/integration/prebuild-fixture.mjs",
7171
"test:integration:js-api": "node scripts/integration/js-api.mjs",
7272
"test:integration:js-api:verbose": "SIMDECK_INTEGRATION_SHOW_SIMULATOR=1 node scripts/integration/js-api.mjs",
73+
"test:integration:android": "node scripts/integration/android.mjs",
74+
"test:integration:android:verbose": "SIMDECK_INTEGRATION_VERBOSE=1 node scripts/integration/android.mjs",
7375
"test:stream:reliability": "node scripts/check-stream-reliability.mjs",
7476
"test:e2e:webrtc": "node scripts/e2e-webrtc-reliability.mjs",
7577
"test:e2e:webrtc:headed": "SIMDECK_E2E_HEADFUL=1 node scripts/e2e-webrtc-reliability.mjs",

packages/simdeck-test/dist/index.d.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,22 @@ export type TypeTextOptions = {
4242
export type KeySequenceOptions = {
4343
delayMs?: number;
4444
};
45+
export type LogsOptions = {
46+
backfill?: boolean;
47+
seconds?: number;
48+
limit?: number;
49+
levels?: string[];
50+
processes?: string[];
51+
q?: string;
52+
};
4553
export type SimDeckSession = {
4654
endpoint: string;
4755
pid: number;
4856
projectRoot: string;
4957
list(): Promise<unknown>;
58+
boot(udid: string): Promise<unknown>;
59+
shutdown(udid: string): Promise<unknown>;
60+
erase(udid: string): Promise<unknown>;
5061
install(udid: string, appPath: string): Promise<void>;
5162
uninstall(udid: string, bundleId: string): Promise<void>;
5263
launch(udid: string, bundleId: string): Promise<void>;
@@ -92,6 +103,7 @@ export type SimDeckSession = {
92103
pasteboardSet(udid: string, text: string): Promise<void>;
93104
pasteboardGet(udid: string): Promise<string>;
94105
chromeProfile(udid: string): Promise<unknown>;
106+
logs(udid: string, options?: LogsOptions): Promise<unknown[]>;
95107
tree(udid: string, options?: QueryOptions): Promise<unknown>;
96108
query(
97109
udid: string,

packages/simdeck-test/dist/index.js

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,27 @@ export async function connect(options = {}) {
1818
pid: result.pid,
1919
projectRoot: result.projectRoot,
2020
list: () => requestJson(endpoint, "GET", "/api/simulators"),
21+
boot: (udid) =>
22+
requestJson(
23+
endpoint,
24+
"POST",
25+
`/api/simulators/${encodeURIComponent(udid)}/boot`,
26+
null,
27+
),
28+
shutdown: (udid) =>
29+
requestJson(
30+
endpoint,
31+
"POST",
32+
`/api/simulators/${encodeURIComponent(udid)}/shutdown`,
33+
null,
34+
),
35+
erase: (udid) =>
36+
requestJson(
37+
endpoint,
38+
"POST",
39+
`/api/simulators/${encodeURIComponent(udid)}/erase`,
40+
null,
41+
),
2142
install: (udid, appPath) =>
2243
requestOk(
2344
endpoint,
@@ -196,6 +217,14 @@ export async function connect(options = {}) {
196217
"GET",
197218
`/api/simulators/${encodeURIComponent(udid)}/chrome-profile`,
198219
),
220+
logs: async (udid, logsOptions) => {
221+
const result = await requestJson(
222+
endpoint,
223+
"GET",
224+
`/api/simulators/${encodeURIComponent(udid)}/logs?${logsQuery(logsOptions)}`,
225+
);
226+
return result.entries ?? [];
227+
},
199228
tree: (udid, treeOptions) =>
200229
requestJson(
201230
endpoint,
@@ -459,6 +488,19 @@ function treeQuery(options = {}) {
459488
if (options.includeHidden) params.set("includeHidden", "true");
460489
return params.toString();
461490
}
491+
function logsQuery(options = {}) {
492+
const params = new URLSearchParams();
493+
if (options.backfill !== undefined)
494+
params.set("backfill", String(options.backfill));
495+
if (options.seconds !== undefined)
496+
params.set("seconds", String(options.seconds));
497+
if (options.limit !== undefined) params.set("limit", String(options.limit));
498+
if (options.levels?.length) params.set("levels", options.levels.join(","));
499+
if (options.processes?.length)
500+
params.set("processes", options.processes.join(","));
501+
if (options.q) params.set("q", options.q);
502+
return params.toString();
503+
}
462504
function selectorPayload(selector) {
463505
return {
464506
id: selector.id,

packages/simdeck-test/src/index.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,23 @@ export type KeySequenceOptions = {
5858
delayMs?: number;
5959
};
6060

61+
export type LogsOptions = {
62+
backfill?: boolean;
63+
seconds?: number;
64+
limit?: number;
65+
levels?: string[];
66+
processes?: string[];
67+
q?: string;
68+
};
69+
6170
export type SimDeckSession = {
6271
endpoint: string;
6372
pid: number;
6473
projectRoot: string;
6574
list(): Promise<unknown>;
75+
boot(udid: string): Promise<unknown>;
76+
shutdown(udid: string): Promise<unknown>;
77+
erase(udid: string): Promise<unknown>;
6678
install(udid: string, appPath: string): Promise<void>;
6779
uninstall(udid: string, bundleId: string): Promise<void>;
6880
launch(udid: string, bundleId: string): Promise<void>;
@@ -108,6 +120,7 @@ export type SimDeckSession = {
108120
pasteboardSet(udid: string, text: string): Promise<void>;
109121
pasteboardGet(udid: string): Promise<string>;
110122
chromeProfile(udid: string): Promise<unknown>;
123+
logs(udid: string, options?: LogsOptions): Promise<unknown[]>;
111124
tree(udid: string, options?: QueryOptions): Promise<unknown>;
112125
query(
113126
udid: string,
@@ -161,6 +174,27 @@ export async function connect(
161174
pid: result.pid,
162175
projectRoot: result.projectRoot,
163176
list: () => requestJson(endpoint, "GET", "/api/simulators"),
177+
boot: (udid) =>
178+
requestJson(
179+
endpoint,
180+
"POST",
181+
`/api/simulators/${encodeURIComponent(udid)}/boot`,
182+
null,
183+
),
184+
shutdown: (udid) =>
185+
requestJson(
186+
endpoint,
187+
"POST",
188+
`/api/simulators/${encodeURIComponent(udid)}/shutdown`,
189+
null,
190+
),
191+
erase: (udid) =>
192+
requestJson(
193+
endpoint,
194+
"POST",
195+
`/api/simulators/${encodeURIComponent(udid)}/erase`,
196+
null,
197+
),
164198
install: (udid, appPath) =>
165199
requestOk(
166200
endpoint,
@@ -339,6 +373,14 @@ export async function connect(
339373
"GET",
340374
`/api/simulators/${encodeURIComponent(udid)}/chrome-profile`,
341375
),
376+
logs: async (udid, logsOptions) => {
377+
const result = await requestJson<{ entries?: unknown[] }>(
378+
endpoint,
379+
"GET",
380+
`/api/simulators/${encodeURIComponent(udid)}/logs?${logsQuery(logsOptions)}`,
381+
);
382+
return result.entries ?? [];
383+
},
342384
tree: (udid, treeOptions) =>
343385
requestJson(
344386
endpoint,
@@ -643,6 +685,20 @@ function treeQuery(options: QueryOptions = {}): string {
643685
return params.toString();
644686
}
645687

688+
function logsQuery(options: LogsOptions = {}): string {
689+
const params = new URLSearchParams();
690+
if (options.backfill !== undefined)
691+
params.set("backfill", String(options.backfill));
692+
if (options.seconds !== undefined)
693+
params.set("seconds", String(options.seconds));
694+
if (options.limit !== undefined) params.set("limit", String(options.limit));
695+
if (options.levels?.length) params.set("levels", options.levels.join(","));
696+
if (options.processes?.length)
697+
params.set("processes", options.processes.join(","));
698+
if (options.q) params.set("q", options.q);
699+
return params.toString();
700+
}
701+
646702
function selectorPayload(
647703
selector: ElementSelector,
648704
): Record<string, string | undefined> {

0 commit comments

Comments
 (0)