Skip to content

feat(apple): injectable Apple runner transport seam for provider interactors#1389

Open
thymikee wants to merge 5 commits into
mainfrom
claude/github-issue-1297-922b70
Open

feat(apple): injectable Apple runner transport seam for provider interactors#1389
thymikee wants to merge 5 commits into
mainfrom
claude/github-issue-1297-922b70

Conversation

@thymikee

@thymikee thymikee commented Jul 24, 2026

Copy link
Copy Markdown
Member

Closes #1297.

What

Two composable seams let a direct provider reuse the shared Apple interaction stack instead of hand-building a bespoke Interactor (as LimrunIosInteractor and the cloud-webdriver interactor do today):

  1. Interactor transport paramcreateAppleInteractor(device, runnerContext, runnerProvider?). When present, every runner-command method runs inside withAppleRunnerProvider scope (same shape as createAndroidInteractor(device, provider?); the Proxy is now shared in core/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 with UNSUPPORTED_OPERATION instead of silently running local simctl against a remote device id; providers compose their own on top.
  2. Request-boundary scopeProviderDeviceRuntime.getAppleRunnerProvider?(device) composes into the request-level appleRunnerProvider resolver (wired in daemon-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: resolveScopedProviderDevice treats 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 from daemon.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 getAppleRunnerProvider yet, so composeAppleRunnerProviderResolver returns undefined for 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)

  • Integration (provider-ios-runner-transport.test.ts), two worlds:
    • Interactor-seam world (no getAppleRunnerProvider): the shared-stack test (open/get/is/wait/click/fill resolve 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.
    • Request-scope world: keyboard dismiss lands keyboardDismiss on the transport with the request's requestId, pinning both device matching and context preservation across the request boundary.
  • Unit (interactor-runner-provider.test.ts): partitions every method the Apple factory implements (not the full Interactor type — optional unimplemented members like setViewport are 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.
  • Registry invariant: every route: 'lease' command (enumerated from the registry, not a hand list) resolves provider-device intent skip.

Follow-up (behaviour upgrade, not a deletion)

Migrating LimrunIosInteractor (~130 LOC) / webdriver-interactor.ts (~325 LOC) onto the seam is a swap: each needs a RunnerCommand → SDK translator 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-exports green; unit + provider-integration + smoke suites pass (occasional timeouts under CPU contention pass in isolation — known flake pattern, failure set changes between runs).

#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.
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.82 MB 1.82 MB +1.1 kB
JS gzip 582.3 kB 582.8 kB +419 B
npm tarball 696.5 kB 696.9 kB +392 B
npm unpacked 2.44 MB 2.44 MB +1.1 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.8 ms 28.1 ms +0.3 ms
CLI --help 58.5 ms 58.4 ms -0.1 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/context.js +217 B +48 B
dist/src/internal/daemon.js +98 B +27 B

@thymikee

Copy link
Copy Markdown
Member Author

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.
@thymikee

Copy link
Copy Markdown
Member Author

Both findings addressed at 685bd4d.

P1 — direct daemon routes now reach the provider transport. ProviderDeviceRuntime gains optional getAppleRunnerProvider(device); createProviderDeviceRuntimeRequestProviders composes it into an appleRunnerProvider request resolver (owning runtime by ownsDevice), and daemon-runtime.ts wires it into createRequestHandler. The existing request-boundary descriptor then scopes the whole request with withAppleRunnerProvider({deviceId, requestId: req.meta.requestId}), so keyboard, native alert, point read, and iOS sequence paths resolve the provider transport instead of LOCAL_APPLE_RUNNER_RUNTIME. Daemon-route coverage: new integration test drives keyboard dismiss end-to-end and asserts keyboardDismiss arrives on the fake transport.

P2 — per-request RunnerContext now reaches provider interactors. getInteractor(device, runnerContext) threads the dispatch-built context through getProviderDeviceInteractor into ProviderDeviceRuntime.getInteractor(device, runnerContext?) (optional param; existing arity-1 runtimes unaffected). The integration fixture builds createAppleInteractor(device, runnerContext ?? {}, transport) per request; a new test asserts every runner call in a click request carries that request's meta.requestId, restoring cancellation/accounting.

One fallout fixed along the way: wiring the resolver made hasPlatformProviderResolvers true, which sent sessionless lease-route requests (lease_allocate with a platform flag counts as an explicit device selector) into resolveTargetDevice → real local device discovery (~4.4s locally, NO_DEVICE on headless hosts). Lease-route descriptors (lease_allocate/heartbeat/release, artifacts) now declare skipSessionlessProviderDevice — they manage lease lifecycle, not a device session.

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

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head 685bd4d5. The prior provider-routing and per-request interactor findings are fixed, but one regression gap remains.

P2 — prove request IDs survive the direct-route request boundary. The new keyboard dismiss provider-scope test sends no requestId, so it exercises only device matching. The separate request-ID assertion uses click, which goes through the interactor’s own injected-provider proxy and does not prove the new request-boundary scope preserves context for direct runner routes. Give the keyboard request an ID and assert its recorded runner call carries it.

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).
@thymikee

Copy link
Copy Markdown
Member Author

Addressed at e7c7c28: the keyboard dismiss request now carries requestId: 'req-keyboard-1' and the test asserts the recorded keyboardDismiss runner call arrives on the provider transport with that exact id — proving the request-boundary scope preserves per-request context on direct routes, not just device matching. (This also exercises the scope's requestId-equality resolution rule: a dropped or mismatched id would fall back to local XCTest and fail the transport assertion, so the two checks pin both halves.)

🤖 Addressed by Claude Code

@thymikee

Copy link
Copy Markdown
Member Author

Re-reviewed exact head e7c7c28: the prior direct-route request-context gap is fixed. The provider scenario sends keyboard dismiss with requestId: req-keyboard-1 and asserts the injected runner transport receives that exact ID. This is non-vacuous because losing either device or request scope would fall back to local XCTest and the provider-call assertion would fail. The request-router → provider scope → direct keyboard route is clean; typecheck, lint/format, layering, Integration, Coverage, and Fallow pass, with remaining smoke/FreeRange checks still pending at review time. Residual risk: no production remote provider has adopted the seam yet, so evidence is provider-harness rather than live remote-device evidence. Code review is clean.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Jul 25, 2026
…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.
@thymikee

Copy link
Copy Markdown
Member Author

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 getAppleRunnerProvider — the interactor param is the only seam, and I re-ran your experiment: dropping the third arg fails both (local-runtime attempt times out) while the request-scope test correctly still passes on its own seam. The direct-route keyboard dismiss test keeps the request-scope world it pins.

2 (lease trait derivation): the four hand-spread annotations are gone; shouldSkipSessionlessProviderDevice now derives skip from descriptor.route === 'lease', and a new invariant test in daemon-command-registry.test.ts enumerates lease-route commands from the registry (which is how artifacts gets covered without a hand list) and asserts intent skip.

3 (unguarded resolve): resolveScopedProviderDevice catches resolveTargetDevice failures and returns undefined — no provider scope, not a failed request. The lease-route skip stays as the fast path (avoids the pointless ~4s discovery even when it would succeed); the catch is the general safety net.

Also took the one genuine dedup: the Proxy wrapper is now withProviderScopedInteractor in core/interactor-scope.ts, shared by both createAndroidInteractor and the Apple factory — the Android-parity claim is structural now. The transport param's doc comment restates its actual job (local-tooling partition + out-of-daemon scoping; on daemon requests the request-boundary resolver routes too), and the PR body now carries the blast-radius note (no shipped runtime adopts getAppleRunnerProvider; default daemon pipeline unchanged) and the corrected 'implemented-surface' phrasing for the partition test. Limrun/webdriver migration stays framed as a swap, not a deletion.

🤖 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add an injectable Apple interactor transport seam for direct providers

1 participant