diff --git a/plans/phase3-platform-plugin-progress.md b/plans/phase3-platform-plugin-progress.md new file mode 100644 index 0000000000..b6f201216e --- /dev/null +++ b/plans/phase3-platform-plugin-progress.md @@ -0,0 +1,136 @@ +# Phase 3 — PlatformPlugin: progress + plan for the risky remainder + +> Tracks the platform-axis work from [perfect-shape.md](./perfect-shape.md) §5.1 / §6 (row "3 · platform +> plugin") and [apple-platform-consolidation.md](./apple-platform-consolidation.md) / ADR-0009. + +## Status + +| Step | What | State | +|---|---|---| +| **(a)** | `PlatformPlugin` registry + exhaustiveness + parity tests; route `getInteractor` through it | **✅ shipped (this PR — behaviorless)** | +| **(b)** | Move capability columns + daemon columns onto plugin grants; port `supports()`/`unsupportedHint()` closures verbatim | ⛔ planned — **HUMAN REVIEW ONLY, DO NOT AUTO-MERGE** | +| **(c)** | Unwind macOS out of `platforms/ios` into an `apple/` family, runner byte-identical | ⛔ planned — **HUMAN REVIEW ONLY, DO NOT AUTO-MERGE** | + +## Step (a) — what shipped (behaviorless foundation) + +- `src/core/platform-plugin/plugin.ts` — the `PlatformPlugin` type (type-only imports; lazy `createInteractor` + / `discoverDevices`) + the registry: `registerPlatformPlugin`, `getPlugin` (throws the same + `UNSUPPORTED_PLATFORM` AppError as the old switch default), `tryGetPlugin`, `registeredPlatforms`. +- `src/core/platform-plugin/register-builtins.ts` — `apple` (owns `ios`+`macos`), `android`, `linux`, `web` + plugins that WRAP today's `core/interactors/*` factories and the `platform-inventory.ts` branches via lazy + dynamic `import()`. `BuiltinPluginsCoverAllPlatforms` is the compile-time exhaustiveness assertion (a new + `Platform` literal without a plugin fails the build). +- `src/core/interactors.ts` — `getInteractor` now `return getPlugin(device.platform).createInteractor(...)` + after the unchanged provider-device check. Byte-identical (same lazy imports, same factory calls, same + throw). +- `src/core/platform-inventory.ts` — `WEB_DESKTOP_DEVICE` and `shouldUseHostMacFastPath` exported so the + web/apple plugins reuse the SAME instance/predicate (no divergent copy). +- Parity test `src/core/platform-plugin/__tests__/parity.test.ts`. + +**Contract scope (step-a discipline):** the `PlatformPlugin` type carries ONLY the facets this slice actually +implements and parity-tests — `id`, `platforms`, `familySelector?`, `createInteractor`, `discoverDevices`, +`capability { bucket, supportsByDefault? }`. The daemon-owned columns (`providers` / `recording` / `appLog` / +`perf`) are deliberately NOT declared yet. An earlier draft declared a `recording?: { start(req: +IosSimulatorRecordingRequest): RecordingProcess }` facet; that was REMOVED because it baked the +iOS-simulator provider seam into the contract (it cannot represent the Android / web / macOS-runner / +iOS-device-runner / stop-path recording contracts, which need the daemon recording context, not +`{device,outPath} -> child/wait`). Those facets arrive in step (b), platform-neutral — see §b.3. + +**Placement note (deviation from §5.1's `src/platforms/plugin.ts`):** the registry lives under +`src/core/platform-plugin/` — mirroring the existing `src/core/platform-descriptor/` and +`src/core/command-descriptor/` foundations (#905–911), and because everything it wraps today +(`core/interactors/*`, `core/platform-inventory.ts`, the `core/capabilities` bucket) lives in `core/`. Keeping +it in `core/` makes `getInteractor`'s routing and the `createInteractor` wraps `core→core` (the allowed +direction); a `platforms/`-resident registry would have to import `core/interactors/*` backwards at runtime. +The move to `src/platforms/apple/` is part of step (c)'s leaf relocation, not the behaviorless foundation. + +**Deliberately NOT done (left hand-authored — parity-tested, not derived):** `PLATFORMS` +(`src/kernel/device.ts:8`) and `parsePlatform` (`src/utils/parsing.ts:109-117`) remain the source of truth. +The parity test proves `registeredPlatforms()` is byte-for-byte equal to both; nothing is derived FROM the +registry yet (per the roadmap's "err toward leaving hand lists"). The CLI `--platform` enum already derives +from `PLATFORM_SELECTORS` (`src/utils/cli-flags.ts:352`), so it is not a hand-sync hazard. + +--- + +## Step (b) — capability + daemon columns onto plugin grants ⛔ DO NOT AUTO-MERGE + +**Principle (perfect-shape §7):** RELOCATE the device-shaped `supports()`/`unsupportedHint()` closures +verbatim; NEVER flatten them to data. Each derived table is pinned by a **table-equivalence parity test that +asserts byte-for-byte equality across the full sample-device matrix BEFORE any hand table is deleted.** + +### (b.1) Route the capability-bucket selection through the plugin (pure swap, lowest risk) + +- Today: `selectCapabilityForPlatform` (`src/core/capabilities.ts:80-85`) already derives from + `platformDescriptors` via `deriveCapabilityForPlatform`. The new plugin carries the SAME bucket in + `capability.bucket` (already parity-tested here against `platformDescriptors`). +- Change: have `isCommandSupportedOnDevice` (`capabilities.ts:87-95`) read the bucket via + `getPlugin(device.platform).capability.bucket` (falling through `tryGetPlugin` exactly as the §5.1 sketch: + `if (!plugin) return false`). +- Gate: a parity test asserting `isCommandSupportedOnDevice` is unchanged for the full + `{command × sample-device}` matrix (reuse `src/__tests__/test-utils/device-fixtures.ts`) before removing the + `platformDescriptors` indirection. **Keep `platformDescriptors` until proven redundant.** + +### (b.2) Port the `supports()` / `unsupportedHint()` device closures verbatim + +These encode the irreducible device nuance and live today in `src/core/command-descriptor/registry.ts`: +- `isNotMacOs` (`:41`), `isMacOsOrAppleSimulator` (`:42-43`), `isIosMobileSimulator` (`:44`), + `supportsAndroidOrIosNonTv` (`:46-47`), `supportsSynthesisGesture`, and + `synthesisGestureUnsupportedHint` (`:51-`) — the latter encodes **macOS-coordinate-pinch** (`:52`) and + **tvOS-no-touch** (`:54`, `device.platform === 'ios' && device.target === 'tv'`). +- Used at the `supports:`/`unsupportedHint:` sites (`:81, :145, :212-215, :227, :260, :319, :330-331, :429, + :440, :463-464, :505-506, :517-518, :530`), notably the two-finger synthesis commands (pinch / rotateGesture + / transformGesture). +- Plan: move these closures verbatim onto the relevant plugin's `capability.supportsByDefault` (declared but + unpopulated today) OR keep them on the command facet and have the platform-level default flow through the + plugin — **do not rewrite the predicate bodies.** Pin with a closure-equivalence test (same inputs → same + boolean / same hint string) before deleting any hand site. + +### (b.3) INTRODUCE the daemon-column facets (platform-neutral) onto the plugin + +Step (a) deliberately ships **no** `providers` / `recording` / `appLog` / `perf` facets (an earlier draft's +iOS-shaped `recording` facet was removed — see "Contract scope" above). Step (b) ADDS each facet to the +`PlatformPlugin` type, **typed against a PLATFORM-NEUTRAL, daemon-owned wrapper** — never the +`IosSimulatorRecordingRequest` provider seam — then populates it by wrapping the existing daemon branch, pins +it with a table-equivalence parity test, and only then routes the daemon lookup through `getPlugin(...)`: + +| Facet | Hand branch to wrap (file:line) | Neutral wrapper the facet must be typed against | Parity oracle | +|---|---|---|---| +| `providers` | `REQUEST_PLATFORM_PROVIDER_DESCRIPTORS` `src/daemon/request-platform-providers.ts:117-233` (per-platform `resolve` gates) | `() => Partial` (already platform-neutral) | each resolver returns the same provider/`undefined` per sample device | +| `recording` | `resolveRecordingBackendForDevice` / `stopActiveRecording` `src/daemon/handlers/record-trace-recording-backends.ts:73-101` | a daemon-owned `RecordingBackend` start+stop context carrying **session, deps, fps flag, recording base, resolved output path** for `start` and the **recording tag** for `stop` — NOT `{device,outPath} -> child/wait`; `startIosSimulatorRecording` (`src/daemon/recording-provider.ts:16-18`) is **de-iOS-named** here | same backend tag per device; same stop dispatch per recording tag | +| `appLog` | `resolveLogBackend` `src/daemon/app-log.ts:179-185`; `startLocalAppLog` if-chain `:344-375` | the existing `AppLogStartRequest` (carries device/appBundleId/outPath) + `LogBackend` resolver | same `LogBackend` + same start path per device | +| `perf` | `buildPerfResponseData` `src/daemon/handlers/session-perf.ts:109-131`; `supportsPlatformPerfMetrics` `:324-329`; native-perf Android gate `src/daemon/handlers/session-native-perf.ts:34-39` | the daemon perf request/response context | same metrics/support per device | + +**Layering caveat:** these facets reference daemon-owned types, so the facet types must live in / be imported +the right direction. When populated, the plugin's home likely moves to `src/platforms/` (so `daemon → +platforms` stays the allowed direction), which is why step (b.3) is naturally sequenced WITH step (c)'s +relocation. Until each facet is populated AND a real call-site routed through it with a passing parity test, +the daemon branches stay the source of truth and the facet is NOT added to the contract. + +--- + +## Step (c) — unwind macOS out of `platforms/ios` ⛔ DO NOT AUTO-MERGE (touches the shared XCTest runner) + +Per [apple-platform-consolidation.md](./apple-platform-consolidation.md) §"Sequencing" and ADR-0009. **Gated +behind the sim-validation request-counting harness** (count iOS runner requests via `--debug` per-request +ndjson; isolate daemons with `--state-dir`) — the runner request count must be unchanged before/after each +relocation commit. + +1. **macOS leaf relocation** — move `src/platforms/ios/{macos-helper.ts, macos-apps.ts, macos-host-provider.ts, + desktop-scroll.ts}` (~797 LOC) into `src/platforms/apple/os/macos/`, and invert the + `src/platforms/macos/devices.ts` 19-LOC stub (today `platform-inventory.ts:34` imports it). Pure + move + re-export; the AppKit specifics (helper binary, coordinate-pinch, menubar/desktop surfaces) stay in + the leaf — NOT flattened into the touch model. +2. **OS-agnostic engine relocation** — move the `runner/` stack (6,136 LOC, 17 files), `tool-provider`, + discovery, snapshot, screenshot, perf, debug-symbols, and `apple-runner-platform.ts` (`RUNNER_PROFILES`) + from `platforms/ios` → `platforms/apple/core`. **Byte-identical move** — the runner never needed to know + which Apple OS it drives. The runner request-counting harness is the gate here. +3. **Relocate the plugin + interactor** — `core/platform-plugin/` → `src/platforms/apple/` (plus + `core/interactors/apple.ts` → `apple/interactor.ts`), making `getInteractor`'s `core→platforms` routing the + final shape. Populate the `providers`/`recording`/`appLog`/`perf` facets from step (b.3) here. +4. **tvOS promotion** — rename `ios + target:'tv'` to an `apple/os/tvos/` leaf; behavior (XCUIRemote focus, + no coordinate tap) already exists. Keep the focus-only interaction contract — do NOT flatten a uniform tap. +5. (Future) visionOS net-new leaf; watchOS unsupported sentinel; per-`AppleOS` capability data table. + +**Do-not-flatten (perfect-shape §7):** the iOS XCTest two-finger synthesis (`RunnerSynthesizedGesture`) and +adb/idb leaf code stay untouched; the plugin's job is to stop core/daemon BRANCHING on platform, not to +homogenize the leaves. The `Platform` collapse of `ios`+`macos` → `apple` is the LAST, highest-diff step. diff --git a/src/core/interactors.ts b/src/core/interactors.ts index 7f7140e9b3..77e26f65fe 100644 --- a/src/core/interactors.ts +++ b/src/core/interactors.ts @@ -2,6 +2,12 @@ import type { DeviceInfo } from '../kernel/device.ts'; import { AppError } from '../kernel/errors.ts'; import { getProviderDeviceInteractor, isActiveProviderDevice } from '../provider-device-runtime.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; +import { getPlugin } from './platform-plugin/plugin.ts'; +import { registerBuiltinPlatformPlugins } from './platform-plugin/register-builtins.ts'; + +// Populate the platform-plugin registry once, at module load (only registers +// lazy closures — no leaf code is imported here, so CLI cold-start is unaffected). +registerBuiltinPlatformPlugins(); export async function getInteractor( device: DeviceInfo, @@ -17,25 +23,10 @@ export async function getInteractor( ); } - switch (device.platform) { - case 'android': { - const { createAndroidInteractor } = await import('./interactors/android.ts'); - return createAndroidInteractor(device); - } - case 'linux': { - const { createLinuxInteractor } = await import('./interactors/linux.ts'); - return createLinuxInteractor(); - } - case 'web': { - const { createWebInteractor } = await import('./interactors/web.ts'); - return createWebInteractor(); - } - case 'ios': - case 'macos': { - const { createAppleInteractor } = await import('./interactors/apple.ts'); - return createAppleInteractor(device, runnerContext); - } - default: - throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${device.platform}`); - } + // Byte-identical replacement for the former per-platform switch: each plugin's + // `createInteractor` is the SAME lazy dynamic import + factory call the switch + // arm performed, and `getPlugin` throws the SAME `UNSUPPORTED_PLATFORM` AppError + // the switch default threw. Registry exhaustiveness (BuiltinPluginsCoverAllPlatforms) + // guarantees every leaf `Platform` resolves. + return await getPlugin(device.platform).createInteractor(device, runnerContext); } diff --git a/src/core/platform-inventory.ts b/src/core/platform-inventory.ts index f55f5ccdb6..35b368a47a 100644 --- a/src/core/platform-inventory.ts +++ b/src/core/platform-inventory.ts @@ -14,7 +14,9 @@ export type DeviceInventoryRequest = { androidSerialAllowlist?: string[]; }; -const WEB_DESKTOP_DEVICE: DeviceInfo = { +// Exported so the web platform-plugin's `discoverDevices` reuses the SAME static +// device instance instead of carrying a divergent copy. +export const WEB_DESKTOP_DEVICE: DeviceInfo = { platform: 'web', id: 'agent-browser-chrome', name: 'Agent Browser Chrome', @@ -86,7 +88,9 @@ export async function listLocalDeviceInventory( return devices; } -function shouldUseHostMacFastPath(selector: { +// Exported so the Apple platform-plugin's `discoverDevices` reuses the SAME +// host-mac fast-path predicate instead of carrying a divergent copy. +export function shouldUseHostMacFastPath(selector: { platform?: PlatformSelector; target?: DeviceTarget; }): boolean { diff --git a/src/core/platform-plugin/__tests__/parity.test.ts b/src/core/platform-plugin/__tests__/parity.test.ts new file mode 100644 index 0000000000..96bb26e794 --- /dev/null +++ b/src/core/platform-plugin/__tests__/parity.test.ts @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { PLATFORMS, type Platform } from '../../../kernel/device.ts'; +import { AppError } from '../../../kernel/errors.ts'; +import { platformDescriptors } from '../../platform-descriptor/registry.ts'; +import { getPlugin, registeredPlatforms, registerPlatformPlugin, tryGetPlugin } from '../plugin.ts'; +import { BUILTIN_PLATFORM_PLUGINS, registerBuiltinPlatformPlugins } from '../register-builtins.ts'; + +// Idempotently populate the registry for this test module. +registerBuiltinPlatformPlugins(); + +// Independent VERBATIM copy of the hand-authored `parsePlatform` accept-set +// (src/utils/parsing.ts) — the one truly hand-maintained platform allow-list +// (the CLI `--platform` enum already derives from `PLATFORM_SELECTORS`). The +// registry's covered set is proven byte-for-byte equal to THIS reference list, +// so the assertion stays meaningful even if `parsePlatform` is later derived. +function parsePlatformByHand(value: unknown): Platform | undefined { + return value === 'ios' || + value === 'macos' || + value === 'android' || + value === 'linux' || + value === 'web' + ? value + : undefined; +} + +test('registeredPlatforms() equals the canonical PLATFORMS tuple, in order', () => { + // Byte-for-byte allow-list parity: the registry derives exactly PLATFORMS, + // in the same order. (Left as a parity assertion — PLATFORMS stays the + // hand-authored source of truth; nothing is derived FROM the registry yet.) + assert.deepEqual(registeredPlatforms(), [...PLATFORMS]); +}); + +test('registry coverage is byte-for-byte equal to the parsePlatform hand allow-list', () => { + // Every value either both register a plugin AND parse, or neither — including + // the `apple` SELECTOR (not a leaf platform) and assorted non-platforms. + const candidates: unknown[] = [ + 'ios', + 'macos', + 'android', + 'linux', + 'web', + 'apple', + 'tvos', + 'ipados', + 'windows', + '', + 'IOS', + undefined, + ]; + for (const candidate of candidates) { + const registered = tryGetPlugin(candidate as Platform) !== undefined; + const parses = parsePlatformByHand(candidate) !== undefined; + assert.equal(registered, parses, `coverage parity for ${JSON.stringify(candidate)}`); + } +}); + +test('every plugin capability bucket matches the platform-descriptor registry', () => { + // Ties the plugin capability facet to the existing `platformDescriptors` + // data registry (which `capabilities.ts` already derives from), so the two + // cannot drift. + for (const descriptor of platformDescriptors) { + assert.equal( + getPlugin(descriptor.platform).capability.bucket, + descriptor.capabilityBucket, + `bucket for ${descriptor.platform}`, + ); + } +}); + +test('a family plugin resolves to the SAME instance for every leaf it owns', () => { + // Apple owns both ios + macos (folds in the eventual macOS unwind). + assert.equal(getPlugin('ios'), getPlugin('macos')); + assert.equal(getPlugin('ios').id, 'apple'); + assert.equal(getPlugin('ios').familySelector, 'apple'); + // Single-platform plugins are distinct objects. + assert.notEqual(getPlugin('android'), getPlugin('linux')); +}); + +test('each registered platform resolves to a plugin that owns it', () => { + for (const platform of PLATFORMS) { + const plugin = getPlugin(platform); + assert.ok( + plugin.platforms.includes(platform), + `${platform} plugin lists ${platform} in its platforms`, + ); + assert.equal(typeof plugin.createInteractor, 'function'); + assert.equal(typeof plugin.discoverDevices, 'function'); + } +}); + +test('getPlugin throws UNSUPPORTED_PLATFORM (verbatim) for an unregistered platform', () => { + // Same code + message the deleted getInteractor switch default produced. + const unregistered = 'beos' as unknown as Platform; + assert.throws( + () => getPlugin(unregistered), + (error: unknown) => + error instanceof AppError && + error.code === 'UNSUPPORTED_PLATFORM' && + error.message === 'Unsupported platform: beos', + ); + assert.equal(tryGetPlugin(unregistered), undefined); +}); + +test('registering a duplicate platform is a hard error', () => { + assert.throws( + () => registerPlatformPlugin(BUILTIN_PLATFORM_PLUGINS[0]), + /already registered for platform/, + ); +}); diff --git a/src/core/platform-plugin/plugin.ts b/src/core/platform-plugin/plugin.ts new file mode 100644 index 0000000000..38c99d743c --- /dev/null +++ b/src/core/platform-plugin/plugin.ts @@ -0,0 +1,92 @@ +import { AppError } from '../../kernel/errors.ts'; +import type { DeviceInfo, Platform, PlatformSelector } from '../../kernel/device.ts'; +import type { Interactor, RunnerContext } from '../interactor-types.ts'; +import type { DeviceInventoryRequest } from '../platform-inventory.ts'; +import type { CapabilityBucket } from '../platform-descriptor/types.ts'; + +/** + * The platform-plugin contract (plans/perfect-shape.md §5.1, ADR-0009). + * + * One plugin owns one platform FAMILY: a plugin may cover several leaf + * {@link Platform} literals (the Apple plugin owns both `ios` and `macos`, + * folding in the eventual macOS unwind). The plugin's only job is to stop + * core/daemon from BRANCHING on platform — it WRAPS today's existing factories + * and discovery, it does NOT homogenize the irreducible leaf code (XCTest + * synthesis, adb/idb), which stays exactly where it is. + * + * Imports are TYPE-ONLY; the concrete leaf code is reached through LAZY dynamic + * `import()` inside `createInteractor` / `discoverDevices`, preserving the + * CLI cold-start laziness that today's `getInteractor` switch relies on. + * + * Step-a scope: this contract intentionally contains ONLY the facets this slice + * genuinely implements and parity-tests. The daemon-owned columns + * (`providers` / `recording` / `appLog` / `perf`) are NOT declared here — they + * arrive in step (b), typed against PLATFORM-NEUTRAL, daemon-owned wrappers + * (not the iOS-simulator-shaped provider seam). See + * plans/phase3-platform-plugin-progress.md. + */ +export type PlatformPlugin = { + /** Plugin/family id; also the capability-matrix bucket key for its platforms. */ + readonly id: string; + /** Leaf platforms this plugin owns (e.g. `['ios', 'macos']` for Apple). */ + readonly platforms: readonly Platform[]; + /** The multi-platform family selector, when the plugin owns more than one leaf (`apple`). */ + readonly familySelector?: PlatformSelector; + /** Lazily builds the {@link Interactor} for `device` — wraps today's `getInteractor` switch arm. */ + createInteractor(device: DeviceInfo, runner: RunnerContext): Promise; + /** Lazily discovers devices for this family — wraps today's inventory if-chain branch. */ + discoverDevices(request: DeviceInventoryRequest): Promise; + /** + * The capability facet. `bucket` is the {@link CapabilityBucket} this family + * reads from a `CommandCapability` (parity-checked against the existing + * `platformDescriptors` registry). `supportsByDefault` is reserved for the + * step-(b) relocation of the `supports()` device closures — left undefined + * here so those closures stay verbatim in `capabilities.ts` for now. + */ + readonly capability: { + readonly bucket: CapabilityBucket; + supportsByDefault?(device: DeviceInfo): boolean; + }; +}; + +// The single registry instance: leaf platform -> owning plugin. A family plugin +// is registered once per leaf platform it owns, so `getPlugin('ios')` and +// `getPlugin('macos')` resolve to the SAME Apple plugin object. +const registry = new Map(); + +/** + * Registers `plugin` for each leaf platform it owns. Throws on a duplicate + * registration so a double-owned platform is a hard error, not a silent + * last-writer-wins. + */ +export function registerPlatformPlugin(plugin: PlatformPlugin): void { + for (const platform of plugin.platforms) { + if (registry.has(platform)) { + throw new Error(`PlatformPlugin already registered for platform "${platform}"`); + } + registry.set(platform, plugin); + } +} + +/** + * Returns the plugin for `platform`, throwing the SAME `UNSUPPORTED_PLATFORM` + * AppError (identical code + message) that the hand-authored `getInteractor` + * switch default threw, so routing through it is byte-identical. + */ +export function getPlugin(platform: Platform): PlatformPlugin { + const plugin = registry.get(platform); + if (!plugin) { + throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform: ${platform}`); + } + return plugin; +} + +/** Non-throwing lookup, for call-sites that branch on plugin presence. */ +export function tryGetPlugin(platform: Platform): PlatformPlugin | undefined { + return registry.get(platform); +} + +/** The leaf platforms that currently carry a plugin, in registration order. */ +export function registeredPlatforms(): Platform[] { + return [...registry.keys()]; +} diff --git a/src/core/platform-plugin/register-builtins.ts b/src/core/platform-plugin/register-builtins.ts new file mode 100644 index 0000000000..23aee7e9a1 --- /dev/null +++ b/src/core/platform-plugin/register-builtins.ts @@ -0,0 +1,126 @@ +import { registerPlatformPlugin, type PlatformPlugin } from './plugin.ts'; +import { shouldUseHostMacFastPath, WEB_DESKTOP_DEVICE } from '../platform-inventory.ts'; +import type { Platform, DeviceInfo } from '../../kernel/device.ts'; +import type { DeviceInventoryRequest } from '../platform-inventory.ts'; +import type { RunnerContext } from '../interactor-types.ts'; + +// Each plugin WRAPS today's existing factories (src/core/interactors/*) and the +// inventory if-chain (src/core/platform-inventory.ts) as LAZY methods. No leaf +// code is rewritten: the dynamic `import()`s and the per-platform list calls are +// byte-for-byte the same as the hand-authored `getInteractor` switch arms and +// `listLocalDeviceInventory` branches. `as const satisfies PlatformPlugin` +// preserves each plugin's literal `platforms` tuple so the totality assertion +// below is a real compile-time check. + +const applePlugin = { + id: 'apple', + // Apple owns BOTH leaf platforms today — mirrors `case 'ios': case 'macos':`. + platforms: ['ios', 'macos'], + familySelector: 'apple', + capability: { bucket: 'apple' }, + createInteractor: async (device: DeviceInfo, runner: RunnerContext) => { + const { createAppleInteractor } = await import('../interactors/apple.ts'); + return createAppleInteractor(device, runner); + }, + // Reproduces the macOS host fast-path + Apple-simulator branch of the + // inventory if-chain, reusing the SAME predicate (no divergent copy). + discoverDevices: async (request: DeviceInventoryRequest) => { + if (shouldUseHostMacFastPath(request)) { + const { listMacosDevices } = await import('../../platforms/macos/devices.ts'); + return await listMacosDevices(); + } + const { listAppleDevices } = await import('../../platforms/ios/devices.ts'); + return await listAppleDevices({ + simulatorSetPath: request.iosSimulatorSetPath, + udid: request.udid, + }); + }, +} as const satisfies PlatformPlugin; + +const androidPlugin = { + id: 'android', + platforms: ['android'], + capability: { bucket: 'android' }, + createInteractor: async (device: DeviceInfo) => { + const { createAndroidInteractor } = await import('../interactors/android.ts'); + return createAndroidInteractor(device); + }, + discoverDevices: async (request: DeviceInventoryRequest) => { + const { listAndroidDevices } = await import('../../platforms/android/devices.ts'); + return await listAndroidDevices({ + serialAllowlist: request.androidSerialAllowlist + ? new Set(request.androidSerialAllowlist) + : undefined, + }); + }, +} as const satisfies PlatformPlugin; + +const linuxPlugin = { + id: 'linux', + platforms: ['linux'], + capability: { bucket: 'linux' }, + createInteractor: async () => { + const { createLinuxInteractor } = await import('../interactors/linux.ts'); + return createLinuxInteractor(); + }, + discoverDevices: async () => { + const { listLinuxDevices } = await import('../../platforms/linux/devices.ts'); + return await listLinuxDevices(); + }, +} as const satisfies PlatformPlugin; + +const webPlugin = { + id: 'web', + platforms: ['web'], + capability: { bucket: 'web' }, + createInteractor: async () => { + const { createWebInteractor } = await import('../interactors/web.ts'); + return createWebInteractor(); + }, + // Mirrors the `request.platform === 'web'` branch (the single static device). + discoverDevices: async () => [WEB_DESKTOP_DEVICE], +} as const satisfies PlatformPlugin; + +/** + * The builtin plugins, in `PLATFORMS` order so `registeredPlatforms()` derives + * the canonical tuple's order (asserted by the parity test). + */ +export const BUILTIN_PLATFORM_PLUGINS = [ + applePlugin, + androidPlugin, + linuxPlugin, + webPlugin, +] as const satisfies readonly PlatformPlugin[]; + +// The leaf platforms covered by at least one builtin plugin, recovered from the +// preserved literal `platforms` tuples. +type CoveredPlatform = (typeof BUILTIN_PLATFORM_PLUGINS)[number]['platforms'][number]; + +/** + * Compile-time EXHAUSTIVENESS: a new `Platform` literal added to `PLATFORMS` + * without a plugin makes `Platform` no longer extend `CoveredPlatform`, so this + * alias resolves to `false`, violating the `extends true` constraint and failing + * the build. This is the registry counterpart of the deleted `getInteractor` + * switch's exhaustive `never` default. (Equivalent in spirit to the §5.1 + * `Object.fromEntries(registeredPlatforms()...) satisfies Record` + * sketch, but type-level so it cannot be satisfied vacuously by a runtime map.) + */ +type AssertTrue = T; +export type BuiltinPluginsCoverAllPlatforms = AssertTrue< + [Platform] extends [CoveredPlatform] ? true : false +>; + +let registered = false; + +/** + * Registers every builtin plugin into the shared registry exactly once + * (idempotent). Called at the top of `core/interactors.ts` so the registry is + * populated before any `getPlugin` lookup; safe to call again from tests. + */ +export function registerBuiltinPlatformPlugins(): void { + if (registered) return; + for (const plugin of BUILTIN_PLATFORM_PLUGINS) { + registerPlatformPlugin(plugin); + } + registered = true; +}