Skip to content

Commit ea1c4d7

Browse files
committed
fix: expose push notification simulation across iOS and Android
1 parent 71df2ff commit ea1c4d7

17 files changed

Lines changed: 728 additions & 8 deletions

File tree

README.md

Lines changed: 22 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 (simulator + physical device core automation) and Android (emulator + device).
17-
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`.
17+
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`, `push`.
1818
- Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
1919
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
2020
- Minimal dependencies; TypeScript executed directly on Node 22+ (no build step).
@@ -137,6 +137,7 @@ agent-device scrollintoview @e42
137137

138138
## Command Index
139139
- `boot`, `open`, `close`, `reinstall`, `home`, `back`, `app-switcher`
140+
- `push`
140141
- `batch`
141142
- `snapshot`, `diff snapshot`, `find`, `get`
142143
- `press` (alias: `click`), `focus`, `type`, `fill`, `long-press`, `swipe`, `scroll`, `scrollintoview`, `pinch`, `is`
@@ -146,6 +147,26 @@ agent-device scrollintoview @e42
146147
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
147148
- `appstate`, `apps`, `devices`, `session list`
148149

150+
Push notification simulation:
151+
152+
```bash
153+
# iOS simulator: app bundle + payload file
154+
agent-device push com.example.app ./payload.apns --platform ios --device "iPhone 16"
155+
156+
# iOS simulator: inline JSON payload
157+
agent-device push com.example.app '{"aps":{"alert":"Welcome","badge":1}}' --platform ios
158+
159+
# Android: package + payload (action/extras map)
160+
agent-device push com.example.app '{"action":"com.example.app.PUSH","extras":{"title":"Welcome","unread":3,"promo":true}}' --platform android
161+
```
162+
163+
Payload notes:
164+
- iOS uses `xcrun simctl push <device> <bundle> <payload>` and requires APNs-style JSON object (for example `{"aps":{"alert":"..."}}`).
165+
- Android uses `adb shell am broadcast` with payload JSON shape:
166+
`{"action":"<intent-action>","receiver":"<optional component>","extras":{"key":"value","flag":true,"count":3}}`.
167+
- Android extras support string/boolean/number values.
168+
- `push` works with session context (uses session device) or explicit device selectors.
169+
149170
## iOS Snapshots
150171

151172
Notes:

skills/agent-device/SKILL.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,19 @@ agent-device open "https://example.com" --platform ios # iOS deep link (op
5050
agent-device open MyApp "myapp://screen/to" --platform ios # iOS deep link in app context
5151
agent-device close [app] # Close app or just end session
5252
agent-device reinstall <app> <path> # Uninstall + install app in one command
53+
agent-device push <bundle|package> <payload.json|inline-json> # Simulate push notification delivery
5354
agent-device session list # List active sessions
5455
```
5556

5657
`boot` requires either an active session or an explicit selector (`--platform`, `--device`, `--udid`, or `--serial`).
5758
`boot` is a fallback, not a regular step: use it when starting a new session only if `open` cannot find/connect to an available target.
5859

60+
Push payloads:
61+
- iOS simulator (`simctl push`) expects APNs-style JSON, e.g. `{"aps":{"alert":"Welcome","badge":1}}`.
62+
- Android (`adb shell am broadcast`) expects:
63+
`{"action":"com.example.app.PUSH","receiver":"com.example.app/.PushReceiver","extras":{"title":"Welcome","unread":3,"promo":true}}`.
64+
- Android extras support string/boolean/number values.
65+
5966
### Snapshot (page analysis)
6067

6168
```bash

src/core/__tests__/capabilities.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ test('iOS simulator-only commands reject iOS devices and Android', () => {
3333
});
3434

3535
test('simulator-only iOS commands with Android support reject iOS devices', () => {
36-
for (const cmd of ['settings']) {
36+
for (const cmd of ['settings', 'push']) {
3737
assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
3838
assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
3939
assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
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+
test('dispatch push reports missing payload file as INVALID_ARGS', async () => {
19+
await assert.rejects(
20+
() => dispatchCommand(ANDROID_DEVICE, 'push', ['com.example.app', './missing-payload.json']),
21+
(error: unknown) => {
22+
assert.equal(error instanceof AppError, true);
23+
assert.equal((error as AppError).code, 'INVALID_ARGS');
24+
assert.match((error as AppError).message, /payload file not found/i);
25+
return true;
26+
},
27+
);
28+
});
29+
30+
test('dispatch push reports directory payload path as INVALID_ARGS', async () => {
31+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-dispatch-push-dir-'));
32+
try {
33+
await assert.rejects(
34+
() => dispatchCommand(ANDROID_DEVICE, 'push', ['com.example.app', tempDir]),
35+
(error: unknown) => {
36+
assert.equal(error instanceof AppError, true);
37+
assert.equal((error as AppError).code, 'INVALID_ARGS');
38+
assert.match((error as AppError).message, /not a file/i);
39+
return true;
40+
},
41+
);
42+
} finally {
43+
await fs.rm(tempDir, { recursive: true, force: true });
44+
}
45+
});
46+
47+
test('dispatch push prefers existing brace-prefixed payload file over inline parsing', async () => {
48+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-dispatch-push-brace-'));
49+
const adbPath = path.join(tempDir, 'adb');
50+
const argsLogPath = path.join(tempDir, 'args.log');
51+
const payloadPath = path.join(tempDir, '{payload}.json');
52+
await fs.writeFile(
53+
adbPath,
54+
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
55+
'utf8',
56+
);
57+
await fs.chmod(adbPath, 0o755);
58+
await fs.writeFile(payloadPath, '{"action":"com.example.app.PUSH","extras":{"title":"Hello"}}\n', 'utf8');
59+
60+
const previousPath = process.env.PATH;
61+
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
62+
process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`;
63+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
64+
65+
try {
66+
const result = await dispatchCommand(ANDROID_DEVICE, 'push', ['com.example.app', payloadPath]);
67+
assert.deepEqual(result, {
68+
platform: 'android',
69+
package: 'com.example.app',
70+
action: 'com.example.app.PUSH',
71+
extrasCount: 1,
72+
});
73+
const args = (await fs.readFile(argsLogPath, 'utf8'))
74+
.trim()
75+
.split('\n')
76+
.filter(Boolean);
77+
assert.equal(args.includes('-a'), true);
78+
assert.equal(args.includes('com.example.app.PUSH'), true);
79+
assert.equal(args.includes('--es'), true);
80+
assert.equal(args.includes('title'), true);
81+
} finally {
82+
process.env.PATH = previousPath;
83+
if (previousArgsFile === undefined) {
84+
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
85+
} else {
86+
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
87+
}
88+
await fs.rm(tempDir, { recursive: true, force: true });
89+
}
90+
});
91+

src/core/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
3333
open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3434
reinstall: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3535
press: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
36+
push: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
3637
record: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3738
screenshot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3839
scroll: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },

src/core/dispatch.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import {
88
backAndroid,
99
ensureAdb,
1010
homeAndroid,
11+
pushAndroidNotification,
1112
setAndroidSetting,
1213
snapshotAndroid,
1314
} from '../platforms/android/index.ts';
1415
import { listIosDevices } from '../platforms/ios/devices.ts';
1516
import { getInteractor, type RunnerContext } from '../utils/interactors.ts';
1617
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
17-
import { setIosSetting } from '../platforms/ios/index.ts';
18+
import { pushIosNotification, setIosSetting } from '../platforms/ios/index.ts';
1819
import { isDeepLinkTarget } from './open-target.ts';
1920
import type { RawSnapshotNode } from '../utils/snapshot.ts';
2021
import type { CliFlags } from '../utils/command-schema.ts';
@@ -429,6 +430,28 @@ export async function dispatchCommand(
429430
await setAndroidSetting(device, setting, state);
430431
return { setting, state };
431432
}
433+
case 'push': {
434+
const target = positionals[0]?.trim();
435+
const payloadArg = positionals[1]?.trim();
436+
if (!target || !payloadArg) {
437+
throw new AppError(
438+
'INVALID_ARGS',
439+
'push requires <bundle|package> <payload.json|inline-json>',
440+
);
441+
}
442+
const payload = await readNotificationPayload(payloadArg);
443+
if (device.platform === 'ios') {
444+
await pushIosNotification(device, target, payload);
445+
return { platform: 'ios', bundleId: target };
446+
}
447+
const androidResult = await pushAndroidNotification(device, target, payload);
448+
return {
449+
platform: 'android',
450+
package: target,
451+
action: androidResult.action,
452+
extrasCount: androidResult.extrasCount,
453+
};
454+
}
432455
case 'snapshot': {
433456
if (device.platform === 'ios') {
434457
const result = (await withDiagnosticTimer(
@@ -516,6 +539,51 @@ function clampIosSwipeDuration(durationMs: number): number {
516539
return Math.min(60, Math.max(16, Math.round(durationMs)));
517540
}
518541

542+
async function readNotificationPayload(payloadArg: string): Promise<Record<string, unknown>> {
543+
const trimmed = payloadArg.trim();
544+
if (!trimmed) {
545+
throw new AppError('INVALID_ARGS', 'push payload cannot be empty');
546+
}
547+
const payloadText = await resolvePushPayloadText(payloadArg, trimmed);
548+
try {
549+
const parsed = JSON.parse(payloadText) as unknown;
550+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
551+
throw new AppError('INVALID_ARGS', 'push payload must be a JSON object');
552+
}
553+
return parsed as Record<string, unknown>;
554+
} catch (error) {
555+
if (error instanceof AppError) throw error;
556+
throw new AppError('INVALID_ARGS', `Invalid push payload JSON: ${payloadArg}`);
557+
}
558+
}
559+
560+
function looksLikeInlineJson(value: string): boolean {
561+
return (value.startsWith('{') && value.endsWith('}')) || (value.startsWith('[') && value.endsWith(']'));
562+
}
563+
564+
async function resolvePushPayloadText(payloadArg: string, trimmedArg: string): Promise<string> {
565+
const filePayload = await tryReadPushPayloadFile(payloadArg);
566+
if (filePayload !== null) return filePayload;
567+
if (looksLikeInlineJson(trimmedArg)) return trimmedArg;
568+
throw new AppError('INVALID_ARGS', `Push payload file not found: ${payloadArg}`);
569+
}
570+
571+
async function tryReadPushPayloadFile(payloadArg: string): Promise<string | null> {
572+
try {
573+
return await fs.readFile(payloadArg, 'utf8');
574+
} catch (error) {
575+
const code = (error as NodeJS.ErrnoException).code;
576+
if (code === 'ENOENT') return null;
577+
if (code === 'EISDIR') {
578+
throw new AppError('INVALID_ARGS', `Push payload path is not a file: ${payloadArg}`);
579+
}
580+
if (code === 'EACCES' || code === 'EPERM') {
581+
throw new AppError('INVALID_ARGS', `Push payload file is not readable: ${payloadArg}`);
582+
}
583+
throw new AppError('COMMAND_FAILED', `Unable to read push payload file: ${payloadArg}`, { cause: String(error) });
584+
}
585+
}
586+
519587
export function shouldUseIosTapSeries(
520588
device: DeviceInfo,
521589
count: number,

0 commit comments

Comments
 (0)