Skip to content

Commit 256887f

Browse files
authored
feat(apple): injectable Apple runner transport seam for provider interactors (#1389)
* feat(apple): injectable runner transport seam for provider interactors (#1297) createAppleInteractor now accepts an optional AppleRunnerProvider (or bare command executor). When injected, every runner-command method runs inside withAppleRunnerProvider scope, so the shared selector/tap/fill/scroll/ snapshot stack rides the provider transport instead of local XCTest — mirroring createAndroidInteractor's AndroidAdbProvider parameter. Methods backed by local Apple tooling (simctl/devicectl: open, openDevice, close, screenshot, clipboard, setSetting) fail fast with UNSUPPORTED_OPERATION in provider mode instead of silently running local tooling against a remote device; provider sessions compose their own implementations on top. Local behavior is unchanged: without the new parameter the factory returns the same interactor as before, and daemon-owned sessions keep resolving the local XCTest runtime. * fix(daemon): request-boundary provider runner scope + per-request interactor context Review findings on #1389 (P1/P2): P1 — daemon routes that issue Apple runner commands outside interactor methods (keyboard, native alert, point read, iOS sequence chunks) escaped to the local XCTest runtime for provider devices. ProviderDeviceRuntime now exposes getAppleRunnerProvider; createProviderDeviceRuntimeRequestProviders composes it into an appleRunnerProvider request resolver and the daemon runtime wires it, so the existing request-boundary scope covers those routes with the provider transport. P2 — per-request RunnerContext was discarded for provider devices: getInteractor threads it through getProviderDeviceInteractor into ProviderDeviceRuntime.getInteractor, so runtimes composing the shared Apple interactor keep requestId (cancellation/accounting), appBundleId, and log paths per request. Lease-route commands (lease_allocate/heartbeat/release, artifacts) now skip sessionless provider-device resolution: they manage lease lifecycle, not a device session, and resolving a default device there spuriously triggered local device discovery before any lease existed. Integration coverage: keyboardDismiss reaches the provider transport via the request scope; every runner call in a request carries that request's id. * test(daemon): assert direct-route runner calls keep the request id through the provider scope Re-review follow-up on #1389: the keyboard-dismiss provider-scope test now sends a requestId and asserts the recorded runner call carries it, proving the request-boundary appleRunnerProvider scope preserves per-request context for direct daemon routes (not just deviceId matching). * fix(daemon): revert-sensitive transport tests, route-derived lease skip, guarded provider-scope resolve Review round 3 on #1389: 1. The integration acceptance test passed with the interactor transport param removed — the request-boundary scope was routing for it. Tests now run in two worlds: the shared-stack and per-request-id tests use a world WITHOUT getAppleRunnerProvider (the interactor param is the only seam; verified failing when the param is dropped), while the direct-route test keeps the request scope it pins. 2. skipSessionlessProviderDevice for lease-route commands is now derived from daemon.route === 'lease' in shouldSkipSessionlessProviderDevice instead of hand-spread across four descriptors, with a registry-driven invariant test enumerating the route. 3. resolveScopedProviderDevice catches resolveTargetDevice failures and returns undefined: provider-scope plumbing failing to find a device means 'no provider scope', never a failed request. Also collapses the duplicated provider-scope Proxy from android.ts and apple/interactor.ts into core/interactor-scope.ts, and restates the transport param's role (local-tooling partition + out-of-daemon scoping) in its doc comment. * fix(core): move the provider-scope proxy below the ranked spine Layering Guard (R5 zero-back-edges) rejected platforms/apple/interactor.ts value-importing core/interactor-scope.ts: platforms (rank 1) may not import core (rank 2). The helper needs nothing from core — generic withMethodScope in utils (unranked, already imported by both zones) replaces it. The prior local layering pass was a false green: the guard enumerates tracked files and the new helper was untracked when the gate ran.
1 parent 152894c commit 256887f

11 files changed

Lines changed: 630 additions & 20 deletions

File tree

‎src/core/interactors.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export async function getInteractor(
1414
runnerContext: RunnerContext,
1515
): Promise<Interactor> {
1616
if (isActiveProviderDevice(device)) {
17-
const providerInteractor = getProviderDeviceInteractor(device);
17+
const providerInteractor = getProviderDeviceInteractor(device, runnerContext);
1818
if (providerInteractor) return providerInteractor;
1919
throw new AppError(
2020
'UNSUPPORTED_OPERATION',

‎src/core/interactors/android.ts‎

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { setAndroidSetting } from '../../platforms/android/settings.ts';
3232
import { snapshotAndroid } from '../../platforms/android/snapshot.ts';
3333
import { screenshotAndroid } from '../../platforms/android/screenshot.ts';
3434
import { withDiagnosticTimer } from '../../utils/diagnostics.ts';
35+
import { withMethodScope } from '../../utils/method-scope.ts';
3536
import type { DeviceInfo } from '../../kernel/device.ts';
3637
import type { Interactor } from '../interactor-types.ts';
3738
import { snapshotCaptureAnnotationsFrom } from '../../snapshot-capture-annotations.ts';
@@ -98,16 +99,7 @@ export function createAndroidInteractor(
9899
setAndroidSetting(device, setting, state, appId, options),
99100
};
100101
if (!provider) return interactor;
101-
return new Proxy(interactor, {
102-
get(target, property, receiver) {
103-
const value = Reflect.get(target, property, receiver);
104-
if (typeof value !== 'function') return value;
105-
return (...args: unknown[]) =>
106-
withAndroidAdbProvider(
107-
provider,
108-
{ serial: device.id },
109-
async () => await value.apply(target, args),
110-
);
111-
},
112-
});
102+
return withMethodScope(interactor, (task) =>
103+
withAndroidAdbProvider(provider, { serial: device.id }, task),
104+
);
113105
}

‎src/daemon/__tests__/daemon-command-registry.test.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,27 @@ test('daemon command registry preserves provider device resolution traits', () =
216216
);
217217
});
218218

219+
test('every lease-route command skips sessionless provider-device resolution', () => {
220+
// Enumerated from the registry route, not a hand list: lease-route requests
221+
// manage lease lifecycle/artifacts, never a device session, so sessionless
222+
// provider scoping must not trigger local device discovery for them.
223+
const leaseRouteCommands = [
224+
...Object.values(PUBLIC_COMMANDS),
225+
...Object.values(INTERNAL_COMMANDS),
226+
].filter((command) => getDaemonCommandRoute(command) === 'lease');
227+
assert.ok(leaseRouteCommands.length >= 4, 'expected the lease route to enumerate its commands');
228+
for (const command of leaseRouteCommands) {
229+
assert.equal(
230+
resolveProviderDeviceResolutionIntent(makeRequest(command), {
231+
hasExistingSession: false,
232+
hasExplicitDeviceSelector: true,
233+
}),
234+
'skip',
235+
`${command} must not resolve a provider device sessionless`,
236+
);
237+
}
238+
});
239+
219240
function makeRequest(command: string, positionals: string[] = []): DaemonRequest {
220241
return {
221242
command,

‎src/daemon/daemon-command-registry.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,12 @@ function buildDaemonCommandRegistry(descriptors: readonly DaemonCommandDescripto
156156
}
157157

158158
function shouldSkipSessionlessProviderDevice(req: DaemonRequest): boolean {
159-
const skip = getDaemonCommandDescriptor(req.command)?.skipSessionlessProviderDevice;
159+
const descriptor = getDaemonCommandDescriptor(req.command);
160+
// Lease-route requests manage lease lifecycle/artifacts, not a device
161+
// session: resolving a default device for provider scoping would spuriously
162+
// trigger local device discovery before any lease exists. Derived from the
163+
// route so a new lease-route command cannot regress it.
164+
if (descriptor?.route === 'lease') return true;
165+
const skip = descriptor?.skipSessionlessProviderDevice;
160166
return typeof skip === 'function' ? skip(req) : false;
161167
}

‎src/daemon/request-platform-providers.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,14 @@ async function resolveScopedProviderDevice(
372372
return existingSession?.device;
373373
case 'explicit-device':
374374
case 'sessionless-default-device':
375-
return await resolveTargetDevice(req.flags ?? {});
375+
// Provider-scope plumbing only: an unresolvable device means "no provider
376+
// scope for this request", never a failed request — the command's own
377+
// device resolution still reports its errors downstream.
378+
try {
379+
return await resolveTargetDevice(req.flags ?? {});
380+
} catch {
381+
return undefined;
382+
}
376383
case 'skip':
377384
return undefined;
378385
}

‎src/daemon/server/daemon-runtime.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ export async function startDaemonRuntime(
198198
leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider,
199199
cloudArtifactProvider,
200200
deviceInventoryProvider: providerRuntimeProviders.deviceInventoryProvider,
201+
appleRunnerProvider: providerRuntimeProviders.appleRunnerProvider,
201202
providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds,
202203
providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds,
203204
providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope,
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
4+
import { createAppleInteractor } from '../interactor.ts';
5+
import { AppError } from '../../../kernel/errors.ts';
6+
import type { GesturePlan } from '../../../contracts/gesture-plan-types.ts';
7+
import type { Interactor, RunnerContext } from '../../../core/interactor-types.ts';
8+
import type {
9+
AppleRunnerCommandOptions,
10+
AppleRunnerProvider,
11+
} from '../core/runner/runner-provider.ts';
12+
import type { RunnerCommand } from '../core/runner/runner-contract.ts';
13+
14+
type RecordedRunnerCall = { command: RunnerCommand; options: AppleRunnerCommandOptions };
15+
16+
// Every Interactor method must either ride the injected runner transport or
17+
// fail fast as a local-tooling method the provider composes itself. The two
18+
// tables below partition the surface; the partition test keeps them total, so
19+
// a new Interactor method fails loudly here until it is classified.
20+
const RUNNER_TRANSPORT_METHODS: Record<
21+
string,
22+
{ invoke: (interactor: Interactor) => Promise<unknown>; runnerCommand: string }
23+
> = {
24+
tap: { invoke: (i) => i.tap(10, 20), runnerCommand: 'tap' },
25+
tapElementSelector: {
26+
invoke: (i) => i.tapElementSelector!({ key: 'label', value: 'Go' }),
27+
runnerCommand: 'tap',
28+
},
29+
doubleTap: { invoke: (i) => i.doubleTap(10, 20), runnerCommand: 'sequence' },
30+
longPress: { invoke: (i) => i.longPress(10, 20, 600), runnerCommand: 'longPress' },
31+
focus: { invoke: (i) => i.focus(10, 20), runnerCommand: 'tap' },
32+
type: { invoke: (i) => i.type('hi'), runnerCommand: 'type' },
33+
fillElementSelector: {
34+
invoke: (i) => i.fillElementSelector!({ key: 'label', value: 'Go' }, 'hi'),
35+
runnerCommand: 'type',
36+
},
37+
fill: { invoke: (i) => i.fill(10, 20, 'hi'), runnerCommand: 'type' },
38+
scroll: { invoke: (i) => i.scroll('down'), runnerCommand: 'scroll' },
39+
performGesture: {
40+
invoke: (i) => i.performGesture!(singlePointerPanPlan()),
41+
runnerCommand: 'gesture',
42+
},
43+
gestureViewport: { invoke: (i) => i.gestureViewport!(), runnerCommand: 'gestureViewport' },
44+
snapshot: { invoke: (i) => i.snapshot(), runnerCommand: 'snapshot' },
45+
back: { invoke: (i) => i.back(), runnerCommand: 'backInApp' },
46+
home: { invoke: (i) => i.home(), runnerCommand: 'home' },
47+
setOrientation: { invoke: (i) => i.setOrientation('portrait'), runnerCommand: 'rotate' },
48+
appSwitcher: { invoke: (i) => i.appSwitcher(), runnerCommand: 'appSwitcher' },
49+
tvRemote: { invoke: (i) => i.tvRemote('select'), runnerCommand: 'remotePress' },
50+
};
51+
52+
const LOCAL_TOOL_METHODS: Record<string, (interactor: Interactor) => Promise<unknown>> = {
53+
open: (i) => i.open('com.example.app'),
54+
openDevice: (i) => i.openDevice(),
55+
close: (i) => i.close('com.example.app'),
56+
screenshot: (i) => i.screenshot('/dev/null'),
57+
readClipboard: (i) => i.readClipboard(),
58+
writeClipboard: (i) => i.writeClipboard('hi'),
59+
setSetting: (i) => i.setSetting('wifi', 'on'),
60+
};
61+
62+
test('the runner/local partition covers the full provider-backed interactor surface', () => {
63+
const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([]));
64+
const classified = [
65+
...Object.keys(RUNNER_TRANSPORT_METHODS),
66+
...Object.keys(LOCAL_TOOL_METHODS),
67+
].sort();
68+
assert.deepEqual(Object.keys(interactor).sort(), classified);
69+
});
70+
71+
test('provider-backed interactor routes runner-command methods through the injected transport', async () => {
72+
const calls: RecordedRunnerCall[] = [];
73+
const interactor = createAppleInteractor(
74+
IOS_SIMULATOR,
75+
{ appBundleId: 'com.example.app' },
76+
recordingRunnerProvider(calls),
77+
);
78+
for (const [method, { invoke, runnerCommand }] of Object.entries(RUNNER_TRANSPORT_METHODS)) {
79+
calls.length = 0;
80+
await invoke(interactor);
81+
assert.ok(calls.length >= 1, `${method} never reached the injected runner transport`);
82+
assert.equal(calls[0]!.command.command, runnerCommand, `${method} sent a different command`);
83+
}
84+
});
85+
86+
test('provider-backed interactor rejects local Apple tooling methods with a clear error', async () => {
87+
const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([]));
88+
for (const [method, invoke] of Object.entries(LOCAL_TOOL_METHODS)) {
89+
await assert.rejects(
90+
invoke(interactor),
91+
(error: unknown) =>
92+
error instanceof AppError &&
93+
error.code === 'UNSUPPORTED_OPERATION' &&
94+
error.message.includes('provider session'),
95+
`${method} should reject as a local-tooling method`,
96+
);
97+
}
98+
});
99+
100+
test('injected transport still resolves when the runner context carries a request id', async () => {
101+
const calls: RecordedRunnerCall[] = [];
102+
const runnerContext: RunnerContext = { requestId: 'req-42' };
103+
const interactor = createAppleInteractor(
104+
IOS_SIMULATOR,
105+
runnerContext,
106+
recordingRunnerProvider(calls),
107+
);
108+
await interactor.tap(10, 20);
109+
assert.equal(calls.length, 1);
110+
assert.equal(calls[0]!.options.requestId, 'req-42');
111+
});
112+
113+
test('snapshot over the injected transport keeps the shared xctest result shape', async () => {
114+
const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([]));
115+
const result = await interactor.snapshot();
116+
assert.equal(result.backend, 'xctest');
117+
assert.equal(result.nodes?.length, 2);
118+
});
119+
120+
function recordingRunnerProvider(calls: RecordedRunnerCall[]): AppleRunnerProvider {
121+
return {
122+
runCommand: async (_device, command, options) => {
123+
calls.push({ command, options });
124+
return runnerResultFor(command);
125+
},
126+
};
127+
}
128+
129+
function runnerResultFor(command: RunnerCommand): Record<string, unknown> {
130+
switch (command.command) {
131+
case 'snapshot':
132+
return {
133+
nodes: [
134+
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
135+
{
136+
index: 1,
137+
parentIndex: 0,
138+
type: 'Button',
139+
label: 'Go',
140+
hittable: true,
141+
rect: { x: 10, y: 10, width: 80, height: 40 },
142+
},
143+
],
144+
};
145+
case 'gestureViewport':
146+
return { x: 0, y: 0, x2: 390, y2: 844 };
147+
default:
148+
return {};
149+
}
150+
}
151+
152+
function singlePointerPanPlan(): GesturePlan {
153+
return {
154+
topology: 'single',
155+
intent: 'pan',
156+
executionProfile: 'timed-pan',
157+
durationMs: 120,
158+
viewport: { x: 0, y: 0, width: 390, height: 844 },
159+
pointers: [
160+
{
161+
pointerId: 0,
162+
samples: [
163+
{ offsetMs: 0, point: { x: 100, y: 400 } },
164+
{ offsetMs: 120, point: { x: 100, y: 200 } },
165+
],
166+
},
167+
],
168+
};
169+
}

‎src/platforms/apple/interactor.ts‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,16 @@ import { iosRunnerOverrides, resolveAppleBackRunnerCommand } from './interaction
1212
import { appleRemotePressCommand } from './os/tvos/remote.ts';
1313
import { runMacOsScreenshotAction } from './os/macos/helper.ts';
1414
import { runAppleRunnerCommand } from './core/runner/runner-client.ts';
15+
import {
16+
withAppleRunnerProvider,
17+
type AppleRunnerCommandExecutor,
18+
type AppleRunnerProvider,
19+
} from './core/runner/runner-provider.ts';
1520
import { toAppleTvRemoteButton } from '../../contracts/tv-remote.ts';
1621
import { withDiagnosticTimer } from '../../utils/diagnostics.ts';
1722
import { isMacOs, isTvOsDevice, type DeviceInfo } from '../../kernel/device.ts';
1823
import { AppError } from '../../kernel/errors.ts';
24+
import { withMethodScope } from '../../utils/method-scope.ts';
1925
import type { RawSnapshotNode } from '../../kernel/snapshot.ts';
2026
import type {
2127
Interactor,
@@ -31,6 +37,7 @@ import {
3137
export function createAppleInteractor(
3238
device: DeviceInfo,
3339
runnerContext: RunnerContext,
40+
runnerProvider?: AppleRunnerProvider | AppleRunnerCommandExecutor,
3441
): Interactor {
3542
// watchOS unsupported sentinel: XCUITest cannot drive watchOS UI (no
3643
// XCUIApplication), so a watchOS device has no runner backend. Reject it
@@ -43,7 +50,7 @@ export function createAppleInteractor(
4350
);
4451
}
4552
const { overrides, runnerOpts } = iosRunnerOverrides(device, runnerContext);
46-
return {
53+
const interactor: Interactor = {
4754
open: (app, options) =>
4855
openIosApp(device, app, {
4956
appBundleId: options?.appBundleId,
@@ -156,6 +163,49 @@ export function createAppleInteractor(
156163
setIosSetting(device, setting, state, appId, options),
157164
...overrides,
158165
};
166+
if (!runnerProvider) return interactor;
167+
return withInjectedAppleRunnerTransport(device, runnerContext, interactor, runnerProvider);
168+
}
169+
170+
/**
171+
* Partitions the interactor for an injected provider transport. Its unique
172+
* jobs are the local-tooling rejection and in-process scoping for interactors
173+
* composed OUTSIDE a daemon request (on daemon requests the request-boundary
174+
* `appleRunnerProvider` resolver scopes the same transport around everything):
175+
* runner-command methods run inside the provider scope, while methods backed
176+
* by local Apple tooling (simctl/devicectl) have no provider-neutral transport
177+
* and fail fast until the provider session composes its own on top.
178+
*/
179+
function withInjectedAppleRunnerTransport(
180+
device: DeviceInfo,
181+
runnerContext: RunnerContext,
182+
interactor: Interactor,
183+
runnerProvider: AppleRunnerProvider | AppleRunnerCommandExecutor,
184+
): Interactor {
185+
const providerInteractor: Interactor = {
186+
...interactor,
187+
open: async () => rejectLocalAppleToolMethod('open'),
188+
openDevice: async () => rejectLocalAppleToolMethod('openDevice'),
189+
close: async () => rejectLocalAppleToolMethod('close'),
190+
screenshot: async () => rejectLocalAppleToolMethod('screenshot'),
191+
readClipboard: async () => rejectLocalAppleToolMethod('readClipboard'),
192+
writeClipboard: async () => rejectLocalAppleToolMethod('writeClipboard'),
193+
setSetting: async () => rejectLocalAppleToolMethod('setSetting'),
194+
};
195+
return withMethodScope(providerInteractor, (task) =>
196+
withAppleRunnerProvider(
197+
runnerProvider,
198+
{ deviceId: device.id, requestId: runnerContext.requestId },
199+
task,
200+
),
201+
);
202+
}
203+
204+
function rejectLocalAppleToolMethod(method: string): never {
205+
throw new AppError(
206+
'UNSUPPORTED_OPERATION',
207+
`${method} uses local Apple tooling (simctl/devicectl), which an injected runner transport cannot reach; the provider session must supply its own ${method} implementation.`,
208+
);
159209
}
160210

161211
async function runAppleScreenshot(

0 commit comments

Comments
 (0)