Skip to content

Commit 66b0bdb

Browse files
authored
fix: expose push notification simulation (#109)
* fix: expose push notification simulation across iOS and Android * refactor: share push JSON detection and clean extras validation * fix: harden push payload path handling in session * refactor: consolidate push payload source resolution
1 parent 8660b04 commit 66b0bdb

19 files changed

Lines changed: 757 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
- App logs: `logs path` returns session log metadata; `logs start` / `logs stop` stream app output; `logs clear` truncates session app logs; `logs clear --restart` resets and restarts stream in one step; `logs doctor` checks readiness; `logs mark` writes timeline markers.
2020
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
@@ -140,6 +140,7 @@ agent-device scrollintoview @e42
140140

141141
## Command Index
142142
- `boot`, `open`, `close`, `reinstall`, `home`, `back`, `app-switcher`
143+
- `push`
143144
- `batch`
144145
- `snapshot`, `diff snapshot`, `find`, `get`
145146
- `press` (alias: `click`), `focus`, `type`, `fill`, `long-press`, `swipe`, `scroll`, `scrollintoview`, `pinch`, `is`
@@ -152,6 +153,26 @@ agent-device scrollintoview @e42
152153
- `settings permission grant|deny|reset camera|microphone|photos|contacts|notifications [full|limited]`
153154
- `appstate`, `apps`, `devices`, `session list`
154155

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

157178
Notes:

skills/agent-device/SKILL.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ agent-device is visible 'id="anchor"'
8686

8787
```bash
8888
agent-device appstate
89+
agent-device push <bundle|package> <payload.json|inline-json>
8990
agent-device get text @e1
9091
agent-device screenshot out.png
9192
agent-device settings permission grant notifications
@@ -107,7 +108,11 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
107108
- Use refs for discovery, selectors for replay/assertions.
108109
- Use `fill` for clear-then-type semantics; use `type` for focused append typing.
109110
- iOS `appstate` is session-scoped; Android `appstate` is live foreground state.
111+
<<<<<<< HEAD
110112
- iOS settings helpers are simulator-only; use `appearance light|dark|toggle` and faceid `match|nonmatch|enroll|unenroll`.
113+
- `push` simulates notification delivery:
114+
- iOS simulator uses APNs-style payload JSON.
115+
- Android uses broadcast action + typed extras (string/boolean/number).
111116
- Permission settings are app-scoped and require an active session app:
112117
`settings permission <grant|deny|reset> <camera|microphone|photos|contacts|notifications> [full|limited]`
113118
- `full|limited` mode applies only to iOS `photos`; other targets reject mode.

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
@@ -34,6 +34,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
3434
open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3535
reinstall: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3636
press: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
37+
push: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
3738
record: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3839
screenshot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
3940
scroll: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },

src/core/dispatch.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,19 @@ 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';
2122
import { emitDiagnostic, withDiagnosticTimer } from '../utils/diagnostics.ts';
23+
import { resolvePayloadInput } from '../utils/payload-input.ts';
2224

2325
export type BatchStep = {
2426
command: string;
@@ -438,6 +440,28 @@ export async function dispatchCommand(
438440
await setAndroidSetting(device, setting, state, appBundleId ?? context?.appBundleId, permissionOptions);
439441
return { setting, state };
440442
}
443+
case 'push': {
444+
const target = positionals[0]?.trim();
445+
const payloadArg = positionals[1]?.trim();
446+
if (!target || !payloadArg) {
447+
throw new AppError(
448+
'INVALID_ARGS',
449+
'push requires <bundle|package> <payload.json|inline-json>',
450+
);
451+
}
452+
const payload = await readNotificationPayload(payloadArg);
453+
if (device.platform === 'ios') {
454+
await pushIosNotification(device, target, payload);
455+
return { platform: 'ios', bundleId: target };
456+
}
457+
const androidResult = await pushAndroidNotification(device, target, payload);
458+
return {
459+
platform: 'android',
460+
package: target,
461+
action: androidResult.action,
462+
extrasCount: androidResult.extrasCount,
463+
};
464+
}
441465
case 'snapshot': {
442466
if (device.platform === 'ios') {
443467
const result = (await withDiagnosticTimer(
@@ -525,6 +549,43 @@ function clampIosSwipeDuration(durationMs: number): number {
525549
return Math.min(60, Math.max(16, Math.round(durationMs)));
526550
}
527551

552+
async function readNotificationPayload(payloadArg: string): Promise<Record<string, unknown>> {
553+
const source = resolvePayloadInput(payloadArg, { subject: 'Push payload' });
554+
const payloadText = source.kind === 'inline'
555+
? source.text
556+
: await readPushPayloadFile(source.path);
557+
try {
558+
const parsed = JSON.parse(payloadText) as unknown;
559+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
560+
throw new AppError('INVALID_ARGS', 'push payload must be a JSON object');
561+
}
562+
return parsed as Record<string, unknown>;
563+
} catch (error) {
564+
if (error instanceof AppError) throw error;
565+
throw new AppError('INVALID_ARGS', `Invalid push payload JSON: ${payloadArg}`);
566+
}
567+
}
568+
569+
async function readPushPayloadFile(payloadPath: string): Promise<string> {
570+
try {
571+
return await fs.readFile(payloadPath, 'utf8');
572+
} catch (error) {
573+
const code = (error as NodeJS.ErrnoException).code;
574+
if (code === 'ENOENT') {
575+
throw new AppError('INVALID_ARGS', `Push payload file not found: ${payloadPath}`);
576+
}
577+
if (code === 'EISDIR') {
578+
throw new AppError('INVALID_ARGS', `Push payload path is not a file: ${payloadPath}`);
579+
}
580+
if (code === 'EACCES' || code === 'EPERM') {
581+
throw new AppError('INVALID_ARGS', `Push payload file is not readable: ${payloadPath}`);
582+
}
583+
throw new AppError('COMMAND_FAILED', `Unable to read push payload file: ${payloadPath}`, {
584+
cause: String(error),
585+
});
586+
}
587+
}
588+
528589
export function shouldUseIosTapSeries(
529590
device: DeviceInfo,
530591
count: number,

0 commit comments

Comments
 (0)