feat(apple): injectable Apple runner transport seam for provider interactors#1389
feat(apple): injectable Apple runner transport seam for provider interactors#1389thymikee wants to merge 5 commits into
Conversation
#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.
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
Reviewed exact head 2451658. Not ready. P1 — provider-owned commands still escape to the local XCTest runtime outside the interactor proxy. keyboard, native alert, point-read, and iOS sequence paths call runAppleRunnerCommand directly; the provider request setup supplies no outer appleRunnerProvider scope, so these routes select LOCAL_APPLE_RUNNER_RUNTIME. Scope the transport at the request boundary or explicitly reject those provider routes, with daemon-route coverage. P2 — per-request RunnerContext/requestId is discarded for provider devices. Dispatch constructs it, but getInteractor returns the prebuilt provider interactor and ProviderDeviceRuntime.getInteractor cannot accept context, so the scope uses construction-time context (the integration fixture uses {}). This loses request cancellation/accounting required by ADR 0001. The covered selector/snapshot/tap/fill route is meaningful, but no live provider evidence is attached. The macOS smoke failure is an unrelated System Settings launch flake; code findings still block readiness. |
…eractor 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.
|
Both findings addressed at 685bd4d. P1 — direct daemon routes now reach the provider transport. P2 — per-request RunnerContext now reaches provider interactors. One fallout fixed along the way: wiring the resolver made On live provider evidence: no in-repo runtime adopts the seam yet (Limrun still uses its bespoke interactor — intentionally out of scope per the issue), so there is no production code path to capture live; the harness-level daemon-route + requestId coverage is the honest extent until a provider migrates. Happy to do the Limrun migration as the follow-up if you want it next. 🤖 Addressed by Claude Code |
|
Re-reviewed exact head P2 — prove request IDs survive the direct-route request boundary. The new All checks are green. No live provider currently adopts the seam, so live-device validation remains residual risk. |
…rough 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).
|
Addressed at e7c7c28: the 🤖 Addressed by Claude Code |
|
Re-reviewed exact head |
…ip, 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.
|
All three findings addressed at fea40eb; PR body rewritten to match. 1 (revert-sensitive acceptance test): the integration file now runs two worlds. The shared-stack test and the per-request-id test use a world whose runtime has NO 2 (lease trait derivation): the four hand-spread annotations are gone; 3 (unguarded resolve): Also took the one genuine dedup: the Proxy wrapper is now 🤖 Addressed by Claude Code |
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.
Closes #1297.
What
Two composable seams let a direct provider reuse the shared Apple interaction stack instead of hand-building a bespoke
Interactor(asLimrunIosInteractorand the cloud-webdriver interactor do today):createAppleInteractor(device, runnerContext, runnerProvider?). When present, every runner-command method runs insidewithAppleRunnerProviderscope (same shape ascreateAndroidInteractor(device, provider?); the Proxy is now shared incore/interactor-scope.ts). Its unique jobs are the local-tooling partition and in-process scoping for interactors composed outside a daemon request: methods backed by simctl/devicectl (open,openDevice,close,screenshot, clipboard,setSetting— runner screenshot capture also pulls its file with local tooling) fail fast withUNSUPPORTED_OPERATIONinstead of silently running local simctl against a remote device id; providers compose their own on top.ProviderDeviceRuntime.getAppleRunnerProvider?(device)composes into the request-levelappleRunnerProviderresolver (wired indaemon-runtime.ts), so daemon routes that issue runner commands outside interactor methods (keyboard, native alert, point read, iOS sequence chunks) also reach the provider transport. On the daemon path the two seams overlap on routing by design; the param is not redundant (see its doc comment).getInteractor(device, runnerContext?)threads per-request context (requestId for cancellation/accounting, appBundleId, log paths) into provider runtimes.Supporting hardening:
resolveScopedProviderDevicetreats an unresolvable device as "no provider scope" instead of failing the request, and lease-route commands (lease_allocate/heartbeat/release,artifacts) skip sessionless provider-device resolution — derived fromdaemon.route === 'lease'and pinned by a registry-enumerated invariant test, so a future lease-route command cannot regress into spurious local device discovery.Blast radius
Neither shipped runtime (limrun, cloud-webdriver) implements
getAppleRunnerProvideryet, socomposeAppleRunnerProviderResolverreturnsundefinedfor the default daemon and the request pipeline is unchanged in production today. The seams activate when a runtime adopts them (SDK embedders included).Tests (revert-sensitive by construction)
provider-ios-runner-transport.test.ts), two worlds:getAppleRunnerProvider): the shared-stack test (open/get/is/wait/click/fillresolve selectors and land element-center taps/fills/snapshots on the fake transport) and the per-request-id test run with the param as the only seam — verified failing when the param is dropped.keyboard dismisslandskeyboardDismisson the transport with the request'srequestId, pinning both device matching and context preservation across the request boundary.interactor-runner-provider.test.ts): partitions every method the Apple factory implements (not the fullInteractortype — optional unimplemented members likesetViewportare out of scope) into runner-transport vs local-tooling and asserts the partition is total; plus per-method command routing, requestId propagation, snapshot shape, and rejection messages.route: 'lease'command (enumerated from the registry, not a hand list) resolves provider-device intentskip.Follow-up (behaviour upgrade, not a deletion)
Migrating
LimrunIosInteractor(~130 LOC) /webdriver-interactor.ts(~325 LOC) onto the seam is a swap: each needs aRunnerCommand → SDKtranslator of comparable size plus the snapshot response shape. The payoff is inheriting selector resolution, gesture plans, response guarantees, and the shared error surface — and it would produce the live-provider evidence this PR cannot: no shipped runtime adopts the seam yet, so live-device validation remains residual risk.Verification
pnpm typecheck,lint,check:layering,format:check,check:production-exportsgreen; unit + provider-integration + smoke suites pass (occasional timeouts under CPU contention pass in isolation — known flake pattern, failure set changes between runs).