mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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.
This commit is contained in:
committed by
GitHub
parent
152894cce2
commit
256887f194
@@ -14,7 +14,7 @@ export async function getInteractor(
|
||||
runnerContext: RunnerContext,
|
||||
): Promise<Interactor> {
|
||||
if (isActiveProviderDevice(device)) {
|
||||
const providerInteractor = getProviderDeviceInteractor(device);
|
||||
const providerInteractor = getProviderDeviceInteractor(device, runnerContext);
|
||||
if (providerInteractor) return providerInteractor;
|
||||
throw new AppError(
|
||||
'UNSUPPORTED_OPERATION',
|
||||
|
||||
@@ -32,6 +32,7 @@ import { setAndroidSetting } from '../../platforms/android/settings.ts';
|
||||
import { snapshotAndroid } from '../../platforms/android/snapshot.ts';
|
||||
import { screenshotAndroid } from '../../platforms/android/screenshot.ts';
|
||||
import { withDiagnosticTimer } from '../../utils/diagnostics.ts';
|
||||
import { withMethodScope } from '../../utils/method-scope.ts';
|
||||
import type { DeviceInfo } from '../../kernel/device.ts';
|
||||
import type { Interactor } from '../interactor-types.ts';
|
||||
import { snapshotCaptureAnnotationsFrom } from '../../snapshot-capture-annotations.ts';
|
||||
@@ -98,16 +99,7 @@ export function createAndroidInteractor(
|
||||
setAndroidSetting(device, setting, state, appId, options),
|
||||
};
|
||||
if (!provider) return interactor;
|
||||
return new Proxy(interactor, {
|
||||
get(target, property, receiver) {
|
||||
const value = Reflect.get(target, property, receiver);
|
||||
if (typeof value !== 'function') return value;
|
||||
return (...args: unknown[]) =>
|
||||
withAndroidAdbProvider(
|
||||
provider,
|
||||
{ serial: device.id },
|
||||
async () => await value.apply(target, args),
|
||||
);
|
||||
},
|
||||
});
|
||||
return withMethodScope(interactor, (task) =>
|
||||
withAndroidAdbProvider(provider, { serial: device.id }, task),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,6 +216,27 @@ test('daemon command registry preserves provider device resolution traits', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('every lease-route command skips sessionless provider-device resolution', () => {
|
||||
// Enumerated from the registry route, not a hand list: lease-route requests
|
||||
// manage lease lifecycle/artifacts, never a device session, so sessionless
|
||||
// provider scoping must not trigger local device discovery for them.
|
||||
const leaseRouteCommands = [
|
||||
...Object.values(PUBLIC_COMMANDS),
|
||||
...Object.values(INTERNAL_COMMANDS),
|
||||
].filter((command) => getDaemonCommandRoute(command) === 'lease');
|
||||
assert.ok(leaseRouteCommands.length >= 4, 'expected the lease route to enumerate its commands');
|
||||
for (const command of leaseRouteCommands) {
|
||||
assert.equal(
|
||||
resolveProviderDeviceResolutionIntent(makeRequest(command), {
|
||||
hasExistingSession: false,
|
||||
hasExplicitDeviceSelector: true,
|
||||
}),
|
||||
'skip',
|
||||
`${command} must not resolve a provider device sessionless`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function makeRequest(command: string, positionals: string[] = []): DaemonRequest {
|
||||
return {
|
||||
command,
|
||||
|
||||
@@ -156,6 +156,12 @@ function buildDaemonCommandRegistry(descriptors: readonly DaemonCommandDescripto
|
||||
}
|
||||
|
||||
function shouldSkipSessionlessProviderDevice(req: DaemonRequest): boolean {
|
||||
const skip = getDaemonCommandDescriptor(req.command)?.skipSessionlessProviderDevice;
|
||||
const descriptor = getDaemonCommandDescriptor(req.command);
|
||||
// Lease-route requests manage lease lifecycle/artifacts, not a device
|
||||
// session: resolving a default device for provider scoping would spuriously
|
||||
// trigger local device discovery before any lease exists. Derived from the
|
||||
// route so a new lease-route command cannot regress it.
|
||||
if (descriptor?.route === 'lease') return true;
|
||||
const skip = descriptor?.skipSessionlessProviderDevice;
|
||||
return typeof skip === 'function' ? skip(req) : false;
|
||||
}
|
||||
|
||||
@@ -372,7 +372,14 @@ async function resolveScopedProviderDevice(
|
||||
return existingSession?.device;
|
||||
case 'explicit-device':
|
||||
case 'sessionless-default-device':
|
||||
return await resolveTargetDevice(req.flags ?? {});
|
||||
// Provider-scope plumbing only: an unresolvable device means "no provider
|
||||
// scope for this request", never a failed request — the command's own
|
||||
// device resolution still reports its errors downstream.
|
||||
try {
|
||||
return await resolveTargetDevice(req.flags ?? {});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
case 'skip':
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ export async function startDaemonRuntime(
|
||||
leaseLifecycleProvider: providerRuntimeProviders.leaseLifecycleProvider,
|
||||
cloudArtifactProvider,
|
||||
deviceInventoryProvider: providerRuntimeProviders.deviceInventoryProvider,
|
||||
appleRunnerProvider: providerRuntimeProviders.appleRunnerProvider,
|
||||
providerRuntimeIds: providerRuntimeProviders.providerRuntimeIds,
|
||||
providerRuntimeRequiredIds: providerRuntimeProviders.providerRuntimeRequiredIds,
|
||||
providerDeviceRuntimeScope: providerRuntimeProviders.providerDeviceRuntimeScope,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'vitest';
|
||||
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
|
||||
import { createAppleInteractor } from '../interactor.ts';
|
||||
import { AppError } from '../../../kernel/errors.ts';
|
||||
import type { GesturePlan } from '../../../contracts/gesture-plan-types.ts';
|
||||
import type { Interactor, RunnerContext } from '../../../core/interactor-types.ts';
|
||||
import type {
|
||||
AppleRunnerCommandOptions,
|
||||
AppleRunnerProvider,
|
||||
} from '../core/runner/runner-provider.ts';
|
||||
import type { RunnerCommand } from '../core/runner/runner-contract.ts';
|
||||
|
||||
type RecordedRunnerCall = { command: RunnerCommand; options: AppleRunnerCommandOptions };
|
||||
|
||||
// Every Interactor method must either ride the injected runner transport or
|
||||
// fail fast as a local-tooling method the provider composes itself. The two
|
||||
// tables below partition the surface; the partition test keeps them total, so
|
||||
// a new Interactor method fails loudly here until it is classified.
|
||||
const RUNNER_TRANSPORT_METHODS: Record<
|
||||
string,
|
||||
{ invoke: (interactor: Interactor) => Promise<unknown>; runnerCommand: string }
|
||||
> = {
|
||||
tap: { invoke: (i) => i.tap(10, 20), runnerCommand: 'tap' },
|
||||
tapElementSelector: {
|
||||
invoke: (i) => i.tapElementSelector!({ key: 'label', value: 'Go' }),
|
||||
runnerCommand: 'tap',
|
||||
},
|
||||
doubleTap: { invoke: (i) => i.doubleTap(10, 20), runnerCommand: 'sequence' },
|
||||
longPress: { invoke: (i) => i.longPress(10, 20, 600), runnerCommand: 'longPress' },
|
||||
focus: { invoke: (i) => i.focus(10, 20), runnerCommand: 'tap' },
|
||||
type: { invoke: (i) => i.type('hi'), runnerCommand: 'type' },
|
||||
fillElementSelector: {
|
||||
invoke: (i) => i.fillElementSelector!({ key: 'label', value: 'Go' }, 'hi'),
|
||||
runnerCommand: 'type',
|
||||
},
|
||||
fill: { invoke: (i) => i.fill(10, 20, 'hi'), runnerCommand: 'type' },
|
||||
scroll: { invoke: (i) => i.scroll('down'), runnerCommand: 'scroll' },
|
||||
performGesture: {
|
||||
invoke: (i) => i.performGesture!(singlePointerPanPlan()),
|
||||
runnerCommand: 'gesture',
|
||||
},
|
||||
gestureViewport: { invoke: (i) => i.gestureViewport!(), runnerCommand: 'gestureViewport' },
|
||||
snapshot: { invoke: (i) => i.snapshot(), runnerCommand: 'snapshot' },
|
||||
back: { invoke: (i) => i.back(), runnerCommand: 'backInApp' },
|
||||
home: { invoke: (i) => i.home(), runnerCommand: 'home' },
|
||||
setOrientation: { invoke: (i) => i.setOrientation('portrait'), runnerCommand: 'rotate' },
|
||||
appSwitcher: { invoke: (i) => i.appSwitcher(), runnerCommand: 'appSwitcher' },
|
||||
tvRemote: { invoke: (i) => i.tvRemote('select'), runnerCommand: 'remotePress' },
|
||||
};
|
||||
|
||||
const LOCAL_TOOL_METHODS: Record<string, (interactor: Interactor) => Promise<unknown>> = {
|
||||
open: (i) => i.open('com.example.app'),
|
||||
openDevice: (i) => i.openDevice(),
|
||||
close: (i) => i.close('com.example.app'),
|
||||
screenshot: (i) => i.screenshot('/dev/null'),
|
||||
readClipboard: (i) => i.readClipboard(),
|
||||
writeClipboard: (i) => i.writeClipboard('hi'),
|
||||
setSetting: (i) => i.setSetting('wifi', 'on'),
|
||||
};
|
||||
|
||||
test('the runner/local partition covers the full provider-backed interactor surface', () => {
|
||||
const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([]));
|
||||
const classified = [
|
||||
...Object.keys(RUNNER_TRANSPORT_METHODS),
|
||||
...Object.keys(LOCAL_TOOL_METHODS),
|
||||
].sort();
|
||||
assert.deepEqual(Object.keys(interactor).sort(), classified);
|
||||
});
|
||||
|
||||
test('provider-backed interactor routes runner-command methods through the injected transport', async () => {
|
||||
const calls: RecordedRunnerCall[] = [];
|
||||
const interactor = createAppleInteractor(
|
||||
IOS_SIMULATOR,
|
||||
{ appBundleId: 'com.example.app' },
|
||||
recordingRunnerProvider(calls),
|
||||
);
|
||||
for (const [method, { invoke, runnerCommand }] of Object.entries(RUNNER_TRANSPORT_METHODS)) {
|
||||
calls.length = 0;
|
||||
await invoke(interactor);
|
||||
assert.ok(calls.length >= 1, `${method} never reached the injected runner transport`);
|
||||
assert.equal(calls[0]!.command.command, runnerCommand, `${method} sent a different command`);
|
||||
}
|
||||
});
|
||||
|
||||
test('provider-backed interactor rejects local Apple tooling methods with a clear error', async () => {
|
||||
const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([]));
|
||||
for (const [method, invoke] of Object.entries(LOCAL_TOOL_METHODS)) {
|
||||
await assert.rejects(
|
||||
invoke(interactor),
|
||||
(error: unknown) =>
|
||||
error instanceof AppError &&
|
||||
error.code === 'UNSUPPORTED_OPERATION' &&
|
||||
error.message.includes('provider session'),
|
||||
`${method} should reject as a local-tooling method`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('injected transport still resolves when the runner context carries a request id', async () => {
|
||||
const calls: RecordedRunnerCall[] = [];
|
||||
const runnerContext: RunnerContext = { requestId: 'req-42' };
|
||||
const interactor = createAppleInteractor(
|
||||
IOS_SIMULATOR,
|
||||
runnerContext,
|
||||
recordingRunnerProvider(calls),
|
||||
);
|
||||
await interactor.tap(10, 20);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]!.options.requestId, 'req-42');
|
||||
});
|
||||
|
||||
test('snapshot over the injected transport keeps the shared xctest result shape', async () => {
|
||||
const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([]));
|
||||
const result = await interactor.snapshot();
|
||||
assert.equal(result.backend, 'xctest');
|
||||
assert.equal(result.nodes?.length, 2);
|
||||
});
|
||||
|
||||
function recordingRunnerProvider(calls: RecordedRunnerCall[]): AppleRunnerProvider {
|
||||
return {
|
||||
runCommand: async (_device, command, options) => {
|
||||
calls.push({ command, options });
|
||||
return runnerResultFor(command);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runnerResultFor(command: RunnerCommand): Record<string, unknown> {
|
||||
switch (command.command) {
|
||||
case 'snapshot':
|
||||
return {
|
||||
nodes: [
|
||||
{ index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } },
|
||||
{
|
||||
index: 1,
|
||||
parentIndex: 0,
|
||||
type: 'Button',
|
||||
label: 'Go',
|
||||
hittable: true,
|
||||
rect: { x: 10, y: 10, width: 80, height: 40 },
|
||||
},
|
||||
],
|
||||
};
|
||||
case 'gestureViewport':
|
||||
return { x: 0, y: 0, x2: 390, y2: 844 };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function singlePointerPanPlan(): GesturePlan {
|
||||
return {
|
||||
topology: 'single',
|
||||
intent: 'pan',
|
||||
executionProfile: 'timed-pan',
|
||||
durationMs: 120,
|
||||
viewport: { x: 0, y: 0, width: 390, height: 844 },
|
||||
pointers: [
|
||||
{
|
||||
pointerId: 0,
|
||||
samples: [
|
||||
{ offsetMs: 0, point: { x: 100, y: 400 } },
|
||||
{ offsetMs: 120, point: { x: 100, y: 200 } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -12,10 +12,16 @@ import { iosRunnerOverrides, resolveAppleBackRunnerCommand } from './interaction
|
||||
import { appleRemotePressCommand } from './os/tvos/remote.ts';
|
||||
import { runMacOsScreenshotAction } from './os/macos/helper.ts';
|
||||
import { runAppleRunnerCommand } from './core/runner/runner-client.ts';
|
||||
import {
|
||||
withAppleRunnerProvider,
|
||||
type AppleRunnerCommandExecutor,
|
||||
type AppleRunnerProvider,
|
||||
} from './core/runner/runner-provider.ts';
|
||||
import { toAppleTvRemoteButton } from '../../contracts/tv-remote.ts';
|
||||
import { withDiagnosticTimer } from '../../utils/diagnostics.ts';
|
||||
import { isMacOs, isTvOsDevice, type DeviceInfo } from '../../kernel/device.ts';
|
||||
import { AppError } from '../../kernel/errors.ts';
|
||||
import { withMethodScope } from '../../utils/method-scope.ts';
|
||||
import type { RawSnapshotNode } from '../../kernel/snapshot.ts';
|
||||
import type {
|
||||
Interactor,
|
||||
@@ -31,6 +37,7 @@ import {
|
||||
export function createAppleInteractor(
|
||||
device: DeviceInfo,
|
||||
runnerContext: RunnerContext,
|
||||
runnerProvider?: AppleRunnerProvider | AppleRunnerCommandExecutor,
|
||||
): Interactor {
|
||||
// watchOS unsupported sentinel: XCUITest cannot drive watchOS UI (no
|
||||
// XCUIApplication), so a watchOS device has no runner backend. Reject it
|
||||
@@ -43,7 +50,7 @@ export function createAppleInteractor(
|
||||
);
|
||||
}
|
||||
const { overrides, runnerOpts } = iosRunnerOverrides(device, runnerContext);
|
||||
return {
|
||||
const interactor: Interactor = {
|
||||
open: (app, options) =>
|
||||
openIosApp(device, app, {
|
||||
appBundleId: options?.appBundleId,
|
||||
@@ -156,6 +163,49 @@ export function createAppleInteractor(
|
||||
setIosSetting(device, setting, state, appId, options),
|
||||
...overrides,
|
||||
};
|
||||
if (!runnerProvider) return interactor;
|
||||
return withInjectedAppleRunnerTransport(device, runnerContext, interactor, runnerProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions the interactor for an injected provider transport. Its unique
|
||||
* jobs are the local-tooling rejection and in-process scoping for interactors
|
||||
* composed OUTSIDE a daemon request (on daemon requests the request-boundary
|
||||
* `appleRunnerProvider` resolver scopes the same transport around everything):
|
||||
* runner-command methods run inside the provider scope, while methods backed
|
||||
* by local Apple tooling (simctl/devicectl) have no provider-neutral transport
|
||||
* and fail fast until the provider session composes its own on top.
|
||||
*/
|
||||
function withInjectedAppleRunnerTransport(
|
||||
device: DeviceInfo,
|
||||
runnerContext: RunnerContext,
|
||||
interactor: Interactor,
|
||||
runnerProvider: AppleRunnerProvider | AppleRunnerCommandExecutor,
|
||||
): Interactor {
|
||||
const providerInteractor: Interactor = {
|
||||
...interactor,
|
||||
open: async () => rejectLocalAppleToolMethod('open'),
|
||||
openDevice: async () => rejectLocalAppleToolMethod('openDevice'),
|
||||
close: async () => rejectLocalAppleToolMethod('close'),
|
||||
screenshot: async () => rejectLocalAppleToolMethod('screenshot'),
|
||||
readClipboard: async () => rejectLocalAppleToolMethod('readClipboard'),
|
||||
writeClipboard: async () => rejectLocalAppleToolMethod('writeClipboard'),
|
||||
setSetting: async () => rejectLocalAppleToolMethod('setSetting'),
|
||||
};
|
||||
return withMethodScope(providerInteractor, (task) =>
|
||||
withAppleRunnerProvider(
|
||||
runnerProvider,
|
||||
{ deviceId: device.id, requestId: runnerContext.requestId },
|
||||
task,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function rejectLocalAppleToolMethod(method: string): never {
|
||||
throw new AppError(
|
||||
'UNSUPPORTED_OPERATION',
|
||||
`${method} uses local Apple tooling (simctl/devicectl), which an injected runner transport cannot reach; the provider session must supply its own ${method} implementation.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function runAppleScreenshot(
|
||||
|
||||
@@ -4,8 +4,13 @@ import type {
|
||||
CloudArtifactsQuery,
|
||||
CloudArtifactsResult,
|
||||
} from './cloud-artifacts.ts';
|
||||
import type { Interactor } from './core/interactor-types.ts';
|
||||
import type { Interactor, RunnerContext } from './core/interactor-types.ts';
|
||||
import type { DeviceInventoryProvider } from './core/dispatch-resolve.ts';
|
||||
import type { AppleRunnerProviderResolver } from './daemon/request-platform-providers.ts';
|
||||
import type {
|
||||
AppleRunnerCommandExecutor,
|
||||
AppleRunnerProvider,
|
||||
} from './platforms/apple/core/runner/runner-provider.ts';
|
||||
import type { LeaseLifecycleContext, LeaseLifecycleProvider } from './daemon/handlers/lease.ts';
|
||||
import type { DeviceLease } from './daemon/lease-registry.ts';
|
||||
import { publicPlatformString, type DeviceInfo } from './kernel/device.ts';
|
||||
@@ -31,7 +36,23 @@ export type ProviderDeviceRuntime = {
|
||||
cloudArtifacts?: CloudArtifactProvider;
|
||||
deviceInventoryProvider: DeviceInventoryProvider;
|
||||
ownsDevice(device: DeviceInfo): boolean;
|
||||
getInteractor(device: DeviceInfo): Interactor | undefined;
|
||||
/**
|
||||
* Builds the interactor for an owned device. `runnerContext` carries the
|
||||
* per-request context (requestId for cancellation, appBundleId, log paths);
|
||||
* runtimes composing the shared Apple interactor should thread it through so
|
||||
* runner calls stay attributable to the request that issued them.
|
||||
*/
|
||||
getInteractor(device: DeviceInfo, runnerContext?: RunnerContext): Interactor | undefined;
|
||||
/**
|
||||
* Apple runner transport for an owned device. When present, the daemon scopes
|
||||
* the WHOLE request with it (via the `appleRunnerProvider` request resolver),
|
||||
* so daemon routes that issue runner commands outside interactor methods
|
||||
* (keyboard, native alert, point read, iOS sequences) reach the provider
|
||||
* transport instead of the local XCTest runtime.
|
||||
*/
|
||||
getAppleRunnerProvider?(
|
||||
device: DeviceInfo,
|
||||
): AppleRunnerProvider | AppleRunnerCommandExecutor | undefined;
|
||||
installApp?(
|
||||
device: DeviceInfo,
|
||||
app: string,
|
||||
@@ -65,6 +86,7 @@ export type ProviderDeviceRuntimeRequestProviders = {
|
||||
recoverExpiredLease?: ProviderExpiredLeaseRecovery;
|
||||
cloudArtifactProvider?: CloudArtifactProvider;
|
||||
deviceInventoryProvider?: DeviceInventoryProvider;
|
||||
appleRunnerProvider?: AppleRunnerProviderResolver;
|
||||
providerDeviceRuntimeScope?: <T>(task: () => Promise<T>) => Promise<T>;
|
||||
};
|
||||
|
||||
@@ -87,10 +109,13 @@ async function withProviderDeviceRuntimeScope<T>(
|
||||
return await providerDeviceRuntimeScope.run([...runtimes], task);
|
||||
}
|
||||
|
||||
export function getProviderDeviceInteractor(device: DeviceInfo): Interactor | undefined {
|
||||
export function getProviderDeviceInteractor(
|
||||
device: DeviceInfo,
|
||||
runnerContext?: RunnerContext,
|
||||
): Interactor | undefined {
|
||||
for (const runtime of getActiveProviderDeviceRuntimes()) {
|
||||
if (!runtime.ownsDevice(device)) continue;
|
||||
const interactor = runtime.getInteractor(device);
|
||||
const interactor = runtime.getInteractor(device, runnerContext);
|
||||
if (interactor) return interactor;
|
||||
}
|
||||
return undefined;
|
||||
@@ -168,11 +193,26 @@ export function createProviderDeviceRuntimeRequestProviders(
|
||||
recoverExpiredLease: composeExpiredLeaseRecovery(runtimes),
|
||||
cloudArtifactProvider: composeCloudArtifactProvider(runtimes),
|
||||
deviceInventoryProvider: composeDeviceInventoryProvider(runtimes),
|
||||
appleRunnerProvider: composeAppleRunnerProviderResolver(runtimes),
|
||||
providerDeviceRuntimeScope: async (task) =>
|
||||
await withProviderDeviceRuntimeScope(runtimes, task),
|
||||
};
|
||||
}
|
||||
|
||||
function composeAppleRunnerProviderResolver(
|
||||
runtimes: ProviderDeviceRuntime[],
|
||||
): AppleRunnerProviderResolver | undefined {
|
||||
if (!runtimes.some((runtime) => runtime.getAppleRunnerProvider !== undefined)) return undefined;
|
||||
return (context) => {
|
||||
for (const runtime of runtimes) {
|
||||
if (!runtime.ownsDevice(context.device)) continue;
|
||||
const provider = runtime.getAppleRunnerProvider?.(context.device);
|
||||
if (provider) return provider;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
}
|
||||
|
||||
function composeExpiredLeaseRecovery(
|
||||
runtimes: ProviderDeviceRuntime[],
|
||||
): ProviderExpiredLeaseRecovery | undefined {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Wraps every method call on `target` in `runScope`, so leaf code below the
|
||||
* method resolves scope-injected state (AsyncLocalStorage transports) instead
|
||||
* of the local default. Shared by the Android adb-provider and Apple
|
||||
* runner-provider interactor seams.
|
||||
*/
|
||||
export function withMethodScope<T extends object>(
|
||||
target: T,
|
||||
runScope: <R>(task: () => Promise<R>) => Promise<R>,
|
||||
): T {
|
||||
return new Proxy(target, {
|
||||
get(proxyTarget, property, receiver) {
|
||||
const value = Reflect.get(proxyTarget, property, receiver);
|
||||
if (typeof value !== 'function') return value;
|
||||
return (...args: unknown[]) => runScope(async () => await value.apply(proxyTarget, args));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { test } from 'vitest';
|
||||
import {
|
||||
createProviderDeviceRuntimeRequestProviders,
|
||||
type ProviderDeviceRuntime,
|
||||
} from '../../../src/provider-device-runtime.ts';
|
||||
import type { DeviceInventoryProvider } from '../../../src/core/dispatch-resolve.ts';
|
||||
import type { Interactor, RunnerContext } from '../../../src/core/interactor-types.ts';
|
||||
import type { LeaseLifecycleProvider } from '../../../src/daemon/handlers/lease.ts';
|
||||
import type { DeviceLease } from '../../../src/daemon/lease-registry.ts';
|
||||
import type { DaemonRequest } from '../../../src/daemon/types.ts';
|
||||
import type { DeviceInfo } from '../../../src/kernel/device.ts';
|
||||
import { createAppleInteractor } from '../../../src/platforms/apple/interactor.ts';
|
||||
import type { RunnerCommand } from '../../../src/platforms/apple/core/runner/runner-contract.ts';
|
||||
import type {
|
||||
AppleRunnerCommandOptions,
|
||||
AppleRunnerProvider,
|
||||
} from '../../../src/platforms/apple/core/runner/runner-provider.ts';
|
||||
import { assertRpcOk } from './assertions.ts';
|
||||
import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts';
|
||||
|
||||
const PROVIDER = 'fake-ios-runner-provider';
|
||||
const DEVICE: DeviceInfo = {
|
||||
platform: 'apple',
|
||||
appleOs: 'ios',
|
||||
id: 'fake-ios-runner-provider:simulator-1',
|
||||
name: 'Fake Runner Transport iOS Simulator',
|
||||
kind: 'simulator',
|
||||
target: 'mobile',
|
||||
booted: true,
|
||||
};
|
||||
|
||||
type RecordedRunnerCall = { command: RunnerCommand; options: AppleRunnerCommandOptions };
|
||||
type RunnerTransportCalls = { runner: RecordedRunnerCall[]; opens: number };
|
||||
|
||||
// Issue #1297 acceptance path: a provider that only supplies an
|
||||
// AppleRunnerProvider transport (plus its own `open`) reuses the SHARED Apple
|
||||
// interactor — selector resolution, tap, fill, and snapshot all arrive at the
|
||||
// provider transport as runner-protocol commands instead of local XCTest.
|
||||
// This world has NO request-boundary resolver, so the interactor's injected
|
||||
// transport is the only thing keeping runner traffic off the local runtime:
|
||||
// removing the createAppleInteractor provider param fails this test.
|
||||
test('provider-supplied Apple runner transport reuses the shared interactor stack', async () => {
|
||||
await withProviderScenarioResource(createInteractorSeamWorld, async ({ daemon, calls }) => {
|
||||
const lease = await allocateLease(daemon);
|
||||
const request = { flags: leaseFlags(lease.leaseId), meta: leaseMeta(lease.leaseId) };
|
||||
|
||||
assertRpcOk(await daemon.callCommand('open', ['com.example.app'], request.flags, request));
|
||||
assert.equal(calls.opens, 1);
|
||||
|
||||
assert.match(
|
||||
String(
|
||||
assertRpcOk(
|
||||
await daemon.callCommand(
|
||||
'get',
|
||||
['text', 'label="Provider Ready"'],
|
||||
request.flags,
|
||||
request,
|
||||
),
|
||||
).text,
|
||||
),
|
||||
/^Provider Ready$/,
|
||||
);
|
||||
assert.equal(
|
||||
assertRpcOk(
|
||||
await daemon.callCommand(
|
||||
'is',
|
||||
['exists', 'label="Provider Ready"'],
|
||||
request.flags,
|
||||
request,
|
||||
),
|
||||
).pass,
|
||||
true,
|
||||
);
|
||||
assertRpcOk(
|
||||
await daemon.callCommand('wait', ['label="Provider Ready"'], request.flags, request),
|
||||
);
|
||||
|
||||
assertRpcOk(
|
||||
await daemon.callCommand('click', ['label="Provider Ready"'], request.flags, request),
|
||||
);
|
||||
const tap = calls.runner.find((call) => call.command.command === 'tap');
|
||||
assert.ok(tap, 'expected a runner-protocol tap on the provider transport');
|
||||
// The shared runtime resolved the selector against the transport's snapshot
|
||||
// and tapped the element center, whichever tap vehicle it picked.
|
||||
assert.deepEqual({ x: tap.command.x, y: tap.command.y }, { x: 60, y: 30 });
|
||||
|
||||
assertRpcOk(
|
||||
await daemon.callCommand('fill', ['label="Provider Input"', 'hello'], request.flags, request),
|
||||
);
|
||||
const fill = calls.runner.find(
|
||||
(call) => call.command.command === 'type' && call.command.textEntryMode === 'replace',
|
||||
);
|
||||
assert.ok(fill, 'expected a runner-protocol fill on the provider transport');
|
||||
assert.equal(fill.command.text, 'hello');
|
||||
assert.deepEqual({ x: fill.command.x, y: fill.command.y }, { x: 60, y: 90 });
|
||||
|
||||
const snapshots = calls.runner.filter((call) => call.command.command === 'snapshot');
|
||||
assert.ok(
|
||||
snapshots.length >= 4,
|
||||
`expected shared snapshot runtime traffic on the transport, got ${snapshots.length}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Daemon routes that issue runner commands OUTSIDE interactor methods (keyboard,
|
||||
// native alert, point read, iOS sequences) must reach the provider transport via
|
||||
// the request-boundary `appleRunnerProvider` scope instead of escaping to the
|
||||
// local XCTest runtime — and that scope must preserve the request's id, since
|
||||
// scoped-provider resolution requires the scope and call requestIds to match.
|
||||
test('daemon direct runner routes reach the provider transport through the request scope', async () => {
|
||||
await withProviderScenarioResource(createRequestScopeWorld, async ({ daemon, calls }) => {
|
||||
const lease = await allocateLease(daemon);
|
||||
const flags = leaseFlags(lease.leaseId);
|
||||
assertRpcOk(
|
||||
await daemon.callCommand('open', ['com.example.app'], flags, {
|
||||
meta: leaseMeta(lease.leaseId),
|
||||
}),
|
||||
);
|
||||
|
||||
calls.runner.length = 0;
|
||||
const keyboardMeta = { ...leaseMeta(lease.leaseId), requestId: 'req-keyboard-1' };
|
||||
assertRpcOk(await daemon.callCommand('keyboard', ['dismiss'], flags, { meta: keyboardMeta }));
|
||||
const dismiss = calls.runner.find((call) => call.command.command === 'keyboardDismiss');
|
||||
assert.ok(dismiss, 'expected keyboardDismiss on the provider transport, not local XCTest');
|
||||
assert.equal(
|
||||
dismiss.options.requestId,
|
||||
'req-keyboard-1',
|
||||
'direct-route runner call lost the per-request id across the request-boundary scope',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Per-request RunnerContext threading: the daemon builds the interactor per
|
||||
// request, so runner calls carry the request's id (cancellation/accounting)
|
||||
// instead of the construction-time context. Runs without the request-boundary
|
||||
// resolver so the id can only arrive via getInteractor's runnerContext.
|
||||
test('provider transport runner calls carry the per-request id', async () => {
|
||||
await withProviderScenarioResource(createInteractorSeamWorld, async ({ daemon, calls }) => {
|
||||
const lease = await allocateLease(daemon);
|
||||
const flags = leaseFlags(lease.leaseId);
|
||||
const openMeta = { ...leaseMeta(lease.leaseId), requestId: 'req-open-1' };
|
||||
assertRpcOk(await daemon.callCommand('open', ['com.example.app'], flags, { meta: openMeta }));
|
||||
|
||||
calls.runner.length = 0;
|
||||
const clickMeta = { ...leaseMeta(lease.leaseId), requestId: 'req-click-1' };
|
||||
assertRpcOk(
|
||||
await daemon.callCommand('click', ['label="Provider Ready"'], flags, { meta: clickMeta }),
|
||||
);
|
||||
assert.ok(calls.runner.length >= 1, 'expected runner traffic for the click request');
|
||||
for (const call of calls.runner) {
|
||||
assert.equal(
|
||||
call.options.requestId,
|
||||
'req-click-1',
|
||||
`runner ${call.command.command} lost the per-request id`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function allocateLease(
|
||||
daemon: Awaited<ReturnType<typeof createProviderScenarioHarness>>,
|
||||
): Promise<DeviceLease> {
|
||||
const response = await daemon.callCommand('lease_allocate', [], leaseFlags(), {
|
||||
meta: leaseMeta(),
|
||||
});
|
||||
return assertRpcOk<{ lease: DeviceLease }>(response).lease;
|
||||
}
|
||||
|
||||
// The interactor seam alone: no getAppleRunnerProvider, so nothing scopes the
|
||||
// request — only the transport injected into createAppleInteractor routes.
|
||||
async function createInteractorSeamWorld() {
|
||||
return await createRunnerTransportWorld({ requestScope: false });
|
||||
}
|
||||
|
||||
// The request-boundary seam: getAppleRunnerProvider scopes the whole request,
|
||||
// covering runner commands issued outside interactor methods.
|
||||
async function createRequestScopeWorld() {
|
||||
return await createRunnerTransportWorld({ requestScope: true });
|
||||
}
|
||||
|
||||
async function createRunnerTransportWorld(options: { requestScope: boolean }) {
|
||||
const calls: RunnerTransportCalls = { runner: [], opens: 0 };
|
||||
const runtime = createProviderRuntime(calls, options);
|
||||
const providers = createProviderDeviceRuntimeRequestProviders([runtime]);
|
||||
const daemon = await createProviderScenarioHarness({
|
||||
...providers,
|
||||
deviceInventoryProvider: providers.deviceInventoryProvider!,
|
||||
});
|
||||
return {
|
||||
daemon,
|
||||
calls,
|
||||
close: async () => {
|
||||
await runtime.shutdown();
|
||||
await daemon.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderRuntime(
|
||||
calls: RunnerTransportCalls,
|
||||
options: { requestScope: boolean },
|
||||
): ProviderDeviceRuntime {
|
||||
const transport: AppleRunnerProvider = {
|
||||
runCommand: async (_device, command, options) => {
|
||||
calls.runner.push({ command, options });
|
||||
return runnerResultFor(command);
|
||||
},
|
||||
};
|
||||
const leaseLifecycle: LeaseLifecycleProvider = {
|
||||
allocate: async (lease) =>
|
||||
lease.leaseProvider === PROVIDER ? { provider: PROVIDER, deviceId: DEVICE.id } : undefined,
|
||||
};
|
||||
const deviceInventoryProvider: DeviceInventoryProvider = async (request) =>
|
||||
request.leaseProvider === PROVIDER && request.leaseId ? [DEVICE] : null;
|
||||
return {
|
||||
provider: PROVIDER,
|
||||
leaseLifecycle,
|
||||
deviceInventoryProvider,
|
||||
ownsDevice: (device) => device.id === DEVICE.id,
|
||||
getInteractor: (device, runnerContext) =>
|
||||
device.id === DEVICE.id
|
||||
? createRunnerTransportInteractor(calls, transport, runnerContext)
|
||||
: undefined,
|
||||
...(options.requestScope
|
||||
? {
|
||||
getAppleRunnerProvider: (device: DeviceInfo) =>
|
||||
device.id === DEVICE.id ? transport : undefined,
|
||||
}
|
||||
: {}),
|
||||
shutdown: async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function createRunnerTransportInteractor(
|
||||
calls: RunnerTransportCalls,
|
||||
transport: AppleRunnerProvider,
|
||||
runnerContext: RunnerContext | undefined,
|
||||
): Interactor {
|
||||
return {
|
||||
...createAppleInteractor(DEVICE, runnerContext ?? {}, transport),
|
||||
// App lifecycle stays provider-owned: the transport seam covers runner
|
||||
// commands only, so the provider composes its own `open` on top.
|
||||
open: async () => {
|
||||
calls.opens += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runnerResultFor(command: RunnerCommand): Record<string, unknown> {
|
||||
switch (command.command) {
|
||||
case 'snapshot':
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
index: 0,
|
||||
type: 'Application',
|
||||
label: 'Example',
|
||||
rect: { x: 0, y: 0, width: 400, height: 800 },
|
||||
},
|
||||
{
|
||||
index: 1,
|
||||
parentIndex: 0,
|
||||
type: 'Button',
|
||||
label: 'Provider Ready',
|
||||
hittable: true,
|
||||
rect: { x: 20, y: 10, width: 80, height: 40 },
|
||||
},
|
||||
{
|
||||
index: 2,
|
||||
parentIndex: 0,
|
||||
type: 'TextField',
|
||||
label: 'Provider Input',
|
||||
hittable: true,
|
||||
rect: { x: 20, y: 70, width: 80, height: 40 },
|
||||
},
|
||||
],
|
||||
};
|
||||
case 'tap':
|
||||
return { x: command.x, y: command.y };
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function leaseFlags(leaseId?: string): DaemonRequest['flags'] {
|
||||
return {
|
||||
platform: 'ios',
|
||||
tenant: 'team-a',
|
||||
runId: 'run-a',
|
||||
leaseId,
|
||||
leaseProvider: PROVIDER,
|
||||
};
|
||||
}
|
||||
|
||||
function leaseMeta(leaseId?: string): DaemonRequest['meta'] {
|
||||
return {
|
||||
tenantId: 'team-a',
|
||||
runId: 'run-a',
|
||||
leaseId,
|
||||
leaseBackend: 'ios-instance',
|
||||
leaseProvider: PROVIDER,
|
||||
deviceKey: DEVICE.id,
|
||||
clientId: 'client-a',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user