Skip to content

Commit f83c5e6

Browse files
committed
feat: add trigger app event aliases and routing
1 parent ef20dea commit f83c5e6

15 files changed

Lines changed: 900 additions & 42 deletions

File tree

README.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The project is in early development and considered experimental. Pull requests a
1414

1515
## Features
1616
- Platforms: iOS/tvOS (simulator + physical device core automation) and Android/AndroidTV (emulator + device).
17-
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`, `push`.
17+
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`, `push`, `trigger-app-event`.
1818
- Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
1919
- Clipboard commands: `clipboard read`, `clipboard write <text>`.
2020
- Performance command: `perf` (alias: `metrics`) returns a metrics JSON blob for the active session; startup timing is currently sampled.
@@ -147,6 +147,9 @@ agent-device scrollintoview @e42
147147
- `snapshot`, `diff snapshot`, `find`, `get`
148148
- `press` (alias: `click`), `focus`, `type`, `fill`, `long-press`, `swipe`, `scroll`, `scrollintoview`, `pinch`, `is`
149149
- `alert`, `wait`, `screenshot`
150+
- `trigger-app-event <event> [payloadJson]`
151+
- `trigger-screenshot-notification` (alias: `trigger-screenshot`)
152+
- `trigger-memory-warning`, `trigger-device-shake`
150153
- `trace start`, `trace stop`
151154
- `logs path`, `logs start`, `logs stop`, `logs clear`, `logs clear --restart`, `logs doctor`, `logs mark` (session app log file for grep; iOS simulator + iOS device + Android)
152155
- `clipboard read`, `clipboard write <text>` (iOS simulator + Android)
@@ -177,6 +180,32 @@ Payload notes:
177180
- Android extras support string/boolean/number values.
178181
- `push` works with session context (uses session device) or explicit device selectors.
179182

183+
App event triggers (app hook):
184+
185+
```bash
186+
# Generic app event trigger
187+
agent-device trigger-app-event screenshot_taken '{"source":"qa"}'
188+
189+
# Convenience aliases
190+
agent-device trigger-screenshot-notification
191+
agent-device trigger-screenshot
192+
agent-device trigger-memory-warning
193+
agent-device trigger-device-shake
194+
```
195+
196+
- `trigger-*` commands dispatch an app event via deep link and require an app-side test/debug hook.
197+
- `trigger-*` commands require either an active session or explicit device selectors (`--platform`, `--device`, `--udid`, `--serial`).
198+
- On iOS physical devices, custom-scheme deep links require active app context (open the app in-session first).
199+
- Configure one of:
200+
- `AGENT_DEVICE_APP_EVENT_URL_TEMPLATE`
201+
- `AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE`
202+
- `AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE`
203+
- Template placeholders: `{event}`, `{payload}`, `{platform}`.
204+
- Example template: `myapp://agent-device/event?name={event}&payload={payload}`.
205+
- `payloadJson` must be a JSON object.
206+
- This is app-hook-based simulation, not an OS-global notification injector.
207+
- Canonical trigger contract lives in [`website/docs/docs/commands.md`](website/docs/docs/commands.md) under **App event triggers**.
208+
180209
## iOS Snapshots
181210

182211
Notes:

skills/agent-device/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ agent-device clipboard read
9898
agent-device clipboard write "token"
9999
agent-device perf --json
100100
agent-device push <bundle|package> <payload.json|inline-json>
101+
agent-device trigger-app-event screenshot_taken '{"source":"qa"}'
102+
agent-device trigger-screenshot-notification
101103
agent-device get text @e1
102104
agent-device screenshot out.png
103105
agent-device settings permission grant notifications
@@ -130,6 +132,9 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
130132
- `push` simulates notification delivery:
131133
- iOS simulator uses APNs-style payload JSON.
132134
- Android uses broadcast action + typed extras (string/boolean/number).
135+
- `trigger-app-event` and `trigger-*` aliases require app-defined deep-link hooks and URL template configuration (`AGENT_DEVICE_APP_EVENT_URL_TEMPLATE` or platform-specific variants).
136+
- `trigger-*` commands require an active session or explicit selectors (`--platform`, `--device`, `--udid`, `--serial`); on iOS physical devices, custom-scheme triggers require active app context.
137+
- Canonical trigger behavior and caveats are documented in [`website/docs/docs/commands.md`](../../website/docs/docs/commands.md) under **App event triggers**.
133138
- Permission settings are app-scoped and require an active session app:
134139
`settings permission <grant|deny|reset> <camera|microphone|photos|contacts|notifications> [full|limited]`
135140
- `full|limited` mode applies only to iOS `photos`; other targets reject mode.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { parseTriggerAppEventArgs, normalizeTriggerAliasCommand } from '../app-events.ts';
4+
import { AppError } from '../../utils/errors.ts';
5+
6+
test('normalizeTriggerAliasCommand maps aliases to trigger-app-event', () => {
7+
const normalized = normalizeTriggerAliasCommand('trigger-screenshot-notification', []);
8+
assert.equal(normalized.command, 'trigger-app-event');
9+
assert.deepEqual(normalized.positionals, ['screenshot_taken']);
10+
});
11+
12+
test('normalizeTriggerAliasCommand rejects alias arguments', () => {
13+
assert.throws(
14+
() => normalizeTriggerAliasCommand('trigger-device-shake', ['extra']),
15+
(error) => error instanceof AppError && error.code === 'INVALID_ARGS',
16+
);
17+
});
18+
19+
test('parseTriggerAppEventArgs validates event name format', () => {
20+
assert.throws(
21+
() => parseTriggerAppEventArgs(['bad event']),
22+
(error) => error instanceof AppError && error.code === 'INVALID_ARGS',
23+
);
24+
});
25+
26+
test('parseTriggerAppEventArgs accepts JSON object payload', () => {
27+
const parsed = parseTriggerAppEventArgs(['screenshot_taken', '{"source":"qa"}']);
28+
assert.equal(parsed.eventName, 'screenshot_taken');
29+
assert.deepEqual(parsed.payload, { source: 'qa' });
30+
});

src/core/__tests__/capabilities.test.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ test('core commands support iOS simulator, iOS device, and Android', () => {
9292
'scroll',
9393
'scrollintoview',
9494
'snapshot',
95+
'trigger-app-event',
9596
'type',
9697
'wait',
9798
]) {
@@ -108,7 +109,16 @@ test('Android TV uses Android capabilities for core commands', () => {
108109
});
109110

110111
test('tvOS follows iOS capability matrix by device kind', () => {
111-
for (const cmd of ['open', 'close', 'apps', 'screenshot', 'logs', 'reinstall', 'boot']) {
112+
for (const cmd of [
113+
'open',
114+
'close',
115+
'apps',
116+
'screenshot',
117+
'trigger-app-event',
118+
'logs',
119+
'reinstall',
120+
'boot',
121+
]) {
112122
assert.equal(isCommandSupportedOnDevice(cmd, tvOsSimulator), true, `${cmd} on tvOS`);
113123
}
114124
for (const cmd of ['snapshot', 'wait', 'press', 'get', 'fill', 'scroll', 'back', 'home', 'app-switcher', 'record']) {
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { promises as fs } from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { dispatchCommand } from '../dispatch.ts';
7+
import { AppError } from '../../utils/errors.ts';
8+
import type { DeviceInfo } from '../../utils/device.ts';
9+
10+
const ANDROID_DEVICE: DeviceInfo = {
11+
platform: 'android',
12+
id: 'emulator-5554',
13+
name: 'Pixel',
14+
kind: 'emulator',
15+
booted: true,
16+
};
17+
18+
const IOS_DEVICE: DeviceInfo = {
19+
platform: 'ios',
20+
id: 'ios-device-1',
21+
name: 'iPhone Device',
22+
kind: 'device',
23+
booted: true,
24+
};
25+
26+
test('trigger-app-event reports missing URL template as UNSUPPORTED_OPERATION', async () => {
27+
const previousGlobalTemplate = process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE;
28+
const previousAndroidTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
29+
delete process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE;
30+
delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
31+
32+
try {
33+
await assert.rejects(
34+
() => dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', ['screenshot_taken']),
35+
(error: unknown) => {
36+
assert.equal(error instanceof AppError, true);
37+
assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION');
38+
assert.match((error as AppError).message, /No app event URL template configured/i);
39+
return true;
40+
},
41+
);
42+
} finally {
43+
if (previousGlobalTemplate === undefined) delete process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE;
44+
else process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = previousGlobalTemplate;
45+
if (previousAndroidTemplate === undefined) delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
46+
else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousAndroidTemplate;
47+
}
48+
});
49+
50+
test('trigger-app-event validates payload JSON', async () => {
51+
const previousTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
52+
process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = 'myapp://agent-device/event?name={event}&payload={payload}';
53+
try {
54+
await assert.rejects(
55+
() => dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', ['screenshot_taken', '{invalid-json']),
56+
(error: unknown) => {
57+
assert.equal(error instanceof AppError, true);
58+
assert.equal((error as AppError).code, 'INVALID_ARGS');
59+
assert.match((error as AppError).message, /Invalid trigger-app-event payload JSON/i);
60+
return true;
61+
},
62+
);
63+
} finally {
64+
if (previousTemplate === undefined) delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
65+
else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousTemplate;
66+
}
67+
});
68+
69+
test('trigger-app-event opens deep link with encoded event payload', async () => {
70+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-dispatch-trigger-event-'));
71+
const adbPath = path.join(tempDir, 'adb');
72+
const argsLogPath = path.join(tempDir, 'args.log');
73+
await fs.writeFile(
74+
adbPath,
75+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
76+
'utf8',
77+
);
78+
await fs.chmod(adbPath, 0o755);
79+
80+
const previousPath = process.env.PATH;
81+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
82+
const previousTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
83+
process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`;
84+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
85+
process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE =
86+
'myapp://agent-device/event?name={event}&payload={payload}&platform={platform}';
87+
88+
try {
89+
const result = await dispatchCommand(
90+
ANDROID_DEVICE,
91+
'trigger-app-event',
92+
['screenshot_taken', '{"source":"qa","count":2}'],
93+
);
94+
assert.equal(result?.event, 'screenshot_taken');
95+
assert.equal(result?.transport, 'deep-link');
96+
const expectedUrl =
97+
'myapp://agent-device/event?name=screenshot_taken&payload=%7B%22source%22%3A%22qa%22%2C%22count%22%3A2%7D&platform=android';
98+
assert.equal(result?.eventUrl, expectedUrl);
99+
100+
const args = (await fs.readFile(argsLogPath, 'utf8'))
101+
.trim()
102+
.split('\n')
103+
.filter(Boolean);
104+
assert.equal(args.includes('-d'), true);
105+
assert.equal(args.includes(expectedUrl), true);
106+
} finally {
107+
process.env.PATH = previousPath;
108+
if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
109+
else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
110+
if (previousTemplate === undefined) delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
111+
else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousTemplate;
112+
await fs.rm(tempDir, { recursive: true, force: true });
113+
}
114+
});
115+
116+
test('trigger-app-event prefers platform-specific template over global template', async () => {
117+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-dispatch-trigger-template-'));
118+
const adbPath = path.join(tempDir, 'adb');
119+
const argsLogPath = path.join(tempDir, 'args.log');
120+
await fs.writeFile(
121+
adbPath,
122+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
123+
'utf8',
124+
);
125+
await fs.chmod(adbPath, 0o755);
126+
127+
const previousPath = process.env.PATH;
128+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
129+
const previousGlobalTemplate = process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE;
130+
const previousAndroidTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
131+
process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`;
132+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
133+
process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = 'myapp://global?name={event}';
134+
process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = 'myapp://android?name={event}';
135+
136+
try {
137+
const result = await dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', ['screenshot_taken']);
138+
assert.equal(result?.eventUrl, 'myapp://android?name=screenshot_taken');
139+
} finally {
140+
process.env.PATH = previousPath;
141+
if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
142+
else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
143+
if (previousGlobalTemplate === undefined) delete process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE;
144+
else process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = previousGlobalTemplate;
145+
if (previousAndroidTemplate === undefined) delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
146+
else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousAndroidTemplate;
147+
await fs.rm(tempDir, { recursive: true, force: true });
148+
}
149+
});
150+
151+
test('trigger-app-event supports iOS device path and prefers iOS template', async () => {
152+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-dispatch-trigger-ios-'));
153+
const xcrunPath = path.join(tempDir, 'xcrun');
154+
const argsLogPath = path.join(tempDir, 'args.log');
155+
await fs.writeFile(
156+
xcrunPath,
157+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
158+
'utf8',
159+
);
160+
await fs.chmod(xcrunPath, 0o755);
161+
162+
const previousPath = process.env.PATH;
163+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
164+
const previousGlobalTemplate = process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE;
165+
const previousIosTemplate = process.env.AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE;
166+
process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`;
167+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
168+
process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = 'myapp://global?name={event}';
169+
process.env.AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE = 'myapp://ios?name={event}&payload={payload}';
170+
171+
try {
172+
const result = await dispatchCommand(
173+
IOS_DEVICE,
174+
'trigger-app-event',
175+
['screenshot_taken', '{"source":"ios"}'],
176+
undefined,
177+
{ appBundleId: 'com.example.app' },
178+
);
179+
const expectedUrl = 'myapp://ios?name=screenshot_taken&payload=%7B%22source%22%3A%22ios%22%7D';
180+
assert.equal(result?.eventUrl, expectedUrl);
181+
const args = (await fs.readFile(argsLogPath, 'utf8'))
182+
.trim()
183+
.split('\n')
184+
.filter(Boolean);
185+
assert.deepEqual(args, [
186+
'devicectl',
187+
'device',
188+
'process',
189+
'launch',
190+
'--device',
191+
'ios-device-1',
192+
'com.example.app',
193+
'--payload-url',
194+
expectedUrl,
195+
]);
196+
} finally {
197+
process.env.PATH = previousPath;
198+
if (previousArgsFile === undefined) delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
199+
else process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
200+
if (previousGlobalTemplate === undefined) delete process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE;
201+
else process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE = previousGlobalTemplate;
202+
if (previousIosTemplate === undefined) delete process.env.AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE;
203+
else process.env.AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE = previousIosTemplate;
204+
await fs.rm(tempDir, { recursive: true, force: true });
205+
}
206+
});
207+
208+
test('trigger-app-event rejects invalid event names', async () => {
209+
const previousTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
210+
process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = 'myapp://agent-device/event?name={event}';
211+
try {
212+
await assert.rejects(
213+
() => dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', ['bad event']),
214+
(error: unknown) => {
215+
assert.equal(error instanceof AppError, true);
216+
assert.equal((error as AppError).code, 'INVALID_ARGS');
217+
assert.match((error as AppError).message, /Invalid trigger-app-event event name/i);
218+
return true;
219+
},
220+
);
221+
} finally {
222+
if (previousTemplate === undefined) delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
223+
else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousTemplate;
224+
}
225+
});
226+
227+
test('trigger-app-event rejects payloads that exceed size limits', async () => {
228+
const previousTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
229+
process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = 'myapp://agent-device/event?name={event}&payload={payload}';
230+
const oversizedPayload = JSON.stringify({ value: 'x'.repeat(9000) });
231+
try {
232+
await assert.rejects(
233+
() => dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', ['screenshot_taken', oversizedPayload]),
234+
(error: unknown) => {
235+
assert.equal(error instanceof AppError, true);
236+
assert.equal((error as AppError).code, 'INVALID_ARGS');
237+
assert.match((error as AppError).message, /exceeds 8192 bytes/i);
238+
return true;
239+
},
240+
);
241+
} finally {
242+
if (previousTemplate === undefined) delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
243+
else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousTemplate;
244+
}
245+
});
246+
247+
test('trigger-app-event rejects event URLs that exceed length limits', async () => {
248+
const previousTemplate = process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
249+
process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = `myapp://${'a'.repeat(5000)}?name={event}`;
250+
try {
251+
await assert.rejects(
252+
() => dispatchCommand(ANDROID_DEVICE, 'trigger-app-event', ['screenshot_taken']),
253+
(error: unknown) => {
254+
assert.equal(error instanceof AppError, true);
255+
assert.equal((error as AppError).code, 'INVALID_ARGS');
256+
assert.match((error as AppError).message, /URL exceeds maximum supported length/i);
257+
return true;
258+
},
259+
);
260+
} finally {
261+
if (previousTemplate === undefined) delete process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE;
262+
else process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE = previousTemplate;
263+
}
264+
});

0 commit comments

Comments
 (0)