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
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ task touches:
cloud bridge, or `limrun`.
- Runner/process lease: backend helper mutual-exclusion guard for platform runners or tools; it is
not the remote client ownership boundary.
- iOS physical-device control: Apple-local module selected from discovery evidence. CoreDevice
devices retain the `devicectl` controller; devices found only by `xctrace` use the XCTest
controller for readiness, app activation/termination, and cable-bound usbmux runner transport
without claiming unsupported app inventory or installation capabilities.
- Host process primitive: low-level host PID helpers in `src/utils/host-process.ts` for liveness,
start-time/command reads, process listing, process-tree expansion, PID de-duplication, and
best-effort signaling. It must not own domain cleanup policy such as browser ownership markers,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1362,6 +1362,28 @@ extension RunnerTests {
}
case .uptime:
return executeUptime()
case .activate:
guard
let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleId.isEmpty
else {
return Response(ok: false, error: ErrorPayload(message: "activate requires appBundleId"))
}
// prepareActiveCommandContext already activated this bundle. Keep this case as the
// explicit acknowledgement after that preflight, not as a second activation.
return Response(ok: true, data: DataPayload(message: "app activated"))
case .terminate:
guard
let bundleId = command.appBundleId?.trimmingCharacters(in: .whitespacesAndNewlines),
!bundleId.isEmpty
else {
return Response(ok: false, error: ErrorPayload(message: "terminate requires appBundleId"))
}
XCUIApplication(bundleIdentifier: bundleId).terminate()
if currentBundleId == bundleId {
invalidateCachedTarget(reason: "target_terminated")
}
return Response(ok: true, data: DataPayload(message: "app terminated"))
default:
break
}
Expand Down Expand Up @@ -1451,7 +1473,7 @@ extension RunnerTests {
) throws -> Response {
var activeApp = activeApp
switch command.command {
case .status, .targetReset, .shutdown, .recordStart, .recordStop, .uptime:
case .status, .activate, .terminate, .targetReset, .shutdown, .recordStart, .recordStop, .uptime:
return Response(
ok: false,
error: ErrorPayload(
Expand Down Expand Up @@ -1838,6 +1860,12 @@ extension RunnerTests {
guard let pngData = runnerPngData(for: screenshot.image) else {
return Response(ok: false, error: ErrorPayload(message: "Failed to encode screenshot as PNG"))
}
if command.inlineScreenshot == true {
return Response(
ok: true,
data: DataPayload(imageBase64: pngData.base64EncodedString())
)
}
let fileName = "screenshot-\(Int(Date().timeIntervalSince1970 * 1000)).png"
let filePath = (NSTemporaryDirectory() as NSString).appendingPathComponent(fileName)
do {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ final class RunnerCommandJournal {
.remotePress, .type, .swipe, .scroll, .desktopScroll, .findText, .querySelector, .readText, .back,
.backInApp, .backSystem, .home, .rotate, .appSwitcher, .keyboardDismiss, .keyboardReturn,
.alert, .sequence, .gesture, .gestureViewport, .recordStart, .recordStop,
.status, .uptime, .targetReset, .shutdown:
.status, .uptime, .activate, .terminate, .targetReset, .shutdown:
return true
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ enum CommandType: String, Codable {
case recordStop
case status
case uptime
case activate
case terminate
case targetReset
case shutdown
}
Expand Down Expand Up @@ -90,7 +92,7 @@ extension CommandType {
return CommandTraits(isInteraction: false, readOnly: .conditional, isLifecycle: false)

// Runner-lifecycle commands: skip the app-activation preflight.
case .recordStop, .uptime, .targetReset, .shutdown:
case .recordStop, .uptime, .terminate, .targetReset, .shutdown:
return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: true)

case .status:
Expand All @@ -101,7 +103,7 @@ extension CommandType {
// guard interacts with bespoke macOS activation, so classifying it needs a macOS smoke
// check first (tracked as a follow-up). Also preserved: querySelector is NOT read-only;
// recordStart is NOT a lifecycle command; home/alert remain non-interaction by design.
case .mouseClick, .querySelector, .home, .recordStart:
case .mouseClick, .querySelector, .home, .recordStart, .activate:
return CommandTraits(isInteraction: false, readOnly: .never, isLifecycle: false)
}
}
Expand Down Expand Up @@ -140,6 +142,7 @@ struct Command: Codable {
let scope: String?
let raw: Bool?
let fullscreen: Bool?
let inlineScreenshot: Bool?
let synthesized: Bool?
let steps: [SequenceStep]?
}
Expand Down Expand Up @@ -227,6 +230,7 @@ extension Response {

struct DataPayload: Codable {
let message: String?
let imageBase64: String?
let text: String?
let found: Bool?
let items: [String]?
Expand Down Expand Up @@ -266,6 +270,7 @@ struct DataPayload: Codable {

init(
message: String? = nil,
imageBase64: String? = nil,
text: String? = nil,
found: Bool? = nil,
items: [String]? = nil,
Expand Down Expand Up @@ -304,6 +309,7 @@ struct DataPayload: Codable {
sequenceResults: [SequenceStepResult]? = nil
) {
self.message = message
self.imageBase64 = imageBase64
self.text = text
self.found = found
self.items = items
Expand Down
6 changes: 6 additions & 0 deletions src/cli/parser/__tests__/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,12 @@ test('usageForCommand resolves physical-device help topic', async () => {
assert.match(help, /AGENT_DEVICE_IOS_TEAM_ID=ABCDE12345/);
assert.match(help, /AGENT_DEVICE_IOS_BUNDLE_ID=com\.yourname\.agentdevice\.runner/);
assert.match(help, /profile name\/specifier, not a file path/);
assert.match(help, /Older devices visible only to xctrace use the XCTest backend automatically/);
assert.match(help, /runner commands travel through macOS usbmuxd/);
assert.match(
help,
/app inventory, install\/reinstall, logs, performance sampling, recording, deep links, and launch arguments/,
);
});

test('usageForCommand resolves manual QA help topic', async () => {
Expand Down
5 changes: 4 additions & 1 deletion src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -824,8 +824,11 @@ Discovery:
Use --device <name-or-udid> only when multiple devices are present.

iOS physical-device prerequisites:
Xcode and xcrun devicectl must be available from the selected Xcode.
Xcode, xcrun xcdevice, and xcrun xctrace must be available from the selected Xcode.
The device must be paired/trusted, connected, unlocked when needed, and have Developer Mode enabled.
Modern devices visible to devicectl use CoreDevice. Older devices visible only to xctrace use the XCTest backend automatically.
XCTest-backed devices must already have the target app installed and should be opened by bundle ID; app inventory, install/reinstall, logs, performance sampling, recording, deep links, and launch arguments require CoreDevice.
XCTest-backed runner commands travel through macOS usbmuxd; keep the trusted device connected by cable.
The AgentDeviceRunner XCTest host must be signed before commands can run on a physical device.
Start with Automatic Signing and only these env vars:
AGENT_DEVICE_IOS_TEAM_ID=ABCDE12345
Expand Down
41 changes: 32 additions & 9 deletions src/contracts/__tests__/apple-os-capability-table-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,16 @@ import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-
// (`apple-os-capabilities.ts`). This test pins that the swap is byte-for-byte
// behaviorless: the closures now living on the Apple plugin return an identical
// boolean / identical hint STRING to an INDEPENDENT verbatim copy of the ORIGINAL
// predicates, across the full {command x sample-device} matrix — real discovery shapes
// predicates plus intentional backend-specific gates, across the full
// {command x sample-device} matrix — real discovery shapes
// for iOS/iPadOS/tvOS/macOS/visionOS plus the exhaustive synthetic cross-product.

registerBuiltinPlatformPlugins();

// ---------------------------------------------------------------------------
// Independent VERBATIM copies of the ORIGINAL command-facet predicates (before the
// table read), kept BYTE-FOR-BYTE by hand so this oracle stays INDEPENDENT of the
// table it pins (mirrors the copy in capability-plugin-routing-parity.test.ts).
// Independent copies of the command capability contracts, including the original
// AppleOS predicates and current backend-specific gates. This oracle stays independent
// of the table it pins (mirrors capability-plugin-routing-parity.test.ts).
// ---------------------------------------------------------------------------
const isNotMacOs = (device: DeviceInfo): boolean => !isMacOs(device);
const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean =>
Expand All @@ -55,11 +56,25 @@ const supportsAndroidOrIosNonTv = (device: DeviceInfo): boolean =>
const supportsTvRemote = (device: DeviceInfo): boolean =>
(device.platform === 'android' && device.target === 'tv') ||
(isIosFamily(device) && device.target === 'tv');
const supportsCoreDevicePhysicalOperation = (device: DeviceInfo): boolean =>
device.platform !== 'apple' ||
device.kind !== 'device' ||
device.iosPhysicalDeviceBackend !== 'xctest';
const supportsAppInstallation = (device: DeviceInfo): boolean =>
isNotMacOs(device) && supportsCoreDevicePhysicalOperation(device);
const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undefined =>
supportsCoreDevicePhysicalOperation(device)
? undefined
: 'This command requires a CoreDevice-backed physical iOS device. The selected XCTest backend supports open, close, interactions, snapshots, and screenshots.';
const SUPPORTS_REF: Record<string, (device: DeviceInfo) => boolean> = {
boot: isNotMacOs,
install: isNotMacOs,
reinstall: isNotMacOs,
'install-from-source': isNotMacOs,
apps: supportsCoreDevicePhysicalOperation,
install: supportsAppInstallation,
reinstall: supportsAppInstallation,
'install-from-source': supportsAppInstallation,
logs: supportsCoreDevicePhysicalOperation,
perf: supportsCoreDevicePhysicalOperation,
record: supportsCoreDevicePhysicalOperation,
push: isNotMacOs,
home: isNotMacOs,
'app-switcher': isNotMacOs,
Expand All @@ -82,6 +97,13 @@ const SUPPORTS_REF: Record<string, (device: DeviceInfo) => boolean> = {
audio: isAudioProbeSupportedDevice,
};
const HINT_REF: Record<string, (device: DeviceInfo) => string | undefined> = {
apps: coreDeviceOnlyPhysicalOperationHint,
install: coreDeviceOnlyPhysicalOperationHint,
reinstall: coreDeviceOnlyPhysicalOperationHint,
'install-from-source': coreDeviceOnlyPhysicalOperationHint,
logs: coreDeviceOnlyPhysicalOperationHint,
perf: coreDeviceOnlyPhysicalOperationHint,
record: coreDeviceOnlyPhysicalOperationHint,
'tv-remote': (device) => {
if (device.platform === 'android') {
return device.target === 'tv'
Expand Down Expand Up @@ -143,6 +165,7 @@ const SAMPLE_DEVICES: DeviceInfo[] = [
LINUX_DEVICE,
WEB_DESKTOP_DEVICE,
...APPLE_FIXTURES,
{ ...IOS_DEVICE, id: 'xctest-ios-device', iosPhysicalDeviceBackend: 'xctest' },
...APPLE_FIXTURES.flatMap(withKinds),
...buildSyntheticMatrix(),
];
Expand All @@ -167,7 +190,7 @@ test('resolveDeviceAppleOs prefers the stored discriminant, else infers from tar
assert.equal(resolveDeviceAppleOs(MACOS_DEVICE), 'macos');
});

test('table-driven Apple supports() closures are byte-for-byte the verbatim originals', () => {
test('table-driven Apple supports() closures match the independent command contracts', () => {
const appleSupports = getPlugin('apple').capability.supportsByDefault;
assert.ok(appleSupports, 'the Apple plugin carries supportsByDefault');
// Every command that had an original predicate must still carry one, keyed the same.
Expand All @@ -185,7 +208,7 @@ test('table-driven Apple supports() closures are byte-for-byte the verbatim orig
}
});

test('table-driven Apple unsupportedHint() closures are byte-for-byte the verbatim originals', () => {
test('table-driven Apple unsupportedHint() closures match the independent contracts', () => {
const appleHints = getPlugin('apple').capability.unsupportedHintByDefault;
assert.ok(appleHints, 'the Apple plugin carries unsupportedHintByDefault');
assert.deepEqual(Object.keys(appleHints).sort(), Object.keys(HINT_REF).sort());
Expand Down
31 changes: 30 additions & 1 deletion src/core/__tests__/capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { isCommandSupportedOnDevice } from '../capabilities.ts';
import { isCommandSupportedOnDevice, unsupportedHintForDevice } from '../capabilities.ts';
import { matchesPlatformSelector, type DeviceInfo } from '../../kernel/device.ts';
import { WEB_DESKTOP_DEVICE } from '../../__tests__/test-utils/index.ts';

Expand All @@ -18,6 +18,12 @@ const iosDevice: DeviceInfo = {
kind: 'device',
};

const xctestIosDevice: DeviceInfo = {
...iosDevice,
id: 'xctest-dev-1',
iosPhysicalDeviceBackend: 'xctest',
};

const iPadOsDevice: DeviceInfo = {
platform: 'apple',
appleOs: 'ipados',
Expand Down Expand Up @@ -215,6 +221,29 @@ test('core commands support iOS simulator, iOS device, and Android', () => {
);
});

test('capabilities reject CoreDevice-only commands for XCTest-backed devices', () => {
const coreDeviceOnlyCommands = [
'apps',
'install',
'install-from-source',
'logs',
'perf',
'record',
'reinstall',
];
assertCommandSupport(coreDeviceOnlyCommands, [
{ device: iosDevice, expected: true, label: 'on CoreDevice' },
{ device: xctestIosDevice, expected: false, label: 'on XCTest backend' },
]);
for (const command of coreDeviceOnlyCommands) {
assert.match(unsupportedHintForDevice(command, xctestIosDevice) ?? '', /CoreDevice-backed/);
}
assertCommandSupport(
['close', 'open', 'screenshot', 'snapshot'],
[{ device: xctestIosDevice, expected: true, label: 'on XCTest backend' }],
);
});

test('macOS supports the Apple runner interaction core but excludes mobile-only commands', () => {
assertCommandSupport(
[
Expand Down
43 changes: 32 additions & 11 deletions src/core/__tests__/capability-plugin-routing-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,17 +38,17 @@ import { registerBuiltinPlatformPlugins } from '../interactors/register-builtins
// flows through the PlatformPlugin registry. `CAPABILITY_BUCKET_BY_PLATFORM`
// is kept here as an independent hardcoded oracle, so a plugin-bucket
// regression fails this test.
// (b.2) the per-command `supports()` / `unsupportedHint()` device closures were
// RELOCATED VERBATIM off the command-descriptor facet onto the owning
// (b.2) the per-command `supports()` / `unsupportedHint()` device closures live on
// the owning
// PlatformPlugin's `capability.supportsByDefault` / `unsupportedHintByDefault`
// (ADR-0009: relocate, never flatten). Most such closures are Apple
// family gates; audio is also an Android gate because Android emulator capture
// depends on the macOS host backend. The independent VERBATIM copies below
// depends on the macOS host backend. The independent copies below
// are the oracle: they pin (a) that production admission (`isCommand
// SupportedOnDevice`) and hint output (`unsupportedHintForDevice`) are unchanged
// across the full {platform x command x device-kind x target} matrix, and (b)
// that the closures now living on the Apple plugin are byte-for-byte behaviorally
// identical to the originals across the sample-device matrix.
// identical to the intended command contracts across the sample-device matrix.

registerBuiltinPlatformPlugins();

Expand Down Expand Up @@ -82,6 +82,7 @@ const SAMPLE_DEVICES: DeviceInfo[] = [
ANDROID_EMULATOR,
ANDROID_TV_DEVICE,
IOS_DEVICE,
{ ...IOS_DEVICE, id: 'xctest-ios-device', iosPhysicalDeviceBackend: 'xctest' },
IOS_SIMULATOR,
// The appleOs-bearing iPadOS/visionOS shapes exercise the per-AppleOS table's
// stored-`appleOs` read path (step d.5); the target-based oracle below still agrees
Expand All @@ -96,9 +97,8 @@ const SAMPLE_DEVICES: DeviceInfo[] = [
];

// ---------------------------------------------------------------------------
// (b.2) Independent VERBATIM copies of the per-command supports()/unsupportedHint()
// closures (src/core/command-descriptor/registry.ts). Kept BYTE-FOR-BYTE in sync by
// hand so this oracle stays INDEPENDENT of the descriptor it pins.
// (b.2) Independent copies of the per-command supports()/unsupportedHint()
// contracts. Kept in sync by hand so this oracle stays independent of production.
// ---------------------------------------------------------------------------
const isNotMacOs = (device: DeviceInfo): boolean => !isMacOs(device);
const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean =>
Expand All @@ -117,14 +117,28 @@ const supportsHostAudioProbe = (device: DeviceInfo): boolean =>
(isMacOs(device) ||
(isIosFamily(device) && device.kind === 'simulator') ||
(device.platform === 'android' && device.kind === 'emulator')));
const supportsCoreDevicePhysicalOperation = (device: DeviceInfo): boolean =>
device.platform !== 'apple' ||
device.kind !== 'device' ||
device.iosPhysicalDeviceBackend !== 'xctest';
const supportsAppInstallation = (device: DeviceInfo): boolean =>
isNotMacOs(device) && supportsCoreDevicePhysicalOperation(device);
const coreDeviceOnlyPhysicalOperationHint = (device: DeviceInfo): string | undefined =>
supportsCoreDevicePhysicalOperation(device)
? undefined
: 'This command requires a CoreDevice-backed physical iOS device. The selected XCTest backend supports open, close, interactions, snapshots, and screenshots.';
// Which commands carry which supports()/unsupportedHint() closure today. The
// end-to-end assertions cross-check this map against production: a command that
// gains/loses a closure (or whose closure body changes) breaks parity.
const SUPPORTS_REF: Record<string, (device: DeviceInfo) => boolean> = {
boot: isNotMacOs,
install: isNotMacOs,
reinstall: isNotMacOs,
'install-from-source': isNotMacOs,
apps: supportsCoreDevicePhysicalOperation,
install: supportsAppInstallation,
reinstall: supportsAppInstallation,
'install-from-source': supportsAppInstallation,
logs: supportsCoreDevicePhysicalOperation,
perf: supportsCoreDevicePhysicalOperation,
record: supportsCoreDevicePhysicalOperation,
push: isNotMacOs,
home: isNotMacOs,
'app-switcher': isNotMacOs,
Expand All @@ -143,6 +157,13 @@ const SUPPORTS_REF: Record<string, (device: DeviceInfo) => boolean> = {
audio: supportsHostAudioProbe,
};
const HINT_REF: Record<string, (device: DeviceInfo) => string | undefined> = {
apps: coreDeviceOnlyPhysicalOperationHint,
install: coreDeviceOnlyPhysicalOperationHint,
reinstall: coreDeviceOnlyPhysicalOperationHint,
'install-from-source': coreDeviceOnlyPhysicalOperationHint,
logs: coreDeviceOnlyPhysicalOperationHint,
perf: coreDeviceOnlyPhysicalOperationHint,
record: coreDeviceOnlyPhysicalOperationHint,
'tv-remote': (device) => {
if (device.platform === 'android') {
return device.target === 'tv'
Expand Down Expand Up @@ -253,7 +274,7 @@ test('(b.2) the Apple plugin carries exactly the relocated supports/hint closure
assert.equal(getPlugin('apple').capability, getPlugin('apple').capability);
});

test('(b.2) the relocated Apple closures are byte-for-byte the verbatim originals', () => {
test('(b.2) the relocated Apple closures match the independent command contracts', () => {
// Closure-equivalence: for every command x sample-device, the closure now living on
// the Apple plugin returns an identical boolean / identical hint STRING to the
// independent verbatim copy of the original command-facet closure.
Expand Down
Loading
Loading