Skip to content

Commit 807dd96

Browse files
committed
Merge origin/main into codex/open-relaunch-semantics
2 parents 3378039 + 8cef2a6 commit 807dd96

16 files changed

Lines changed: 418 additions & 78 deletions

File tree

.github/workflows/ios.yml

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,50 +19,88 @@ jobs:
1919
runs-on: macos-26
2020
timeout-minutes: 80
2121
continue-on-error: true
22+
env:
23+
IOS_RUNTIME_VERSION: '26.2'
24+
DERIVED_DATA_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
25+
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: ${{ github.workspace }}/.tmp/ios-runner-derived
26+
AGENT_DEVICE_IOS_SIMCTL_LIST_TIMEOUT_MS: "60000"
27+
AGENT_DEVICE_DAEMON_TIMEOUT_MS: "300000"
28+
AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS: "180000"
29+
AGENT_DEVICE_IOS_APP_LAUNCH_TIMEOUT_MS: "60000"
2230
steps:
2331
- name: Checkout
2432
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
2533

2634
- name: Setup toolchain
2735
uses: ./.github/actions/setup-node-pnpm
2836

37+
- name: Resolve Xcode cache key
38+
id: xcode
39+
run: |
40+
set -euo pipefail
41+
XCODE_VERSION="$(xcodebuild -version | tr '\n' ' ' | sed -E 's/[[:space:]]+/ /g; s/[[:space:]]$//')"
42+
XCODE_KEY="$(echo "$XCODE_VERSION" | tr ' ' '-' | tr -cd '[:alnum:]._-')"
43+
echo "key=$XCODE_KEY" >> "$GITHUB_OUTPUT"
44+
45+
- name: Resolve prebuild source hash
46+
id: source-hash
47+
run: echo "value=${{ hashFiles('ios-runner/**', 'package.json', 'pnpm-lock.yaml') }}" >> "$GITHUB_OUTPUT"
48+
49+
- name: Cache iOS runner prebuilt
50+
id: restore-prebuilt
51+
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.2.3
52+
with:
53+
path: ${{ env.DERIVED_DATA_PATH }}
54+
key: ios-runner-prebuilt-${{ steps.xcode.outputs.key }}-ios-${{ env.IOS_RUNTIME_VERSION }}-${{ steps.source-hash.outputs.value }}
55+
2956
- name: Resolve agent-device home
3057
id: ios-agent-home
3158
run: echo "dir=$HOME/.agent-device" >> "$GITHUB_OUTPUT"
3259

33-
- name: Select and start iOS simulator
60+
- name: Build iOS integration artifacts
61+
if: steps.restore-prebuilt.outputs.cache-hit != 'true'
62+
run: |
63+
set -euo pipefail
64+
rm -rf "$DERIVED_DATA_PATH"
65+
xcodebuild build-for-testing \
66+
-project ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj \
67+
-scheme AgentDeviceRunner \
68+
-destination "platform=iOS Simulator,name=iPhone 17 Pro,OS=${IOS_RUNTIME_VERSION}" \
69+
-derivedDataPath "$DERIVED_DATA_PATH"
70+
71+
- name: Resolve and boot iOS test simulator
3472
run: |
73+
set -euo pipefail
74+
RUNTIME_TOKEN="SimRuntime.iOS-${IOS_RUNTIME_VERSION//./-}"
75+
export RUNTIME_TOKEN
3576
UDID="$(
36-
xcrun simctl list devices -j | node -e "
37-
const fs = require('node:fs');
38-
const payload = JSON.parse(fs.readFileSync(0, 'utf8'));
39-
const all = Object.values(payload.devices ?? {}).flat();
40-
const available = all.filter((d) => d.isAvailable);
77+
xcrun simctl list devices -j | node -e '
78+
const fs = require("node:fs");
79+
const runtimeToken = process.env.RUNTIME_TOKEN;
80+
const payload = JSON.parse(fs.readFileSync(0, "utf8"));
81+
const entries = Object.entries(payload.devices ?? {});
82+
const iosRuntimes = entries.filter(([runtime]) => runtime.includes("SimRuntime.iOS-"));
83+
const runtimeMatches = iosRuntimes.filter(([runtime]) => runtime.includes(runtimeToken));
84+
const pool = runtimeMatches.length > 0 ? runtimeMatches : iosRuntimes;
85+
const available = pool.flatMap(([runtime, devices]) =>
86+
(devices ?? [])
87+
.filter((device) => device.isAvailable)
88+
.map((device) => ({ ...device, runtime })),
89+
);
4190
const preferred =
42-
available.find((d) => d.state === 'Booted') ??
43-
available.find((d) => d.name === 'iPhone 17 Pro') ??
91+
available.find((device) => device.state === "Booted" && device.name === "iPhone 17 Pro") ??
92+
available.find((device) => device.name === "iPhone 17 Pro") ??
93+
available.find((device) => device.state === "Booted") ??
4494
available[0];
4595
if (!preferred?.udid) process.exit(1);
4696
process.stdout.write(preferred.udid);
47-
"
97+
'
4898
)"
99+
xcrun simctl shutdown all || true
49100
xcrun simctl boot "$UDID" || true
50-
echo "IOS_UDID=$UDID" >> "$GITHUB_ENV"
51-
52-
- name: Build iOS integration artifacts
53-
run: pnpm build:xcuitest
54-
55-
- name: Boot preflight via agent-device
56-
run: |
57-
set -euo pipefail
58-
node --experimental-strip-types src/bin.ts boot --platform ios --udid "$IOS_UDID" --json
59-
env:
60-
AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS: "180000"
61-
AGENT_DEVICE_RETRY_LOGS: "1"
101+
xcrun simctl bootstatus "$UDID" -b
62102
63103
- name: Run iOS integration test
64-
env:
65-
AGENT_DEVICE_DAEMON_TIMEOUT_MS: "300000"
66104
run: node --test test/integration/ios.test.ts
67105

68106
- name: Upload iOS artifacts

README.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ Flags:
101101
- `--device <name>`
102102
- `--udid <udid>` (iOS)
103103
- `--serial <serial>` (Android)
104-
- `--activity <component>` (Android; package/Activity or package/.Activity)
104+
- `--activity <component>` (Android app launch only; package/Activity or package/.Activity; not for URL opens)
105105
- `--session <name>`
106106
- `--verbose` for daemon and runner logs
107107
- `--json` for structured output
@@ -117,7 +117,7 @@ npx skills add https://github.com/callstackincubator/agent-device --skill agent-
117117
Sessions:
118118
- `open` starts a session. Without args boots/activates the target device/simulator without launching an app.
119119
- All interaction commands require an open session.
120-
- If a session is already open, `open <app>` switches the active app and updates the session app bundle.
120+
- If a session is already open, `open <app|url>` switches the active app or opens a deep link URL.
121121
- `close` stops the session and releases device resources. Pass an app to close it explicitly, or omit to just close the session.
122122
- Use `--session <name>` to manage multiple sessions.
123123
- Session scripts are written to `~/.agent-device/sessions/<session>-<timestamp>.ad` when recording is enabled with `--save-script`.
@@ -126,10 +126,21 @@ Sessions:
126126
Navigation helpers:
127127
- `boot --platform ios|android` ensures the target is ready without launching an app.
128128
- Use `boot` mainly when starting a new session and `open` fails because no booted simulator/emulator is available.
129-
- `open [app]` already boots/activates the selected target when needed.
129+
- `open [app|url]` already boots/activates the selected target when needed.
130130
- `reinstall <app> <path>` uninstalls and installs the app binary in one command (Android + iOS simulator in v1).
131131
- `reinstall` accepts package/bundle id style app names and supports `~` in paths.
132132

133+
Deep links:
134+
- `open <url>` supports deep links with `scheme://...`.
135+
- Android opens deep links via `VIEW` intent.
136+
- iOS deep link open is simulator-only in v1.
137+
- `--activity` cannot be combined with URL opens.
138+
139+
```bash
140+
agent-device open "myapp://home" --platform android
141+
agent-device open "https://example.com" --platform ios
142+
```
143+
133144
Find (semantic):
134145
- `find <text> <action> [value]` finds by any text (label/value/identifier) using a scoped snapshot.
135146
- `find text|label|value|role|id <value> <action> [value]` for specific locators.

skills/agent-device/SKILL.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ npx -y agent-device
2727

2828
## Core workflow
2929

30-
1. Open app: `open [app]` (`open` handles target selection + boot/activation in the normal flow)
30+
1. Open app or deep link: `open [app|url]` (`open` handles target selection + boot/activation in the normal flow)
3131
2. Snapshot: `snapshot` to get refs from accessibility tree
3232
3. Interact using refs (`click @ref`, `fill @ref "text"`)
3333
4. Re-snapshot after navigation/UI changes
@@ -41,9 +41,11 @@ npx -y agent-device
4141
agent-device boot # Ensure target is booted/ready without opening app
4242
agent-device boot --platform ios # Boot iOS simulator
4343
agent-device boot --platform android # Boot Android emulator/device target
44-
agent-device open [app] # Boot device/simulator; optionally launch app
44+
agent-device open [app|url] # Boot device/simulator; optionally launch app or deep link URL
4545
agent-device open [app] --relaunch # Terminate app process first, then launch (fresh runtime)
46-
agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity
46+
agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity (app targets only)
47+
agent-device open "myapp://home" --platform android # Android deep link
48+
agent-device open "https://example.com" --platform ios # iOS simulator deep link
4749
agent-device close [app] # Close app or just end session
4850
agent-device reinstall <app> <path> # Uninstall + install app in one command
4951
agent-device session list # List active sessions
@@ -171,11 +173,13 @@ agent-device apps --platform android --user-installed
171173
- Prefer `snapshot -i` to reduce output size.
172174
- On iOS, `xctest` is the default and does not require Accessibility permission.
173175
- If XCTest returns 0 nodes (foreground app changed), agent-device falls back to AX when available.
174-
- `open <app>` can be used within an existing session to switch apps and update the session bundle id.
176+
- `open <app|url>` can be used within an existing session to switch apps or open deep links.
177+
- `open <app>` updates session app bundle context; URL opens do not set an app bundle id.
175178
- Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
176179
- If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
177180
- Use `--session <name>` for parallel sessions; avoid device contention.
178-
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK).
181+
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
182+
- iOS deep-link opens are simulator-only in v1.
179183
- Use `fill` when you want clear-then-type semantics.
180184
- Use `type` when you want to append/enter text without clearing.
181185
- On Android, prefer `fill` for important fields; it verifies entered text and retries once when IME reorders characters.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { isDeepLinkTarget } from '../open-target.ts';
4+
5+
test('isDeepLinkTarget accepts URL-style deep links', () => {
6+
assert.equal(isDeepLinkTarget('myapp://home'), true);
7+
assert.equal(isDeepLinkTarget('https://example.com'), true);
8+
});
9+
10+
test('isDeepLinkTarget rejects app identifiers and malformed URLs', () => {
11+
assert.equal(isDeepLinkTarget('com.example.app'), false);
12+
assert.equal(isDeepLinkTarget('settings'), false);
13+
assert.equal(isDeepLinkTarget('http:/x'), false);
14+
});

src/core/dispatch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export async function dispatchCommand(
107107
await interactor.openDevice();
108108
return { app: null };
109109
}
110-
await interactor.open(app, { activity: context?.activity });
110+
await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId });
111111
return { app };
112112
}
113113
case 'close': {

src/core/open-target.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export function isDeepLinkTarget(input: string): boolean {
2+
const value = input.trim();
3+
if (!value) return false;
4+
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\/.+/.test(value);
5+
}

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,98 @@ test('boot succeeds for supported device in session', async () => {
121121
}
122122
});
123123

124+
test('open URL on existing iOS session clears stale app bundle id', async () => {
125+
const sessionStore = makeSessionStore();
126+
const sessionName = 'ios-session';
127+
sessionStore.set(
128+
sessionName,
129+
{
130+
...makeSession(sessionName, {
131+
platform: 'ios',
132+
id: 'sim-1',
133+
name: 'iPhone 15',
134+
kind: 'simulator',
135+
booted: true,
136+
}),
137+
appBundleId: 'com.example.old',
138+
appName: 'Old App',
139+
},
140+
);
141+
142+
let dispatchedContext: Record<string, unknown> | undefined;
143+
const response = await handleSessionCommands({
144+
req: {
145+
token: 't',
146+
session: sessionName,
147+
command: 'open',
148+
positionals: ['https://example.com/path'],
149+
flags: {},
150+
},
151+
sessionName,
152+
logPath: path.join(os.tmpdir(), 'daemon.log'),
153+
sessionStore,
154+
invoke: noopInvoke,
155+
dispatch: async (_device, _command, _positionals, _out, context) => {
156+
dispatchedContext = context as Record<string, unknown> | undefined;
157+
return {};
158+
},
159+
ensureReady: async () => {},
160+
});
161+
162+
assert.ok(response);
163+
assert.equal(response?.ok, true);
164+
const updated = sessionStore.get(sessionName);
165+
assert.equal(updated?.appBundleId, undefined);
166+
assert.equal(updated?.appName, 'https://example.com/path');
167+
assert.equal(dispatchedContext?.appBundleId, undefined);
168+
});
169+
170+
test('open app on existing iOS session resolves and stores bundle id', async () => {
171+
const sessionStore = makeSessionStore();
172+
const sessionName = 'ios-session';
173+
sessionStore.set(
174+
sessionName,
175+
{
176+
...makeSession(sessionName, {
177+
platform: 'ios',
178+
id: 'sim-1',
179+
name: 'iPhone 15',
180+
kind: 'simulator',
181+
booted: true,
182+
}),
183+
appBundleId: 'com.example.old',
184+
appName: 'Old App',
185+
},
186+
);
187+
188+
let dispatchedContext: Record<string, unknown> | undefined;
189+
const response = await handleSessionCommands({
190+
req: {
191+
token: 't',
192+
session: sessionName,
193+
command: 'open',
194+
positionals: ['settings'],
195+
flags: {},
196+
},
197+
sessionName,
198+
logPath: path.join(os.tmpdir(), 'daemon.log'),
199+
sessionStore,
200+
invoke: noopInvoke,
201+
dispatch: async (_device, _command, _positionals, _out, context) => {
202+
dispatchedContext = context as Record<string, unknown> | undefined;
203+
return {};
204+
},
205+
ensureReady: async () => {},
206+
});
207+
208+
assert.ok(response);
209+
assert.equal(response?.ok, true);
210+
const updated = sessionStore.get(sessionName);
211+
assert.equal(updated?.appBundleId, 'com.apple.Preferences');
212+
assert.equal(updated?.appName, 'settings');
213+
assert.equal(dispatchedContext?.appBundleId, 'com.apple.Preferences');
214+
});
215+
124216
test('open --relaunch closes and reopens active session app', async () => {
125217
const sessionStore = makeSessionStore();
126218
const sessionName = 'android-session';
@@ -164,6 +256,30 @@ test('open --relaunch closes and reopens active session app', async () => {
164256
assert.deepEqual(calls[1], { command: 'open', positionals: ['com.example.app'] });
165257
});
166258

259+
test('open --relaunch rejects URL targets', async () => {
260+
const sessionStore = makeSessionStore();
261+
const response = await handleSessionCommands({
262+
req: {
263+
token: 't',
264+
session: 'default',
265+
command: 'open',
266+
positionals: ['https://example.com/path'],
267+
flags: { relaunch: true },
268+
},
269+
sessionName: 'default',
270+
logPath: path.join(os.tmpdir(), 'daemon.log'),
271+
sessionStore,
272+
invoke: noopInvoke,
273+
});
274+
275+
assert.ok(response);
276+
assert.equal(response?.ok, false);
277+
if (response && !response.ok) {
278+
assert.equal(response.error.code, 'INVALID_ARGS');
279+
assert.match(response.error.message, /does not support URL targets/i);
280+
}
281+
});
282+
167283
test('open --relaunch fails without app when no session exists', async () => {
168284
const sessionStore = makeSessionStore();
169285
const response = await handleSessionCommands({

0 commit comments

Comments
 (0)