Skip to content

Commit 4ec5deb

Browse files
committed
refactor: unify apple target handling and tvOS capability gates
1 parent e18e666 commit 4ec5deb

13 files changed

Lines changed: 125 additions & 55 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,7 @@ agent-device push com.example.app '{"action":"com.example.app.PUSH","extras":{"t
168168

169169
Payload notes:
170170
- iOS uses `xcrun simctl push <device> <bundle> <payload>` and requires APNs-style JSON object (for example `{"aps":{"alert":"..."}}`).
171+
- tvOS is not supported for `push`.
171172
- Android uses `adb shell am broadcast` with payload JSON shape:
172173
`{"action":"<intent-action>","receiver":"<optional component>","extras":{"key":"value","flag":true,"count":3}}`.
173174
- Android extras support string/boolean/number values.
@@ -222,6 +223,7 @@ TV targets:
222223
- AndroidTV app launch/app listing use TV launcher discovery (`LEANBACK_LAUNCHER`) and fallback component resolution when needed.
223224
- tvOS uses the same runner-driven interaction/snapshot flow as iOS (`snapshot`, `wait`, `press`, `fill`, `get`, `scroll`, `back`, `home`, `app-switcher`, `record`, and related selector flows).
224225
- tvOS back/home/app-switcher use Siri Remote semantics in the runner (`menu`, `home`, double-home).
226+
- tvOS does not support iOS simulator-only helpers `pinch`, `settings`, or `push`.
225227

226228
Examples:
227229
- `agent-device open YouTube --platform android --target tv`
@@ -230,7 +232,7 @@ Examples:
230232
- `agent-device screenshot ./apple-tv.png --platform ios --target tv`
231233

232234
Pinch:
233-
- `pinch` is supported on iOS simulators.
235+
- `pinch` is supported on iOS simulators (mobile target).
234236
- On Android, `pinch` currently returns `UNSUPPORTED_OPERATION` in the adb backend.
235237

236238
Swipe timing:
@@ -423,7 +425,7 @@ Environment selectors:
423425
- `AGENT_DEVICE_IOS_SIGNING_IDENTITY=<identity>` optional signing identity override.
424426
- `AGENT_DEVICE_IOS_PROVISIONING_PROFILE=<profile>` optional provisioning profile specifier for iOS device runner signing.
425427
- `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH=<path>` optional override for iOS runner derived data root. By default, simulator uses `~/.agent-device/ios-runner/derived` and physical device uses `~/.agent-device/ios-runner/derived/device`. If you set this override, use separate paths per kind to avoid simulator/device artifact collisions.
426-
- `AGENT_DEVICE_IOS_CLEAN_DERIVED=1` rebuild iOS runner artifacts from scratch for runtime daemon-triggered builds (`pnpm ad ...`) on the selected path. `pnpm build:xcuitest`/`pnpm build:all` already clear `~/.agent-device/ios-runner/derived/device` and do not require this variable. When `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is set, cleanup is blocked by default; set `AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1` only for trusted custom paths.
428+
- `AGENT_DEVICE_IOS_CLEAN_DERIVED=1` rebuild iOS runner artifacts from scratch for runtime daemon-triggered builds (`pnpm ad ...`) on the selected path. `pnpm build:xcuitest` (alias of `pnpm build:xcuitest:ios`), `pnpm build:xcuitest:tvos`, and `pnpm build:all` already clear their default derived paths and do not require this variable. When `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is set, cleanup is blocked by default; set `AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1` only for trusted custom paths.
427429

428430
Test screenshots are written to:
429431
- `test/screenshots/android-settings.png`

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 56 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -990,42 +990,62 @@ final class RunnerTests: XCTestCase {
990990
back.tap()
991991
return true
992992
}
993-
#if os(tvOS)
994-
XCUIRemote.shared.press(.menu)
995-
return true
996-
#endif
997-
return false
993+
return pressTvRemoteMenuIfAvailable()
998994
}
999995

1000996
private func performBackGesture(app: XCUIApplication) {
1001-
#if os(tvOS)
1002-
XCUIRemote.shared.press(.menu)
1003-
#else
997+
if pressTvRemoteMenuIfAvailable() {
998+
return
999+
}
10041000
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
10051001
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.05, dy: 0.5))
10061002
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5))
10071003
start.press(forDuration: 0.05, thenDragTo: end)
1008-
#endif
10091004
}
10101005

10111006
private func performAppSwitcherGesture(app: XCUIApplication) {
1012-
#if os(tvOS)
1013-
XCUIRemote.shared.press(.home)
1014-
usleep(120_000)
1015-
XCUIRemote.shared.press(.home)
1016-
#else
1007+
if performTvRemoteAppSwitcherIfAvailable() {
1008+
return
1009+
}
10171010
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
10181011
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.99))
10191012
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.7))
10201013
start.press(forDuration: 0.6, thenDragTo: end)
1021-
#endif
10221014
}
10231015

10241016
private func pressHomeButton() {
1017+
if pressTvRemoteHomeIfAvailable() {
1018+
return
1019+
}
1020+
XCUIDevice.shared.press(.home)
1021+
}
1022+
1023+
private func pressTvRemoteMenuIfAvailable() -> Bool {
1024+
#if os(tvOS)
1025+
XCUIRemote.shared.press(.menu)
1026+
return true
1027+
#else
1028+
return false
1029+
#endif
1030+
}
1031+
1032+
private func pressTvRemoteHomeIfAvailable() -> Bool {
10251033
#if os(tvOS)
10261034
XCUIRemote.shared.press(.home)
1035+
return true
10271036
#else
1028-
XCUIDevice.shared.press(.home)
1037+
return false
1038+
#endif
1039+
}
1040+
1041+
private func performTvRemoteAppSwitcherIfAvailable() -> Bool {
1042+
#if os(tvOS)
1043+
XCUIRemote.shared.press(.home)
1044+
usleep(120_000)
1045+
XCUIRemote.shared.press(.home)
1046+
return true
1047+
#else
1048+
return false
10291049
#endif
10301050
}
10311051

@@ -1131,19 +1151,9 @@ final class RunnerTests: XCTestCase {
11311151
}
11321152

11331153
private func swipe(app: XCUIApplication, direction: SwipeDirection) {
1134-
#if os(tvOS)
1135-
switch direction {
1136-
case .up:
1137-
XCUIRemote.shared.press(.up)
1138-
case .down:
1139-
XCUIRemote.shared.press(.down)
1140-
case .left:
1141-
XCUIRemote.shared.press(.left)
1142-
case .right:
1143-
XCUIRemote.shared.press(.right)
1154+
if performTvRemoteSwipeIfAvailable(direction: direction) {
1155+
return
11441156
}
1145-
return
1146-
#endif
11471157
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
11481158
let start = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2))
11491159
let end = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.8))
@@ -1162,6 +1172,24 @@ final class RunnerTests: XCTestCase {
11621172
}
11631173
}
11641174

1175+
private func performTvRemoteSwipeIfAvailable(direction: SwipeDirection) -> Bool {
1176+
#if os(tvOS)
1177+
switch direction {
1178+
case .up:
1179+
XCUIRemote.shared.press(.up)
1180+
case .down:
1181+
XCUIRemote.shared.press(.down)
1182+
case .left:
1183+
XCUIRemote.shared.press(.left)
1184+
case .right:
1185+
XCUIRemote.shared.press(.right)
1186+
}
1187+
return true
1188+
#else
1189+
return false
1190+
#endif
1191+
}
1192+
11651193
private func pinch(app: XCUIApplication, scale: Double, x: Double?, y: Double?) {
11661194
let target = app.windows.firstMatch.exists ? app.windows.firstMatch : app
11671195

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@
1616
"build": "rslib build",
1717
"clean:daemon": "rm -f ~/.agent-device/daemon.json && rm -f ~/.agent-device/daemon.lock",
1818
"build:node": "pnpm build && pnpm clean:daemon",
19-
"build:xcuitest": "rm -rf ~/.agent-device/ios-runner/derived/device && xcodebuild build-for-testing -project ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -destination \"generic/platform=iOS Simulator\" -derivedDataPath ~/.agent-device/ios-runner/derived",
19+
"build:xcuitest": "pnpm build:xcuitest:ios",
20+
"build:xcuitest:ios": "rm -rf ~/.agent-device/ios-runner/derived/device && xcodebuild build-for-testing -project ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -destination \"generic/platform=iOS Simulator\" -derivedDataPath ~/.agent-device/ios-runner/derived",
21+
"build:xcuitest:tvos": "rm -rf ~/.agent-device/ios-runner/derived/tvos && xcodebuild build-for-testing -project ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj -scheme AgentDeviceRunner -destination \"generic/platform=tvOS Simulator\" -derivedDataPath ~/.agent-device/ios-runner/derived/tvos",
2022
"build:all": "pnpm build:node && pnpm build:xcuitest",
2123
"ad": "node bin/agent-device.mjs",
2224
"format": "prettier --write .",

skills/agent-device/SKILL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ TV quick reference:
7474
- AndroidTV: `open`/`apps` use TV launcher discovery automatically.
7575
- tvOS: runner-driven interactions and snapshots are supported (`snapshot`, `wait`, `press`, `fill`, `get`, `scroll`, `back`, `home`, `app-switcher`, `record` and related selector flows).
7676
- tvOS `back`/`home`/`app-switcher` map to Siri Remote actions (`menu`, `home`, double-home) in the runner.
77+
- tvOS does not support iOS simulator-only helpers `pinch`, `settings`, or `push`.
7778

7879
### Snapshot and targeting
7980

@@ -115,6 +116,7 @@ agent-device batch --steps-file /tmp/batch-steps.json --json
115116
- Use `fill` for clear-then-type semantics; use `type` for focused append typing.
116117
- iOS `appstate` is session-scoped; Android `appstate` is live foreground state.
117118
- iOS settings helpers are simulator-only; use `appearance light|dark|toggle` and faceid `match|nonmatch|enroll|unenroll`.
119+
- tvOS is excluded from iOS simulator-only helpers (`settings`, `push`, `pinch`).
118120
- For AndroidTV/tvOS selection, always pair `--target` with `--platform` (`ios`, `android`, or `apple` alias); target-only selection is invalid.
119121
- `push` simulates notification delivery:
120122
- iOS simulator uses APNs-style payload JSON.

src/core/__tests__/capabilities.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ test('tvOS follows iOS capability matrix by device kind', () => {
113113
for (const cmd of ['snapshot', 'wait', 'press', 'get', 'fill', 'scroll', 'back', 'home', 'app-switcher', 'record']) {
114114
assert.equal(isCommandSupportedOnDevice(cmd, tvOsSimulator), true, `${cmd} on tvOS`);
115115
}
116+
for (const cmd of ['pinch', 'push', 'settings']) {
117+
assert.equal(isCommandSupportedOnDevice(cmd, tvOsSimulator), false, `${cmd} unsupported on tvOS`);
118+
}
116119
});
117120

118121
test('unknown commands default to supported', () => {

src/core/capabilities.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,17 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
4646
wait: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
4747
};
4848

49+
const TVOS_UNSUPPORTED_COMMANDS = new Set<string>([
50+
// tvOS does not expose these iOS simulator helpers.
51+
'pinch',
52+
'push',
53+
'settings',
54+
]);
55+
4956
export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean {
57+
if (device.platform === 'ios' && device.target === 'tv' && TVOS_UNSUPPORTED_COMMANDS.has(command)) {
58+
return false;
59+
}
5060
const capability = COMMAND_CAPABILITY_MATRIX[command];
5161
if (!capability) return true;
5262
const byPlatform = capability[device.platform];

src/core/dispatch.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { promises as fs } from 'node:fs';
22
import pathModule from 'node:path';
33
import { AppError } from '../utils/errors.ts';
4-
import { selectDevice, type DeviceInfo } from '../utils/device.ts';
4+
import { normalizePlatformSelector, selectDevice, type DeviceInfo } from '../utils/device.ts';
55
import { listAndroidDevices } from '../platforms/android/devices.ts';
66
import {
77
appSwitcherAndroid,
@@ -33,10 +33,10 @@ export type CommandFlags = Omit<CliFlags, 'json' | 'help' | 'version' | 'batchSt
3333
};
3434

3535
export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceInfo> {
36+
const normalizedPlatform = normalizePlatformSelector(flags.platform);
3637
return await withDiagnosticTimer(
3738
'resolve_target_device',
3839
async () => {
39-
const normalizedPlatform = flags.platform === 'apple' ? 'ios' : flags.platform;
4040
const selector = {
4141
platform: normalizedPlatform,
4242
target: flags.target,
@@ -76,7 +76,7 @@ export async function resolveTargetDevice(flags: CommandFlags): Promise<DeviceIn
7676
return await selectDevice(devices, selector);
7777
},
7878
{
79-
platform: flags.platform === 'apple' ? 'ios' : flags.platform,
79+
platform: normalizedPlatform,
8080
target: flags.target,
8181
},
8282
);

src/daemon/handlers/session.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
1010
import { isDeepLinkTarget, resolveIosDeviceDeepLinkBundleId } from '../../core/open-target.ts';
1111
import { AppError, asAppError, normalizeError } from '../../utils/errors.ts';
12-
import type { DeviceInfo } from '../../utils/device.ts';
12+
import { normalizePlatformSelector, type DeviceInfo } from '../../utils/device.ts';
1313
import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts';
1414
import { SessionStore } from '../session-store.ts';
1515
import { contextFromFlags } from '../context.ts';
@@ -54,11 +54,6 @@ const REPLAY_PARENT_FLAG_KEYS: Array<keyof CommandFlags> = ['platform', 'target'
5454
const LOG_ACTIONS = ['path', 'start', 'stop', 'doctor', 'mark', 'clear'] as const;
5555
const LOG_ACTIONS_MESSAGE = `logs requires ${LOG_ACTIONS.slice(0, -1).join(', ')}, or ${LOG_ACTIONS.at(-1)}`;
5656

57-
function normalizePlatformSelector(platform: CommandFlags['platform'] | undefined): DeviceInfo['platform'] | undefined {
58-
if (platform === 'apple') return 'ios';
59-
return platform;
60-
}
61-
6257
function requireSessionOrExplicitSelector(
6358
command: string,
6459
session: SessionState | undefined,

src/daemon/session-selector.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
11
import { AppError } from '../utils/errors.ts';
22
import type { CommandFlags } from '../core/dispatch.ts';
33
import type { SessionState } from './types.ts';
4-
5-
function normalizePlatformSelector(platform: CommandFlags['platform'] | undefined): SessionState['device']['platform'] | undefined {
6-
if (platform === 'apple') return 'ios';
7-
return platform;
8-
}
4+
import { normalizePlatformSelector } from '../utils/device.ts';
95

106
export function assertSessionSelectorMatches(
117
session: SessionState,

src/platforms/ios/runner-client.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url';
55
import { AppError } from '../../utils/errors.ts';
66
import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts';
77
import { Deadline, isEnvTruthy, retryWithPolicy, withRetry } from '../../utils/retry.ts';
8-
import type { DeviceInfo } from '../../utils/device.ts';
8+
import { resolveApplePlatformName, type DeviceInfo } from '../../utils/device.ts';
99
import { withKeyedLock } from '../../utils/keyed-lock.ts';
1010
import { isProcessAlive } from '../../utils/process-identity.ts';
1111
import net from 'node:net';
@@ -506,34 +506,35 @@ function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
506506
return path.resolve(override);
507507
}
508508
if (kind === 'simulator') {
509-
// Keep simulator runtime path aligned with pnpm build:xcuitest/build:all.
509+
// Keep simulator runtime path aligned with pnpm build:xcuitest:ios/build:all.
510510
return path.join(RUNNER_DERIVED_ROOT, 'derived');
511511
}
512512
return path.join(RUNNER_DERIVED_ROOT, 'derived', kind);
513513
}
514514

515515
export function resolveRunnerDestination(device: DeviceInfo): string {
516-
if (device.platform !== 'ios') {
517-
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
518-
}
519-
const platformName = device.target === 'tv' ? 'tvOS' : 'iOS';
516+
const platformName = resolveRunnerPlatformName(device);
520517
if (device.kind === 'simulator') {
521518
return `platform=${platformName} Simulator,id=${device.id}`;
522519
}
523520
return `platform=${platformName},id=${device.id}`;
524521
}
525522

526523
export function resolveRunnerBuildDestination(device: DeviceInfo): string {
527-
if (device.platform !== 'ios') {
528-
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
529-
}
530-
const platformName = device.target === 'tv' ? 'tvOS' : 'iOS';
524+
const platformName = resolveRunnerPlatformName(device);
531525
if (device.kind === 'simulator') {
532526
return `platform=${platformName} Simulator,id=${device.id}`;
533527
}
534528
return `generic/platform=${platformName}`;
535529
}
536530

531+
function resolveRunnerPlatformName(device: DeviceInfo): 'iOS' | 'tvOS' {
532+
if (device.platform !== 'ios') {
533+
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
534+
}
535+
return resolveApplePlatformName(device.target);
536+
}
537+
537538
function ensureBootedIfNeeded(device: DeviceInfo): Promise<void> {
538539
if (device.kind !== 'simulator') {
539540
return Promise.resolve();

0 commit comments

Comments
 (0)