Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The project is in early development and considered experimental. Pull requests a

## Features
- Platforms: iOS (simulator + physical device core automation) and Android (emulator + device).
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`.
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`, `push`.
- Inspection commands: `snapshot` (accessibility tree), `diff snapshot` (structural baseline diff), `appstate`, `apps`, `devices`.
- 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.
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
Expand Down Expand Up @@ -140,6 +140,7 @@ agent-device scrollintoview @e42

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

Push notification simulation:

```bash
# iOS simulator: app bundle + payload file
agent-device push com.example.app ./payload.apns --platform ios --device "iPhone 16"

# iOS simulator: inline JSON payload
agent-device push com.example.app '{"aps":{"alert":"Welcome","badge":1}}' --platform ios

# Android: package + payload (action/extras map)
agent-device push com.example.app '{"action":"com.example.app.PUSH","extras":{"title":"Welcome","unread":3,"promo":true}}' --platform android
```

Payload notes:
- iOS uses `xcrun simctl push <device> <bundle> <payload>` and requires APNs-style JSON object (for example `{"aps":{"alert":"..."}}`).
- Android uses `adb shell am broadcast` with payload JSON shape:
`{"action":"<intent-action>","receiver":"<optional component>","extras":{"key":"value","flag":true,"count":3}}`.
- Android extras support string/boolean/number values.
- `push` works with session context (uses session device) or explicit device selectors.

## iOS Snapshots

Notes:
Expand Down
5 changes: 5 additions & 0 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ agent-device is visible 'id="anchor"'

```bash
agent-device appstate
agent-device push <bundle|package> <payload.json|inline-json>
agent-device get text @e1
agent-device screenshot out.png
agent-device settings permission grant notifications
Expand All @@ -107,7 +108,11 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
- Use refs for discovery, selectors for replay/assertions.
- Use `fill` for clear-then-type semantics; use `type` for focused append typing.
- iOS `appstate` is session-scoped; Android `appstate` is live foreground state.
<<<<<<< HEAD
- iOS settings helpers are simulator-only; use `appearance light|dark|toggle` and faceid `match|nonmatch|enroll|unenroll`.
- `push` simulates notification delivery:
- iOS simulator uses APNs-style payload JSON.
- Android uses broadcast action + typed extras (string/boolean/number).
- Permission settings are app-scoped and require an active session app:
`settings permission <grant|deny|reset> <camera|microphone|photos|contacts|notifications> [full|limited]`
- `full|limited` mode applies only to iOS `photos`; other targets reject mode.
Expand Down
2 changes: 1 addition & 1 deletion src/core/__tests__/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ test('iOS simulator-only commands reject iOS devices and Android', () => {
});

test('simulator-only iOS commands with Android support reject iOS devices', () => {
for (const cmd of ['settings']) {
for (const cmd of ['settings', 'push']) {
assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);
Expand Down
91 changes: 91 additions & 0 deletions src/core/__tests__/dispatch-push.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { dispatchCommand } from '../dispatch.ts';
import { AppError } from '../../utils/errors.ts';
import type { DeviceInfo } from '../../utils/device.ts';

const ANDROID_DEVICE: DeviceInfo = {
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
booted: true,
};

test('dispatch push reports missing payload file as INVALID_ARGS', async () => {
await assert.rejects(
() => dispatchCommand(ANDROID_DEVICE, 'push', ['com.example.app', './missing-payload.json']),
(error: unknown) => {
assert.equal(error instanceof AppError, true);
assert.equal((error as AppError).code, 'INVALID_ARGS');
assert.match((error as AppError).message, /payload file not found/i);
return true;
},
);
});

test('dispatch push reports directory payload path as INVALID_ARGS', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-dispatch-push-dir-'));
try {
await assert.rejects(
() => dispatchCommand(ANDROID_DEVICE, 'push', ['com.example.app', tempDir]),
(error: unknown) => {
assert.equal(error instanceof AppError, true);
assert.equal((error as AppError).code, 'INVALID_ARGS');
assert.match((error as AppError).message, /not a file/i);
return true;
},
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});

test('dispatch push prefers existing brace-prefixed payload file over inline parsing', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-dispatch-push-brace-'));
const adbPath = path.join(tempDir, 'adb');
const argsLogPath = path.join(tempDir, 'args.log');
const payloadPath = path.join(tempDir, '{payload}.json');
await fs.writeFile(
adbPath,
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
'utf8',
);
await fs.chmod(adbPath, 0o755);
await fs.writeFile(payloadPath, '{"action":"com.example.app.PUSH","extras":{"title":"Hello"}}\n', 'utf8');

const previousPath = process.env.PATH;
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
process.env.PATH = `${tempDir}${path.delimiter}${previousPath ?? ''}`;
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;

try {
const result = await dispatchCommand(ANDROID_DEVICE, 'push', ['com.example.app', payloadPath]);
assert.deepEqual(result, {
platform: 'android',
package: 'com.example.app',
action: 'com.example.app.PUSH',
extrasCount: 1,
});
const args = (await fs.readFile(argsLogPath, 'utf8'))
.trim()
.split('\n')
.filter(Boolean);
assert.equal(args.includes('-a'), true);
assert.equal(args.includes('com.example.app.PUSH'), true);
assert.equal(args.includes('--es'), true);
assert.equal(args.includes('title'), true);
} finally {
process.env.PATH = previousPath;
if (previousArgsFile === undefined) {
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
} else {
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
}
await fs.rm(tempDir, { recursive: true, force: true });
}
});

1 change: 1 addition & 0 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
open: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
reinstall: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
press: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
push: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
record: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
screenshot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
scroll: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
Expand Down
63 changes: 62 additions & 1 deletion src/core/dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@ import {
backAndroid,
ensureAdb,
homeAndroid,
pushAndroidNotification,
setAndroidSetting,
snapshotAndroid,
} from '../platforms/android/index.ts';
import { listIosDevices } from '../platforms/ios/devices.ts';
import { getInteractor, type RunnerContext } from '../utils/interactors.ts';
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
import { setIosSetting } from '../platforms/ios/index.ts';
import { pushIosNotification, setIosSetting } from '../platforms/ios/index.ts';
import { isDeepLinkTarget } from './open-target.ts';
import type { RawSnapshotNode } from '../utils/snapshot.ts';
import type { CliFlags } from '../utils/command-schema.ts';
import { emitDiagnostic, withDiagnosticTimer } from '../utils/diagnostics.ts';
import { resolvePayloadInput } from '../utils/payload-input.ts';

export type BatchStep = {
command: string;
Expand Down Expand Up @@ -438,6 +440,28 @@ export async function dispatchCommand(
await setAndroidSetting(device, setting, state, appBundleId ?? context?.appBundleId, permissionOptions);
return { setting, state };
}
case 'push': {
const target = positionals[0]?.trim();
const payloadArg = positionals[1]?.trim();
if (!target || !payloadArg) {
throw new AppError(
'INVALID_ARGS',
'push requires <bundle|package> <payload.json|inline-json>',
);
}
const payload = await readNotificationPayload(payloadArg);
if (device.platform === 'ios') {
await pushIosNotification(device, target, payload);
return { platform: 'ios', bundleId: target };
}
const androidResult = await pushAndroidNotification(device, target, payload);
return {
platform: 'android',
package: target,
action: androidResult.action,
extrasCount: androidResult.extrasCount,
};
}
case 'snapshot': {
if (device.platform === 'ios') {
const result = (await withDiagnosticTimer(
Expand Down Expand Up @@ -525,6 +549,43 @@ function clampIosSwipeDuration(durationMs: number): number {
return Math.min(60, Math.max(16, Math.round(durationMs)));
}

async function readNotificationPayload(payloadArg: string): Promise<Record<string, unknown>> {
const source = resolvePayloadInput(payloadArg, { subject: 'Push payload' });
const payloadText = source.kind === 'inline'
? source.text
: await readPushPayloadFile(source.path);
try {
const parsed = JSON.parse(payloadText) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new AppError('INVALID_ARGS', 'push payload must be a JSON object');
}
return parsed as Record<string, unknown>;
} catch (error) {
if (error instanceof AppError) throw error;
throw new AppError('INVALID_ARGS', `Invalid push payload JSON: ${payloadArg}`);
}
}

async function readPushPayloadFile(payloadPath: string): Promise<string> {
try {
return await fs.readFile(payloadPath, 'utf8');
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
throw new AppError('INVALID_ARGS', `Push payload file not found: ${payloadPath}`);
}
if (code === 'EISDIR') {
throw new AppError('INVALID_ARGS', `Push payload path is not a file: ${payloadPath}`);
}
if (code === 'EACCES' || code === 'EPERM') {
throw new AppError('INVALID_ARGS', `Push payload file is not readable: ${payloadPath}`);
}
throw new AppError('COMMAND_FAILED', `Unable to read push payload file: ${payloadPath}`, {
cause: String(error),
});
}
}

export function shouldUseIosTapSeries(
device: DeviceInfo,
count: number,
Expand Down
Loading
Loading