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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ agent-device swipe 540 1500 540 500 120 --count 8 --pause-ms 30 --pattern ping-p
- `alert`, `wait`, `screenshot`
- `trace start`, `trace stop`
- `settings wifi|airplane|location on|off`
- `settings faceid match|nonmatch|enroll|unenroll` (iOS simulator only)
- `appstate`, `apps`, `devices`, `session list`

## iOS Snapshots
Expand Down
6 changes: 6 additions & 0 deletions skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,17 @@ agent-device settings airplane on
agent-device settings airplane off
agent-device settings location on
agent-device settings location off
agent-device settings faceid match
agent-device settings faceid nonmatch
agent-device settings faceid enroll
agent-device settings faceid unenroll
```

Note: iOS wifi/airplane toggles status bar indicators, not actual network state.
Airplane off clears status bar overrides.
iOS settings helpers are simulator-only.
Use `match`/`nonmatch` as the canonical command values.
Think of them as validate/invalidate outcomes when describing intent.

### App state

Expand Down
24 changes: 24 additions & 0 deletions src/daemon/handlers/__tests__/snapshot-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,27 @@ test('settings rejects unsupported iOS physical devices', async () => {
assert.match(response.error.message, /settings is not supported/i);
}
});

test('settings usage hint documents canonical faceid states', async () => {
const sessionStore = makeSessionStore();
const response = await handleSnapshotCommands({
req: {
token: 't',
session: 'default',
command: 'settings',
positionals: [],
flags: {},
},
sessionName: 'default',
logPath: '/tmp/daemon.log',
sessionStore,
});

assert.ok(response);
assert.equal(response?.ok, false);
if (response && !response.ok) {
assert.equal(response.error.code, 'INVALID_ARGS');
assert.match(response.error.message, /match\|nonmatch\|enroll\|unenroll/);
assert.doesNotMatch(response.error.message, /validate\|unvalidate/);
}
});
3 changes: 2 additions & 1 deletion src/daemon/handlers/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,8 @@ export async function handleSnapshotCommands(params: {
ok: false,
error: {
code: 'INVALID_ARGS',
message: 'settings requires <wifi|airplane|location> <on|off>',
message:
'settings requires <wifi|airplane|location> <on|off> or faceid <match|nonmatch|enroll|unenroll>',
},
};
}
Expand Down
87 changes: 86 additions & 1 deletion src/platforms/ios/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,14 @@ import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { listIosApps, openIosApp, parseIosDeviceAppsPayload, reinstallIosApp, resolveIosApp } from '../index.ts';
import {
listIosApps,
openIosApp,
parseIosDeviceAppsPayload,
reinstallIosApp,
resolveIosApp,
setIosSetting,
} from '../index.ts';
import type { DeviceInfo } from '../../../utils/device.ts';
import { AppError } from '../../../utils/errors.ts';

Expand Down Expand Up @@ -452,3 +459,81 @@ test('listIosApps applies user-installed filter on simulator', async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});

test('setIosSetting faceid match uses simctl biometric match', async () => {
await withMockedXcrun(
'agent-device-ios-faceid-match-test-',
`#!/bin/sh
printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then
cat <<'JSON'
{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}}
JSON
exit 0
fi
if [ "$1" = "simctl" ] && [ "$2" = "biometric" ] && [ "$3" = "sim-1" ] && [ "$4" = "match" ] && [ "$5" = "face" ]; then
exit 0
fi
echo "unexpected xcrun args: $@" >&2
exit 1
`,
async ({ argsLogPath }) => {
const device: DeviceInfo = {
platform: 'ios',
id: 'sim-1',
name: 'iPhone Sim',
kind: 'simulator',
booted: true,
};
await setIosSetting(device, 'faceid', 'match');
const lines = (await fs.readFile(argsLogPath, 'utf8'))
.trim()
.split('\n')
.filter(Boolean);
const logged = lines.join(' ');
assert.match(logged, /simctl biometric sim-1 match face/);
},
);
});

test('setIosSetting faceid retries alternate biometric argument order', async () => {
await withMockedXcrun(
'agent-device-ios-faceid-fallback-test-',
`#!/bin/sh
printf "__CMD__\\n" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
printf "%s\\n" "$@" >> "$AGENT_DEVICE_TEST_ARGS_FILE"
if [ "$1" = "simctl" ] && [ "$2" = "list" ] && [ "$3" = "devices" ] && [ "$4" = "-j" ]; then
cat <<'JSON'
{"devices":{"com.apple.CoreSimulator.SimRuntime.iOS-18-0":[{"udid":"sim-1","state":"Booted"}]}}
JSON
exit 0
fi
if [ "$1" = "simctl" ] && [ "$2" = "biometric" ] && [ "$3" = "sim-1" ] && [ "$4" = "match" ] && [ "$5" = "face" ]; then
exit 2
fi
if [ "$1" = "simctl" ] && [ "$2" = "biometric" ] && [ "$3" = "match" ] && [ "$4" = "sim-1" ] && [ "$5" = "face" ]; then
exit 0
fi
echo "unexpected xcrun args: $@" >&2
exit 1
`,
async ({ argsLogPath }) => {
const device: DeviceInfo = {
platform: 'ios',
id: 'sim-1',
name: 'iPhone Sim',
kind: 'simulator',
booted: true,
};
await setIosSetting(device, 'faceid', 'match');
const lines = (await fs.readFile(argsLogPath, 'utf8'))
.trim()
.split('\n')
.filter(Boolean);
const logged = lines.join(' ');
assert.match(logged, /simctl biometric sim-1 match face/);
assert.match(logged, /simctl biometric match sim-1 face/);
},
);
});
84 changes: 83 additions & 1 deletion src/platforms/ios/apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,15 +225,16 @@ export async function setIosSetting(
ensureSimulator(device, 'settings');
await ensureBootedSimulator(device);
const normalized = setting.toLowerCase();
const enabled = parseSettingState(state);

switch (normalized) {
case 'wifi': {
const enabled = parseSettingState(state);
const mode = enabled ? 'active' : 'failed';
await runCmd('xcrun', ['simctl', 'status_bar', device.id, 'override', '--wifiMode', mode]);
return;
}
case 'airplane': {
const enabled = parseSettingState(state);
if (enabled) {
await runCmd('xcrun', [
'simctl',
Expand All @@ -259,13 +260,19 @@ export async function setIosSetting(
return;
}
case 'location': {
const enabled = parseSettingState(state);
if (!appBundleId) {
throw new AppError('INVALID_ARGS', 'location setting requires an active app in session');
}
const action = enabled ? 'grant' : 'revoke';
await runCmd('xcrun', ['simctl', 'privacy', device.id, action, 'location', appBundleId]);
return;
}
case 'faceid': {
const action = parseFaceIdAction(state);
await runFaceIdSimctlCommand(device.id, action);
return;
}
default:
throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`);
}
Expand Down Expand Up @@ -328,6 +335,81 @@ function parseSettingState(state: string): boolean {
throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
}

type FaceIdAction = 'match' | 'nonmatch' | 'enroll' | 'unenroll';

function parseFaceIdAction(state: string): FaceIdAction {
const normalized = state.trim().toLowerCase();
if (normalized === 'match') return 'match';
if (normalized === 'nonmatch') return 'nonmatch';
if (normalized === 'enroll') return 'enroll';
if (normalized === 'unenroll') return 'unenroll';
throw new AppError(
'INVALID_ARGS',
`Invalid faceid state: ${state}. Use match|nonmatch|enroll|unenroll.`,
);
}

async function runFaceIdSimctlCommand(deviceId: string, action: FaceIdAction): Promise<void> {
const attempts = biometricCommandAttempts(deviceId, action);
const failures: Array<{ args: string[]; stderr: string; stdout: string; exitCode: number }> = [];

for (const args of attempts) {
const result = await runCmd('xcrun', args, { allowFailure: true });
if (result.exitCode === 0) return;
failures.push({
args,
stderr: result.stderr,
stdout: result.stdout,
exitCode: result.exitCode,
});
}

throw new AppError(
'COMMAND_FAILED',
'simctl biometric command failed. Ensure your Xcode Simulator runtime supports Face ID control.',
{
deviceId,
action,
attempts: failures.map((failure) => ({
args: failure.args.join(' '),
exitCode: failure.exitCode,
stderr: failure.stderr.slice(0, 400),
})),
},
);
}

function biometricCommandAttempts(deviceId: string, action: FaceIdAction): string[][] {
switch (action) {
case 'match':
return [
['simctl', 'biometric', deviceId, 'match', 'face'],
['simctl', 'biometric', 'match', deviceId, 'face'],
];
case 'nonmatch':
return [
['simctl', 'biometric', deviceId, 'nonmatch', 'face'],
['simctl', 'biometric', deviceId, 'nomatch', 'face'],
['simctl', 'biometric', 'nonmatch', deviceId, 'face'],
['simctl', 'biometric', 'nomatch', deviceId, 'face'],
];
case 'enroll':
return [
['simctl', 'biometric', deviceId, 'enroll', 'yes'],
['simctl', 'biometric', deviceId, 'enroll', '1'],
['simctl', 'biometric', 'enroll', deviceId, 'yes'],
['simctl', 'biometric', 'enroll', deviceId, '1'],
];
case 'unenroll':
return [
['simctl', 'biometric', deviceId, 'enroll', 'no'],
['simctl', 'biometric', deviceId, 'enroll', '0'],
['simctl', 'biometric', 'enroll', deviceId, 'no'],
['simctl', 'biometric', 'enroll', deviceId, '0'],
];
}
}

function isTransientSimulatorLaunchFailure(error: unknown): boolean {
if (!(error instanceof AppError)) return false;
if (error.code !== 'COMMAND_FAILED') return false;
Expand Down
8 changes: 8 additions & 0 deletions src/utils/__tests__/args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ test('usage includes swipe and press series options', () => {
assert.match(help, /swipe <x1> <y1> <x2> <y2>/);
assert.match(help, /--pattern one-way\|ping-pong/);
assert.match(help, /--interval-ms/);
assert.match(help, /settings <wifi\|airplane\|location\|faceid>/);
});

test('command usage shows command and global flags separately', () => {
Expand All @@ -280,3 +281,10 @@ test('command usage shows no command flags when unsupported', () => {
assert.doesNotMatch(help, /Command flags:/);
assert.match(help, /Global flags:/);
});

test('settings usage documents canonical faceid states', () => {
const help = usageForCommand('settings');
if (help === null) throw new Error('Expected command help text');
assert.match(help, /match\|nonmatch\|enroll\|unenroll/);
assert.doesNotMatch(help, /validate\|unvalidate/);
});
4 changes: 3 additions & 1 deletion src/utils/command-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,9 @@ export const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
allowedFlags: [...SELECTOR_SNAPSHOT_FLAGS],
},
settings: {
description: 'Toggle OS settings (simulators)',
usageOverride:
'settings <wifi|airplane|location|faceid> <on|off|match|nonmatch|enroll|unenroll>',
description: 'Toggle OS settings (simulators), including Face ID on iOS simulators',
positionalArgs: ['setting', 'state'],
allowedFlags: [],
},
Expand Down
6 changes: 6 additions & 0 deletions website/docs/docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,15 @@ agent-device settings airplane on
agent-device settings airplane off
agent-device settings location on
agent-device settings location off
agent-device settings faceid match
agent-device settings faceid nonmatch
agent-device settings faceid enroll
agent-device settings faceid unenroll
```

- iOS `settings` support is simulator-only.
- Face ID controls are iOS simulator-only.
- Use `match`/`nonmatch` to simulate valid/invalid Face ID outcomes.

## App state and app lists

Expand Down