Skip to content
Open
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 @@ -45,6 +45,10 @@
such as `proxy`, 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
3 changes: 3 additions & 0 deletions src/cli/parser/__tests__/cli-help-topics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,9 @@ 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, 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 @@ -799,8 +799,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, 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
10 changes: 10 additions & 0 deletions src/core/__tests__/dispatch-resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,16 @@ test('resolveTargetDevice uses injected device inventory without local discovery
assert.equal(mockListAppleDevices.mock.calls.length, 0);
});

test('resolveTargetDevice preserves physical-device backend evidence from injected inventory', async () => {
const result = await withDeviceInventoryProvider(
async () => [{ ...physical, iosPhysicalDeviceBackend: 'xctest' }],
async () => await resolveTargetDevice({ platform: 'ios', udid: physical.id }),
);

assert.equal(result.iosPhysicalDeviceBackend, 'xctest');
assert.equal(mockListAppleDevices.mock.calls.length, 0);
});

test('resolveTargetDevice preserves Apple simulator preference with injected inventory', async () => {
mockFindBootableIosSimulator.mockResolvedValue(simulator);

Expand Down
57 changes: 1 addition & 56 deletions src/daemon/__tests__/device-ready.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
import type { DeviceInfo } from '../../kernel/device.ts';

Expand All @@ -19,12 +18,7 @@ import { runCmd } from '../../utils/exec.ts';
import { waitForAndroidBoot } from '../../platforms/android/devices.ts';
import { ensureBootedSimulator } from '../../platforms/apple/core/simulator.ts';
import { ANDROID_EMULATOR, IOS_DEVICE, IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts';
import {
DEVICE_READY_CACHE_TTL_MS,
ensureDeviceReady,
parseIosReadyPayload,
resolveIosReadyHint,
} from '../device-ready.ts';
import { DEVICE_READY_CACHE_TTL_MS, ensureDeviceReady } from '../device-ready.ts';

const mockRunCmd = vi.mocked(runCmd);
const mockEnsureBootedSimulator = vi.mocked(ensureBootedSimulator);
Expand Down Expand Up @@ -130,52 +124,3 @@ test('ensureDeviceReady does not cache failed readiness checks', async () => {

expect(mockEnsureBootedSimulator).toHaveBeenCalledTimes(2);
});

test('parseIosReadyPayload reads tunnelState from direct connectionProperties', () => {
const parsed = parseIosReadyPayload({
result: {
connectionProperties: {
tunnelState: 'connected',
},
},
});
assert.equal(parsed.tunnelState, 'connected');
});

test('parseIosReadyPayload reads tunnelState from nested device connectionProperties', () => {
const parsed = parseIosReadyPayload({
result: {
device: {
connectionProperties: {
tunnelState: 'connecting',
},
},
},
});
assert.equal(parsed.tunnelState, 'connecting');
});

test('parseIosReadyPayload returns empty payload for malformed input', () => {
assert.deepEqual(parseIosReadyPayload(null), {});
assert.deepEqual(parseIosReadyPayload({}), {});
assert.deepEqual(
parseIosReadyPayload({
result: { connectionProperties: { tunnelState: 123 } },
}),
{},
);
});

test('resolveIosReadyHint maps known connection errors', () => {
const connecting = resolveIosReadyHint('', 'Device is busy (Connecting to iPhone)');
assert.match(connecting, /still connecting/i);

const coreDeviceTimeout = resolveIosReadyHint('CoreDeviceService timed out', '');
assert.match(coreDeviceTimeout, /coredevice service/i);
});

test('resolveIosReadyHint falls back to generic guidance', () => {
const hint = resolveIosReadyHint('unexpected failure', '');
assert.match(hint, /unlocked/i);
assert.match(hint, /xcode/i);
});
Loading
Loading