From 41089c538e11685b84b693a8e133e46a5ce78cd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 17:51:17 +0200 Subject: [PATCH 1/3] refactor: collapse public Platform ios/macos into apple (#979) Phase 3 d.3: collapse the internal `Platform` union from `ios`/`macos` to a single `apple` platform, with `appleOs` as the sole OS discriminant. Approach (b) NON-BREAKING: the daemon still ACCEPTS the legacy `ios`/`macos` selectors on every read path and still EMITS the leaf `ios`/`macos` strings on every output, so machine consumers see no change. Kernel (src/kernel/device.ts): - PLATFORMS = ['apple','android','linux','web']; add PUBLIC_PLATFORMS (leaf) and PublicPlatform; PLATFORM_SELECTORS keeps legacy `ios`/`macos` as input aliases. - New predicates: isMacOs (appleOs- or legacy-leaf-based), isIosFamily (the post-collapse equivalent of `platform === 'ios'`), publicPlatformString (output projection), deviceFieldsFromPublicPlatform (inverse), isPublicPlatform. - isMobilePlatform and matchesPlatformSelector are now device-aware (appleOs). Discovery now stamps `platform: 'apple'` (+ appleOs); ~125 internal `device.platform === 'ios'|'macos'` branch sites migrated to the predicates, behavior-preserving. Apple plugin owns `['apple']`; platformDescriptors collapse to one `apple` row. Output projection (approach b) emits the leaf via publicPlatformString at: devices / session_list (session-inventory), boot / shutdown / appstate / prepare-ios-runner (session-state, session), the selector/backend platform (selector-runtime/screenshot-runtime/snapshot-runtime/interaction-runtime), proxy device key, request-lock backfill, runtime-set binding, click-button validation, and both `.ad` context-line writers. Contracts/client keep leaf types (PublicPlatform); read paths (parsePlatform, REPLAY_METADATA_PLATFORMS, matchesPlatformSelector) accept `apple` + legacy leaves. Adds a parity test gate (platform-collapse-parity.test.ts). Refs #979 (part of #972). --- src/__tests__/client-normalizers.test.ts | 6 +- src/__tests__/provider-device-runtime.test.ts | 2 +- src/__tests__/test-utils/device-fixtures.ts | 16 +- src/backend.ts | 7 +- src/cli/commands/connection-runtime.ts | 16 +- src/client/client-normalizers.ts | 4 +- src/client/client-shared.ts | 4 +- src/client/client-types.ts | 13 +- src/cloud-webdriver/runtime.ts | 6 +- .../command-surface-metadata.test.ts | 2 +- src/commands/capture/index.test.ts | 2 +- src/compat/maestro/runtime-targets.ts | 19 +- src/contracts/device.ts | 6 +- src/core/__tests__/capabilities.test.ts | 13 +- .../capability-plugin-routing-parity.test.ts | 30 ++-- src/core/__tests__/dispatch-open.test.ts | 2 +- src/core/__tests__/dispatch-resolve.test.ts | 12 +- src/core/__tests__/dispatch-series.test.ts | 2 +- src/core/app-events.ts | 22 +-- src/core/dispatch-interactions.ts | 26 +-- src/core/dispatch.ts | 10 +- .../__tests__/parity.test.ts | 3 +- src/core/platform-descriptor/registry.ts | 9 +- src/core/platform-inventory.ts | 10 +- .../apple-os-capability-table-parity.test.ts | 24 +-- .../platform-plugin/__tests__/parity.test.ts | 12 +- .../platform-plugin/apple-os-capabilities.ts | 4 +- src/core/platform-plugin/plugin.ts | 4 +- .../applog-plugin-routing-parity.test.ts | 12 +- .../perf-plugin-routing-parity.test.ts | 8 +- .../__tests__/request-lock-policy.test.ts | 2 +- .../request-platform-providers.test.ts | 2 +- .../request-recording-health.test.ts | 2 +- .../__tests__/request-router-cost.test.ts | 2 +- .../request-router-lock-policy.test.ts | 2 +- .../__tests__/request-router-open.test.ts | 2 +- .../request-router-recording-health.test.ts | 4 +- .../request-router-response-level.test.ts | 2 +- .../request-router-screenshot.test.ts | 3 +- .../request-router-typed-error.test.ts | 2 +- src/daemon/__tests__/runtime-hints.test.ts | 2 +- src/daemon/__tests__/session-selector.test.ts | 4 +- src/daemon/__tests__/session-store.test.ts | 2 +- src/daemon/__tests__/target-shutdown.test.ts | 6 +- src/daemon/app-log.ts | 20 +-- src/daemon/apple-runner-options.ts | 4 +- src/daemon/device-ready.ts | 4 +- src/daemon/device-targets.ts | 4 +- src/daemon/direct-ios-selector.ts | 3 +- .../handlers/__tests__/interaction.test.ts | 6 +- .../handlers/__tests__/react-native.test.ts | 14 +- .../handlers/__tests__/record-trace.test.ts | 40 ++--- .../__tests__/session-close-shutdown.test.ts | 13 +- .../__tests__/session-device-utils.test.ts | 2 +- .../__tests__/session-observability.test.ts | 2 +- .../__tests__/session-open-runtime.test.ts | 2 +- .../__tests__/session-open-surface.test.ts | 2 +- .../__tests__/session-reinstall.test.ts | 4 +- .../__tests__/session-replay-vars.test.ts | 6 +- src/daemon/handlers/__tests__/session.test.ts | 167 ++++++++++-------- .../__tests__/snapshot-handler.test.ts | 9 +- src/daemon/handlers/install-source.ts | 3 +- src/daemon/handlers/interaction-read.ts | 3 +- src/daemon/handlers/interaction-runtime.ts | 3 +- .../handlers/interaction-touch-policy.ts | 3 +- src/daemon/handlers/interaction-touch.ts | 3 +- src/daemon/handlers/record-trace-ios.ts | 3 +- .../record-trace-recording-backends.ts | 7 +- src/daemon/handlers/session-deploy.ts | 9 +- src/daemon/handlers/session-device-utils.ts | 4 +- src/daemon/handlers/session-doctor-app.ts | 4 +- src/daemon/handlers/session-inventory.ts | 25 ++- src/daemon/handlers/session-open-surface.ts | 8 +- src/daemon/handlers/session-open-target.ts | 4 +- src/daemon/handlers/session-open.ts | 4 +- .../handlers/session-runtime-command.ts | 18 +- src/daemon/handlers/session-runtime.ts | 6 +- src/daemon/handlers/session-state.ts | 30 ++-- src/daemon/handlers/session-test-sharding.ts | 4 +- src/daemon/handlers/session.ts | 6 +- src/daemon/handlers/snapshot-alert.ts | 3 +- src/daemon/handlers/snapshot-capture.ts | 6 +- src/daemon/handlers/snapshot-session.ts | 3 +- src/daemon/handlers/snapshot-settings.ts | 3 +- src/daemon/interaction-outcome-policy.ts | 2 +- src/daemon/post-gesture-stabilization.ts | 8 +- src/daemon/recording-gestures.ts | 3 +- src/daemon/request-lock-policy.ts | 6 +- src/daemon/request-recording-health.ts | 3 +- src/daemon/runtime-hints.ts | 6 +- src/daemon/screenshot-runtime.ts | 5 +- src/daemon/selector-runtime-backend.ts | 6 +- src/daemon/selectors-match.ts | 10 +- src/daemon/selectors-resolve.ts | 10 +- src/daemon/session-script-writer.ts | 4 +- src/daemon/session-selector.ts | 8 +- src/daemon/session-teardown.ts | 4 +- src/daemon/snapshot-runtime.ts | 5 +- .../platform-collapse-parity.test.ts | 136 ++++++++++++++ src/kernel/audio-probe-support.ts | 5 +- src/kernel/device.ts | 102 +++++++++-- .../apple/__tests__/watchos-sentinel.test.ts | 4 +- .../__tests__/apple-runner-platform.test.ts | 6 +- .../apple/core/__tests__/devices.test.ts | 13 +- .../apple/core/__tests__/index.test.ts | 53 +++--- .../apple/core/__tests__/perf.test.ts | 13 +- .../core/__tests__/runner-client.test.ts | 26 +-- .../core/__tests__/runner-session.test.ts | 5 +- .../core/__tests__/runner-transport.test.ts | 4 +- .../core/__tests__/runner-xctestrun.test.ts | 4 +- .../apple/core/__tests__/simctl.test.ts | 2 +- .../apple/core/apple-runner-platform.ts | 3 +- src/platforms/apple/core/apps.ts | 24 +-- src/platforms/apple/core/devices.ts | 12 +- src/platforms/apple/core/perf-xctrace.ts | 6 +- src/platforms/apple/core/perf.ts | 76 ++++---- .../apple/core/runner/runner-client.ts | 6 +- .../core/runner/runner-macos-products.ts | 4 +- .../apple/core/runner/runner-session.ts | 4 +- .../apple/core/runner/runner-xctestrun.ts | 14 +- src/platforms/apple/core/screenshot.ts | 6 +- src/platforms/apple/core/simctl.ts | 4 +- src/platforms/apple/interactions.ts | 8 +- src/platforms/apple/interactor.ts | 4 +- src/platforms/apple/os/macos/audio-probe.ts | 13 +- src/platforms/apple/os/macos/devices.ts | 2 +- src/platforms/apple/plugin.ts | 18 +- src/replay/__tests__/script.test.ts | 12 +- src/replay/script.ts | 20 ++- src/snapshot/snapshot-processing.ts | 4 +- src/utils/__tests__/cli-flags.test.ts | 2 +- src/utils/__tests__/device.test.ts | 34 ++-- src/utils/__tests__/interactors.test.ts | 4 +- src/utils/parsing.ts | 12 +- src/utils/selector-build.ts | 4 +- src/utils/selector-is-predicates.ts | 10 +- src/utils/selector-node.ts | 4 +- .../provider-scenarios/fixtures.ts | 11 +- .../ios-alert-settings.test.ts | 4 +- .../ios-record-trace.test.ts | 4 +- .../provider-scenarios/ios-world.ts | 26 +-- .../provider-scenarios/macos-desktop.test.ts | 4 +- .../macos-recording.test.ts | 4 +- .../provider-scenarios/tvos-remote.test.ts | 6 +- 144 files changed, 977 insertions(+), 652 deletions(-) create mode 100644 src/kernel/__tests__/platform-collapse-parity.test.ts diff --git a/src/__tests__/client-normalizers.test.ts b/src/__tests__/client-normalizers.test.ts index 7a3e5c09f..1e58ca16a 100644 --- a/src/__tests__/client-normalizers.test.ts +++ b/src/__tests__/client-normalizers.test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import { normalizeOpenDevice } from '../client/client-normalizers.ts'; -import { PLATFORMS } from '../kernel/device.ts'; +import { PUBLIC_PLATFORMS } from '../kernel/device.ts'; test('normalizeOpenDevice accepts exactly the canonical leaf platforms', () => { - for (const platform of PLATFORMS) { + for (const platform of PUBLIC_PLATFORMS) { const result = normalizeOpenDevice({ platform, id: 'device-1', @@ -14,7 +14,7 @@ test('normalizeOpenDevice accepts exactly the canonical leaf platforms', () => { assert.equal(result.platform, platform); } // Lock the membership so the derived check cannot silently widen/narrow. - assert.deepEqual([...PLATFORMS], ['ios', 'macos', 'android', 'linux', 'web']); + assert.deepEqual([...PUBLIC_PLATFORMS], ['ios', 'macos', 'android', 'linux', 'web']); }); test('normalizeOpenDevice rejects the apple selector and unknown platforms', () => { diff --git a/src/__tests__/provider-device-runtime.test.ts b/src/__tests__/provider-device-runtime.test.ts index bb74d4d31..439ee06e6 100644 --- a/src/__tests__/provider-device-runtime.test.ts +++ b/src/__tests__/provider-device-runtime.test.ts @@ -51,7 +51,7 @@ function makeProviderRuntimeWorld() { expiresAt: 60_001, }; const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', kind: 'simulator', id: 'provider:ios:lease-a', name: 'Provider iOS', diff --git a/src/__tests__/test-utils/device-fixtures.ts b/src/__tests__/test-utils/device-fixtures.ts index d935bec5b..ce69fe147 100644 --- a/src/__tests__/test-utils/device-fixtures.ts +++ b/src/__tests__/test-utils/device-fixtures.ts @@ -9,27 +9,30 @@ export const ANDROID_EMULATOR: DeviceInfo = { }; export const IOS_SIMULATOR: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', + appleOs: 'ios', booted: true, }; export const IOS_DEVICE: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone', kind: 'device', + appleOs: 'ios', booted: true, }; export const MACOS_DEVICE: DeviceInfo = { - platform: 'macos', + platform: 'apple', id: 'host-macos-local', name: 'Mac', kind: 'device', target: 'desktop', + appleOs: 'macos', booted: true, }; @@ -59,11 +62,12 @@ export const ANDROID_TV_DEVICE: DeviceInfo = { }; export const TVOS_SIMULATOR: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'tv-sim-1', name: 'Apple TV', kind: 'simulator', target: 'tv', + appleOs: 'tvos', }; // iPadOS / visionOS carry the explicit `appleOs` discriminant discovery stores, so @@ -72,7 +76,7 @@ export const TVOS_SIMULATOR: DeviceInfo = { // the touch iOS engine (`platform: 'ios'`, mobile target) and are capability-identical // to iOS today. export const IPADOS_SIMULATOR: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'ipad-sim-1', name: 'iPad Pro 11-inch', kind: 'simulator', @@ -81,7 +85,7 @@ export const IPADOS_SIMULATOR: DeviceInfo = { }; export const VISIONOS_SIMULATOR: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'vision-sim-1', name: 'Apple Vision Pro', kind: 'simulator', diff --git a/src/backend.ts b/src/backend.ts index 6e4af2d41..e58e6db11 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -2,7 +2,7 @@ import type { AlertAction, AlertInfo } from './alert-contract.ts'; import type { AppsFilter } from './contracts/app-inventory.ts'; import type { Point, SnapshotNode, SnapshotOptions, SnapshotState } from './kernel/snapshot.ts'; import type { NetworkIncludeMode } from './kernel/contracts.ts'; -import type { DeviceTarget, Platform, PlatformSelector } from './kernel/device.ts'; +import type { DeviceTarget, Platform, PlatformSelector, PublicPlatform } from './kernel/device.ts'; import type { BackMode } from './core/back-mode.ts'; import type { RepeatedInput } from './commands/command-input.ts'; import type { ClickButton } from './core/click-button.ts'; @@ -18,7 +18,10 @@ import type { } from './snapshot-capture-annotations.ts'; import type { ScreenshotResultData } from './utils/screenshot-result.ts'; -export type AgentDeviceBackendPlatform = Platform; +// The backend's public leaf platform (approach b): backends distinguish iOS from +// macOS (e.g. snapshot backend routing, the macOS surface guard), so this carries the +// leaf string, not the internal collapsed `apple`. +export type AgentDeviceBackendPlatform = PublicPlatform; export const BACKEND_CAPABILITY_NAMES = [ 'android.shell', diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index af1078707..46b7bab1a 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -2,7 +2,13 @@ import { resolveDaemonPaths } from '../../daemon/config.ts'; import { stopReactDevtoolsCompanion } from '../../client/client-react-devtools-companion.ts'; import { stopMetroTunnel } from '../../metro/metro.ts'; import { resolveRemoteConfigProfile } from '../../remote/remote-config.ts'; -import { resolveDevice, type DeviceInfo } from '../../kernel/device.ts'; +import { + deviceFieldsFromPublicPlatform, + isIosFamily, + publicPlatformString, + resolveDevice, + type DeviceInfo, +} from '../../kernel/device.ts'; import { shouldAgentCdpUseRemoteBridgeUrl } from './agent-cdp.ts'; import type { MetroBridgeScope } from '../../client/client-companion-tunnel-contract.ts'; import { @@ -718,7 +724,7 @@ async function resolveProxyLeaseState(options: { function applyResolvedDeviceSelector(flags: CliFlags, device: DeviceInfo): void { flags.platform = device.platform; flags.target = device.target ?? flags.target; - if (device.platform === 'ios') { + if (isIosFamily(device)) { flags.udid = device.id; return; } @@ -742,7 +748,7 @@ async function resolveSelectedDevice( }); return await resolveDevice( devices.map((device) => ({ - platform: device.platform, + ...deviceFieldsFromPublicPlatform(device.platform), id: device.id, name: device.name, kind: device.kind, @@ -760,11 +766,11 @@ async function resolveSelectedDevice( } function buildProxyDeviceKey(device: DeviceInfo): string { - return `${device.platform}:${device.target ?? 'mobile'}:${device.id}`; + return `${publicPlatformString(device)}:${device.target ?? 'mobile'}:${device.id}`; } function leaseBackendForDevice(device: DeviceInfo): LeaseBackend | undefined { - if (device.platform === 'ios') return 'ios-instance'; + if (isIosFamily(device)) return 'ios-instance'; if (device.platform === 'android') return 'android-instance'; return undefined; } diff --git a/src/client/client-normalizers.ts b/src/client/client-normalizers.ts index 2b82a1360..ad0a866fb 100644 --- a/src/client/client-normalizers.ts +++ b/src/client/client-normalizers.ts @@ -4,7 +4,7 @@ import type { DaemonRequest, SessionRuntimeHints } from '../daemon/types.ts'; import { AppError, type NormalizedError } from '../kernel/errors.ts'; import type { SnapshotNode } from '../kernel/snapshot.ts'; import { buildAppIdentifiers, buildDeviceIdentifiers } from './client-shared.ts'; -import { isPlatform } from '../kernel/device.ts'; +import { isPublicPlatform } from '../kernel/device.ts'; import { leaseScopeFromOptions, leaseScopeToCommandFlags, @@ -184,7 +184,7 @@ export function normalizeOpenDevice( const platform = value.platform; const id = readOptionalString(value, 'id'); const name = readOptionalString(value, 'device'); - if (!isPlatform(platform) || !id || !name) { + if (!isPublicPlatform(platform) || !id || !name) { return undefined; } const target = readDeviceTarget(value, 'target'); diff --git a/src/client/client-shared.ts b/src/client/client-shared.ts index 3a052fc6f..17c905f57 100644 --- a/src/client/client-shared.ts +++ b/src/client/client-shared.ts @@ -14,7 +14,7 @@ import { publicSnapshotCaptureAnnotations, type SnapshotCaptureAnnotations, } from '../snapshot-capture-annotations.ts'; -import type { Platform } from '../kernel/device.ts'; +import type { PublicPlatform } from '../kernel/device.ts'; import { successText, withSuccessText } from '../utils/success-text.ts'; export function buildAppIdentifiers(params: { @@ -33,7 +33,7 @@ export function buildAppIdentifiers(params: { } export function buildDeviceIdentifiers( - platform: Platform, + platform: PublicPlatform, id: string, name: string, ): AgentDeviceIdentifiers { diff --git a/src/client/client-types.ts b/src/client/client-types.ts index d9bb56213..abfd33cb3 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -12,7 +12,12 @@ import type { SessionIsolationMode, SessionRuntimeHints, } from '../kernel/contracts.ts'; -import type { DeviceKind, DeviceTarget, Platform, PlatformSelector } from '../kernel/device.ts'; +import type { + DeviceKind, + DeviceTarget, + PublicPlatform, + PlatformSelector, +} from '../kernel/device.ts'; import type { BackMode } from '../core/back-mode.ts'; import type { ClickButton } from '../core/click-button.ts'; import type { RecordingExportQuality } from '../core/recording-export-quality.ts'; @@ -151,7 +156,7 @@ export type AgentDeviceSelectionOptions = { }; export type AgentDeviceDevice = { - platform: Platform; + platform: PublicPlatform; target: DeviceTarget; kind: DeviceKind; id: string; @@ -167,7 +172,7 @@ export type AgentDeviceDevice = { }; export type AgentDeviceSessionDevice = { - platform: Platform; + platform: PublicPlatform; target: DeviceTarget; id: string; name: string; @@ -225,7 +230,7 @@ export type AppDeployOptions = AgentDeviceRequestOverrides & export type AppDeployResult = { app: string; appPath: string; - platform: Platform; + platform: PublicPlatform; appId?: string; bundleId?: string; package?: string; diff --git a/src/cloud-webdriver/runtime.ts b/src/cloud-webdriver/runtime.ts index d9ae411d9..768aef5ca 100644 --- a/src/cloud-webdriver/runtime.ts +++ b/src/cloud-webdriver/runtime.ts @@ -13,7 +13,7 @@ import type { ProviderDeviceInstallResult, ProviderDeviceRuntime, } from '../provider-device-runtime.ts'; -import type { DeviceInfo, Platform } from '../kernel/device.ts'; +import { deviceFieldsFromPublicPlatform, type DeviceInfo } from '../kernel/device.ts'; import { AppError } from '../kernel/errors.ts'; import { unavailableCloudArtifactsResult } from './artifact-results.ts'; import { @@ -31,7 +31,7 @@ import { } from './webdriver-client.ts'; import { createWebDriverInteractor } from './webdriver-interactor.ts'; -export type CloudWebDriverPlatform = Extract; +export type CloudWebDriverPlatform = 'android' | 'ios'; export type CloudWebDriverUploadResult = ProviderDeviceInstallResult & { appReference: string; @@ -337,7 +337,7 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { private deviceForLease(lease: DeviceLease, prepared: CloudWebDriverPreparedSession): DeviceInfo { return { - platform: prepared.platform, + ...deviceFieldsFromPublicPlatform(prepared.platform), id: prepared.deviceId ?? this.options.deviceId?.(lease) ?? diff --git a/src/commands/__tests__/command-surface-metadata.test.ts b/src/commands/__tests__/command-surface-metadata.test.ts index 20b8014e6..74feb6aee 100644 --- a/src/commands/__tests__/command-surface-metadata.test.ts +++ b/src/commands/__tests__/command-surface-metadata.test.ts @@ -45,7 +45,7 @@ test('common command input accepts web platform selector', () => { const platformSchema = snapshotMetadata.inputSchema.properties?.platform; const input = snapshotMetadata.readInput({ platform: 'web' }) as { platform?: unknown }; - assert.deepEqual(platformSchema?.enum, ['ios', 'macos', 'android', 'linux', 'web', 'apple']); + assert.deepEqual(platformSchema?.enum, ['apple', 'android', 'linux', 'web', 'ios', 'macos']); assert.equal(input.platform, 'web'); }); diff --git a/src/commands/capture/index.test.ts b/src/commands/capture/index.test.ts index a55c3fc7b..d3afdf204 100644 --- a/src/commands/capture/index.test.ts +++ b/src/commands/capture/index.test.ts @@ -64,7 +64,7 @@ describe('capture command interface', () => { p95Ms: 1_900, maxMs: 1_900, slowThresholdMs: 1_500, - platform: 'ios', + platform: 'apple', }, warning: 'Warning: ios snapshots are slow in this run: p95 1900ms over 2 captures.', }, diff --git a/src/compat/maestro/runtime-targets.ts b/src/compat/maestro/runtime-targets.ts index b4c270ab0..6ec6df8af 100644 --- a/src/compat/maestro/runtime-targets.ts +++ b/src/compat/maestro/runtime-targets.ts @@ -1,4 +1,3 @@ -import type { Platform } from '../../kernel/device.ts'; import type { ElementSelectorKey } from '../../core/interactor-types.ts'; import type { Rect, SnapshotNode, SnapshotState } from '../../kernel/snapshot.ts'; import { parseSelectorChain } from '../../daemon/selectors.ts'; @@ -82,7 +81,7 @@ export function resolveMaestroNodeFromSnapshot( snapshot: SnapshotState, selector: string, options: MaestroTapOnOptions, - platform: Platform, + platform: 'ios' | 'android', frame: TouchReferenceFrame | undefined, resolutionOptions: MaestroMatchResolutionOptions = {}, ): { ok: true; node: SnapshotNode; rect: Rect } | { ok: false; message: string } { @@ -132,7 +131,7 @@ export function resolveMaestroNodeFromSnapshot( export function resolveMaestroFuzzyTextNodeFromSnapshot( snapshot: SnapshotState, query: string, - platform: Platform, + platform: 'ios' | 'android', frame: TouchReferenceFrame | undefined, resolutionOptions: MaestroMatchResolutionOptions = {}, ): { ok: true; node: SnapshotNode; rect: Rect } | { ok: false; message: string } { @@ -161,7 +160,7 @@ export function resolveMaestroFuzzyTextNodeFromSnapshot( export function resolveVisibleMaestroNodeFromSnapshot( snapshot: SnapshotState, selector: string, - platform: Platform, + platform: 'ios' | 'android', frame: TouchReferenceFrame | undefined, ): { ok: true; node: SnapshotNode; rect: Rect; matches: number } | { ok: false; message: string } { const matches = findMaestroSelectorMatches(snapshot, selector, platform, { @@ -201,7 +200,7 @@ export function resolveVisibleMaestroNodeFromSnapshot( function filterVisibleMaestroMatches(params: { nodes: SnapshotState['nodes']; matches: SnapshotNode[]; - platform: Platform; + platform: 'ios' | 'android'; }): { matches: SnapshotNode[]; blockedByReactNativeOverlay: boolean } { const visibleMatches = params.matches.filter( (node) => @@ -226,7 +225,7 @@ function filterVisibleMaestroMatches(params: { function filterReactNativeOverlayBlockedMatches( nodes: SnapshotState['nodes'], matches: SnapshotNode[], - platform: Platform, + platform: 'ios' | 'android', ): ReactNativeOverlayFilterResult { const overlay = detectReactNativeOverlay(nodes); if (!overlay.detected) { @@ -259,7 +258,7 @@ function filterReactNativeOverlayBlockedMatches( }; } -export function readMaestroSelectorPlatform(flags: DaemonRequest['flags']): Platform { +export function readMaestroSelectorPlatform(flags: DaemonRequest['flags']): 'ios' | 'android' { return flags?.platform === 'android' ? 'android' : 'ios'; } @@ -280,7 +279,7 @@ export function extractMaestroVisibleTextQuery(selectorExpression: string): stri function findMaestroSelectorMatches( snapshot: SnapshotState, selectorExpression: string, - platform: Platform, + platform: 'ios' | 'android', options: MaestroSelectorMatchOptions = {}, ): SnapshotNode[] { const chain = parseSelectorChain(selectorExpression); @@ -315,7 +314,7 @@ function findMaestroFuzzyTextMatches(snapshot: SnapshotState, query: string): Sn function matchesMaestroSelector( node: SnapshotNode, selector: Selector, - platform: Platform, + platform: 'ios' | 'android', options: MaestroSelectorMatchOptions, ): boolean { if (matchesSelector(node, selector, platform)) return true; @@ -325,7 +324,7 @@ function matchesMaestroSelector( function matchesMaestroTerm( node: SnapshotNode, term: SelectorTerm, - platform: Platform, + platform: 'ios' | 'android', options: MaestroSelectorMatchOptions, ): boolean { if (typeof term.value !== 'string' || !isMaestroRegexTextKey(term.key)) { diff --git a/src/contracts/device.ts b/src/contracts/device.ts index aa2e8cff8..c1be161e2 100644 --- a/src/contracts/device.ts +++ b/src/contracts/device.ts @@ -1,4 +1,4 @@ -import type { DeviceKind, DeviceTarget, Platform } from '../kernel/device.ts'; +import type { DeviceKind, DeviceTarget, PublicPlatform } from '../kernel/device.ts'; import type { TargetShutdownResult } from '../target-shutdown-contract.ts'; /** @@ -8,7 +8,7 @@ import type { TargetShutdownResult } from '../target-shutdown-contract.ts'; * spreads nothing, so this shape is intentionally closed. */ export type BootCommandResult = { - platform: Platform; + platform: PublicPlatform; target: DeviceTarget; /** Human-readable device name (`device.name`). */ device: string; @@ -26,7 +26,7 @@ export type BootCommandResult = { * field is the raw {@link TargetShutdownResult} from `shutdownDeviceTarget`. */ export type ShutdownCommandResult = { - platform: Platform; + platform: PublicPlatform; target: DeviceTarget; /** Human-readable device name (`device.name`). */ device: string; diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 62ad4dffb..39e8025e2 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -5,14 +5,14 @@ import { matchesPlatformSelector, type DeviceInfo } from '../../kernel/device.ts import { WEB_DESKTOP_DEVICE } from '../../__tests__/test-utils/index.ts'; const iosSimulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', }; const iosDevice: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'dev-1', name: 'iPhone', kind: 'device', @@ -33,7 +33,8 @@ const androidEmulator: DeviceInfo = { }; const macOsDevice: DeviceInfo = { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'mac-1', name: 'Mac', kind: 'device', @@ -51,7 +52,7 @@ const linuxDevice: DeviceInfo = { const webDevice = WEB_DESKTOP_DEVICE; const tvOsSimulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'tv-sim-1', name: 'Apple TV', kind: 'simulator', @@ -458,8 +459,8 @@ test('audio probe support is limited to browser and host-rendered audio targets' }); test('apple selector does not match web platform', () => { - assert.equal(matchesPlatformSelector(webDevice.platform, 'apple'), false); - assert.equal(matchesPlatformSelector(webDevice.platform, 'web'), true); + assert.equal(matchesPlatformSelector(webDevice, 'apple'), false); + assert.equal(matchesPlatformSelector(webDevice, 'web'), true); }); test('unknown commands default to supported', () => { diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index 91c010e3f..91c128812 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { + isIosFamily, + isMacOs, DEVICE_TARGETS, PLATFORMS, type DeviceInfo, @@ -101,27 +103,27 @@ const SAMPLE_DEVICES: DeviceInfo[] = [ // hand so this oracle stays INDEPENDENT of the descriptor it pins (mirrors the // `selectCapabilityByHandSwitch` copy in platform-descriptor/__tests__/parity.test.ts). // --------------------------------------------------------------------------- -const isNotMacOs = (device: DeviceInfo): boolean => device.platform !== 'macos'; +const isNotMacOs = (device: DeviceInfo): boolean => !isMacOs(device); const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => - device.platform === 'macos' || device.kind === 'simulator'; + isMacOs(device) || device.kind === 'simulator'; const isIosMobileSimulator = (device: DeviceInfo): boolean => - device.platform === 'ios' && device.kind === 'simulator' && device.target !== 'tv'; + isIosFamily(device) && device.kind === 'simulator' && device.target !== 'tv'; const supportsSynthesisGesture = (device: DeviceInfo): boolean => device.platform === 'android' || isIosMobileSimulator(device); const supportsAndroidOrIosNonTv = (device: DeviceInfo): boolean => - device.platform === 'android' || (device.platform === 'ios' && device.target !== 'tv'); + device.platform === 'android' || (isIosFamily(device) && device.target !== 'tv'); const supportsHostAudioProbe = (device: DeviceInfo): boolean => device.platform === 'web' || (process.platform === 'darwin' && - (device.platform === 'macos' || - (device.platform === 'ios' && device.kind === 'simulator') || + (isMacOs(device) || + (isIosFamily(device) && device.kind === 'simulator') || (device.platform === 'android' && device.kind === 'emulator'))); const synthesisGestureUnsupportedHint = (device: DeviceInfo): string | undefined => { - if (device.platform === 'macos') + if (isMacOs(device)) return 'macOS automation has no multi-touch input — this gesture is supported on Android and the iOS simulator only.'; - if (device.platform === 'ios' && device.target === 'tv') + if (isIosFamily(device) && device.target === 'tv') return 'tvOS has no touch input — this gesture is supported on Android and the iOS simulator only.'; - if (device.platform === 'ios' && device.kind === 'device') + if (isIosFamily(device) && device.kind === 'device') return 'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.'; return undefined; }; @@ -140,13 +142,13 @@ const SUPPORTS_REF: Record boolean> = { clipboard: (device) => device.platform === 'android' || device.platform === 'linux' || - device.platform === 'macos' || + isMacOs(device) || device.kind === 'simulator', keyboard: supportsAndroidOrIosNonTv, rotate: supportsAndroidOrIosNonTv, alert: (device) => device.platform === 'android' || isMacOsOrAppleSimulator(device), settings: (device) => - device.platform === 'android' || device.platform === 'macos' || device.kind === 'simulator', + device.platform === 'android' || isMacOs(device) || device.kind === 'simulator', audio: supportsHostAudioProbe, pinch: supportsSynthesisGesture, 'rotate-gesture': supportsSynthesisGesture, @@ -235,7 +237,7 @@ test('(b.2) the Apple plugin carries exactly the relocated supports/hint closure // plugin (the family that owns every discriminating device). Pin the RELOCATED maps' // key sets against the independent verbatim reference so no closure was silently // dropped or added while moving off the command facet. - const appleCapability = getPlugin('ios').capability; + const appleCapability = getPlugin('apple').capability; assert.deepEqual( Object.keys(appleCapability.supportsByDefault ?? {}).sort(), Object.keys(SUPPORTS_REF).sort(), @@ -247,14 +249,14 @@ test('(b.2) the Apple plugin carries exactly the relocated supports/hint closure 'unsupportedHintByDefault key set equals the verbatim reference', ); // ios and macos are the SAME Apple plugin instance, so both leaves read one map. - assert.equal(getPlugin('ios').capability, getPlugin('macos').capability); + assert.equal(getPlugin('apple').capability, getPlugin('apple').capability); }); test('(b.2) the relocated Apple closures are byte-for-byte the verbatim originals', () => { // Closure-equivalence: for every command x sample-device, the closure now living on // the Apple plugin returns an identical boolean / identical hint STRING to the // independent verbatim copy of the original command-facet closure. - const appleCapability = getPlugin('ios').capability; + const appleCapability = getPlugin('apple').capability; for (const [command, reference] of Object.entries(SUPPORTS_REF)) { const relocated = appleCapability.supportsByDefault?.[command]; assert.ok(relocated, `${command} supports closure present on the Apple plugin`); diff --git a/src/core/__tests__/dispatch-open.test.ts b/src/core/__tests__/dispatch-open.test.ts index 62ecb7881..71ead89ee 100644 --- a/src/core/__tests__/dispatch-open.test.ts +++ b/src/core/__tests__/dispatch-open.test.ts @@ -47,7 +47,7 @@ beforeEach(() => { test('dispatch open rejects URL as first argument when second URL is provided', async () => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 15', kind: 'simulator', diff --git a/src/core/__tests__/dispatch-resolve.test.ts b/src/core/__tests__/dispatch-resolve.test.ts index f0c8dd414..bd790b2ce 100644 --- a/src/core/__tests__/dispatch-resolve.test.ts +++ b/src/core/__tests__/dispatch-resolve.test.ts @@ -24,7 +24,7 @@ import type { DeviceInfo } from '../../kernel/device.ts'; import { AppError } from '../../kernel/errors.ts'; const physical: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'phys-1', name: 'My iPhone', kind: 'device', @@ -33,7 +33,7 @@ const physical: DeviceInfo = { }; const simulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 16', kind: 'simulator', @@ -42,7 +42,7 @@ const simulator: DeviceInfo = { }; const bootedSimulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-2', name: 'iPhone 15', kind: 'simulator', @@ -175,7 +175,8 @@ test('resolveTargetDevice resolves web through generic inventory without Apple f test('resolveTargetDevice fast-paths explicit macOS without Apple mobile discovery', async () => { const result = await resolveTargetDevice({ platform: 'macos' }); - assert.equal(result.platform, 'macos'); + assert.equal(result.platform, 'apple'); + assert.equal(result.appleOs, 'macos'); assert.equal(result.id, 'host-macos-local'); assert.equal(mockListAppleDevices.mock.calls.length, 0); }); @@ -187,7 +188,8 @@ test('resolveTargetDevice fast-paths Apple desktop target without simulator-set iosSimulatorDeviceSet: '/tmp/simulators', }); - assert.equal(result.platform, 'macos'); + assert.equal(result.platform, 'apple'); + assert.equal(result.appleOs, 'macos'); assert.equal(result.target, 'desktop'); assert.equal(mockListAppleDevices.mock.calls.length, 0); }); diff --git a/src/core/__tests__/dispatch-series.test.ts b/src/core/__tests__/dispatch-series.test.ts index eda16a0fc..641709120 100644 --- a/src/core/__tests__/dispatch-series.test.ts +++ b/src/core/__tests__/dispatch-series.test.ts @@ -9,7 +9,7 @@ import { import { AppError } from '../../kernel/errors.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; -const iosDevice: DeviceInfo = { platform: 'ios', id: 'test', name: 'iPhone', kind: 'simulator' }; +const iosDevice: DeviceInfo = { platform: 'apple', id: 'test', name: 'iPhone', kind: 'simulator' }; const androidDevice: DeviceInfo = { platform: 'android', id: 'emu', diff --git a/src/core/app-events.ts b/src/core/app-events.ts index 29b0f478c..7706250be 100644 --- a/src/core/app-events.ts +++ b/src/core/app-events.ts @@ -1,6 +1,8 @@ -import type { DeviceInfo } from '../kernel/device.ts'; +import { isIosFamily, isMacOs, publicPlatformString, type DeviceInfo } from '../kernel/device.ts'; import { AppError } from '../kernel/errors.ts'; +type AppEventDevice = Pick; + const APP_EVENT_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/; const MAX_APP_EVENT_PAYLOAD_BYTES = 8 * 1024; const MAX_APP_EVENT_URL_LENGTH = 4 * 1024; @@ -32,11 +34,12 @@ export function parseTriggerAppEventArgs(positionals: string[]): { } export function resolveAppEventUrl( - platform: DeviceInfo['platform'], + device: AppEventDevice, eventName: string, payload?: AppEventPayload, ): string { - const template = readAppEventUrlTemplate(platform); + const platform = publicPlatformString(device); + const template = readAppEventUrlTemplate(device); if (!template) { throw new AppError( 'UNSUPPORTED_OPERATION', @@ -89,13 +92,12 @@ function parseTriggerEventPayload( } } -function readAppEventUrlTemplate(platform: DeviceInfo['platform']): string | undefined { - const platformSpecific = - platform === 'ios' - ? process.env.AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE - : platform === 'macos' - ? process.env.AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE - : process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; +function readAppEventUrlTemplate(device: AppEventDevice): string | undefined { + const platformSpecific = isIosFamily(device) + ? process.env.AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE + : isMacOs(device) + ? process.env.AGENT_DEVICE_MACOS_APP_EVENT_URL_TEMPLATE + : process.env.AGENT_DEVICE_ANDROID_APP_EVENT_URL_TEMPLATE; const candidate = platformSpecific ?? process.env.AGENT_DEVICE_APP_EVENT_URL_TEMPLATE; const trimmed = candidate?.trim(); return trimmed ? trimmed : undefined; diff --git a/src/core/dispatch-interactions.ts b/src/core/dispatch-interactions.ts index 160712651..1776abb4a 100644 --- a/src/core/dispatch-interactions.ts +++ b/src/core/dispatch-interactions.ts @@ -1,5 +1,11 @@ import { AppError } from '../kernel/errors.ts'; -import { isTvOsDevice, type DeviceInfo } from '../kernel/device.ts'; +import { + isIosFamily, + isMacOs, + isTvOsDevice, + publicPlatformString, + type DeviceInfo, +} from '../kernel/device.ts'; import { successText, withSuccessText } from '../utils/success-text.ts'; import { findMistargetedTypeRefToken } from '../utils/type-target-warning.ts'; import { @@ -146,13 +152,13 @@ export async function handlePressCommand( positionals: string[], context: DispatchContext | undefined, ): Promise> { - if (context?.directElementSelector && device.platform === 'ios') { + if (context?.directElementSelector && isIosFamily(device)) { return await handleDirectElementSelectorPress(interactor, context.directElementSelector); } const { x, y } = readPoint(positionals, 'press requires x y'); - if (device.platform === 'macos' && context?.surface && context.surface !== 'app') { + if (isMacOs(device) && context?.surface && context.surface !== 'app') { return await handleMacOsSurfacePress(x, y, context); } @@ -267,7 +273,7 @@ function assertAlternateClickSupported( ): void { const validationError = getClickButtonValidationError({ commandLabel: 'click', - platform: device.platform, + platform: publicPlatformString(device), button, count: context?.count, intervalMs: context?.intervalMs, @@ -419,7 +425,7 @@ function buildPressSequenceSteps( // Mirror the individual `tap` command: on touch-input iOS (not the tvOS leaf), tap steps // use synthesized HID taps (synthesizedTapAt) rather than the drag-based XCUICoordinate // tapAt, matching iosTapCommand. - const synthesized = kind === 'tap' && device.platform === 'ios' && !isTvOsDevice(device); + const synthesized = kind === 'tap' && isIosFamily(device) && !isTvOsDevice(device); return Array.from({ length: series.count }, (_, index) => { const [dx, dy] = computeDeterministicJitter(index, series.jitterPx); const isLast = index === series.count - 1; @@ -450,7 +456,7 @@ function buildSwipeSequenceSteps(params: { effectiveDurationMs: number; }): RunnerSequenceStep[] { const { device, x1, y1, x2, y2, count, pauseMs, pattern, effectiveDurationMs } = params; - const synthesized = device.platform === 'ios' && !isTvOsDevice(device); + const synthesized = isIosFamily(device) && !isTvOsDevice(device); return Array.from({ length: count }, (_, index) => { const reverse = pattern === 'ping-pong' && index % 2 === 1; const isLast = index === count - 1; @@ -869,7 +875,7 @@ export async function handlePinchCommand( if (device.target === 'tv') { throw new AppError('UNSUPPORTED_OPERATION', 'gesture pinch is not supported on tvOS'); } - if (device.platform === 'macos' && context?.surface && context.surface !== 'app') { + if (isMacOs(device) && context?.surface && context.surface !== 'app') { throw new AppError( 'UNSUPPORTED_OPERATION', 'gesture pinch is only supported in macOS app sessions. Re-open the target app without --surface desktop|menubar|frontmost-app first.', @@ -894,7 +900,7 @@ export async function handleRotateGestureCommand( if (device.target === 'tv') { throw new AppError('UNSUPPORTED_OPERATION', 'gesture rotate is not supported on tvOS'); } - if (device.platform === 'macos') { + if (isMacOs(device)) { throw new AppError( 'UNSUPPORTED_OPERATION', 'gesture rotate is not supported on macOS; XCTest rotation gestures are available only for iOS app sessions.', @@ -922,7 +928,7 @@ export async function handleTransformGestureCommand( if (device.target === 'tv') { throw new AppError('UNSUPPORTED_OPERATION', 'gesture transform is not supported on tvOS'); } - const supportedIosSimulator = device.platform === 'ios' && device.kind === 'simulator'; + const supportedIosSimulator = isIosFamily(device) && device.kind === 'simulator'; if (device.platform !== 'android' && !supportedIosSimulator) { throw new AppError( 'UNSUPPORTED_OPERATION', @@ -1050,7 +1056,7 @@ export async function handleReadCommand( const text = await readLinuxTextAtPoint(x, y, context?.surface); return { action: 'read', text }; } - if (device.platform === 'macos' && context?.surface && context.surface !== 'app') { + if (isMacOs(device) && context?.surface && context.surface !== 'app') { const { runMacOsReadTextAction } = await import('../platforms/apple/os/macos/helper.ts'); const result = await runMacOsReadTextAction(x, y, { bundleId: context.appBundleId, diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 049ab1b05..131ba6c80 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -1,7 +1,7 @@ import { promises as fs } from 'node:fs'; import pathModule from 'node:path'; import { AppError } from '../kernel/errors.ts'; -import type { DeviceInfo } from '../kernel/device.ts'; +import { isIosFamily, type DeviceInfo } from '../kernel/device.ts'; import { getInteractor } from './interactors.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; import { isDeepLinkTarget } from './open-target.ts'; @@ -260,7 +260,7 @@ async function handleOpenCommand( await interactor.openDevice(); return { app: null, ...successText('Opened device') }; } - if (launchConsole && (device.platform !== 'ios' || device.kind !== 'simulator')) { + if (launchConsole && (!isIosFamily(device) || device.kind !== 'simulator')) { throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE); } if (device.platform === 'linux' && launchArgs && launchArgs.length > 0) { @@ -315,7 +315,7 @@ async function handleTriggerAppEventCommand( context: DispatchContext | undefined, ): Promise> { const { eventName, payload } = parseTriggerAppEventArgs(positionals); - const eventUrl = resolveAppEventUrl(device.platform, eventName, payload); + const eventUrl = resolveAppEventUrl(device, eventName, payload); await interactor.open(eventUrl, { appBundleId: context?.appBundleId }); return { event: eventName, @@ -408,7 +408,7 @@ async function handleKeyboardCommand( if (device.platform === 'android') { return await handleAndroidKeyboardCommand(device, action); } - if (device.platform === 'ios') { + if (isIosFamily(device)) { return await handleIosKeyboardCommand(device, action, context, runnerCtx); } throw new AppError('UNSUPPORTED_OPERATION', 'keyboard is supported only on Android and iOS'); @@ -590,7 +590,7 @@ async function handlePushCommand( throw new AppError('INVALID_ARGS', 'push requires '); } const payload = await readNotificationPayload(payloadArg); - if (device.platform === 'ios') { + if (isIosFamily(device)) { const { pushIosNotification } = await import('../platforms/apple/core/apps.ts'); await pushIosNotification(device, target, payload); return { diff --git a/src/core/platform-descriptor/__tests__/parity.test.ts b/src/core/platform-descriptor/__tests__/parity.test.ts index 73dd094f5..cf85fe37b 100644 --- a/src/core/platform-descriptor/__tests__/parity.test.ts +++ b/src/core/platform-descriptor/__tests__/parity.test.ts @@ -17,8 +17,7 @@ function selectCapabilityByHandSwitch( platform: Platform, ): CommandCapability[CapabilityBucket] { switch (platform) { - case 'ios': - case 'macos': + case 'apple': return capability.apple; case 'android': return capability.android; diff --git a/src/core/platform-descriptor/registry.ts b/src/core/platform-descriptor/registry.ts index 5c3603031..21913e1d7 100644 --- a/src/core/platform-descriptor/registry.ts +++ b/src/core/platform-descriptor/registry.ts @@ -8,17 +8,16 @@ import type { PlatformDescriptor } from './types.ts'; * Each row is copied VERBATIM from the facts the hand-authored control flow * implies today: * - `capabilityBucket` — the bucket `selectCapabilityForPlatform` returned for - * the platform (`ios`/`macos`→`apple`, `android`→`android`, + * the platform (`apple`→`apple`, `android`→`android`, * `linux`→`linux`, `web`→`web`). - * - `isApple` — whether `isApplePlatform` is true for the leaf platform - * (`ios`/`macos` only). + * - `isApple` — whether `isApplePlatform` is true for the platform + * (`apple` only). * * `as const satisfies` pins each literal while checking the shape, and the row * order matches the `PLATFORMS` tuple so the parity test can prove totality. */ export const platformDescriptors = [ - { platform: 'ios', capabilityBucket: 'apple', isApple: true }, - { platform: 'macos', capabilityBucket: 'apple', isApple: true }, + { platform: 'apple', capabilityBucket: 'apple', isApple: true }, { platform: 'android', capabilityBucket: 'android', isApple: false }, { platform: 'linux', capabilityBucket: 'linux', isApple: false }, { platform: 'web', capabilityBucket: 'web', isApple: false }, diff --git a/src/core/platform-inventory.ts b/src/core/platform-inventory.ts index 3428e4fd2..a4dca3745 100644 --- a/src/core/platform-inventory.ts +++ b/src/core/platform-inventory.ts @@ -1,4 +1,10 @@ -import type { DeviceInfo, DeviceTarget, PlatformSelector } from '../kernel/device.ts'; +import { + isIosFamily, + isMacOs, + type DeviceInfo, + type DeviceTarget, + type PlatformSelector, +} from '../kernel/device.ts'; export const LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS = ['android', 'apple', 'linux'] as const; @@ -98,7 +104,7 @@ function emptyDeviceInventoryGroupCounts(): DeviceInventoryGroupCounts { } function deviceInventoryGroupForDevice(device: DeviceInfo): DeviceInventoryGroup { - if (device.platform === 'ios' || device.platform === 'macos') return 'apple'; + if (isIosFamily(device) || isMacOs(device)) return 'apple'; return device.platform; } diff --git a/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts b/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts index dfbf14cec..efbd49449 100644 --- a/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts +++ b/src/core/platform-plugin/__tests__/apple-os-capability-table-parity.test.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { isAudioProbeSupportedDevice } from '../../../kernel/audio-probe-support.ts'; import { + isIosFamily, + isMacOs, DEVICE_TARGETS, PLATFORMS, type AppleOS, @@ -41,21 +43,21 @@ registerBuiltinPlatformPlugins(); // table read), kept BYTE-FOR-BYTE by hand so this oracle stays INDEPENDENT of the // table it pins (mirrors the copy in capability-plugin-routing-parity.test.ts). // --------------------------------------------------------------------------- -const isNotMacOs = (device: DeviceInfo): boolean => device.platform !== 'macos'; +const isNotMacOs = (device: DeviceInfo): boolean => !isMacOs(device); const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean => - device.platform === 'macos' || device.kind === 'simulator'; + isMacOs(device) || device.kind === 'simulator'; const isIosMobileSimulator = (device: DeviceInfo): boolean => - device.platform === 'ios' && device.kind === 'simulator' && device.target !== 'tv'; + isIosFamily(device) && device.kind === 'simulator' && device.target !== 'tv'; const supportsSynthesisGesture = (device: DeviceInfo): boolean => device.platform === 'android' || isIosMobileSimulator(device); const supportsAndroidOrIosNonTv = (device: DeviceInfo): boolean => - device.platform === 'android' || (device.platform === 'ios' && device.target !== 'tv'); + device.platform === 'android' || (isIosFamily(device) && device.target !== 'tv'); const synthesisGestureUnsupportedHint = (device: DeviceInfo): string | undefined => { - if (device.platform === 'macos') + if (isMacOs(device)) return 'macOS automation has no multi-touch input — this gesture is supported on Android and the iOS simulator only.'; - if (device.platform === 'ios' && device.target === 'tv') + if (isIosFamily(device) && device.target === 'tv') return 'tvOS has no touch input — this gesture is supported on Android and the iOS simulator only.'; - if (device.platform === 'ios' && device.kind === 'device') + if (isIosFamily(device) && device.kind === 'device') return 'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.'; return undefined; }; @@ -71,13 +73,13 @@ const SUPPORTS_REF: Record boolean> = { clipboard: (device) => device.platform === 'android' || device.platform === 'linux' || - device.platform === 'macos' || + isMacOs(device) || device.kind === 'simulator', keyboard: supportsAndroidOrIosNonTv, rotate: supportsAndroidOrIosNonTv, alert: (device) => device.platform === 'android' || isMacOsOrAppleSimulator(device), settings: (device) => - device.platform === 'android' || device.platform === 'macos' || device.kind === 'simulator', + device.platform === 'android' || isMacOs(device) || device.kind === 'simulator', // `audio` is NOT part of the AppleOS-table relocation — it stays the standalone // `isAudioProbeSupportedDevice` predicate. Included here only so the key-set // assertion stays strict (catches a dropped command) and confirms the rebase @@ -169,7 +171,7 @@ test('resolveDeviceAppleOs prefers the stored discriminant, else infers from tar }); test('table-driven Apple supports() closures are byte-for-byte the verbatim originals', () => { - const appleSupports = getPlugin('ios').capability.supportsByDefault; + const appleSupports = getPlugin('apple').capability.supportsByDefault; assert.ok(appleSupports, 'the Apple plugin carries supportsByDefault'); // Every command that had an original predicate must still carry one, keyed the same. assert.deepEqual(Object.keys(appleSupports).sort(), Object.keys(SUPPORTS_REF).sort()); @@ -187,7 +189,7 @@ test('table-driven Apple supports() closures are byte-for-byte the verbatim orig }); test('table-driven Apple unsupportedHint() closures are byte-for-byte the verbatim originals', () => { - const appleHints = getPlugin('ios').capability.unsupportedHintByDefault; + const appleHints = getPlugin('apple').capability.unsupportedHintByDefault; assert.ok(appleHints, 'the Apple plugin carries unsupportedHintByDefault'); assert.deepEqual(Object.keys(appleHints).sort(), Object.keys(HINT_REF).sort()); for (const [command, reference] of Object.entries(HINT_REF)) { diff --git a/src/core/platform-plugin/__tests__/parity.test.ts b/src/core/platform-plugin/__tests__/parity.test.ts index 42d0c79c0..b79a99e84 100644 --- a/src/core/platform-plugin/__tests__/parity.test.ts +++ b/src/core/platform-plugin/__tests__/parity.test.ts @@ -18,11 +18,7 @@ registerBuiltinPlatformPlugins(); // 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' + return value === 'apple' || value === 'android' || value === 'linux' || value === 'web' ? value : undefined; } @@ -73,9 +69,9 @@ test('every plugin capability bucket matches the platform-descriptor registry', 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'); + assert.equal(getPlugin('apple'), getPlugin('apple')); + assert.equal(getPlugin('apple').id, 'apple'); + assert.equal(getPlugin('apple').familySelector, 'apple'); // Single-platform plugins are distinct objects. assert.notEqual(getPlugin('android'), getPlugin('linux')); }); diff --git a/src/core/platform-plugin/apple-os-capabilities.ts b/src/core/platform-plugin/apple-os-capabilities.ts index e2d01a6fa..64da24ef4 100644 --- a/src/core/platform-plugin/apple-os-capabilities.ts +++ b/src/core/platform-plugin/apple-os-capabilities.ts @@ -124,7 +124,9 @@ export function resolveDeviceAppleOs( // Real discovery sets `appleOs`; legacy/synthetic records without it fall back to // the target-based inference the capability predicates used before this table. if (device.appleOs) return device.appleOs; - if (device.platform === 'macos') return 'macos'; + // Back-compat: a legacy persisted record may still carry the pre-collapse `macos` + // leaf platform string (the type no longer allows it, hence the cast). + if ((device.platform as string) === 'macos') return 'macos'; if (isTvOsDevice(device)) return 'tvos'; // iOS / iPadOS / visionOS are indistinguishable without discovery descriptors and // are capability-identical, so an unlabeled mobile Apple record collapses to `ios`. diff --git a/src/core/platform-plugin/plugin.ts b/src/core/platform-plugin/plugin.ts index 19168c4a4..bc9496ef3 100644 --- a/src/core/platform-plugin/plugin.ts +++ b/src/core/platform-plugin/plugin.ts @@ -96,8 +96,8 @@ export type PlatformPlugin = { }; // 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. +// is registered once per leaf platform it owns, so `getPlugin('apple')` and +// `getPlugin('apple')` resolve to the SAME Apple plugin object. const registry = new Map(); /** diff --git a/src/daemon/__tests__/applog-plugin-routing-parity.test.ts b/src/daemon/__tests__/applog-plugin-routing-parity.test.ts index 41791e42b..5e1ddd5ed 100644 --- a/src/daemon/__tests__/applog-plugin-routing-parity.test.ts +++ b/src/daemon/__tests__/applog-plugin-routing-parity.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { + isIosFamily, + isMacOs, DEVICE_TARGETS, PLATFORMS, type DeviceInfo, @@ -33,8 +35,8 @@ registerBuiltinPlatformPlugins(); // --- INDEPENDENT verbatim copy of the former `resolveLogBackend` hand branch --- function resolveLogBackendByHand(device: DeviceInfo): LogBackend { - if (device.platform === 'macos') return 'macos'; - if (device.platform === 'ios') { + if (isMacOs(device)) return 'macos'; + if (isIosFamily(device)) { return device.kind === 'device' ? 'ios-device' : 'ios-simulator'; } return 'android'; @@ -89,8 +91,8 @@ test('resolveLogBackend routed through the plugin is byte-identical to the forme test('only families with an app-log backend carry the appLog facet', () => { // Apple owns ios + macos (SAME plugin instance); Android carries its own. - assert.equal(getPlugin('ios'), getPlugin('macos')); - assert.ok(getPlugin('ios').appLog, 'apple plugin exposes appLog'); + assert.equal(getPlugin('apple'), getPlugin('apple')); + assert.ok(getPlugin('apple').appLog, 'apple plugin exposes appLog'); assert.ok(getPlugin('android').appLog, 'android plugin exposes appLog'); // linux/web historically fell through to the `'android'` default; they get NO // facet, and the daemon lookup preserves that fallthrough (asserted below). @@ -100,7 +102,7 @@ test('only families with an app-log backend carry the appLog facet', () => { test('each populated appLog facet resolves the backend its family owns', () => { for (const device of SAMPLE_DEVICES.filter( - (d) => d.platform === 'ios' || d.platform === 'macos' || d.platform === 'android', + (d) => isIosFamily(d) || isMacOs(d) || d.platform === 'android', )) { assert.equal( getPlugin(device.platform).appLog?.resolveBackend(device), diff --git a/src/daemon/__tests__/perf-plugin-routing-parity.test.ts b/src/daemon/__tests__/perf-plugin-routing-parity.test.ts index 17b258f81..e780c23ee 100644 --- a/src/daemon/__tests__/perf-plugin-routing-parity.test.ts +++ b/src/daemon/__tests__/perf-plugin-routing-parity.test.ts @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; import { + isIosFamily, + isMacOs, DEVICE_TARGETS, PLATFORMS, type DeviceInfo, @@ -35,7 +37,7 @@ registerBuiltinPlatformPlugins(); // --- INDEPENDENT verbatim copy of the former `supportsPlatformPerfMetrics` branch --- function supportsPlatformPerfMetricsByHand(device: DeviceInfo): boolean { - return device.platform === 'android' || device.platform === 'ios' || device.platform === 'macos'; + return device.platform === 'android' || isIosFamily(device) || isMacOs(device); } // --- the exhaustive synthetic device matrix (every platform x kind x target) --- @@ -87,8 +89,8 @@ test('perf.supportsMetrics facet is byte-identical to the former hand predicate' test('only families with perf metrics carry the perf facet', () => { // Apple owns ios + macos (SAME plugin instance); Android carries its own. - assert.equal(getPlugin('ios'), getPlugin('macos')); - assert.ok(getPlugin('ios').perf, 'apple plugin exposes perf'); + assert.equal(getPlugin('apple'), getPlugin('apple')); + assert.ok(getPlugin('apple').perf, 'apple plugin exposes perf'); assert.ok(getPlugin('android').perf, 'android plugin exposes perf'); // linux/web historically returned `false`; they get NO facet, and the daemon // lookup preserves that fallthrough (asserted below). diff --git a/src/daemon/__tests__/request-lock-policy.test.ts b/src/daemon/__tests__/request-lock-policy.test.ts index 49ac826f5..bfdb718af 100644 --- a/src/daemon/__tests__/request-lock-policy.test.ts +++ b/src/daemon/__tests__/request-lock-policy.test.ts @@ -8,7 +8,7 @@ const IOS_SESSION: SessionState = { createdAt: Date.now(), actions: [], device: { - platform: 'ios', + platform: 'apple', target: 'mobile', id: 'SIM-001', name: 'iPhone 16', diff --git a/src/daemon/__tests__/request-platform-providers.test.ts b/src/daemon/__tests__/request-platform-providers.test.ts index 47820aa0b..40050fd5f 100644 --- a/src/daemon/__tests__/request-platform-providers.test.ts +++ b/src/daemon/__tests__/request-platform-providers.test.ts @@ -19,7 +19,7 @@ import { withRequestPlatformProviderScope } from '../request-platform-providers. import type { DaemonRequest } from '../types.ts'; const OTHER_IOS_SIMULATOR: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-2', name: 'iPhone 17', kind: 'simulator', diff --git a/src/daemon/__tests__/request-recording-health.test.ts b/src/daemon/__tests__/request-recording-health.test.ts index 1deb8bbad..9c9310a0f 100644 --- a/src/daemon/__tests__/request-recording-health.test.ts +++ b/src/daemon/__tests__/request-recording-health.test.ts @@ -20,7 +20,7 @@ function makeIosSimulatorSession(showTouches: boolean): SessionState { createdAt: Date.now(), actions: [], device: { - platform: 'ios', + platform: 'apple', target: 'mobile', id: 'sim-1', name: 'iPhone 17 Pro', diff --git a/src/daemon/__tests__/request-router-cost.test.ts b/src/daemon/__tests__/request-router-cost.test.ts index 1e79a9b4b..59e2c171a 100644 --- a/src/daemon/__tests__/request-router-cost.test.ts +++ b/src/daemon/__tests__/request-router-cost.test.ts @@ -39,7 +39,7 @@ function makeIosSession(name: string): SessionState { createdAt: 1_700_000_000_000, actions: [], device: { - platform: 'ios', + platform: 'apple', target: 'mobile', id: 'SIM-001', name: 'iPhone 16', diff --git a/src/daemon/__tests__/request-router-lock-policy.test.ts b/src/daemon/__tests__/request-router-lock-policy.test.ts index bdbbf2e07..3afb3b84d 100644 --- a/src/daemon/__tests__/request-router-lock-policy.test.ts +++ b/src/daemon/__tests__/request-router-lock-policy.test.ts @@ -29,7 +29,7 @@ function makeIosSession(name: string): SessionState { createdAt: Date.now(), actions: [], device: { - platform: 'ios', + platform: 'apple', target: 'mobile', id: 'SIM-001', name: 'iPhone 16', diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index a35dd0f77..84df8764d 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -20,7 +20,7 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady); function makeIosDevice(id: string): DeviceInfo { return { - platform: 'ios', + platform: 'apple', id, name: `iPhone ${id}`, kind: 'simulator', diff --git a/src/daemon/__tests__/request-router-recording-health.test.ts b/src/daemon/__tests__/request-router-recording-health.test.ts index 9ba13a2ed..c6879d5a5 100644 --- a/src/daemon/__tests__/request-router-recording-health.test.ts +++ b/src/daemon/__tests__/request-router-recording-health.test.ts @@ -35,7 +35,7 @@ test('router blocks non-record commands when recording was invalidated', async ( actions: [], appBundleId: 'com.apple.Preferences', device: { - platform: 'ios', + platform: 'apple', target: 'mobile', id: 'sim-1', name: 'iPhone 17 Pro', @@ -88,7 +88,7 @@ test('router allows iOS simulator gestures during overlay recording after runner actions: [], appBundleId: 'com.apple.Preferences', device: { - platform: 'ios', + platform: 'apple', target: 'mobile', id: 'sim-1', name: 'iPhone 17 Pro', diff --git a/src/daemon/__tests__/request-router-response-level.test.ts b/src/daemon/__tests__/request-router-response-level.test.ts index fd808d78e..c9a30098f 100644 --- a/src/daemon/__tests__/request-router-response-level.test.ts +++ b/src/daemon/__tests__/request-router-response-level.test.ts @@ -48,7 +48,7 @@ function makeIosSession(name: string): SessionState { createdAt: 1_700_000_000_000, actions: [], device: { - platform: 'ios', + platform: 'apple', target: 'mobile', id: 'SIM-001', name: 'iPhone 16', diff --git a/src/daemon/__tests__/request-router-screenshot.test.ts b/src/daemon/__tests__/request-router-screenshot.test.ts index a1d673099..f42f2ff4f 100644 --- a/src/daemon/__tests__/request-router-screenshot.test.ts +++ b/src/daemon/__tests__/request-router-screenshot.test.ts @@ -41,7 +41,8 @@ function makeMacOsMenubarSession(name: string): SessionState { return { name, device: { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-macos-local', name: 'Mac', kind: 'device', diff --git a/src/daemon/__tests__/request-router-typed-error.test.ts b/src/daemon/__tests__/request-router-typed-error.test.ts index 2529115f4..cff9d44b8 100644 --- a/src/daemon/__tests__/request-router-typed-error.test.ts +++ b/src/daemon/__tests__/request-router-typed-error.test.ts @@ -31,7 +31,7 @@ function makeIosSession(name: string): SessionState { createdAt: 1_700_000_000_000, actions: [], device: { - platform: 'ios', + platform: 'apple', target: 'mobile', id: 'SIM-001', name: 'iPhone 16', diff --git a/src/daemon/__tests__/runtime-hints.test.ts b/src/daemon/__tests__/runtime-hints.test.ts index e6f56f5ea..e266a061c 100644 --- a/src/daemon/__tests__/runtime-hints.test.ts +++ b/src/daemon/__tests__/runtime-hints.test.ts @@ -146,7 +146,7 @@ async function withMockedXcrun( process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', diff --git a/src/daemon/__tests__/session-selector.test.ts b/src/daemon/__tests__/session-selector.test.ts index 244dd1279..b5a349599 100644 --- a/src/daemon/__tests__/session-selector.test.ts +++ b/src/daemon/__tests__/session-selector.test.ts @@ -63,7 +63,7 @@ test('selector mismatch explains session recovery commands', () => { test('accepts --platform apple alias for ios sessions', () => { const session = makeSession({ device: { - platform: 'ios', + platform: 'apple', id: 'tv-sim-1', name: 'Apple TV', kind: 'simulator', @@ -132,7 +132,7 @@ test('rejects mismatched device selector', () => { test('accepts matching ios simulator set selector for iOS simulator sessions', () => { const session = makeSession({ device: { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17', kind: 'simulator', diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index 3c3fe4abd..17ece0a89 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -18,7 +18,7 @@ function makeSession(name: string): SessionState { return { name, device: { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', diff --git a/src/daemon/__tests__/target-shutdown.test.ts b/src/daemon/__tests__/target-shutdown.test.ts index 291d192bc..c1bd5053d 100644 --- a/src/daemon/__tests__/target-shutdown.test.ts +++ b/src/daemon/__tests__/target-shutdown.test.ts @@ -32,7 +32,7 @@ beforeEach(() => { test('shutdownDeviceTarget treats already-stopped targets as success', async () => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', @@ -51,7 +51,7 @@ test('shutdownDeviceTarget treats already-stopped targets as success', async () test('shutdownDeviceTarget treats iOS Shutdown final state as success', async () => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', @@ -76,7 +76,7 @@ test('shutdownDeviceTarget treats iOS Shutdown final state as success', async () test('shutdownDeviceTarget preserves iOS shutdown failure when final state probe fails', async () => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', diff --git a/src/daemon/app-log.ts b/src/daemon/app-log.ts index a62d928a0..de5255e39 100644 --- a/src/daemon/app-log.ts +++ b/src/daemon/app-log.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; -import type { DeviceInfo } from '../kernel/device.ts'; +import { isIosFamily, isMacOs, type DeviceInfo } from '../kernel/device.ts'; import { AppError } from '../kernel/errors.ts'; import { tryGetPlugin } from '../core/platform-plugin/plugin.ts'; import { registerBuiltinPlatformPlugins } from '../core/interactors/register-builtins.ts'; @@ -248,7 +248,7 @@ export async function readSessionNetworkCapture(params: { } } const canRecoverIosSimulatorLogShow = - device.platform === 'ios' && device.kind === 'simulator' && Boolean(appBundleId); + isIosFamily(device) && device.kind === 'simulator' && Boolean(appBundleId); if (canRecoverIosSimulatorLogShow && dump.entries.length === 0) { const recovered = await readRecentIosSimulatorNetworkCapture({ deviceId: device.id, @@ -282,7 +282,7 @@ export async function readSessionNetworkCapture(params: { 'Capture uses the session app log file. For fresh traffic, run logs clear --restart before reproducing requests.', ); } else if (appLogState !== 'active' && notes.length === 0) { - if (device.platform === 'ios' && device.kind === 'simulator') { + if (isIosFamily(device) && device.kind === 'simulator') { notes.push( 'Session app log stream is inactive. The iOS simulator recovery path scanned recent simctl log history, but a fresh logs clear --restart window is still the most reliable repro loop.', ); @@ -358,7 +358,7 @@ async function startLocalAppLog({ ensureLogPath(outPath); const stream = fs.createWriteStream(outPath, { flags: 'a' }); const redactionPatterns = getAppLogRedactionPatterns(); - if (device.platform === 'ios') { + if (isIosFamily(device)) { if (device.kind === 'device') { return await startIosDeviceAppLog(device.id, stream, redactionPatterns, pidPath); } @@ -375,7 +375,7 @@ async function startLocalAppLog({ assertAndroidPackageArgSafe(appBundleId); return await startAndroidAppLog(device.id, appBundleId, stream, redactionPatterns, pidPath); } - if (device.platform === 'macos') { + if (isMacOs(device)) { return await startMacOsAppLog(appBundleId, stream, redactionPatterns, pidPath); } stream.end(); @@ -416,10 +416,10 @@ async function readRecentIosSimulatorNetworkCapture(params: { } function buildNoHttpEntriesNote(device: DeviceInfo): string { - if (device.platform === 'ios' && device.kind === 'simulator') { + if (isIosFamily(device) && device.kind === 'simulator') { return 'No HTTP(s) entries were found in recent iOS simulator app logs. If the app only emits non-HTTP diagnostics, inspect logs path or add app-side URLSession/network logging for per-request timing and payload details.'; } - if (device.platform === 'ios') { + if (isIosFamily(device)) { return 'No HTTP(s) entries were found in recent iOS device app logs. iOS network dump only sees what the app emits into Unified Logging for this process.'; } return 'No HTTP(s) entries were found in recent session app logs.'; @@ -463,7 +463,7 @@ export async function runAppLogDoctor( } } } - if (device.platform === 'ios' && device.kind === 'simulator') { + if (isIosFamily(device) && device.kind === 'simulator') { try { const simctl = await runXcrun(['simctl', 'help'], { allowFailure: true }); checks.simctlAvailable = simctl.exitCode === 0; @@ -471,7 +471,7 @@ export async function runAppLogDoctor( checks.simctlAvailable = false; } } - if (device.platform === 'ios' && device.kind === 'device') { + if (isIosFamily(device) && device.kind === 'device') { try { const devicectl = await runXcrun(['devicectl', '--version'], { allowFailure: true }); checks.devicectlAvailable = devicectl.exitCode === 0; @@ -479,7 +479,7 @@ export async function runAppLogDoctor( checks.devicectlAvailable = false; } } - if (device.platform === 'macos') { + if (isMacOs(device)) { try { const log = await runCmd('log', ['help'], { allowFailure: true }); checks.logAvailable = log.exitCode === 0; diff --git a/src/daemon/apple-runner-options.ts b/src/daemon/apple-runner-options.ts index e8f0ef9da..48d6df7eb 100644 --- a/src/daemon/apple-runner-options.ts +++ b/src/daemon/apple-runner-options.ts @@ -2,7 +2,7 @@ import { isDeepLinkTarget } from '../core/open-target.ts'; import type { SessionSurface } from '../core/session-surface.ts'; import type { AppleRunnerLifecycleOptions } from '../platforms/apple/core/runner/runner-provider.ts'; import { prewarmAppleRunnerCache } from '../platforms/apple/core/runner/runner-client.ts'; -import type { DeviceInfo } from '../kernel/device.ts'; +import { isIosFamily, type DeviceInfo } from '../kernel/device.ts'; import { contextFromFlags } from './context.ts'; import type { DaemonRequest } from './types.ts'; @@ -63,7 +63,7 @@ export function createAppleRunnerCachePrewarmOnColdBoot(params: { enabled: boolean; }): ((device: DeviceInfo) => void) | undefined { const { req, logPath, device, traceLogPath, enabled } = params; - if (!enabled || device.platform !== 'ios' || device.kind !== 'simulator') { + if (!enabled || !isIosFamily(device) || device.kind !== 'simulator') { return undefined; } return (bootingDevice) => diff --git a/src/daemon/device-ready.ts b/src/daemon/device-ready.ts index 34997d332..be4f4cb9a 100644 --- a/src/daemon/device-ready.ts +++ b/src/daemon/device-ready.ts @@ -1,4 +1,4 @@ -import type { DeviceInfo } from '../kernel/device.ts'; +import { isIosFamily, type DeviceInfo } from '../kernel/device.ts'; import os from 'node:os'; import path from 'node:path'; import { promises as fs } from 'node:fs'; @@ -39,7 +39,7 @@ export async function ensureDeviceReady( readyCache.delete(cacheKey); } - if (device.platform === 'ios') { + if (isIosFamily(device)) { if (device.kind === 'simulator') { const { ensureBootedSimulator } = await import('../platforms/apple/core/simulator.ts'); await ensureBootedSimulator(device, { diff --git a/src/daemon/device-targets.ts b/src/daemon/device-targets.ts index 1b30e213a..91b0b714f 100644 --- a/src/daemon/device-targets.ts +++ b/src/daemon/device-targets.ts @@ -1,7 +1,7 @@ -import type { DeviceInfo } from '../kernel/device.ts'; +import { isIosFamily, type DeviceInfo } from '../kernel/device.ts'; export function isIosSimulator(device: DeviceInfo): boolean { - return device.platform === 'ios' && device.kind === 'simulator'; + return isIosFamily(device) && device.kind === 'simulator'; } export function isAndroidEmulator(device: DeviceInfo): boolean { diff --git a/src/daemon/direct-ios-selector.ts b/src/daemon/direct-ios-selector.ts index 046adbe5d..edcf0bb7d 100644 --- a/src/daemon/direct-ios-selector.ts +++ b/src/daemon/direct-ios-selector.ts @@ -1,3 +1,4 @@ +import { isIosFamily } from '../kernel/device.ts'; import type { SessionState } from './types.ts'; import { tryParseSelectorChain } from './selectors.ts'; import { asAppError } from '../kernel/errors.ts'; @@ -11,7 +12,7 @@ export function readSimpleIosSelectorTarget(params: { }): DirectIosSelectorTarget | null { const { session, selectorExpression } = params; if (!session) return null; - if (session.device.platform !== 'ios') return null; + if (!isIosFamily(session.device)) return null; if (session.postGestureStabilization) return null; const chain = tryParseSelectorChain(selectorExpression); if (!chain) return null; diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 366db643b..e8c255f5b 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -1932,7 +1932,8 @@ test('click --button secondary on @ref dispatches a secondary press on macOS and const sessionName = 'default'; const session = makeSession(sessionName); session.device = { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'macos-desktop', name: 'My Mac', kind: 'device', @@ -1992,7 +1993,8 @@ test('click --button middle on macOS fails with an explicit unsupported-operatio const sessionName = 'default'; const session = makeSession(sessionName); session.device = { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'macos-desktop', name: 'My Mac', kind: 'device', diff --git a/src/daemon/handlers/__tests__/react-native.test.ts b/src/daemon/handlers/__tests__/react-native.test.ts index b6cf8233b..51a59bfd2 100644 --- a/src/daemon/handlers/__tests__/react-native.test.ts +++ b/src/daemon/handlers/__tests__/react-native.test.ts @@ -78,7 +78,7 @@ test('react-native dismiss-overlay taps collapsed warning close affordance inste expect(response?.ok).toBe(true); expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'ios' }), + expect.objectContaining({ platform: 'apple' }), 'press', ['379', '820'], undefined, @@ -137,7 +137,7 @@ test('react-native dismiss-overlay prefers non-trailing collapsed warning close expect(response?.ok).toBe(true); expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'ios' }), + expect.objectContaining({ platform: 'apple' }), 'press', ['27', '820'], undefined, @@ -209,7 +209,7 @@ test('react-native dismiss-overlay does not confuse app dismiss buttons with ove expect(response?.ok).toBe(true); expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'ios' }), + expect.objectContaining({ platform: 'apple' }), 'press', ['369', '813'], undefined, @@ -324,7 +324,7 @@ test('react-native dismiss-overlay dismisses RedBox error overlays instead of mi expect(response?.ok).toBe(true); expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'ios' }), + expect.objectContaining({ platform: 'apple' }), 'press', ['95', '752'], undefined, @@ -446,7 +446,7 @@ test('react-native dismiss-overlay uses Dismiss when RedBox Minimize is absent', expect(response?.ok).toBe(true); expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'ios' }), + expect.objectContaining({ platform: 'apple' }), 'press', ['95', '752'], undefined, @@ -501,7 +501,7 @@ test('react-native dismiss-overlay accepts RedBox control labels with keyboard s expect(response?.ok).toBe(true); expect(mockDispatchCommand).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'ios' }), + expect.objectContaining({ platform: 'apple' }), 'press', ['70', '722'], undefined, @@ -872,7 +872,7 @@ function makeSession(name: string, platform: 'ios' | 'android' = 'ios'): Session createdAt: Date.now(), actions: [], device: { - platform, + platform: platform === 'ios' ? 'apple' : 'android', id: 'sim-1', name: platform === 'ios' ? 'iPhone' : 'Pixel', kind: platform === 'ios' ? 'simulator' : 'emulator', diff --git a/src/daemon/handlers/__tests__/record-trace.test.ts b/src/daemon/handlers/__tests__/record-trace.test.ts index 08995a9fd..638eb6129 100644 --- a/src/daemon/handlers/__tests__/record-trace.test.ts +++ b/src/daemon/handlers/__tests__/record-trace.test.ts @@ -107,7 +107,7 @@ function makeSession(name: string, device: SessionState['device']): SessionState function makeIosDeviceSession(name: string, appBundleId?: string): SessionState { const session = makeSession(name, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'My iPhone', kind: 'device', @@ -121,7 +121,7 @@ function makeIosDeviceSession(name: string, appBundleId?: string): SessionState function makeIosSimulatorSession(name: string): SessionState { return makeSession(name, { - platform: 'ios', + platform: 'apple', id: 'ios-sim-1', name: 'iPhone 16', kind: 'simulator', @@ -876,7 +876,7 @@ test('record stop resizes iOS simulator recording when max-size is explicit', as sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -917,7 +917,7 @@ test('record stop forwards the requested quality to the resize step', async () = sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -958,7 +958,7 @@ test('record stop leaves a short visual tail after iOS simulator gestures', asyn const sessionName = 'ios-sim-visual-tail'; const kill = vi.fn(); const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -996,7 +996,7 @@ test('record stop reports too-short iOS simulator recordings without leaving inv const outPath = path.join(os.tmpdir(), `agent-device-too-short-${Date.now()}.mp4`); fs.writeFileSync(outPath, 'not-a-video'); const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1033,7 +1033,7 @@ test('record stop measures too-short iOS simulator recordings from stop request const outPath = path.join(os.tmpdir(), `agent-device-too-short-delayed-${Date.now()}.mp4`); fs.writeFileSync(outPath, 'not-a-video'); const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1128,7 +1128,7 @@ test('record start stores iOS simulator recorder pid for scoped cleanup', async sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1162,7 +1162,7 @@ test('record stop prefers session-owned iOS recorder processes before path fallb const sessionName = 'ios-sim-owned-recorder'; const kill = vi.fn(); const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1231,7 +1231,7 @@ test('record stop falls back to path matching for stale iOS simulator recordVide const sessionName = 'ios-sim-stale-recorder'; const kill = vi.fn(); const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1280,7 +1280,7 @@ test('record stop keeps iOS simulator video when overlay export fails', async () sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1326,7 +1326,7 @@ test('record stop skips touch overlay export when no gestures were recorded', as sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1362,7 +1362,7 @@ test('record stop keeps iOS simulator video when resize export fails', async () sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1400,7 +1400,7 @@ test('record start does not fail when iOS simulator runner warm-up fails', async const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-warm-failure'; const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1447,7 +1447,7 @@ test('record start anchors gesture clock from simulator warm-up and skips standa const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-warm-anchor'; const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1492,7 +1492,7 @@ test('record start falls back to standalone uptime when warm response lacks curr const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-warm-missing'; const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1534,7 +1534,7 @@ test('record start rejects non-finite or non-positive warm anchors', async () => const sessionStore = makeSessionStore(); const sessionName = `ios-sim-warm-bad-${badValue}`; const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1576,7 +1576,7 @@ test('record start degrades to wall-clock when warm anchor missing and uptime fa const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-anchor-degraded'; const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -1615,7 +1615,7 @@ test('record start skips iOS simulator runner warm-up when touch overlays are hi const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-hide-touches'; const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'Simulator', kind: 'simulator', @@ -2068,7 +2068,7 @@ test('record stop keeps iOS simulator video when touch overlay recording was inv const sessionStore = makeSessionStore(); const sessionName = 'ios-invalidated-recording'; const session = makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', diff --git a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts index 211088479..a1fbd9950 100644 --- a/src/daemon/handlers/__tests__/session-close-shutdown.test.ts +++ b/src/daemon/handlers/__tests__/session-close-shutdown.test.ts @@ -98,7 +98,7 @@ test('close --shutdown calls shutdownSimulator for iOS simulator and includes re sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-udid-1', name: 'iPhone 15', kind: 'simulator', @@ -230,7 +230,7 @@ test('close --shutdown is ignored for non-simulator iOS devices', async () => { sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'physical-device-1', name: 'My iPhone', kind: 'device', @@ -281,7 +281,7 @@ test('close stops active Apple xctrace perf capture before deleting session', as }; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-udid-4', name: 'iPhone 15', kind: 'simulator', @@ -330,7 +330,7 @@ test('daemon session teardown stops active Apple xctrace perf capture', async () }; const session = { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-udid-5', name: 'iPhone 15', kind: 'simulator', @@ -402,7 +402,8 @@ test('close stops active host audio probe before deleting session', async () => const kill = vi.fn(); const session = { ...makeSession(sessionName, { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'macos', name: 'Mac', kind: 'device', @@ -680,7 +681,7 @@ test('close --shutdown returns success and failure payload when shutdownSimulato sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-udid-3', name: 'iPhone 15', kind: 'simulator', diff --git a/src/daemon/handlers/__tests__/session-device-utils.test.ts b/src/daemon/handlers/__tests__/session-device-utils.test.ts index 3a043d5e0..121ecb162 100644 --- a/src/daemon/handlers/__tests__/session-device-utils.test.ts +++ b/src/daemon/handlers/__tests__/session-device-utils.test.ts @@ -10,7 +10,7 @@ const iosSimulatorSession: SessionState = { name: 'ios-sim', createdAt: Date.now(), device: { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', diff --git a/src/daemon/handlers/__tests__/session-observability.test.ts b/src/daemon/handlers/__tests__/session-observability.test.ts index ef44f1fe7..b1b1c9220 100644 --- a/src/daemon/handlers/__tests__/session-observability.test.ts +++ b/src/daemon/handlers/__tests__/session-observability.test.ts @@ -537,7 +537,7 @@ test('perf memory snapshot reports physical iOS memgraph as unavailable', async actions: [], appBundleId: 'com.example.app', device: { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone', kind: 'device', diff --git a/src/daemon/handlers/__tests__/session-open-runtime.test.ts b/src/daemon/handlers/__tests__/session-open-runtime.test.ts index 4effcd598..9711274ec 100644 --- a/src/daemon/handlers/__tests__/session-open-runtime.test.ts +++ b/src/daemon/handlers/__tests__/session-open-runtime.test.ts @@ -162,7 +162,7 @@ test('open applies launch-only flags only to the direct app launch before runtim launchUrl: 'myapp://dev-client', }); mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 15', kind: 'simulator', diff --git a/src/daemon/handlers/__tests__/session-open-surface.test.ts b/src/daemon/handlers/__tests__/session-open-surface.test.ts index 8e8830f4c..9a5b377d7 100644 --- a/src/daemon/handlers/__tests__/session-open-surface.test.ts +++ b/src/daemon/handlers/__tests__/session-open-surface.test.ts @@ -8,7 +8,7 @@ test('resolveRequestedOpenSurface rejects surface flag on iOS', () => { () => resolveRequestedOpenSurface({ device: { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17', kind: 'simulator', diff --git a/src/daemon/handlers/__tests__/session-reinstall.test.ts b/src/daemon/handlers/__tests__/session-reinstall.test.ts index f56d9b5d3..00c429bee 100644 --- a/src/daemon/handlers/__tests__/session-reinstall.test.ts +++ b/src/daemon/handlers/__tests__/session-reinstall.test.ts @@ -88,7 +88,7 @@ test('reinstall requires active session or explicit device selector', async () = test('reinstall validates required args before device operations', async () => { const sessionStore = makeStore(); const session = makeSession('default', { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', @@ -233,7 +233,7 @@ test('install accepts path without app id hint', async () => { test('reinstall resolves uploaded artifacts by id and cleans temp files after completion', async () => { const sessionStore = makeStore(); const session = makeSession('default', { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index eb298febb..126e6acd1 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -492,7 +492,7 @@ test('runReplayScriptFile reports snapshot diagnostics from per-action session s session?.snapshotDiagnostics?.samples.push({ durationMs: captures === 1 ? 400 : 1_900, backend: 'xctest', - platform: 'ios', + platform: 'apple', }); return { ok: true, @@ -550,7 +550,7 @@ test('runReplayScriptFile reports snapshot diagnostics on replay failure', async session?.snapshotDiagnostics?.samples.push({ durationMs: captures === 1 ? 450 : 2_100, backend: 'xctest', - platform: 'ios', + platform: 'apple', }); if (captures === 1) return { ok: true, data: {} }; return { ok: false, error: { code: 'COMMAND_FAILED', message: 'button missing' } }; @@ -2276,7 +2276,7 @@ test('runReplayScriptFile writes per-action timing events to active trace', asyn sessionStore.set('s', { name: 's', device: { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index fc2dbf788..6b12fe393 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -1,3 +1,4 @@ +import { isMacOs } from '../../../kernel/device.ts'; import { test, expect, vi, beforeEach } from 'vitest'; vi.mock('../../../core/dispatch.ts', async (importOriginal) => { @@ -195,7 +196,7 @@ beforeEach(() => { mockResolveIosApp.mockImplementation(async (device, app) => { const normalizedApp = app.toLowerCase(); if (normalizedApp === 'settings') { - return device.platform === 'macos' ? 'com.apple.systempreferences' : 'com.apple.Preferences'; + return isMacOs(device) ? 'com.apple.systempreferences' : 'com.apple.Preferences'; } if (normalizedApp === 'menubarapp') { return 'com.example.menubarapp'; @@ -263,7 +264,7 @@ test('devices filters Apple-family platform selectors', async () => { ]); mockListAppleDevices.mockResolvedValue([ { - platform: 'ios' as const, + platform: 'apple' as const, id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator' as const, @@ -271,7 +272,8 @@ test('devices filters Apple-family platform selectors', async () => { booted: true, }, { - platform: 'macos' as const, + platform: 'apple', + appleOs: 'macos' as const, id: 'host-macos-local', name: 'Host Mac', kind: 'device' as const, @@ -321,7 +323,7 @@ test('devices omits internal appleOs from the public inventory projection', asyn mockListAndroidDevices.mockResolvedValue([]); mockListAppleDevices.mockResolvedValue([ { - platform: 'ios' as const, + platform: 'apple' as const, id: 'sim-1', name: 'iPad Pro 11-inch (M4)', kind: 'simulator' as const, @@ -729,7 +731,7 @@ test('close clears applied runtime transport hints before deleting the session', }); sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -763,7 +765,7 @@ test('close clears retained materialized install paths bound to the session', as const sessionName = 'materialized-close-active'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -875,7 +877,7 @@ test('boot prefers explicit device selector over active session device', async ( }), ); const selectedDevice: SessionState['device'] = { - platform: 'ios', + platform: 'apple', id: 'sim-2', name: 'iPhone 17 Pro', kind: 'simulator', @@ -1143,7 +1145,7 @@ test('boot keeps --target validation when emulator is fallback-launched', async test('shutdown turns off selected iOS simulator', async () => { const sessionStore = makeSessionStore(); const selectedDevice: SessionState['device'] = { - platform: 'ios', + platform: 'apple', id: 'sim-2', name: 'iPhone 17 Pro', kind: 'simulator', @@ -1186,7 +1188,7 @@ test('shutdown rejects active session device and points to close --shutdown', as const sessionStore = makeSessionStore(); const sessionName = 'default'; const selectedDevice: SessionState['device'] = { - platform: 'ios', + platform: 'apple', id: 'sim-2', name: 'iPhone 17 Pro', kind: 'simulator', @@ -1270,7 +1272,7 @@ test('shutdown turns off selected Android emulator', async () => { test('shutdown rejects unsupported physical devices', async () => { const sessionStore = makeSessionStore(); mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'device-1', name: 'iPhone', kind: 'device', @@ -1305,7 +1307,7 @@ test('shutdown rejects unsupported physical devices', async () => { test('shutdown returns an error response when selected target shutdown fails', async () => { const sessionStore = makeSessionStore(); const selectedDevice: SessionState['device'] = { - platform: 'ios', + platform: 'apple', id: 'sim-2', name: 'iPhone 17 Pro', kind: 'simulator', @@ -1353,7 +1355,7 @@ test('appstate on iOS requires active session on selected device', async () => { const sessionName = 'default'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 15', kind: 'simulator', @@ -1363,7 +1365,7 @@ test('appstate on iOS requires active session on selected device', async () => { appName: 'Settings', }); const selectedDevice: SessionState['device'] = { - platform: 'ios', + platform: 'apple', id: 'sim-2', name: 'iPhone 17 Pro', kind: 'simulator', @@ -1399,7 +1401,7 @@ test('appstate returns session appName when bundle id is unavailable', async () const sessionName = 'sim'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -1409,7 +1411,7 @@ test('appstate returns session appName when bundle id is unavailable', async () }); const selectedDevice: SessionState['device'] = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -1450,7 +1452,7 @@ test('appstate fails when iOS session has no tracked app', async () => { sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -1459,7 +1461,7 @@ test('appstate fails when iOS session has no tracked app', async () => { ); const selectedDevice: SessionState['device'] = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -1492,7 +1494,7 @@ test('appstate fails when iOS session has no tracked app', async () => { test('appstate without session on iOS selector returns SESSION_NOT_FOUND', async () => { const sessionStore = makeSessionStore(); const selectedDevice: SessionState['device'] = { - platform: 'ios', + platform: 'apple', id: 'sim-2', name: 'iPhone 17 Pro', kind: 'simulator', @@ -1629,7 +1631,7 @@ test('clipboard rejects unsupported iOS physical devices', async () => { sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -1790,7 +1792,8 @@ test('perf samples Apple cpu and memory metrics on macOS app sessions', async () const sessionName = 'perf-session-macos'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-mac', name: 'Host Mac', kind: 'device', @@ -1850,7 +1853,7 @@ test('perf samples Apple cpu and memory metrics on iOS simulator app sessions', const sessionName = 'perf-session-ios-sim'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -1907,7 +1910,7 @@ test('perf samples Apple cpu and memory metrics on physical iOS devices', async const sessionName = 'perf-session-ios-device'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -2031,7 +2034,7 @@ test('perf reports physical iOS cpu and memory as unavailable without an app bun const sessionName = 'perf-session-ios-device-no-bundle'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-2', name: 'iPhone Device', kind: 'device', @@ -2070,7 +2073,7 @@ test('open URL on existing iOS session clears stale app bundle id', async () => const sessionName = 'ios-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 15', kind: 'simulator', @@ -2081,7 +2084,7 @@ test('open URL on existing iOS session clears stale app bundle id', async () => }); mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 15', kind: 'simulator', @@ -2120,7 +2123,8 @@ test('open URL on existing macOS session clears stale app bundle id', async () = const sessionName = 'macos-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-mac', name: 'Mac', kind: 'device', @@ -2164,7 +2168,7 @@ test('open URL on existing iOS device session preserves app bundle id context', const sessionName = 'ios-device-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -2207,7 +2211,7 @@ test('open custom URL on existing iOS simulator session preserves app bundle id const sessionName = 'ios-simulator-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -2217,7 +2221,7 @@ test('open custom URL on existing iOS simulator session preserves app bundle id appName: 'Example App', }); mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -2260,7 +2264,7 @@ test('open custom URL on fresh iOS simulator session infers app bundle id from U const sessionStore = makeSessionStore(); const sessionName = 'ios-simulator-url-session'; mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -2305,7 +2309,7 @@ test('open iOS simulator app prewarms runner cache during cold boot', async () = const sessionStore = makeSessionStore(); const sessionName = 'ios-simulator-cold-boot-cache-prewarm'; const device: SessionState['device'] = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -2349,7 +2353,7 @@ test('open iOS app session prewarms runner session when app bundle id is known', const sessionName = 'ios-device-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -2377,7 +2381,7 @@ test('open iOS app session prewarms runner session when app bundle id is known', expect(response?.ok).toBe(true); expect(mockPrewarmIosRunnerSession).toHaveBeenCalledTimes(1); expect(mockPrewarmIosRunnerSession).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'ios', id: 'ios-device-1' }), + expect.objectContaining({ platform: 'apple', id: 'ios-device-1' }), expect.objectContaining({ logPath: expect.stringMatching(/daemon\.log$/) }), ); }); @@ -2389,7 +2393,7 @@ test('open iOS Maestro app link waits for runner prewarm before launching app', let finishPrewarm: (() => void) | undefined; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -2450,7 +2454,7 @@ test('open iOS Maestro app link reports blocking runner prewarm failures before const sessionName = 'ios-maestro-open-link-prewarm-failed'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -2490,7 +2494,7 @@ test('open iOS Maestro app link reports blocking runner prewarm failures before }); expect(mockDispatch).not.toHaveBeenCalled(); expect(mockPrewarmIosRunnerSession).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'ios', id: 'ios-device-1' }), + expect.objectContaining({ platform: 'apple', id: 'ios-device-1' }), expect.objectContaining({ propagateError: true }), ); }); @@ -2501,7 +2505,7 @@ test('open iOS URL without app bundle id skips runner prewarm', async () => { sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -2532,7 +2536,7 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', const sessionStore = makeSessionStore(); const sessionName = 'prepare-ios-runner'; mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -2557,11 +2561,11 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector', expect(response).toBeTruthy(); expect(response?.ok).toBe(true); expect(mockEnsureDeviceReady).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'ios', id: 'sim-1' }), + expect.objectContaining({ platform: 'apple', id: 'sim-1' }), ); expect(mockPrepareIosRunner).toHaveBeenCalledTimes(1); expect(mockPrepareIosRunner).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'ios', id: 'sim-1' }), + expect.objectContaining({ platform: 'apple', id: 'sim-1' }), expect.objectContaining({ cleanStaleBundles: true, buildTimeoutMs: 240000, @@ -2594,7 +2598,8 @@ test('prepare ios-runner starts the XCTest runner on an explicit macOS selector' const sessionStore = makeSessionStore(); const sessionName = 'prepare-macos-runner'; mockResolveTargetDevice.mockResolvedValue({ - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-macos-local', name: 'Host Mac', kind: 'device', @@ -2620,7 +2625,7 @@ test('prepare ios-runner starts the XCTest runner on an explicit macOS selector' expect(response).toBeTruthy(); expect(response?.ok).toBe(true); expect(mockPrepareIosRunner).toHaveBeenCalledWith( - expect.objectContaining({ platform: 'macos', id: 'host-macos-local' }), + expect.objectContaining({ platform: 'apple', id: 'host-macos-local' }), expect.objectContaining({ buildTimeoutMs: 240000, healthTimeoutMs: 240000, @@ -2710,7 +2715,7 @@ test('open web URL on iOS device session without active app falls back to Safari sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -2751,7 +2756,7 @@ test('open app and URL on existing iOS device session keeps app context', async const sessionName = 'ios-device-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -2797,7 +2802,8 @@ test('open app on existing macOS session resolves and stores bundle id', async ( const sessionName = 'macos-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-mac', name: 'Mac', kind: 'device', @@ -2839,7 +2845,7 @@ test('open app on existing macOS session resolves and stores bundle id', async ( test('open rejects --surface on non-macOS devices', async () => { const sessionStore = makeSessionStore(); mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -2871,7 +2877,8 @@ test('open on existing macOS frontmost-app session preserves surface without --s const sessionName = 'macos-frontmost-existing'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-macos-local', name: 'Host Mac', kind: 'device', @@ -2931,7 +2938,7 @@ test('open on existing iOS session refreshes unavailable simulator by name', asy const sessionName = 'ios-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'stale-sim', name: 'iPhone 17 Pro', kind: 'simulator', @@ -2942,7 +2949,7 @@ test('open on existing iOS session refreshes unavailable simulator by name', asy }); const resolvedDevice: SessionState['device'] = { - platform: 'ios', + platform: 'apple', id: 'fresh-sim', name: 'iPhone 17 Pro', kind: 'simulator', @@ -3182,7 +3189,7 @@ test('open --relaunch on iOS stops runner before close/open', async () => { const sessionName = 'ios-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'My iPhone', kind: 'device', @@ -3193,7 +3200,7 @@ test('open --relaunch on iOS stops runner before close/open', async () => { const calls: string[] = []; mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'My iPhone', kind: 'device', @@ -3231,7 +3238,7 @@ test('open --relaunch on iOS simulator stops runner before close/open', async () const sessionName = 'ios-simulator-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -3242,7 +3249,7 @@ test('open --relaunch on iOS simulator stops runner before close/open', async () const calls: string[] = []; mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -3282,7 +3289,7 @@ test('open --relaunch includes timing and waits for iOS runner prewarm after ope const events: string[] = []; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'My iPhone', kind: 'device', @@ -3348,7 +3355,7 @@ test('open --relaunch on iOS without existing session closes then opens target a const sessionStore = makeSessionStore(); const sessionName = 'ios-new-session'; mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'My iPhone', kind: 'device', @@ -3388,7 +3395,7 @@ test('open --relaunch on iOS simulator reaches settle path for close and open', const sessionName = 'ios-sim-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 16', kind: 'simulator', @@ -3398,7 +3405,7 @@ test('open --relaunch on iOS simulator reaches settle path for close and open', }); mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 16', kind: 'simulator', @@ -3435,7 +3442,8 @@ test('close on macOS session stops runner and dismisses automation alert before const sessionName = 'macos-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-macos-local', name: 'Host Mac', kind: 'device', @@ -3485,7 +3493,7 @@ test('close on iOS stops runner before app close dispatch and performs fin const sessionName = 'ios-close-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'My iPhone', kind: 'device', @@ -3527,7 +3535,7 @@ test('close on iOS simulator retains runner while terminating app', async const sessionName = 'ios-simulator-close-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -3569,7 +3577,8 @@ test('close on macOS stops runner before app close dispatch and dismisses const sessionName = 'macos-close-session'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-macos-local', name: 'Host Mac', kind: 'device', @@ -3805,7 +3814,7 @@ test('open on in-use device returns DEVICE_IN_USE before readiness checks', asyn sessionStore.set( 'busy-session', makeSession('busy-session', { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -3814,7 +3823,7 @@ test('open on in-use device returns DEVICE_IN_USE before readiness checks', asyn ); mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -3849,7 +3858,7 @@ test('open on in-use device returns DEVICE_IN_USE before readiness checks', asyn test('open on device owned by recording session returns recording recovery hint', async () => { const sessionStore = makeSessionStore(); const recordingSession = makeSession('default', { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -3868,7 +3877,7 @@ test('open on device owned by recording session returns recording recovery hint' sessionStore.set('default', recordingSession); mockResolveTargetDevice.mockResolvedValue({ - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -4113,7 +4122,7 @@ test('logs rejects invalid action', async () => { sessionStore.set( 'default', makeSession('default', { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', @@ -4146,7 +4155,7 @@ test('logs start requires app session (appBundleId)', async () => { sessionStore.set( 'default', makeSession('default', { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', @@ -4179,7 +4188,7 @@ test('logs stop requires active app log stream', async () => { sessionStore.set( 'default', makeSession('default', { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', @@ -4257,7 +4266,7 @@ test('logs --restart is only supported with logs clear', async () => { const sessionName = 'default'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Simulator', kind: 'simulator', @@ -4292,7 +4301,7 @@ test('logs clear --restart requires app session bundle id', async () => { sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Simulator', kind: 'simulator', @@ -4688,7 +4697,7 @@ test('network dump recovers iOS simulator entries from simctl log show when the ); sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -4696,7 +4705,7 @@ test('network dump recovers iOS simulator entries from simctl log show when the }), appBundleId: 'com.agentdevice.tester', appLog: { - platform: 'ios', + platform: 'apple', backend: 'ios-simulator', outPath: appLogPath, startedAt: 1_712_040_000_000, @@ -4765,7 +4774,7 @@ test('network dump explains when iOS simulator recovery found app logs but no HT ); sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -4773,7 +4782,7 @@ test('network dump explains when iOS simulator recovery found app logs but no HT }), appBundleId: 'com.agentdevice.tester', appLog: { - platform: 'ios', + platform: 'apple', backend: 'ios-simulator', outPath: appLogPath, startedAt: 1_712_040_000_000, @@ -4833,7 +4842,8 @@ test('network dump supports macOS desktop sessions', async () => { const sessionName = 'macos-network'; sessionStore.set(sessionName, { ...makeSession(sessionName, { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-macos-local', name: 'Host Mac', kind: 'device', @@ -4877,7 +4887,7 @@ test('network dump validates include mode and limit', async () => { sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Simulator', kind: 'simulator', @@ -4931,7 +4941,7 @@ test('session_list includes device_udid and ios_simulator_device_set for iOS ses sessionStore.set( 'ios-scoped', makeSession('ios-scoped', { - platform: 'ios', + platform: 'apple', id: 'DEF-456', name: 'iPhone 16', kind: 'simulator', @@ -4952,7 +4962,8 @@ test('session_list includes device_udid and ios_simulator_device_set for iOS ses sessionStore.set( 'macos-1', makeSession('macos-1', { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-macos-local', name: 'Host Mac', kind: 'device', diff --git a/src/daemon/handlers/__tests__/snapshot-handler.test.ts b/src/daemon/handlers/__tests__/snapshot-handler.test.ts index 426e36a72..d4f990f67 100644 --- a/src/daemon/handlers/__tests__/snapshot-handler.test.ts +++ b/src/daemon/handlers/__tests__/snapshot-handler.test.ts @@ -68,7 +68,7 @@ function makeSession(name: string, device: SessionState['device']): SessionState } const iosSimulatorDevice: SessionState['device'] = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'My iPhone Simulator', kind: 'simulator', @@ -76,7 +76,8 @@ const iosSimulatorDevice: SessionState['device'] = { }; const macOsDevice: SessionState['device'] = { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-macos-local', name: 'Host Mac', kind: 'device', @@ -310,7 +311,7 @@ test('snapshot rejects @ref scope without existing session snapshot', async () = sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'My iPhone Simulator', kind: 'simulator', @@ -1479,7 +1480,7 @@ test('settings rejects unsupported iOS physical devices', async () => { sessionStore.set( sessionName, makeSession(sessionName, { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'My iPhone', kind: 'device', diff --git a/src/daemon/handlers/install-source.ts b/src/daemon/handlers/install-source.ts index 6128a19d2..eafa4637c 100644 --- a/src/daemon/handlers/install-source.ts +++ b/src/daemon/handlers/install-source.ts @@ -1,3 +1,4 @@ +import { isIosFamily } from '../../kernel/device.ts'; import { installProviderDeviceInstallablePath, type ProviderDeviceInstallResult, @@ -159,7 +160,7 @@ export async function handleInstallFromSourceCommand(params: { if (unsupported) return unsupported; const requestSignal = getRequestSignal(req.meta?.requestId); - if (device.platform === 'ios') { + if (isIosFamily(device)) { const { prepareIosInstallArtifact } = await import('../../platforms/apple/core/install-artifact.ts'); const prepared = await prepareIosInstallArtifact(resolvedSource.source, { diff --git a/src/daemon/handlers/interaction-read.ts b/src/daemon/handlers/interaction-read.ts index 730f1186e..3ef09a851 100644 --- a/src/daemon/handlers/interaction-read.ts +++ b/src/daemon/handlers/interaction-read.ts @@ -1,3 +1,4 @@ +import { isIosFamily } from '../../kernel/device.ts'; import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { extractNodeReadText } from '../../snapshot/snapshot-processing.ts'; @@ -31,7 +32,7 @@ export async function readTextForNode(params: { // Restricted to iOS because other backends read differently — macOS helper and Linux reads // are value-first (AXValue/title/description), unlike the label-first snapshot readable text, // so skipping their backend read would change the returned text. - if (device.platform === 'ios' && fallbackText && !prefersValueForReadableText(node.type ?? '')) { + if (isIosFamily(device) && fallbackText && !prefersValueForReadableText(node.type ?? '')) { return fallbackText; } diff --git a/src/daemon/handlers/interaction-runtime.ts b/src/daemon/handlers/interaction-runtime.ts index 2bc01dde9..48b655687 100644 --- a/src/daemon/handlers/interaction-runtime.ts +++ b/src/daemon/handlers/interaction-runtime.ts @@ -1,4 +1,5 @@ import { dispatchCommand } from '../../core/dispatch.ts'; +import { publicPlatformString } from '../../kernel/device.ts'; import type { AgentDeviceBackend, BackendActionResult, @@ -47,7 +48,7 @@ function createInteractionBackend( const { req, session } = params; const webProvider = resolveNativeWebInteractionProvider(session); return { - platform: session.device.platform, + platform: publicPlatformString(session.device), captureSnapshot: async (_context, options): Promise => ({ snapshot: await params.captureSnapshotForSession( session, diff --git a/src/daemon/handlers/interaction-touch-policy.ts b/src/daemon/handlers/interaction-touch-policy.ts index 13d30d92c..95ae31279 100644 --- a/src/daemon/handlers/interaction-touch-policy.ts +++ b/src/daemon/handlers/interaction-touch-policy.ts @@ -1,3 +1,4 @@ +import { isMacOs } from '../../kernel/device.ts'; import type { DaemonResponse, SessionState } from '../types.ts'; import { errorResponse } from './response.ts'; @@ -5,7 +6,7 @@ export function unsupportedMacOsDesktopSurfaceInteraction( session: SessionState, command: 'click' | 'press' | 'fill' | 'longpress', ): DaemonResponse | null { - if (session.device.platform !== 'macos') { + if (!isMacOs(session.device)) { return null; } if (session.surface !== 'desktop' && session.surface !== 'menubar') { diff --git a/src/daemon/handlers/interaction-touch.ts b/src/daemon/handlers/interaction-touch.ts index f50c5b76d..853af170e 100644 --- a/src/daemon/handlers/interaction-touch.ts +++ b/src/daemon/handlers/interaction-touch.ts @@ -1,4 +1,5 @@ import type { GestureReferenceFrame } from '../../core/scroll-gesture.ts'; +import { publicPlatformString } from '../../kernel/device.ts'; import { buttonTag, getClickButtonValidationError, @@ -98,7 +99,7 @@ async function dispatchTargetedTouchViaRuntime( if (command !== 'longpress' && clickButton !== 'primary') { const validationError = getClickButtonValidationError({ commandLabel, - platform: session.device.platform, + platform: publicPlatformString(session.device), button: clickButton, count: req.flags?.count, intervalMs: req.flags?.intervalMs, diff --git a/src/daemon/handlers/record-trace-ios.ts b/src/daemon/handlers/record-trace-ios.ts index c50692e18..d1c203f8e 100644 --- a/src/daemon/handlers/record-trace-ios.ts +++ b/src/daemon/handlers/record-trace-ios.ts @@ -1,3 +1,4 @@ +import { isIosFamily } from '../../kernel/device.ts'; import { SessionStore } from '../session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { emitDiagnostic } from '../../utils/diagnostics.ts'; @@ -27,7 +28,7 @@ function findOtherActiveIosRunnerRecording( .find( (session) => session.name !== currentSessionName && - session.device.platform === 'ios' && + isIosFamily(session.device) && session.device.kind === 'device' && session.device.id === deviceId && session.recording?.platform === 'ios-device-runner', diff --git a/src/daemon/handlers/record-trace-recording-backends.ts b/src/daemon/handlers/record-trace-recording-backends.ts index 76ec29ba6..79e65d1dd 100644 --- a/src/daemon/handlers/record-trace-recording-backends.ts +++ b/src/daemon/handlers/record-trace-recording-backends.ts @@ -1,3 +1,4 @@ +import { isIosFamily, isMacOs } from '../../kernel/device.ts'; import fs from 'node:fs'; import path from 'node:path'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; @@ -75,9 +76,9 @@ export function resolveRecordingBackendForDevice( ): RecordingStartBackend { if (device.platform === 'web') return webRecordingBackend; if (device.platform === 'android') return androidRecordingBackend; - if (device.platform === 'macos') return macOsRecordingBackend; - if (device.platform === 'ios' && device.kind === 'device') return iosDeviceRecordingBackend; - if (device.platform === 'ios') return iosSimulatorRecordingBackend; + if (isMacOs(device)) return macOsRecordingBackend; + if (isIosFamily(device) && device.kind === 'device') return iosDeviceRecordingBackend; + if (isIosFamily(device)) return iosSimulatorRecordingBackend; return unsupportedRecordingBackend; } diff --git a/src/daemon/handlers/session-deploy.ts b/src/daemon/handlers/session-deploy.ts index ec315d0d1..37bb25050 100644 --- a/src/daemon/handlers/session-deploy.ts +++ b/src/daemon/handlers/session-deploy.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import { installProviderDeviceApp } from '../../provider-device-runtime.ts'; import { cleanupUploadedArtifact, prepareUploadedArtifact } from '../artifact-tracking.ts'; -import type { DeviceInfo } from '../../kernel/device.ts'; +import { isIosFamily, type DeviceInfo } from '../../kernel/device.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { recordSessionAction } from './handler-utils.ts'; @@ -154,10 +154,9 @@ export async function handleAppDeployCommand(params: { const unsupported = requireCommandSupported(command, device); if (unsupported) return unsupported; - const result = - device.platform === 'ios' - ? buildIosDeployResult(app, appPath, await deployOps.ios(device, app, appPath)) - : buildAndroidDeployResult(app, appPath, await deployOps.android(device, app, appPath)); + const result = isIosFamily(device) + ? buildIosDeployResult(app, appPath, await deployOps.ios(device, app, appPath)) + : buildAndroidDeployResult(app, appPath, await deployOps.android(device, app, appPath)); const data = withSuccessText(result, buildDeployMessage(result)); recordSessionAction(sessionStore, session, req, command, data); diff --git a/src/daemon/handlers/session-device-utils.ts b/src/daemon/handlers/session-device-utils.ts index ef3294df7..12deae796 100644 --- a/src/daemon/handlers/session-device-utils.ts +++ b/src/daemon/handlers/session-device-utils.ts @@ -1,4 +1,4 @@ -import type { DeviceInfo } from '../../kernel/device.ts'; +import { isIosFamily, type DeviceInfo } from '../../kernel/device.ts'; import { AppError } from '../../kernel/errors.ts'; import { ensureDeviceReady } from '../device-ready.ts'; import { resolveTargetDevice } from '../../core/dispatch.ts'; @@ -57,7 +57,7 @@ export async function resolveCommandDevice(params: { } export async function refreshSessionDeviceIfNeeded(device: DeviceInfo): Promise { - if (device.platform !== 'ios' || device.kind !== 'simulator') { + if (!isIosFamily(device) || device.kind !== 'simulator') { return device; } if (process.platform !== 'darwin') { diff --git a/src/daemon/handlers/session-doctor-app.ts b/src/daemon/handlers/session-doctor-app.ts index 62bf03403..47ca46cc6 100644 --- a/src/daemon/handlers/session-doctor-app.ts +++ b/src/daemon/handlers/session-doctor-app.ts @@ -1,4 +1,4 @@ -import type { DeviceInfo } from '../../kernel/device.ts'; +import { isIosFamily, isMacOs, type DeviceInfo } from '../../kernel/device.ts'; import { AppError, normalizeError } from '../../kernel/errors.ts'; import type { SessionState } from '../types.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; @@ -56,7 +56,7 @@ async function resolveInstalledAppForDoctor( ); return match?.id; } - if (device.platform === 'ios' || device.platform === 'macos') { + if (isIosFamily(device) || isMacOs(device)) { const { listIosApps } = await import('../../platforms/apple/core/apps.ts'); const apps = await listIosApps(device, 'all'); const match = resolveUniqueInstalledAppMatch( diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index f75864d29..3ad434559 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -3,6 +3,9 @@ import { assertResolvedAppsFilter } from '../../contracts/app-inventory.ts'; import { asAppError } from '../../kernel/errors.ts'; import { isApplePlatform, + isMacOs, + matchesPlatformSelector, + publicPlatformString, resolveAppleSimulatorSetPathForSelector, type DeviceInfo, type PlatformSelector, @@ -40,17 +43,19 @@ export async function handleSessionInventoryCommands(params: { name: session.name, sessionStateDir, runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir), - platform: session.device.platform, + // approach (b): emit the PUBLIC leaf platform (ios/macos), not `apple`. + platform: publicPlatformString(session.device), target: session.device.target ?? 'mobile', surface: session.surface ?? 'app', device: session.device.name, id: session.device.id, device_id: session.device.id, createdAt: session.createdAt, - ...(session.device.platform === 'ios' && { - device_udid: session.device.id, - ios_simulator_device_set: session.device.simulatorSetPath ?? null, - }), + ...(isApplePlatform(session.device.platform) && + !isMacOs(session.device) && { + device_udid: session.device.id, + ios_simulator_device_set: session.device.simulatorSetPath ?? null, + }), }; }), }, @@ -90,8 +95,12 @@ export async function handleSessionInventoryCommands(params: { // Keep appleOs internal-only for now: it is discovery groundwork and the // public `devices` shape is not yet meant to expose it. Surfacing it (so // agents can tell iPad from iPhone) should be a deliberate later change. + // approach (b): project `platform` back to the PUBLIC leaf (ios/macos). const publicDevices = filtered.map( - ({ simulatorSetPath: _simulatorSetPath, appleOs: _appleOs, ...device }) => device, + ({ simulatorSetPath: _simulatorSetPath, appleOs, ...device }) => ({ + ...device, + platform: publicPlatformString({ platform: device.platform, appleOs }), + }), ); return { ok: true, data: { devices: publicDevices } }; } catch (err) { @@ -145,7 +154,5 @@ function matchesRequestedPlatform( device: DeviceInfo, requestedPlatform: PlatformSelector | undefined, ): boolean { - if (!requestedPlatform) return true; - if (requestedPlatform === 'apple') return isApplePlatform(device.platform); - return device.platform === requestedPlatform; + return matchesPlatformSelector(device, requestedPlatform); } diff --git a/src/daemon/handlers/session-open-surface.ts b/src/daemon/handlers/session-open-surface.ts index 2fa7c4765..ddbfc9161 100644 --- a/src/daemon/handlers/session-open-surface.ts +++ b/src/daemon/handlers/session-open-surface.ts @@ -1,6 +1,6 @@ import { parseSessionSurface, type SessionSurface } from '../../core/session-surface.ts'; import { resolveFrontmostMacOsApp } from '../../platforms/apple/os/macos/helper.ts'; -import type { DeviceInfo } from '../../kernel/device.ts'; +import { isIosFamily, isMacOs, type DeviceInfo } from '../../kernel/device.ts'; import type { SessionRuntimeHints, SessionState } from '../types.ts'; import { AppError } from '../../kernel/errors.ts'; import { successText } from '../../utils/success-text.ts'; @@ -58,7 +58,7 @@ export function buildOpenResult(params: { result.serial = device.id; } } - if (device?.platform === 'ios') { + if (device && isIosFamily(device)) { result.device_udid = device.id; result.ios_simulator_device_set = device.simulatorSetPath ?? null; } @@ -133,7 +133,7 @@ function resolveOpenSurface( } return surface; } - if (device.platform !== 'macos') { + if (!isMacOs(device)) { if (surfaceFlag) { throw new AppError('INVALID_ARGS', 'surface is only supported on macOS and Linux'); } @@ -153,7 +153,7 @@ export function resolveRequestedOpenSurface(params: { existingSurface?: SessionSurface; }): SessionSurface { const { device, surfaceFlag, openTarget, existingSurface } = params; - if ((device.platform === 'macos' || device.platform === 'linux') && !surfaceFlag) { + if ((isMacOs(device) || device.platform === 'linux') && !surfaceFlag) { return existingSurface ?? 'app'; } return resolveOpenSurface(device, surfaceFlag, openTarget); diff --git a/src/daemon/handlers/session-open-target.ts b/src/daemon/handlers/session-open-target.ts index b8a95b7e6..49abc8517 100644 --- a/src/daemon/handlers/session-open-target.ts +++ b/src/daemon/handlers/session-open-target.ts @@ -3,7 +3,7 @@ import { isWebUrl, resolveIosDeviceDeepLinkBundleId, } from '../../core/open-target.ts'; -import { isApplePlatform, type DeviceInfo } from '../../kernel/device.ts'; +import { isMacOs, isApplePlatform, type DeviceInfo } from '../../kernel/device.ts'; async function resolveIosBundleIdForOpen( device: DeviceInfo, @@ -12,7 +12,7 @@ async function resolveIosBundleIdForOpen( ): Promise { if (!isApplePlatform(device.platform) || !openTarget) return undefined; if (isDeepLinkTarget(openTarget)) { - if (device.platform === 'macos') return undefined; + if (isMacOs(device)) return undefined; if (device.kind === 'device') { return resolveIosDeviceDeepLinkBundleId(currentAppBundleId, openTarget); } diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 4f0900287..95ffbc0cd 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -12,7 +12,7 @@ import { createAppleRunnerCacheColdBootPrewarmForOpen, } from '../apple-runner-options.ts'; import { applyRuntimeHintsToApp } from '../runtime-hints.ts'; -import type { DeviceInfo } from '../../kernel/device.ts'; +import { isIosFamily, type DeviceInfo } from '../../kernel/device.ts'; import type { DaemonRequest, DaemonResponse, SessionRuntimeHints, SessionState } from '../types.ts'; import { resolveSessionRequestLogPath, @@ -187,7 +187,7 @@ async function completeOpenCommand(params: { }); timing.runtimeHintsDurationMs = Math.max(0, Date.now() - runtimeHintsStartedAtMs); const shouldPrewarmIosRunner = - device.platform === 'ios' && surface === 'app' && openPositionals.length > 0; + isIosFamily(device) && surface === 'app' && openPositionals.length > 0; const runnerPrewarmOptions = buildAppleRunnerSessionOptions({ req, logPath, diff --git a/src/daemon/handlers/session-runtime-command.ts b/src/daemon/handlers/session-runtime-command.ts index 9bc3fac38..c5e11d300 100644 --- a/src/daemon/handlers/session-runtime-command.ts +++ b/src/daemon/handlers/session-runtime-command.ts @@ -1,4 +1,5 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import { publicPlatformString } from '../../kernel/device.ts'; import { SessionStore } from '../session-store.ts'; import { clearRuntimeHintsFromApp, hasRuntimeTransportHints } from '../runtime-hints.ts'; import { errorResponse } from './response.ts'; @@ -92,6 +93,12 @@ function showRuntimeCommand( }; } +function sessionLeafPlatform( + session: ReturnType, +): ReturnType | undefined { + return session ? publicPlatformString(session.device) : undefined; +} + function setRuntimeCommand(params: { req: DaemonRequest; sessionName: string; @@ -100,19 +107,20 @@ function setRuntimeCommand(params: { current: ReturnType; }): DaemonResponse { const { req, sessionName, sessionStore, session, current } = params; - const platform = toRuntimePlatform( - req.flags?.platform ?? current?.platform ?? session?.device.platform, - ); + // approach (b): resolve the session's PUBLIC leaf platform (ios/macos), never the + // internal `apple`, so the legacy `--platform ios` selector still matches. + const sessionLeaf = sessionLeafPlatform(session); + const platform = toRuntimePlatform(req.flags?.platform ?? current?.platform ?? sessionLeaf); if (!platform) { return errorResponse( 'INVALID_ARGS', 'runtime set only supports iOS and Android sessions. Pass --platform ios|android or open an iOS/Android session first.', ); } - if (session && session.device.platform !== platform) { + if (sessionLeaf !== undefined && sessionLeaf !== platform) { return errorResponse( 'INVALID_ARGS', - `runtime set targets ${platform}, but session "${sessionName}" is already bound to ${session.device.platform}.`, + `runtime set targets ${platform}, but session "${sessionName}" is already bound to ${sessionLeaf}.`, ); } const nextRuntime = mergeRuntimeHints(current, buildRuntimeHints(req.flags, platform)); diff --git a/src/daemon/handlers/session-runtime.ts b/src/daemon/handlers/session-runtime.ts index c0f6cd8c3..332a039e0 100644 --- a/src/daemon/handlers/session-runtime.ts +++ b/src/daemon/handlers/session-runtime.ts @@ -1,5 +1,5 @@ import { AppError, asAppError } from '../../kernel/errors.ts'; -import type { DeviceInfo } from '../../kernel/device.ts'; +import { publicPlatformString, type DeviceInfo } from '../../kernel/device.ts'; import type { CommandFlags } from '../../core/dispatch.ts'; import type { DaemonRequest, SessionRuntimeHints, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; @@ -163,7 +163,7 @@ function resolveSessionRuntimeHints( ): SessionRuntimeHints | undefined { const runtime = sessionStore.getRuntimeHints(sessionName); if (!runtime) return undefined; - const boundPlatform = device?.platform; + const boundPlatform = device ? publicPlatformString(device) : undefined; const deviceRuntimePlatform = toRuntimePlatform(boundPlatform); if (runtime.platform && device && !deviceRuntimePlatform) { throw new AppError( @@ -198,7 +198,7 @@ function resolveOpenRuntimeHints(params: { const explicitRuntime = normalizeExplicitRuntimeHints({ runtime: req.runtime, sessionName, - platform: toRuntimePlatform(device.platform), + platform: toRuntimePlatform(publicPlatformString(device)), }); if (req.runtime === undefined) { return { diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index c5fdda053..c7f1959fa 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -1,5 +1,11 @@ import { asAppError } from '../../kernel/errors.ts'; -import { isApplePlatform, type DeviceInfo } from '../../kernel/device.ts'; +import { + isApplePlatform, + isIosFamily, + isMacOs, + publicPlatformString, + type DeviceInfo, +} from '../../kernel/device.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { ensureDeviceReady } from '../device-ready.ts'; @@ -65,7 +71,7 @@ async function handleAppStateCommand(params: { const appName = session.appName ?? session.appBundleId; if (!session.appName && !session.appBundleId) { if ( - session.device.platform === 'macos' && + isMacOs(session.device) && session.surface && session.surface !== 'app' && session.surface !== 'frontmost-app' @@ -73,7 +79,7 @@ async function handleAppStateCommand(params: { return { ok: true, data: { - platform: session.device.platform, + platform: publicPlatformString(session.device), appName: session.surface, appBundleId: session.appBundleId, source: 'session', @@ -82,7 +88,7 @@ async function handleAppStateCommand(params: { }; } - const sessionPlatform = session.device.platform === 'macos' ? 'macOS' : 'iOS'; + const sessionPlatform = isMacOs(session.device) ? 'macOS' : 'iOS'; return errorResponse( 'COMMAND_FAILED', `No foreground app is tracked for this ${sessionPlatform} session. Open an app in the session, then retry appstate.`, @@ -92,12 +98,12 @@ async function handleAppStateCommand(params: { return { ok: true, data: { - platform: session.device.platform, + platform: publicPlatformString(session.device), appName: appName ?? 'unknown', appBundleId: session.appBundleId, source: 'session', surface: session.surface ?? 'app', - ...(session.device.platform === 'ios' + ...(isIosFamily(session.device) ? { device_udid: session.device.id, ios_simulator_device_set: session.device.simulatorSetPath ?? null, @@ -112,10 +118,10 @@ async function handleAppStateCommand(params: { flags, ensureReady: true, }); - if (device.platform === 'ios') { + if (isIosFamily(device)) { return errorResponse('SESSION_NOT_FOUND', IOS_APPSTATE_SESSION_REQUIRED_MESSAGE); } - if (device.platform === 'macos') { + if (isMacOs(device)) { return errorResponse('SESSION_NOT_FOUND', MACOS_APPSTATE_SESSION_REQUIRED_MESSAGE); } if (device.platform === 'web') { @@ -265,7 +271,7 @@ export async function handleSessionStateCommands(params: { return { ok: true, data: { - platform: device.platform, + platform: publicPlatformString(device), target: device.target ?? 'mobile', device: device.name, id: device.id, @@ -302,7 +308,7 @@ export async function handleSessionStateCommands(params: { { hint: `Run agent-device close --shutdown --session ${sessionName}`, session: sessionName, - platform: device.platform, + platform: publicPlatformString(device), target: device.target ?? 'mobile', device: device.name, id: device.id, @@ -317,7 +323,7 @@ export async function handleSessionStateCommands(params: { shutdown.error?.code ?? 'COMMAND_FAILED', shutdownFailureMessage(shutdown), { - platform: device.platform, + platform: publicPlatformString(device), target: device.target ?? 'mobile', device: device.name, id: device.id, @@ -330,7 +336,7 @@ export async function handleSessionStateCommands(params: { return { ok: true, data: { - platform: device.platform, + platform: publicPlatformString(device), target: device.target ?? 'mobile', device: device.name, id: device.id, diff --git a/src/daemon/handlers/session-test-sharding.ts b/src/daemon/handlers/session-test-sharding.ts index 19cc209c5..4920ed5e0 100644 --- a/src/daemon/handlers/session-test-sharding.ts +++ b/src/daemon/handlers/session-test-sharding.ts @@ -180,12 +180,12 @@ function resolveExplicitShardDevices( function isImplicitShardDevice(device: DeviceInfo, flags: CommandFlags | undefined): boolean { if (!isShardDeviceCandidate(device, flags)) return false; - if (!isMobilePlatform(device.platform)) return false; + if (!isMobilePlatform(device)) return false; return device.booted !== false; } function isShardDeviceCandidate(device: DeviceInfo, flags: CommandFlags | undefined): boolean { - if (!matchesPlatformSelector(device.platform, flags?.platform)) return false; + if (!matchesPlatformSelector(device, flags?.platform)) return false; if (flags?.target && (device.target ?? 'mobile') !== flags.target) return false; return true; } diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 632401cf3..1bff49228 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -7,7 +7,7 @@ import { type PrepareIosRunnerResult, } from '../../platforms/apple/core/runner/runner-client.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; -import { isApplePlatform } from '../../kernel/device.ts'; +import { isApplePlatform, publicPlatformString } from '../../kernel/device.ts'; import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { contextFromFlags } from '../context.ts'; @@ -120,7 +120,7 @@ function prepareIosRunnerResponseData( ): Record { return { action, - platform: device.platform, + platform: publicPlatformString(device), deviceId: device.id, deviceName: device.name, kind: device.kind, @@ -221,7 +221,7 @@ async function handleClipboardCommand(params: { }, ); recordSessionAction(sessionStore, session, req, req.command, result ?? {}); - return { ok: true, data: { platform: device.platform, ...(result ?? {}) } }; + return { ok: true, data: { platform: publicPlatformString(device), ...(result ?? {}) } }; } // fallow-ignore-next-line complexity diff --git a/src/daemon/handlers/snapshot-alert.ts b/src/daemon/handlers/snapshot-alert.ts index 39bb76e05..7416d4e2d 100644 --- a/src/daemon/handlers/snapshot-alert.ts +++ b/src/daemon/handlers/snapshot-alert.ts @@ -1,3 +1,4 @@ +import { isMacOs } from '../../kernel/device.ts'; import { ALERT_ACTION_RETRY_MS, ALERT_POLL_INTERVAL_MS as POLL_INTERVAL_MS, @@ -56,7 +57,7 @@ export async function handleAlertCommand( }), ); } - if (device.platform === 'macos') { + if (isMacOs(device)) { const runAlert: NativeAlertRunner = async (alertAction) => await runMacOsAlertAction(alertAction, macOsAlertTarget); return await handleNativeAlertCommand(params, action, runAlert); diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 4595ee4be..9f06c435c 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -1,5 +1,5 @@ import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts'; -import { isMobilePlatform } from '../../kernel/device.ts'; +import { isMacOs, isMobilePlatform } from '../../kernel/device.ts'; import { sleep } from '../../utils/timeouts.ts'; import { runMacOsSnapshotAction } from '../../platforms/apple/os/macos/helper.ts'; import { snapshotLinux } from '../../platforms/linux/snapshot.ts'; @@ -106,7 +106,7 @@ async function capturePostActionAwareSnapshot( pendingInteractionOutcome, ); } - if (isMobilePlatform(params.device.platform) && params.session?.postGestureStabilization) { + if (isMobilePlatform(params.device) && params.session?.postGestureStabilization) { return await capturePostGestureAwareSnapshot({ ...params, session: params.session }); } const freshness = getActiveAndroidSnapshotFreshness(params.session); @@ -209,7 +209,7 @@ export async function captureSnapshotData(params: CaptureSnapshotParams): Promis }, ); } - if (device.platform === 'macos' && session?.surface && session.surface !== 'app') { + if (isMacOs(device) && session?.surface && session.surface !== 'app') { const helperSnapshot = await runMacOsSnapshotAction(session.surface, { bundleId: session.surface === 'menubar' ? session.appBundleId : undefined, }); diff --git a/src/daemon/handlers/snapshot-session.ts b/src/daemon/handlers/snapshot-session.ts index 6c486f94a..a10c66f95 100644 --- a/src/daemon/handlers/snapshot-session.ts +++ b/src/daemon/handlers/snapshot-session.ts @@ -1,3 +1,4 @@ +import { isIosFamily } from '../../kernel/device.ts'; import { resolveTargetDevice } from '../../core/dispatch.ts'; import { resolveRunnerAppBundleId, @@ -25,7 +26,7 @@ export async function withSessionlessRunnerCleanup( device: SessionState['device'], task: () => Promise, ): Promise { - const shouldCleanupSessionlessIosRunner = !session && device.platform === 'ios'; + const shouldCleanupSessionlessIosRunner = !session && isIosFamily(device); try { return await task(); } finally { diff --git a/src/daemon/handlers/snapshot-settings.ts b/src/daemon/handlers/snapshot-settings.ts index b724b0595..a71e6a1aa 100644 --- a/src/daemon/handlers/snapshot-settings.ts +++ b/src/daemon/handlers/snapshot-settings.ts @@ -1,3 +1,4 @@ +import { isMacOs } from '../../kernel/device.ts'; import { getUnsupportedMacOsSettingMessage, isMacOsSettingSupported, @@ -79,7 +80,7 @@ export async function handleSettingsCommand( } = parsed; const unsupported = requireCommandSupported('settings', device); if (unsupported) return unsupported; - if (device.platform === 'macos' && !isMacOsSettingSupported(setting)) { + if (isMacOs(device) && !isMacOsSettingSupported(setting)) { return errorResponse('INVALID_ARGS', getUnsupportedMacOsSettingMessage(setting)); } diff --git a/src/daemon/interaction-outcome-policy.ts b/src/daemon/interaction-outcome-policy.ts index 99acb6dd1..95b2ec81b 100644 --- a/src/daemon/interaction-outcome-policy.ts +++ b/src/daemon/interaction-outcome-policy.ts @@ -188,7 +188,7 @@ export function areInteractionSurfaceSignaturesStable( } function supportsInteractionOutcomePolicy(session: SessionState): boolean { - return isMobilePlatform(session.device.platform); + return isMobilePlatform(session.device); } function retryCommandForTap(command: string): string | undefined { diff --git a/src/daemon/post-gesture-stabilization.ts b/src/daemon/post-gesture-stabilization.ts index e28a225c4..53ce38a13 100644 --- a/src/daemon/post-gesture-stabilization.ts +++ b/src/daemon/post-gesture-stabilization.ts @@ -19,7 +19,7 @@ export function markPostGestureStabilization( positionals: string[] = [], flags?: CommandFlags, ): void { - if (!supportsPostGestureStabilization(session.device.platform)) return; + if (!supportsPostGestureStabilization(session.device)) return; if (!isPostGestureStabilizingAction(action, positionals, flags)) return; session.postGestureStabilization = { action, @@ -40,7 +40,7 @@ export async function capturePostGestureStabilizedResult(params: { }): Promise { const { session, capture } = params; const pending = session?.postGestureStabilization; - if (!session || !supportsPostGestureStabilization(session.device.platform) || !pending) { + if (!session || !supportsPostGestureStabilization(session.device) || !pending) { return params.initial ?? (await capture()); } @@ -97,6 +97,6 @@ function isPostGestureStabilizingAction( return action === 'gesture' && positionals[0] === 'swipe'; } -function supportsPostGestureStabilization(platform: SessionState['device']['platform']): boolean { - return isMobilePlatform(platform); +function supportsPostGestureStabilization(device: SessionState['device']): boolean { + return isMobilePlatform(device); } diff --git a/src/daemon/recording-gestures.ts b/src/daemon/recording-gestures.ts index 5c2407cca..7f11e649f 100644 --- a/src/daemon/recording-gestures.ts +++ b/src/daemon/recording-gestures.ts @@ -1,3 +1,4 @@ +import { isIosFamily } from '../kernel/device.ts'; import type { RecordingGestureEvent, SessionState } from './types.ts'; import type { SnapshotState } from '../kernel/snapshot.ts'; import { @@ -58,7 +59,7 @@ export function recordTouchVisualizationEvent( fallbackFinishedAtMs: finishedAtMs, }); const tMs = - session.device.platform === 'ios' && + isIosFamily(session.device) && readNumber(merged.gestureStartUptimeMs) === undefined && shouldAnchorTapVisualizationNearCompletion(command, merged) ? resolveTapVisualizationOffsetMs({ ...timingSource, gestureDurationMs }) diff --git a/src/daemon/request-lock-policy.ts b/src/daemon/request-lock-policy.ts index 61d883ba3..0384c1a1a 100644 --- a/src/daemon/request-lock-policy.ts +++ b/src/daemon/request-lock-policy.ts @@ -7,7 +7,7 @@ import { type SessionSelectorConflict, type SessionSelectorConflictKey, } from './session-selector.ts'; -import { isApplePlatform, type PlatformSelector } from '../kernel/device.ts'; +import { isApplePlatform, publicPlatformString, type PlatformSelector } from '../kernel/device.ts'; import { buildSessionRecoveryHint, describeSessionDevice } from './session-recovery-hints.ts'; import { shellQuoteIfNeeded } from '../utils/shell-quote.ts'; import { hasLockableDeviceSelector, hasSelectorValue } from './device-selector-intent.ts'; @@ -124,7 +124,9 @@ function applyStripLockPolicy( ): void { if (existingSession) { stripSessionConflicts(flags, conflicts); - flags.platform = existingSession.device.platform; + // approach (b): backfill the request selector with the PUBLIC leaf platform, not + // the internal `apple` — the leaf selector still matches the session's Apple device. + flags.platform = publicPlatformString(existingSession.device); return; } stripSessionConflicts(flags, conflicts); diff --git a/src/daemon/request-recording-health.ts b/src/daemon/request-recording-health.ts index 4bc1e0b18..612906b5c 100644 --- a/src/daemon/request-recording-health.ts +++ b/src/daemon/request-recording-health.ts @@ -1,3 +1,4 @@ +import { isIosFamily } from '../kernel/device.ts'; import { getRunnerSessionSnapshot } from '../platforms/apple/core/runner/runner-client.ts'; import type { SessionState } from './types.ts'; @@ -27,7 +28,7 @@ export function refreshRecordingHealth(session: SessionState): void { function recordingRequiresRunnerHealth(session: SessionState): boolean { const recording = session.recording; - if (!recording || session.device.platform !== 'ios') return false; + if (!recording || !isIosFamily(session.device)) return false; if (recording.platform === 'ios') return false; return recording.showTouches !== false; } diff --git a/src/daemon/runtime-hints.ts b/src/daemon/runtime-hints.ts index d860a8931..0521cdd59 100644 --- a/src/daemon/runtime-hints.ts +++ b/src/daemon/runtime-hints.ts @@ -1,4 +1,4 @@ -import type { DeviceInfo } from '../kernel/device.ts'; +import { isIosFamily, type DeviceInfo } from '../kernel/device.ts'; import { AppError, asAppError } from '../kernel/errors.ts'; import type { SessionRuntimeHints } from './types.ts'; import { @@ -54,7 +54,7 @@ export async function applyRuntimeHintsToApp(params: { return; } - if (device.platform === 'ios' && device.kind === 'simulator') { + if (isIosFamily(device) && device.kind === 'simulator') { await applyIosSimulatorRuntimeHints(device, appId, transport); } } @@ -71,7 +71,7 @@ export async function clearRuntimeHintsFromApp(params: { return; } - if (device.platform === 'ios' && device.kind === 'simulator') { + if (isIosFamily(device) && device.kind === 'simulator') { await clearIosSimulatorRuntimeHints(device, appId); } } diff --git a/src/daemon/screenshot-runtime.ts b/src/daemon/screenshot-runtime.ts index 4cfaef016..ada8fb24a 100644 --- a/src/daemon/screenshot-runtime.ts +++ b/src/daemon/screenshot-runtime.ts @@ -1,3 +1,4 @@ +import { isIosFamily, publicPlatformString } from '../kernel/device.ts'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -51,7 +52,7 @@ function createDispatchScreenshotBackend(params: { }): AgentDeviceBackend { const { session, outputPlacement, dispatchContext } = params; return { - platform: session.device.platform, + platform: publicPlatformString(session.device), captureScreenshot: async (_context, outPath, options) => { const context = { ...dispatchContext, @@ -59,7 +60,7 @@ function createDispatchScreenshotBackend(params: { surface: options?.surface, skipIosSimulatorBootCheck: dispatchContext.skipIosSimulatorBootCheck ?? - (session.device.platform === 'ios' && session.device.kind === 'simulator'), + (isIosFamily(session.device) && session.device.kind === 'simulator'), }; if (outputPlacement === 'out') { return readScreenshotResultData( diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index e2b7978c1..4a048391f 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -5,7 +5,7 @@ import type { } from '../backend.ts'; import { resolveTargetDevice, type CommandFlags } from '../core/dispatch.ts'; import { createAgentDevice } from '../runtime.ts'; -import { isApplePlatform } from '../kernel/device.ts'; +import { isMacOs, isApplePlatform, publicPlatformString } from '../kernel/device.ts'; import { noActiveSessionError, requireCommandSupported } from './handlers/response.ts'; import type { SnapshotNode } from '../kernel/snapshot.ts'; import { findNodeByLabel } from '../snapshot/snapshot-processing.ts'; @@ -101,7 +101,7 @@ function createSelectorBackend(params: SelectorRuntimeDeviceParams): AgentDevice logPath, }); return { - platform: device.platform, + platform: publicPlatformString(device), captureSnapshot: async (_context, options): Promise => { const flags = { ...req.flags, @@ -166,7 +166,7 @@ async function findTextInMacosNonAppSurface( params: SelectorRuntimeDeviceParams, text: string, ): Promise { - if (params.device.platform !== 'macos') return null; + if (!isMacOs(params.device)) return null; if (!params.session?.surface || params.session.surface === 'app') return null; return await findTextInWaitSnapshot(params, text); } diff --git a/src/daemon/selectors-match.ts b/src/daemon/selectors-match.ts index 1e14631a9..3f9ec6a88 100644 --- a/src/daemon/selectors-match.ts +++ b/src/daemon/selectors-match.ts @@ -1,4 +1,4 @@ -import type { Platform } from '../kernel/device.ts'; +import type { Platform, PublicPlatform } from '../kernel/device.ts'; import type { SnapshotNode } from '../kernel/snapshot.ts'; import { isNodeEditable, isNodeVisible } from '../utils/selector-node.ts'; import { extractNodeText, normalizeType } from '../snapshot/snapshot-processing.ts'; @@ -10,12 +10,16 @@ export { isNodeEditable, isNodeVisible } from '../utils/selector-node.ts'; export function matchesSelector( node: SnapshotNode, selector: Selector, - platform: Platform, + platform: Platform | PublicPlatform, ): boolean { return selector.terms.every((term) => matchesTerm(node, term, platform)); } -function matchesTerm(node: SnapshotNode, term: SelectorTerm, platform: Platform): boolean { +function matchesTerm( + node: SnapshotNode, + term: SelectorTerm, + platform: Platform | PublicPlatform, +): boolean { switch (term.key) { case 'id': return textEquals(node.identifier, String(term.value)); diff --git a/src/daemon/selectors-resolve.ts b/src/daemon/selectors-resolve.ts index 9a96e3f02..66c875f8e 100644 --- a/src/daemon/selectors-resolve.ts +++ b/src/daemon/selectors-resolve.ts @@ -1,4 +1,4 @@ -import type { Platform } from '../kernel/device.ts'; +import type { Platform, PublicPlatform } from '../kernel/device.ts'; import type { SnapshotNode, SnapshotState } from '../kernel/snapshot.ts'; import { matchesSelector } from './selectors-match.ts'; import type { Selector, SelectorChain } from './selectors-parse.ts'; @@ -20,7 +20,7 @@ export function resolveSelectorChain( nodes: SnapshotState['nodes'], chain: SelectorChain, options: { - platform: Platform; + platform: Platform | PublicPlatform; requireRect?: boolean; requireUnique?: boolean; disambiguateAmbiguous?: boolean; @@ -58,7 +58,7 @@ export function findSelectorChainMatch( nodes: SnapshotState['nodes'], chain: SelectorChain, options: { - platform: Platform; + platform: Platform | PublicPlatform; requireRect?: boolean; }, ): { @@ -96,7 +96,7 @@ export function formatSelectorFailure( function analyzeSelectorMatches( nodes: SnapshotState['nodes'], selector: Selector, - platform: Platform, + platform: Platform | PublicPlatform, requireRect: boolean, ): { count: number; firstNode: SnapshotNode | null; disambiguated: SnapshotNode | null } { let count = 0; @@ -130,7 +130,7 @@ function analyzeSelectorMatches( function countSelectorMatchesOnly( nodes: SnapshotState['nodes'], selector: Selector, - platform: Platform, + platform: Platform | PublicPlatform, requireRect: boolean, ): number { let count = 0; diff --git a/src/daemon/session-script-writer.ts b/src/daemon/session-script-writer.ts index e67a4556e..8466534a0 100644 --- a/src/daemon/session-script-writer.ts +++ b/src/daemon/session-script-writer.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { publicPlatformString } from '../kernel/device.ts'; import { inferFillText } from './action-utils.ts'; import { emitDiagnostic } from '../utils/diagnostics.ts'; import { formatPortableActionLine } from '../replay/script-formatting.ts'; @@ -157,7 +158,8 @@ function formatScript(session: SessionState, actions: SessionAction[]): string { const kind = session.device.kind ? ` kind=${session.device.kind}` : ''; const theme = 'unknown'; lines.push( - `context platform=${session.device.platform} device=${formatScriptStringLiteral(session.device.name)}${kind} theme=${theme}`, + // approach (b): emit the PUBLIC leaf platform (ios/macos), never the internal `apple`. + `context platform=${publicPlatformString(session.device)} device=${formatScriptStringLiteral(session.device.name)}${kind} theme=${theme}`, ); for (const action of actions) { if (action.flags?.noRecord) continue; diff --git a/src/daemon/session-selector.ts b/src/daemon/session-selector.ts index 79c4f667b..80399dcb6 100644 --- a/src/daemon/session-selector.ts +++ b/src/daemon/session-selector.ts @@ -1,7 +1,7 @@ import { AppError } from '../kernel/errors.ts'; import type { CommandFlags } from '../core/dispatch.ts'; import type { SessionState } from './types.ts'; -import { matchesPlatformSelector } from '../kernel/device.ts'; +import { isIosFamily, matchesPlatformSelector } from '../kernel/device.ts'; import { parseSerialAllowlist } from '../utils/device-isolation.ts'; import { buildSessionRecoveryHint, describeSessionDevice } from './session-recovery-hints.ts'; @@ -44,14 +44,14 @@ export function listSessionSelectorConflicts( const device = session.device; const normalizedPlatform = flags.platform; - if (normalizedPlatform && !matchesPlatformSelector(device.platform, normalizedPlatform)) { + if (normalizedPlatform && !matchesPlatformSelector(device, normalizedPlatform)) { mismatches.push({ key: 'platform', value: flags.platform! }); } if (flags.target && flags.target !== (device.target ?? 'mobile')) { mismatches.push({ key: 'target', value: flags.target }); } - if (flags.udid && (device.platform !== 'ios' || flags.udid !== device.id)) { + if (flags.udid && (!isIosFamily(device) || flags.udid !== device.id)) { mismatches.push({ key: 'udid', value: flags.udid }); } @@ -67,7 +67,7 @@ export function listSessionSelectorConflicts( const requestedSetPath = flags.iosSimulatorDeviceSet.trim(); const sessionSetPath = device.simulatorSetPath?.trim(); if ( - device.platform !== 'ios' || + !isIosFamily(device) || device.kind !== 'simulator' || requestedSetPath !== sessionSetPath ) { diff --git a/src/daemon/session-teardown.ts b/src/daemon/session-teardown.ts index 1f62cdfaa..5b02ecbe6 100644 --- a/src/daemon/session-teardown.ts +++ b/src/daemon/session-teardown.ts @@ -1,5 +1,5 @@ import { emitDiagnostic } from '../utils/diagnostics.ts'; -import { isApplePlatform } from '../kernel/device.ts'; +import { isMacOs, isApplePlatform } from '../kernel/device.ts'; import { runMacOsAlertAction } from '../platforms/apple/os/macos/helper.ts'; import { stopAppLog } from './app-log.ts'; import { stopIosRunnerSession } from '../platforms/apple/core/runner/runner-client.ts'; @@ -14,7 +14,7 @@ export { stopSessionAudioProbe } from './audio-probe.ts'; export async function stopAppleRunnerForClose(session: SessionState): Promise { await stopIosRunnerSession(session.device.id); - if (session.device.platform !== 'macos') { + if (!isMacOs(session.device)) { return; } diff --git a/src/daemon/snapshot-runtime.ts b/src/daemon/snapshot-runtime.ts index 1e039bf2f..8b241d058 100644 --- a/src/daemon/snapshot-runtime.ts +++ b/src/daemon/snapshot-runtime.ts @@ -1,3 +1,4 @@ +import { isIosFamily, publicPlatformString } from '../kernel/device.ts'; import type { AgentDeviceBackend, BackendSnapshotResult } from '../backend.ts'; import type { CommandSessionRecord } from '../runtime.ts'; import { createAgentDevice } from '../runtime.ts'; @@ -165,7 +166,7 @@ function requireIosAppSessionForSnapshot( session: SessionState | undefined, device: SessionState['device'], ): DaemonResponse | null { - if (device.platform !== 'ios' || session?.appBundleId) { + if (!isIosFamily(device) || session?.appBundleId) { return null; } return errorResponse( @@ -275,7 +276,7 @@ function createDaemonSnapshotBackend(params: { }): AgentDeviceBackend { const { req, logPath, session, device, snapshotScope } = params; return { - platform: device.platform, + platform: publicPlatformString(device), captureSnapshot: async (_context, options): Promise => { const capture = await captureSnapshot({ device, diff --git a/src/kernel/__tests__/platform-collapse-parity.test.ts b/src/kernel/__tests__/platform-collapse-parity.test.ts new file mode 100644 index 000000000..f5ee1ebd8 --- /dev/null +++ b/src/kernel/__tests__/platform-collapse-parity.test.ts @@ -0,0 +1,136 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + deviceFieldsFromPublicPlatform, + isIosFamily, + isMacOs, + isMobilePlatform, + matchesPlatformSelector, + publicPlatformString, + resolveDevice, + type DeviceInfo, +} from '../device.ts'; +import { + ANDROID_EMULATOR, + IOS_DEVICE, + IOS_SIMULATOR, + IPADOS_SIMULATOR, + LINUX_DEVICE, + MACOS_DEVICE, + TVOS_SIMULATOR, + VISIONOS_SIMULATOR, + WEB_DESKTOP_DEVICE, +} from '../../__tests__/test-utils/device-fixtures.ts'; +import { readReplayScriptMetadata, writeReplayScript } from '../../replay/script.ts'; +import type { SessionState } from '../../daemon/types.ts'; + +// Parity gate for the ios/macos -> apple Platform collapse (issue #979, approach b). +// Internal `DeviceInfo.platform` is `apple`; the daemon still ACCEPTS the legacy +// `ios`/`macos` selectors and still EMITS the leaf `ios`/`macos` strings. These tests +// pin the read-path resolution and the output-projection so the collapse stays +// non-breaking for machine consumers. + +const APPLE_NON_MACOS: DeviceInfo[] = [ + IOS_SIMULATOR, + IOS_DEVICE, + IPADOS_SIMULATOR, + VISIONOS_SIMULATOR, + TVOS_SIMULATOR, +]; +const NON_APPLE: DeviceInfo[] = [ANDROID_EMULATOR, LINUX_DEVICE, WEB_DESKTOP_DEVICE]; + +test('OUTPUT: publicPlatformString emits the pre-collapse leaf for every fixture', () => { + for (const device of APPLE_NON_MACOS) { + assert.equal(publicPlatformString(device), 'ios', device.name); + } + assert.equal(publicPlatformString(MACOS_DEVICE), 'macos'); + assert.equal(publicPlatformString(ANDROID_EMULATOR), 'android'); + assert.equal(publicPlatformString(LINUX_DEVICE), 'linux'); + assert.equal(publicPlatformString(WEB_DESKTOP_DEVICE), 'web'); +}); + +test('OUTPUT: publicPlatformString honors legacy leaf `platform` records', () => { + // Persisted pre-collapse records may still carry the leaf platform string. + assert.equal(publicPlatformString({ platform: 'macos' as never }), 'macos'); + assert.equal(publicPlatformString({ platform: 'ios' as never }), 'ios'); +}); + +test('READ: the `apple` selector matches exactly the Apple family', () => { + for (const device of [...APPLE_NON_MACOS, MACOS_DEVICE]) { + assert.equal(matchesPlatformSelector(device, 'apple'), true, device.name); + } + for (const device of NON_APPLE) { + assert.equal(matchesPlatformSelector(device, 'apple'), false, device.name); + } +}); + +test('READ: legacy `ios`/`macos` selectors resolve within the collapsed platform', () => { + // `--platform ios` = every Apple OS except the macOS host. + for (const device of APPLE_NON_MACOS) { + assert.equal(matchesPlatformSelector(device, 'ios'), true, device.name); + } + assert.equal(matchesPlatformSelector(MACOS_DEVICE, 'ios'), false); + // `--platform macos` = the macOS host only. + assert.equal(matchesPlatformSelector(MACOS_DEVICE, 'macos'), true); + for (const device of APPLE_NON_MACOS) { + assert.equal(matchesPlatformSelector(device, 'macos'), false, device.name); + } +}); + +test('READ: --platform apple and --platform ios resolve to the same device', async () => { + const devices = [IOS_SIMULATOR, ANDROID_EMULATOR]; + const viaApple = await resolveDevice(devices, { platform: 'apple' }); + const viaIos = await resolveDevice(devices, { platform: 'ios' }); + assert.deepEqual(viaApple, viaIos); + assert.equal(viaApple.id, IOS_SIMULATOR.id); +}); + +test('deviceFieldsFromPublicPlatform is the inverse projection of publicPlatformString', () => { + assert.deepEqual(deviceFieldsFromPublicPlatform('macos'), { + platform: 'apple', + appleOs: 'macos', + }); + assert.deepEqual(deviceFieldsFromPublicPlatform('ios'), { platform: 'apple' }); + assert.deepEqual(deviceFieldsFromPublicPlatform('android'), { platform: 'android' }); + for (const leaf of ['ios', 'macos', 'android', 'linux', 'web'] as const) { + assert.equal(publicPlatformString(deviceFieldsFromPublicPlatform(leaf)), leaf); + } +}); + +test('predicates preserve the pre-collapse platform families', () => { + for (const device of APPLE_NON_MACOS) { + assert.equal(isIosFamily(device), true, device.name); + assert.equal(isMacOs(device), false, device.name); + assert.equal(isMobilePlatform(device), true, device.name); + } + assert.equal(isIosFamily(MACOS_DEVICE), false); + assert.equal(isMacOs(MACOS_DEVICE), true); + assert.equal(isMobilePlatform(MACOS_DEVICE), false); + assert.equal(isMobilePlatform(ANDROID_EMULATOR), true); + assert.equal(isMobilePlatform(LINUX_DEVICE), false); +}); + +test('REPLAY: heal-write emits the leaf platform and round-trips through the reader', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'platform-collapse-parity-')); + for (const device of [IOS_SIMULATOR, TVOS_SIMULATOR, MACOS_DEVICE, ANDROID_EMULATOR]) { + const session = { + name: 'parity', + device, + createdAt: 0, + actions: [], + } as unknown as SessionState; + const filePath = path.join(dir, `${device.id}.ad`); + writeReplayScript(filePath, [], session); + const written = fs.readFileSync(filePath, 'utf8'); + const leaf = publicPlatformString(device); + assert.match(written, new RegExp(`context platform=${leaf}\\b`), device.name); + // The reader accepts the emitted leaf and echoes it back unchanged. + assert.equal(readReplayScriptMetadata(written).platform, leaf, device.name); + } + // The reader also accepts the collapsed `apple` selector directly. + assert.equal(readReplayScriptMetadata('context platform=apple\nhome\n').platform, 'apple'); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/src/kernel/audio-probe-support.ts b/src/kernel/audio-probe-support.ts index f53bd8e74..e5b72dc94 100644 --- a/src/kernel/audio-probe-support.ts +++ b/src/kernel/audio-probe-support.ts @@ -1,10 +1,11 @@ +import { isIosFamily, isMacOs } from './device.ts'; import type { DeviceInfo } from './device.ts'; export function isHostSystemAudioProbeDevice(device: DeviceInfo): boolean { return ( process.platform === 'darwin' && - (device.platform === 'macos' || - (device.platform === 'ios' && device.kind === 'simulator') || + (isMacOs(device) || + (isIosFamily(device) && device.kind === 'simulator') || (device.platform === 'android' && device.kind === 'emulator')) ); } diff --git a/src/kernel/device.ts b/src/kernel/device.ts index 4eb3146b0..faa2fbcab 100644 --- a/src/kernel/device.ts +++ b/src/kernel/device.ts @@ -1,13 +1,25 @@ import { AppError } from './errors.ts'; +// Legacy Apple leaf platforms. Retained ONLY as accepted `--platform` / read-path +// input aliases (approach b back-compat) and as the PUBLIC leaf strings the daemon +// still emits; the internal `Platform` no longer carries them — every Apple OS +// collapses to the single `apple` platform (ADR-0009 / issue #979). export type ApplePlatform = 'ios' | 'macos'; // Explicit, stored Apple operating system. All six literals are reserved so the // type is stable as platform support grows, but discovery only ever populates // the four currently supported ones ('ios' | 'ipados' | 'tvos' | 'macos'). export type AppleOS = 'ios' | 'ipados' | 'tvos' | 'watchos' | 'visionos' | 'macos'; -export const PLATFORMS = ['ios', 'macos', 'android', 'linux', 'web'] as const; +// Internal device platforms. Apple OSes collapse to a single `apple` platform; the +// `appleOs` field on DeviceInfo is the sole OS discriminant. +export const PLATFORMS = ['apple', 'android', 'linux', 'web'] as const; export type Platform = (typeof PLATFORMS)[number]; -export const PLATFORM_SELECTORS = [...PLATFORMS, 'apple'] as const; +// The PUBLIC leaf platform strings the daemon emits and clients parse (approach b: +// output never changes). Equals the pre-collapse `Platform` set. +export const PUBLIC_PLATFORMS = ['ios', 'macos', 'android', 'linux', 'web'] as const; +export type PublicPlatform = (typeof PUBLIC_PLATFORMS)[number]; +// Accepted `--platform` selectors: the internal platforms plus the legacy Apple leaf +// aliases `ios`/`macos`, which still resolve to `apple` devices (read-path back-compat). +export const PLATFORM_SELECTORS = [...PLATFORMS, 'ios', 'macos'] as const; export type PlatformSelector = (typeof PLATFORM_SELECTORS)[number]; const DEVICE_KINDS = ['simulator', 'emulator', 'device'] as const; export type DeviceKind = (typeof DEVICE_KINDS)[number]; @@ -46,9 +58,62 @@ export function isApplePlatform( return platform === 'apple' || platform === 'ios' || platform === 'macos'; } -export function isMobilePlatform(platform: Platform): boolean { - // Leaf-platform family predicate: the two phone/tablet device platforms. - return platform === 'ios' || platform === 'android'; +/** + * The macOS Apple-OS leaf: the AppKit desktop host. The post-collapse replacement for + * the former `platform === 'macos'` leaf compare — discovery always stamps + * `appleOs: 'macos'` on the host device (buildHostMacDevice), so the OS discriminant + * is authoritative. + */ +export function isMacOs(device: Pick): boolean { + // The `appleOs` discriminant is authoritative for discovered devices; the legacy + // leaf `platform: 'macos'` (persisted pre-collapse records, or synthetic + // leaf-string devices) is still honored via the cast for back-compat. + return device.appleOs === 'macos' || (device.platform as string) === 'macos'; +} + +/** + * The touch iOS family: every Apple OS except the macOS desktop host + * (iOS / iPadOS / tvOS / visionOS). This is the EXACT post-collapse equivalent of the + * pre-collapse `platform === 'ios'` leaf compare — that leaf covered all four of these + * OSes — so `isIosFamily(device)` swaps in for `device.platform === 'ios'` + * behavior-for-behavior (false for macOS and every non-Apple platform). + */ +export function isIosFamily(device: Pick): boolean { + return isApplePlatform(device.platform) && !isMacOs(device); +} + +export function isMobilePlatform(device: Pick): boolean { + // Phone/tablet device family: Android plus every Apple OS except the macOS desktop + // host. Preserves the pre-collapse `platform === 'ios' || platform === 'android'` + // set exactly (the old `ios` platform covered iOS/iPadOS/tvOS/visionOS). + return device.platform === 'android' || (isApplePlatform(device.platform) && !isMacOs(device)); +} + +/** + * The PUBLIC leaf platform string emitted to machine consumers (approach b: output + * keeps emitting `ios`/`macos`, never the internal `apple`). Apple devices project to + * their leaf via `appleOs`; non-Apple platforms pass through unchanged. + */ +export function publicPlatformString( + device: Pick, +): PublicPlatform { + if (!isApplePlatform(device.platform)) return device.platform; + return isMacOs(device) ? 'macos' : 'ios'; +} + +/** + * The inverse of {@link publicPlatformString}: reconstruct the internal `platform` (+ + * `appleOs` where the leaf is unambiguous) from a PUBLIC leaf string. Used where the + * client rebuilds an internal DeviceInfo from a parsed daemon response. The `ios` leaf + * leaves `appleOs` unset so the target-based inference still distinguishes tvOS. + */ +export function deviceFieldsFromPublicPlatform(platform: PublicPlatform): { + platform: Platform; + appleOs?: AppleOS; +} { + if (platform === 'macos') return { platform: 'apple', appleOs: 'macos' }; + if (platform === 'ios') return { platform: 'apple' }; + return { platform }; } /** @@ -60,25 +125,34 @@ export function isMobilePlatform(platform: Platform): boolean { * across the Apple interaction paths. * * Apple-only by design: Android TV also uses `target: 'tv'` but is a DISTINCT leaf, - * so the `platform === 'ios'` gate is load-bearing (do not widen it to any TV target). + * so the `isApplePlatform` gate is load-bearing (do not widen it to any TV target). */ export function isTvOsDevice(device: Pick): boolean { - return device.platform === 'ios' && device.target === 'tv'; + return isApplePlatform(device.platform) && device.target === 'tv'; } export function isPlatform(value: unknown): value is Platform { - // Leaf-platform membership derived from the canonical PLATFORMS tuple (excludes the - // `apple` selector, which is not a concrete device platform). + // Internal device-platform membership derived from the canonical PLATFORMS tuple. return (PLATFORMS as readonly unknown[]).includes(value); } +export function isPublicPlatform(value: unknown): value is PublicPlatform { + // The PUBLIC leaf strings a daemon response carries (approach b). Used by the client + // normalizers, which parse leaf platforms (`ios`/`macos`), not the internal `apple`. + return (PUBLIC_PLATFORMS as readonly unknown[]).includes(value); +} + export function matchesPlatformSelector( - platform: Platform, + device: Pick, selector: PlatformSelector | undefined, ): boolean { if (!selector) return true; - if (selector === 'apple') return isApplePlatform(platform); - return platform === selector; + if (selector === 'apple') return isApplePlatform(device.platform); + // Legacy leaf selectors resolve within the collapsed `apple` platform via `appleOs`, + // preserving the pre-collapse `--platform ios|macos` device sets exactly. + if (selector === 'ios') return isApplePlatform(device.platform) && !isMacOs(device); + if (selector === 'macos') return isApplePlatform(device.platform) && isMacOs(device); + return device.platform === selector; } export function resolveApplePlatformName( @@ -214,7 +288,7 @@ export function matchesDeviceSelector( options: { includeExplicitSelectors?: boolean } = {}, ): boolean { return ( - matchesPlatformSelector(device.platform, selector.platform) && + matchesPlatformSelector(device, selector.platform) && (!selector.target || (device.target ?? 'mobile') === selector.target) && (!options.includeExplicitSelectors || matchesExplicitDeviceSelector(device, selector)) ); @@ -266,7 +340,7 @@ function compareAppleDevicesForSelection( function appleDeviceSelectionRank(device: DeviceInfo): number { if (device.kind === 'simulator') return appleTargetSelectionRank(device, 0, 1, 2, 3); - if (device.kind === 'device' && device.platform === 'ios') + if (device.kind === 'device' && isApplePlatform(device.platform) && !isMacOs(device)) return appleTargetSelectionRank(device, 10, 11, 12, 13); return 14; } diff --git a/src/platforms/apple/__tests__/watchos-sentinel.test.ts b/src/platforms/apple/__tests__/watchos-sentinel.test.ts index 6a70705ca..5951cf12a 100644 --- a/src/platforms/apple/__tests__/watchos-sentinel.test.ts +++ b/src/platforms/apple/__tests__/watchos-sentinel.test.ts @@ -8,7 +8,7 @@ import { AppError } from '../../../kernel/errors.ts'; // so a `appleOs: 'watchos'` device must be rejected at interactor creation (the // admission seam) rather than silently falling through to the iOS runner. const watchOsDevice: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'watch-1', name: 'Apple Watch Series 10', kind: 'device', @@ -34,7 +34,7 @@ test('a non-watchOS appleOs does not trigger the watchOS sentinel', () => { // fail later on the empty runner context, so we only assert it is not the // watchOS sentinel error.) const tvOsDevice: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'tv-1', name: 'Apple TV 4K', kind: 'simulator', diff --git a/src/platforms/apple/core/__tests__/apple-runner-platform.test.ts b/src/platforms/apple/core/__tests__/apple-runner-platform.test.ts index 9d210f2d9..93bbf740e 100644 --- a/src/platforms/apple/core/__tests__/apple-runner-platform.test.ts +++ b/src/platforms/apple/core/__tests__/apple-runner-platform.test.ts @@ -10,7 +10,7 @@ import type { DeviceInfo } from '../../../../kernel/device.ts'; function iosSim(overrides: Partial = {}): DeviceInfo { return { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 16', kind: 'simulator', @@ -45,7 +45,7 @@ test('resolveRunnerPlatformName maps visionOS appleOs to the visionOS profile', test('resolveRunnerPlatformName maps macOS appleOs to the macOS profile', () => { const mac: DeviceInfo = { - platform: 'macos', + platform: 'apple', id: 'host-macos-local', name: 'Studio Mac', kind: 'device', @@ -88,7 +88,7 @@ test('existing platform xctestrun disallowed hints stay unchanged when visionOS ); assert.deepEqual( resolveRunnerXctestrunHints({ - platform: 'macos', + platform: 'apple', id: 'host-macos-local', name: 'Studio Mac', kind: 'device', diff --git a/src/platforms/apple/core/__tests__/devices.test.ts b/src/platforms/apple/core/__tests__/devices.test.ts index d0e121683..541ee32b8 100644 --- a/src/platforms/apple/core/__tests__/devices.test.ts +++ b/src/platforms/apple/core/__tests__/devices.test.ts @@ -1,3 +1,4 @@ +import { isIosFamily } from '../../../../kernel/device.ts'; import { beforeEach, test } from 'vitest'; import assert from 'node:assert/strict'; import { promises as fs } from 'node:fs'; @@ -177,7 +178,7 @@ test('parseXctracePhysicalAppleDevices parses only physical devices from the Dev assert.deepEqual(parsed, [ { - platform: 'ios', + platform: 'apple', id: '00008020-001C2D2234567890', name: 'My iPhone', kind: 'device', @@ -186,7 +187,7 @@ test('parseXctracePhysicalAppleDevices parses only physical devices from the Dev booted: true, }, { - platform: 'ios', + platform: 'apple', id: 'tv-udid-1', name: 'Living Room Apple TV', kind: 'device', @@ -203,7 +204,7 @@ test('parseXctracePhysicalAppleDevices tags physical iPads as iPadOS', () => { ); assert.deepEqual(parsed, [ { - platform: 'ios', + platform: 'apple', id: 'ipad-udid-1', name: 'Studio iPad Pro', kind: 'device', @@ -220,7 +221,7 @@ test('parseXctracePhysicalAppleDevices tags Apple Vision devices as visionOS', ( ); assert.deepEqual(parsed, [ { - platform: 'ios', + platform: 'apple', id: 'vision-udid-1', name: 'Apple Vision Pro', kind: 'device', @@ -492,7 +493,7 @@ test('listAppleDevices prefers devicectl metadata when xctrace reports the same async () => await withMockedAppleTools(async () => await listAppleDevices()), ); const physicalDevices = devices.filter( - (device) => device.kind === 'device' && device.platform === 'ios', + (device) => device.kind === 'device' && isIosFamily(device), ); assert.equal(physicalDevices.length, 1); @@ -521,7 +522,7 @@ test('listAppleDevices keeps physical discovery disabled for simulator-set scope true, ); assert.equal( - devices.some((device) => device.kind === 'device' && device.platform === 'ios'), + devices.some((device) => device.kind === 'device' && isIosFamily(device)), false, ); assert.equal( diff --git a/src/platforms/apple/core/__tests__/index.test.ts b/src/platforms/apple/core/__tests__/index.test.ts index 61c35830a..24d94fdfc 100644 --- a/src/platforms/apple/core/__tests__/index.test.ts +++ b/src/platforms/apple/core/__tests__/index.test.ts @@ -86,7 +86,7 @@ import { retryWithPolicy } from '../../../../utils/retry.ts'; import { parseIosDeviceAppsPayload, parseIosDeviceProcessesPayload } from '../devicectl.ts'; const IOS_TEST_DEVICE: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -94,7 +94,7 @@ const IOS_TEST_DEVICE: DeviceInfo = { }; const IOS_TEST_SIMULATOR: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -102,7 +102,8 @@ const IOS_TEST_SIMULATOR: DeviceInfo = { }; const MACOS_TEST_DEVICE: DeviceInfo = { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-macos-local', name: 'Mac', kind: 'device', @@ -111,7 +112,7 @@ const MACOS_TEST_DEVICE: DeviceInfo = { }; const TVOS_TEST_SIMULATOR: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'tvos-sim-1', name: 'Apple TV', kind: 'simulator', @@ -528,7 +529,7 @@ fi test('openIosApp custom scheme deep links on iOS devices require app bundle context', async () => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -1313,7 +1314,7 @@ test('openIosApp web URL on iOS device without app falls back to Safari', async process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -1362,7 +1363,7 @@ test('openIosApp custom scheme on iOS device uses active app context', async () process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -2154,7 +2155,7 @@ test('openIosApp with app and URL on iOS device launches app bundle with payload process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -2218,7 +2219,7 @@ test('pushIosNotification uses simctl push with temporary payload file', async ( process.env.AGENT_DEVICE_TEST_PAYLOAD_FILE = payloadCapturePath; const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone', kind: 'simulator', @@ -2345,7 +2346,7 @@ test('resolveIosApp resolves app display name on iOS physical devices', async () process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`; const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -2389,7 +2390,7 @@ test('resolveIosApp caches display-name bundle matches but bypasses exact bundle process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath; const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-cache-1', name: 'iPhone Cache', kind: 'simulator', @@ -2527,7 +2528,7 @@ test('installIosInstallablePath invalidates cached display-name bundle matches', process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = markerPath; const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-cache-install-1', name: 'iPhone Cache', kind: 'simulator', @@ -2573,7 +2574,7 @@ exit 1 `, async ({ argsLogPath }) => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -2610,7 +2611,7 @@ exit 1 `, async ({ argsLogPath }) => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -2645,7 +2646,7 @@ exit 1 `, async ({ argsLogPath }) => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -2685,7 +2686,7 @@ exit 1 `, async ({ argsLogPath }) => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -2716,7 +2717,7 @@ exit 1 `, async () => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -2750,7 +2751,7 @@ exit 1 `, async () => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -2922,7 +2923,7 @@ exit 1 `, async ({ argsLogPath }) => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -2955,7 +2956,7 @@ exit 0 `, async () => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -2994,7 +2995,7 @@ exit 1 `, async ({ argsLogPath }) => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -3079,7 +3080,7 @@ exit 1 `, async ({ argsLogPath }) => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -3110,7 +3111,7 @@ exit 1 `, async () => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -3158,7 +3159,7 @@ exit 1 `, async ({ argsLogPath }) => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -3208,7 +3209,7 @@ exit 1 `, async ({ argsLogPath }) => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', @@ -3264,7 +3265,7 @@ exit 1 `, async ({ argsLogPath }) => { const device: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Sim', kind: 'simulator', diff --git a/src/platforms/apple/core/__tests__/perf.test.ts b/src/platforms/apple/core/__tests__/perf.test.ts index c5af6d3e0..c7da621e1 100644 --- a/src/platforms/apple/core/__tests__/perf.test.ts +++ b/src/platforms/apple/core/__tests__/perf.test.ts @@ -36,7 +36,7 @@ type MockRunCmdResult = Awaited>; type XcrunMockHandler = (args: string[]) => Promise; const IOS_SIMULATOR: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17 Pro', kind: 'simulator', @@ -44,7 +44,8 @@ const IOS_SIMULATOR: DeviceInfo = { }; const MACOS_DEVICE: DeviceInfo = { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-mac', name: 'Host Mac', kind: 'device', @@ -53,7 +54,7 @@ const MACOS_DEVICE: DeviceInfo = { }; const IOS_DEVICE: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'iPhone Device', kind: 'device', @@ -727,7 +728,7 @@ test('stopAppleXctracePerfCapture returns compact artifact metadata', async () = outPath: tracePath, appBundleId: 'com.example.app', deviceId: 'sim-1', - platform: 'ios', + platform: 'apple', targetPids: [111], targetProcesses: ['Example'], startedAt: '2026-04-01T10:00:00.000Z', @@ -758,7 +759,7 @@ test('stopAppleXctracePerfCapture force-kills xctrace when graceful stop times o outPath: tracePath, appBundleId: 'com.example.app', deviceId: 'sim-1', - platform: 'ios', + platform: 'apple', targetPids: [111], targetProcesses: ['Example'], startedAt: '2026-04-01T10:00:00.000Z', @@ -809,7 +810,7 @@ test('stopAppleXctracePerfCapture reports confirmed cleanup after forced kill ex outPath: tracePath, appBundleId: 'com.example.app', deviceId: 'sim-1', - platform: 'ios', + platform: 'apple', targetPids: [111], targetProcesses: ['Example'], startedAt: '2026-04-01T10:00:00.000Z', diff --git a/src/platforms/apple/core/__tests__/runner-client.test.ts b/src/platforms/apple/core/__tests__/runner-client.test.ts index af98e552b..3aac3cb31 100644 --- a/src/platforms/apple/core/__tests__/runner-client.test.ts +++ b/src/platforms/apple/core/__tests__/runner-client.test.ts @@ -71,7 +71,7 @@ import { import { parseRunnerResponse } from '../runner/runner-session.ts'; const iosSimulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Simulator', kind: 'simulator', @@ -79,7 +79,7 @@ const iosSimulator: DeviceInfo = { }; const iosDevice: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: '00008110-000E12341234002E', name: 'iPhone', kind: 'device', @@ -87,7 +87,7 @@ const iosDevice: DeviceInfo = { }; const tvOsSimulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'tv-sim-1', name: 'Apple TV', kind: 'simulator', @@ -96,7 +96,7 @@ const tvOsSimulator: DeviceInfo = { }; const tvOsDevice: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: '00008120-000E12341234003F', name: 'Apple TV', kind: 'device', @@ -105,7 +105,8 @@ const tvOsDevice: DeviceInfo = { }; const macOsDevice: DeviceInfo = { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-macos-local', name: 'Host Mac', kind: 'device', @@ -453,12 +454,15 @@ test('resolveRunnerSigningBuildSettings returns empty args without env overrides }); test('resolveRunnerSigningBuildSettings disables signing for macOS desktop builds', () => { - assert.deepEqual(resolveRunnerSigningBuildSettings({}, true, 'macos'), [ - 'CODE_SIGNING_ALLOWED=NO', - 'CODE_SIGNING_REQUIRED=NO', - 'CODE_SIGN_IDENTITY=', - 'DEVELOPMENT_TEAM=', - ]); + assert.deepEqual( + resolveRunnerSigningBuildSettings({}, true, { platform: 'apple', appleOs: 'macos' }), + [ + 'CODE_SIGNING_ALLOWED=NO', + 'CODE_SIGNING_REQUIRED=NO', + 'CODE_SIGN_IDENTITY=', + 'DEVELOPMENT_TEAM=', + ], + ); }); test('resolveRunnerSigningBuildSettings enables automatic signing for device builds without forcing identity', () => { diff --git a/src/platforms/apple/core/__tests__/runner-session.test.ts b/src/platforms/apple/core/__tests__/runner-session.test.ts index b765bcbab..f4364016c 100644 --- a/src/platforms/apple/core/__tests__/runner-session.test.ts +++ b/src/platforms/apple/core/__tests__/runner-session.test.ts @@ -1207,11 +1207,12 @@ test('runner session invalidation skips graceful shutdown and removes stale sess }); test('runner session validates supported Apple runner devices', () => { - validateRunnerDevice({ ...IOS_SIMULATOR, platform: 'ios', kind: 'simulator' }); + validateRunnerDevice({ ...IOS_SIMULATOR, platform: 'apple', kind: 'simulator' }); validateRunnerDevice({ ...IOS_SIMULATOR, id: 'runner-session-macos', - platform: 'macos', + platform: 'apple', + appleOs: 'macos', kind: 'device', target: 'desktop', }); diff --git a/src/platforms/apple/core/__tests__/runner-transport.test.ts b/src/platforms/apple/core/__tests__/runner-transport.test.ts index 887e45510..dc1823229 100644 --- a/src/platforms/apple/core/__tests__/runner-transport.test.ts +++ b/src/platforms/apple/core/__tests__/runner-transport.test.ts @@ -27,7 +27,7 @@ import { } from '../runner/runner-transport.ts'; const iosSimulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Simulator', kind: 'simulator', @@ -35,7 +35,7 @@ const iosSimulator: DeviceInfo = { }; const iosDevice: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'device-1', name: 'iPhone', kind: 'device', diff --git a/src/platforms/apple/core/__tests__/runner-xctestrun.test.ts b/src/platforms/apple/core/__tests__/runner-xctestrun.test.ts index ad9bd0096..e80ab6967 100644 --- a/src/platforms/apple/core/__tests__/runner-xctestrun.test.ts +++ b/src/platforms/apple/core/__tests__/runner-xctestrun.test.ts @@ -21,7 +21,7 @@ import { } from '../runner/runner-xctestrun.ts'; const iosSimulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Simulator', kind: 'simulator', @@ -30,7 +30,7 @@ const iosSimulator: DeviceInfo = { }; const iosDevice: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'device-1', name: 'iPhone', kind: 'device', diff --git a/src/platforms/apple/core/__tests__/simctl.test.ts b/src/platforms/apple/core/__tests__/simctl.test.ts index ea64ee315..e3fb3730c 100644 --- a/src/platforms/apple/core/__tests__/simctl.test.ts +++ b/src/platforms/apple/core/__tests__/simctl.test.ts @@ -4,7 +4,7 @@ import { buildSimctlArgs, buildSimctlArgsForDevice } from '../simctl.ts'; import type { DeviceInfo } from '../../../../kernel/device.ts'; const IOS_SIMULATOR: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 17', kind: 'simulator', diff --git a/src/platforms/apple/core/apple-runner-platform.ts b/src/platforms/apple/core/apple-runner-platform.ts index 8db9aeee9..0fec985d8 100644 --- a/src/platforms/apple/core/apple-runner-platform.ts +++ b/src/platforms/apple/core/apple-runner-platform.ts @@ -1,5 +1,6 @@ import { AppError } from '../../../kernel/errors.ts'; import { + isMacOs, isApplePlatform, resolveApplePlatformName, type DeviceInfo, @@ -119,7 +120,7 @@ export function resolveRunnerPlatformName(device: DeviceInfo): RunnerApplePlatfo `Unsupported platform for Apple runner: ${device.platform}`, ); } - if (device.platform === 'macos') { + if (isMacOs(device)) { return 'macOS'; } // Prefer the stored Apple OS discriminant; fall back to target-based inference diff --git a/src/platforms/apple/core/apps.ts b/src/platforms/apple/core/apps.ts index 4f89dfd7c..065b080eb 100644 --- a/src/platforms/apple/core/apps.ts +++ b/src/platforms/apple/core/apps.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { DeviceInfo } from '../../../kernel/device.ts'; +import { isIosFamily, isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; import { emitDiagnostic } from '../../../utils/diagnostics.ts'; import type { AppsFilter } from '../../../contracts/app-inventory.ts'; @@ -116,7 +116,7 @@ type InstallIosAppOptions = { }; export async function resolveIosApp(device: DeviceInfo, app: string): Promise { - if (device.platform === 'macos') { + if (isMacOs(device)) { return await resolveMacOsApp(app); } const trimmed = app.trim(); @@ -156,7 +156,7 @@ export async function resolveIosSimulatorDeepLinkBundleId( device: DeviceInfo, url: string, ): Promise { - if (device.platform !== 'ios' || device.kind !== 'simulator') return undefined; + if (!isIosFamily(device) || device.kind !== 'simulator') return undefined; const scheme = parseUrlScheme(url); if (!scheme) return undefined; @@ -190,10 +190,10 @@ export async function openIosApp( ): Promise { const launchConsole = options?.launchConsole?.trim(); const launchArgs = options?.launchArgs; - if (launchConsole && (device.platform !== 'ios' || device.kind !== 'simulator')) { + if (launchConsole && (!isIosFamily(device) || device.kind !== 'simulator')) { throw new AppError('UNSUPPORTED_OPERATION', LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE); } - if (device.platform === 'macos') { + if (isMacOs(device)) { if (launchArgs && launchArgs.length > 0) { throw new AppError( 'UNSUPPORTED_OPERATION', @@ -278,7 +278,7 @@ async function openIosSimulatorUrl( } export async function openIosDevice(device: DeviceInfo): Promise { - if (device.platform === 'macos') { + if (isMacOs(device)) { return; } if (device.kind !== 'simulator') return; @@ -289,7 +289,7 @@ export async function openIosDevice(device: DeviceInfo): Promise { } export async function closeIosApp(device: DeviceInfo, app: string): Promise { - if (device.platform === 'macos') { + if (isMacOs(device)) { await closeMacOsApp(device, app); return; } @@ -325,7 +325,7 @@ async function clearIosSimulatorAppState( device: DeviceInfo, app: string, ): Promise<{ bundleId: string; containerPath: string }> { - if (device.platform !== 'ios' || device.kind !== 'simulator') { + if (!isIosFamily(device) || device.kind !== 'simulator') { throw new AppError( 'UNSUPPORTED_OPERATION', 'Clearing app state is currently supported only on iOS simulators.', @@ -482,7 +482,7 @@ export async function installIosInstallablePath( } export async function readIosClipboardText(device: DeviceInfo): Promise { - if (device.platform === 'macos') { + if (isMacOs(device)) { return await readMacOsClipboardText(); } requireSimulatorDevice(device, 'clipboard'); @@ -499,7 +499,7 @@ export async function readIosClipboardText(device: DeviceInfo): Promise } export async function writeIosClipboardText(device: DeviceInfo, text: string): Promise { - if (device.platform === 'macos') { + if (isMacOs(device)) { await writeMacOsClipboardText(text); return; } @@ -542,7 +542,7 @@ export async function setIosSetting( appBundleId?: string, options?: SettingOptions, ): Promise | void> { - if (device.platform === 'macos') { + if (isMacOs(device)) { const normalizedSetting = setting.toLowerCase(); if (normalizedSetting === 'appearance') { await setMacOsAppearance(state); @@ -656,7 +656,7 @@ export async function setIosSetting( } export async function listIosApps(device: DeviceInfo, filter: AppsFilter): Promise { - if (device.platform === 'macos') { + if (isMacOs(device)) { return await listMacApps(filter); } if (device.kind === 'simulator') { diff --git a/src/platforms/apple/core/devices.ts b/src/platforms/apple/core/devices.ts index 116b41d4c..5d008d09b 100644 --- a/src/platforms/apple/core/devices.ts +++ b/src/platforms/apple/core/devices.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { AppError } from '../../../kernel/errors.ts'; import { + isIosFamily, sortAppleDevicesForSelection, type AppleOS, type DeviceInfo, @@ -212,7 +213,7 @@ function parseSimctlAppleDevices( if (!device.isAvailable) continue; const target = resolveAppleTargetFromRuntime(runtime); devices.push({ - platform: 'ios', + platform: 'apple', id: device.udid, name: device.name, kind: 'simulator', @@ -237,7 +238,7 @@ function mapDevicectlAppleDevices(payload: DevicectlListDevicesPayload): DeviceI if (!id) continue; const target = resolveAppleTargetFromDevicectlDevice(device); devices.push({ - platform: 'ios', + platform: 'apple', id, name, kind: 'device', @@ -276,7 +277,7 @@ export function parseXctracePhysicalAppleDevices(output: string): DeviceInfo[] { if (!target) continue; devices.push({ - platform: 'ios', + platform: 'apple', id, name, kind: 'device', @@ -372,10 +373,7 @@ export async function listAppleDevices( } devices.push(buildHostMacDevice()); - if ( - options.udid && - devices.some((device) => device.platform === 'ios' && device.id === options.udid) - ) { + if (options.udid && devices.some((device) => isIosFamily(device) && device.id === options.udid)) { return devices; } diff --git a/src/platforms/apple/core/perf-xctrace.ts b/src/platforms/apple/core/perf-xctrace.ts index 762822d01..77bace687 100644 --- a/src/platforms/apple/core/perf-xctrace.ts +++ b/src/platforms/apple/core/perf-xctrace.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { isApplePlatform, type DeviceInfo } from '../../../kernel/device.ts'; +import { isIosFamily, isApplePlatform, type DeviceInfo } from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; import { runCmdBackground, @@ -219,7 +219,7 @@ async function resolveAppleXctracePerfTarget( hint: 'Android native profiling belongs to the Android perf rollout and is not implemented under Apple xctrace.', }); } - if (device.platform === 'ios' && device.kind === 'device') { + if (isIosFamily(device) && device.kind === 'device') { const processes = await resolveIosDevicePerfTarget(device, appBundleId); return { pids: processes.map((process) => process.pid), @@ -255,7 +255,7 @@ function buildAppleXctraceRecordArgs(params: { 'record', '--template', params.template, - ...(params.device.platform === 'ios' ? ['--device', params.device.id] : []), + ...(isIosFamily(params.device) ? ['--device', params.device.id] : []), ...params.targetPids.flatMap((pid) => ['--attach', String(pid)]), '--output', params.outPath, diff --git a/src/platforms/apple/core/perf.ts b/src/platforms/apple/core/perf.ts index 7463fafc4..390a8aa85 100644 --- a/src/platforms/apple/core/perf.ts +++ b/src/platforms/apple/core/perf.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { DeviceInfo } from '../../../kernel/device.ts'; +import { isIosFamily, isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; import type { ExecResult } from '../../../utils/exec.ts'; import { splitNonEmptyTrimmedLines } from '../../../utils/parsing.ts'; @@ -127,7 +127,7 @@ export async function sampleApplePerfMetrics( device: DeviceInfo, appBundleId: string, ): Promise<{ cpu: AppleCpuPerfSample; memory: AppleMemoryPerfSample }> { - if (device.platform === 'ios' && device.kind === 'device') { + if (isIosFamily(device) && device.kind === 'device') { return await sampleIosDevicePerfMetrics(device, appBundleId); } @@ -226,7 +226,7 @@ async function runAppleMemorySnapshotTool( outPath: string, pid: number, ): Promise { - if (device.platform === 'macos') { + if (isMacOs(device)) { return await runAppleToolCommand('leaks', [`--outputGraph=${outPath}`, String(pid)], { allowFailure: true, timeoutMs: APPLE_MEMORY_SNAPSHOT_TIMEOUT_MS, @@ -306,7 +306,7 @@ export async function sampleAppleFramePerf( device: DeviceInfo, appBundleId: string, ): Promise { - if (device.platform !== 'ios' || device.kind !== 'device') { + if (!isIosFamily(device) || device.kind !== 'device') { throw new AppError( 'COMMAND_FAILED', 'Apple frame-health sampling is currently available only on connected iOS devices.', @@ -335,7 +335,7 @@ export async function sampleAppleFramePerf( } export function buildAppleFrameSamplingMetadata(device: DeviceInfo): Record { - return device.platform === 'ios' && device.kind === 'device' + return isIosFamily(device) && device.kind === 'device' ? { method: APPLE_FRAME_SAMPLE_METHOD, description: APPLE_FRAME_SAMPLE_DESCRIPTION, @@ -355,7 +355,7 @@ export function buildAppleFrameSamplingMetadata(device: DeviceInfo): Record { const fps = buildAppleFrameSamplingMetadata(device); - if (device.platform === 'ios' && device.kind === 'device') { + if (isIosFamily(device) && device.kind === 'device') { return { fps, memory: { @@ -373,10 +373,9 @@ export function buildAppleSamplingMetadata(device: DeviceInfo): Record { - const appPath = - device.platform === 'macos' - ? await resolveMacOsBundlePath(appBundleId) - : await resolveIosSimulatorAppContainer(device, appBundleId); - const infoPlistPath = - device.platform === 'macos' - ? path.join(appPath, 'Contents', 'Info.plist') - : path.join(appPath, 'Info.plist'); + const appPath = isMacOs(device) + ? await resolveMacOsBundlePath(appBundleId) + : await resolveIosSimulatorAppContainer(device, appBundleId); + const infoPlistPath = isMacOs(device) + ? path.join(appPath, 'Contents', 'Info.plist') + : path.join(appPath, 'Info.plist'); const executableName = await readInfoPlistString(infoPlistPath, 'CFBundleExecutable'); if (!executableName) { throw new AppError('COMMAND_FAILED', `Failed to resolve executable for ${appBundleId}`, { @@ -775,10 +772,9 @@ export async function resolveAppleExecutable( return { executableName, - executablePath: - device.platform === 'macos' - ? path.join(appPath, 'Contents', 'MacOS', executableName) - : path.join(appPath, executableName), + executablePath: isMacOs(device) + ? path.join(appPath, 'Contents', 'MacOS', executableName) + : path.join(appPath, executableName), }; } @@ -1039,20 +1035,18 @@ export async function readAppleProcessSamples( device: DeviceInfo, executable: { executableName: string; executablePath?: string }, ): Promise { - const args = - device.platform === 'macos' - ? ['-axo', 'pid=,%cpu=,rss=,command='] - : buildSimctlArgsForDevice(device, [ - 'spawn', - device.id, - 'ps', - '-axo', - 'pid=,%cpu=,rss=,command=', - ]); - const result = - device.platform === 'macos' - ? await runAppleToolCommand('ps', args, { timeoutMs: APPLE_PERF_TIMEOUT_MS }) - : await runAppleSimulatorProcessCommand(args); + const args = isMacOs(device) + ? ['-axo', 'pid=,%cpu=,rss=,command='] + : buildSimctlArgsForDevice(device, [ + 'spawn', + device.id, + 'ps', + '-axo', + 'pid=,%cpu=,rss=,command=', + ]); + const result = isMacOs(device) + ? await runAppleToolCommand('ps', args, { timeoutMs: APPLE_PERF_TIMEOUT_MS }) + : await runAppleSimulatorProcessCommand(args); return parseApplePsOutput(result.stdout).filter((process) => matchesAppleExecutableProcess(process.command, executable), ); @@ -1103,7 +1097,7 @@ async function resolveAppleMemorySnapshotTarget( } function isMissingIosSimulatorProcessToolError(device: DeviceInfo, error: unknown): boolean { - if (device.platform !== 'ios' || device.kind !== 'simulator') return false; + if (!isIosFamily(device) || device.kind !== 'simulator') return false; if (!(error instanceof AppError)) return false; const details = error.details ?? {}; const args = Array.isArray(details.args) ? details.args.join(' ') : ''; @@ -1125,7 +1119,7 @@ function resolveAppleMemorySnapshotHint( return 'Install Xcode command line tools and ensure leaks is available, then retry.'; } if (text.includes('permission') || text.includes('denied') || text.includes('not authorized')) { - return device.platform === 'macos' + return isMacOs(device) ? 'Grant the agent terminal process permission to inspect this macOS app, then retry.' : 'Keep the simulator booted and app running; if inspection is denied, retry with a debug simulator build.'; } diff --git a/src/platforms/apple/core/runner/runner-client.ts b/src/platforms/apple/core/runner/runner-client.ts index 629db0db7..ab5bdc8a5 100644 --- a/src/platforms/apple/core/runner/runner-client.ts +++ b/src/platforms/apple/core/runner/runner-client.ts @@ -1,5 +1,5 @@ import { withRetry } from '../../../../utils/retry.ts'; -import type { DeviceInfo } from '../../../../kernel/device.ts'; +import { isIosFamily, type DeviceInfo } from '../../../../kernel/device.ts'; import { emitDiagnostic } from '../../../../utils/diagnostics.ts'; import { type RunnerSessionOptions, validateRunnerDevice } from './runner-session.ts'; import { @@ -68,7 +68,7 @@ export function prewarmAppleRunnerCache( device: DeviceInfo, options: PrewarmIosRunnerOptions = {}, ): Promise | undefined { - if (device.platform !== 'ios') { + if (!isIosFamily(device)) { return undefined; } return runBestEffortIosRunnerPrewarm({ @@ -85,7 +85,7 @@ export function prewarmIosRunnerSession( device: DeviceInfo, options: PrewarmIosRunnerOptions = {}, ): Promise | undefined { - if (device.platform !== 'ios') { + if (!isIosFamily(device)) { return undefined; } const provider = resolveAppleRunnerRuntime(device, options); diff --git a/src/platforms/apple/core/runner/runner-macos-products.ts b/src/platforms/apple/core/runner/runner-macos-products.ts index 6ae60ab54..f10710491 100644 --- a/src/platforms/apple/core/runner/runner-macos-products.ts +++ b/src/platforms/apple/core/runner/runner-macos-products.ts @@ -1,5 +1,5 @@ import fs from 'node:fs'; -import type { DeviceInfo } from '../../../../kernel/device.ts'; +import { isMacOs, type DeviceInfo } from '../../../../kernel/device.ts'; import { AppError } from '../../../../kernel/errors.ts'; import { runAppleToolCommand } from '../tool-provider.ts'; @@ -13,7 +13,7 @@ export async function repairMacOsRunnerProductsIfNeeded( productPaths: string[], xctestrunPath: string, ): Promise { - if (device.platform !== 'macos') { + if (!isMacOs(device)) { return; } if (productPaths.length === 0) { diff --git a/src/platforms/apple/core/runner/runner-session.ts b/src/platforms/apple/core/runner/runner-session.ts index 02dcadf6f..6a0d2373f 100644 --- a/src/platforms/apple/core/runner/runner-session.ts +++ b/src/platforms/apple/core/runner/runner-session.ts @@ -6,7 +6,7 @@ import { } from '../../../../utils/exec.ts'; import { withKeyedLock } from '../../../../utils/keyed-lock.ts'; import { Deadline } from '../../../../utils/retry.ts'; -import { isApplePlatform, type DeviceInfo } from '../../../../kernel/device.ts'; +import { isIosFamily, isApplePlatform, type DeviceInfo } from '../../../../kernel/device.ts'; import type { RunnerLogicalLeaseContext } from '../../../../core/runner-lease-context.ts'; import type { AppleRunnerLifecycleOptions } from './runner-provider.ts'; import { emitRequestProgress } from '../../../../daemon/request-progress.ts'; @@ -476,7 +476,7 @@ async function ensureBooted(device: DeviceInfo): Promise { } async function verifyDeveloperModeForIosRunner(device: DeviceInfo): Promise { - if (device.platform !== 'ios' || device.kind !== 'device') return; + if (!isIosFamily(device) || device.kind !== 'device') return; const result = await runAppleToolCommand('DevToolsSecurity', ['-status'], { allowFailure: true, timeoutMs: 2_000, diff --git a/src/platforms/apple/core/runner/runner-xctestrun.ts b/src/platforms/apple/core/runner/runner-xctestrun.ts index df9e46df7..e10b56c29 100644 --- a/src/platforms/apple/core/runner/runner-xctestrun.ts +++ b/src/platforms/apple/core/runner/runner-xctestrun.ts @@ -8,7 +8,7 @@ import { resolveIosSimulatorDeviceSetPath } from '../../../../utils/device-isola import { readProcessStartTime } from '../../../../utils/process-identity.ts'; import { acquireProcessLock, type ProcessLockOwner } from '../../../../utils/process-lock.ts'; import { isEnvTruthy } from '../../../../utils/retry.ts'; -import type { DeviceInfo } from '../../../../kernel/device.ts'; +import { isIosFamily, isMacOs, type DeviceInfo } from '../../../../kernel/device.ts'; import type { DefinedEnvMap as EnvMap } from '../../../../utils/env-map.ts'; import { withKeyedLock } from '../../../../utils/keyed-lock.ts'; import { emitRequestProgress } from '../../../../daemon/request-progress.ts'; @@ -207,7 +207,7 @@ export async function acquireXcodebuildSimulatorSetRedirect( device: DeviceInfo, options: XcodebuildSimulatorSetRedirectOptions = {}, ): Promise { - if (device.platform !== 'ios' || device.kind !== 'simulator') { + if (!isIosFamily(device) || device.kind !== 'simulator') { return null; } const simulatorSetPath = resolveIosSimulatorDeviceSetPath(device.simulatorSetPath); @@ -770,7 +770,7 @@ export function resolveExpectedRunnerCacheMetadata( runnerSigningBuildSettings: resolveRunnerSigningBuildSettings( process.env, device.kind === 'device', - device.platform, + device, ), runnerPerformanceBuildSettings: resolveRunnerPerformanceBuildSettings(), runnerSandboxBuildArgs: resolveRunnerSandboxBuildArgs(), @@ -1375,7 +1375,7 @@ async function buildRunnerXctestrun( const signingBuildSettings = resolveRunnerSigningBuildSettings( process.env, device.kind === 'device', - device.platform, + device, ); const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : []; const performanceBuildSettings = resolveRunnerPerformanceBuildSettings(); @@ -1453,7 +1453,7 @@ function resolveRunnerDerivedBasePath(device: DeviceInfo): string { } export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): string { - if (device.platform === 'macos') { + if (isMacOs(device)) { return '-maximum-concurrent-test-device-destinations'; } return device.kind === 'device' @@ -1464,9 +1464,9 @@ export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): export function resolveRunnerSigningBuildSettings( env: NodeJS.ProcessEnv = process.env, forDevice = false, - platform: DeviceInfo['platform'] = 'ios', + device: Pick = { platform: 'apple' }, ): string[] { - if (platform === 'macos') { + if (isMacOs(device)) { return [ 'CODE_SIGNING_ALLOWED=NO', 'CODE_SIGNING_REQUIRED=NO', diff --git a/src/platforms/apple/core/screenshot.ts b/src/platforms/apple/core/screenshot.ts index 128c0d861..f16f3cda3 100644 --- a/src/platforms/apple/core/screenshot.ts +++ b/src/platforms/apple/core/screenshot.ts @@ -1,6 +1,6 @@ import { promises as fs } from 'node:fs'; import path from 'node:path'; -import type { DeviceInfo } from '../../../kernel/device.ts'; +import { isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; import { emitDiagnostic } from '../../../utils/diagnostics.ts'; import { AppError } from '../../../kernel/errors.ts'; import type { ExecOptions } from '../../../utils/exec.ts'; @@ -61,7 +61,7 @@ export async function screenshotIos( outPath: string, options: Omit = {}, ): Promise { - if (device.platform === 'macos') { + if (isMacOs(device)) { await captureScreenshotViaRunner( device, outPath, @@ -211,7 +211,7 @@ export async function captureScreenshotViaRunner( ); } - if (device.platform === 'macos') { + if (isMacOs(device)) { await fs.copyFile(remoteFileName, outPath); return; } diff --git a/src/platforms/apple/core/simctl.ts b/src/platforms/apple/core/simctl.ts index c21e4c66d..5b4ef78cb 100644 --- a/src/platforms/apple/core/simctl.ts +++ b/src/platforms/apple/core/simctl.ts @@ -1,4 +1,4 @@ -import type { DeviceInfo } from '../../../kernel/device.ts'; +import { isIosFamily, type DeviceInfo } from '../../../kernel/device.ts'; import type { ExecOptions, ExecResult } from '../../../utils/exec.ts'; import { resolveIosSimulatorDeviceSetPath } from '../../../utils/device-isolation.ts'; import { runXcrun } from './tool-provider.ts'; @@ -14,7 +14,7 @@ export function buildSimctlArgs(args: string[], options: SimctlArgsOptions = {}) } export function buildSimctlArgsForDevice(device: DeviceInfo, args: string[]): string[] { - if (device.platform !== 'ios' || device.kind !== 'simulator') { + if (!isIosFamily(device) || device.kind !== 'simulator') { return ['simctl', ...args]; } return buildSimctlArgs(args, { simulatorSetPath: device.simulatorSetPath }); diff --git a/src/platforms/apple/interactions.ts b/src/platforms/apple/interactions.ts index 8d2a9f63b..dd221dc97 100644 --- a/src/platforms/apple/interactions.ts +++ b/src/platforms/apple/interactions.ts @@ -1,4 +1,4 @@ -import { isTvOsDevice, type DeviceInfo } from '../../kernel/device.ts'; +import { isIosFamily, isMacOs, isTvOsDevice, type DeviceInfo } from '../../kernel/device.ts'; import { assertScrollGestureInput, type ScrollDirection } from '../../core/scroll-gesture.ts'; import { normalizeScrollDurationMs, SCROLL_DURATION_MAX_MS } from '../../core/scroll-command.ts'; import { runAppleRunnerCommand } from './core/runner/runner-client.ts'; @@ -268,7 +268,7 @@ function iosTapCommand( function shouldUseSynthesizedIosGesture(device: DeviceInfo): boolean { // Two-finger HID synthesis is for touch-input iOS only; the tvOS leaf has no touch. - return device.platform === 'ios' && !isTvOsDevice(device); + return isIosFamily(device) && !isTvOsDevice(device); } function iosDragCommand( @@ -282,7 +282,7 @@ function iosDragCommand( options: IosDragCommandOptions, ): RunnerCommand { const normalizedDurationMs = - device.platform === 'ios' && !isTvOsDevice(device) + isIosFamily(device) && !isTvOsDevice(device) ? iosGestureDurationMs(durationMs, options.defaultDurationMs) : (durationMs ?? options.legacyDefaultDurationMs); return { @@ -335,7 +335,7 @@ async function runAppleScroll( // could cost one runner request first). assertScrollGestureInput(options ?? {}); - if (device.platform === 'macos') { + if (isMacOs(device)) { return await runMacosDesktopScroll( runRunnerCommand, device, diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index 165b8f382..f7931c925 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -12,7 +12,7 @@ import { appleRemotePressCommand } from './os/tvos/remote.ts'; import { runMacOsScreenshotAction } from './os/macos/helper.ts'; import { runAppleRunnerCommand } from './core/runner/runner-client.ts'; import { withDiagnosticTimer } from '../../utils/diagnostics.ts'; -import { isTvOsDevice, type DeviceInfo } from '../../kernel/device.ts'; +import { isMacOs, isTvOsDevice, type DeviceInfo } from '../../kernel/device.ts'; import { AppError } from '../../kernel/errors.ts'; import type { RawSnapshotNode } from '../../kernel/snapshot.ts'; import type { Interactor, RunnerContext } from '../../core/interactor-types.ts'; @@ -47,7 +47,7 @@ export function createAppleInteractor( openDevice: () => openIosDevice(device), close: (app) => closeIosApp(device, app), screenshot: async (outPath, options) => { - if (device.platform === 'macos' && options?.surface && options.surface !== 'app') { + if (isMacOs(device) && options?.surface && options.surface !== 'app') { await runMacOsScreenshotAction(outPath, { surface: options.surface, fullscreen: options.fullscreen, diff --git a/src/platforms/apple/os/macos/audio-probe.ts b/src/platforms/apple/os/macos/audio-probe.ts index aea0bd0ee..714a21aaf 100644 --- a/src/platforms/apple/os/macos/audio-probe.ts +++ b/src/platforms/apple/os/macos/audio-probe.ts @@ -1,4 +1,4 @@ -import type { DeviceInfo } from '../../../../kernel/device.ts'; +import { isIosFamily, type DeviceInfo } from '../../../../kernel/device.ts'; import type { HostAudioProbeBackend } from '../../../audio-probe-backend.ts'; import { startMacOsAudioProbeProcess } from './helper.ts'; @@ -12,12 +12,11 @@ export const macOsScreenCaptureKitAudioProbeBackend = { } as const satisfies HostAudioProbeBackend; function hostSystemAudioProbeNotes(device: DeviceInfo): string[] { - const target = - device.platform === 'ios' - ? 'iOS simulator' - : device.platform === 'android' - ? 'Android emulator' - : 'macOS session'; + const target = isIosFamily(device) + ? 'iOS simulator' + : device.platform === 'android' + ? 'Android emulator' + : 'macOS session'; return [ `Audio probe samples host system audio through ScreenCaptureKit for this ${target}; it is not app-instrumented audio.`, 'Screen Recording permission is required for host system audio capture.', diff --git a/src/platforms/apple/os/macos/devices.ts b/src/platforms/apple/os/macos/devices.ts index cef5eb8c5..6df117fd7 100644 --- a/src/platforms/apple/os/macos/devices.ts +++ b/src/platforms/apple/os/macos/devices.ts @@ -5,7 +5,7 @@ const HOST_MAC_DEVICE_ID = 'host-macos-local'; export function buildHostMacDevice(): DeviceInfo { return { - platform: 'macos', + platform: 'apple', id: HOST_MAC_DEVICE_ID, name: os.hostname(), kind: 'device', diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 54645aedf..0d5c78355 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -3,7 +3,7 @@ import type { PlatformPlugin } from '../../core/platform-plugin/plugin.ts'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { isAudioProbeSupportedDevice } from '../../kernel/audio-probe-support.ts'; import { shouldUseHostMacFastPath } from '../../core/platform-inventory.ts'; -import type { DeviceInfo } from '../../kernel/device.ts'; +import { isMacOs, type DeviceInfo } from '../../kernel/device.ts'; import type { DeviceInventoryRequest } from '../../core/platform-inventory.ts'; import type { RunnerContext } from '../../core/interactor-types.ts'; @@ -23,10 +23,11 @@ import type { RunnerContext } from '../../core/interactor-types.ts'; // --------------------------------------------------------------------------- // `install`/`boot`/`reinstall`/`install-from-source`/`push`/`home`/`app-switcher` -// (was `device.platform !== 'macos'`). Off Apple the original was always true. +// (was `!isMacOs(device)`). Off Apple (caps undefined) the original was +// always true — no non-Apple platform is macOS. const supportsAppAndDeviceLifecycle = (device: DeviceInfo): boolean => { const caps = appleOsCapabilities(device); - return caps ? caps.appAndDeviceLifecycle : device.platform !== 'macos'; + return caps ? caps.appAndDeviceLifecycle : true; }; // `keyboard` (was `android || (ios && target !== 'tv')`). Off Apple: `android`. @@ -118,8 +119,9 @@ const APPLE_UNSUPPORTED_HINT_BY_DEFAULT: Record< export const applePlugin = { id: 'apple', - // Apple owns BOTH leaf platforms today — mirrors `case 'ios': case 'macos':`. - platforms: ['ios', 'macos'], + // Apple owns the single collapsed `apple` platform; the `appleOs` field + // discriminates the OS (ADR-0009 / issue #979). + platforms: ['apple'], familySelector: 'apple', capability: { bucket: 'apple', @@ -130,11 +132,7 @@ export const applePlugin = { // an iOS `device` -> 'ios-device'; every other iOS kind -> 'ios-simulator'. appLog: { resolveBackend: (device: DeviceInfo) => - device.platform === 'macos' - ? 'macos' - : device.kind === 'device' - ? 'ios-device' - : 'ios-simulator', + isMacOs(device) ? 'macos' : device.kind === 'device' ? 'ios-device' : 'ios-simulator', }, // Wraps the Apple arm of `supportsPlatformPerfMetrics`: every Apple device // (ios/macos, any kind/target) reports perf-metrics support. diff --git a/src/replay/__tests__/script.test.ts b/src/replay/__tests__/script.test.ts index 436e72b00..0a9d50a39 100644 --- a/src/replay/__tests__/script.test.ts +++ b/src/replay/__tests__/script.test.ts @@ -275,16 +275,22 @@ test('readReplayScriptMetadata extracts platform from context header', () => { assert.equal(metadata.platform, 'android'); }); -test('readReplayScriptMetadata ignores non-concrete platform aliases', () => { +test('readReplayScriptMetadata accepts the apple selector alias', () => { const metadata = readReplayScriptMetadata( 'context platform=apple device="Host Mac"\nopen "Demo"\n', ); - assert.equal(metadata.platform, undefined); + assert.equal(metadata.platform, 'apple'); }); test('REPLAY_METADATA_PLATFORMS is exactly the non-web leaf platforms', () => { - assert.deepEqual([...REPLAY_METADATA_PLATFORMS].sort(), ['android', 'ios', 'linux', 'macos']); + assert.deepEqual([...REPLAY_METADATA_PLATFORMS].sort(), [ + 'android', + 'apple', + 'ios', + 'linux', + 'macos', + ]); }); test('readReplayScriptMetadata accepts every concrete leaf platform', () => { diff --git a/src/replay/script.ts b/src/replay/script.ts index 21c4c40c8..afdc38e64 100644 --- a/src/replay/script.ts +++ b/src/replay/script.ts @@ -3,7 +3,7 @@ import { AppError } from '../kernel/errors.ts'; import { recordingQualityInputToExportQuality } from '../core/recording-export-quality.ts'; import { readScreenshotScriptFlag } from '../contracts/screenshot.ts'; import type { DeviceTarget, PlatformSelector } from '../kernel/device.ts'; -import { PLATFORM_SELECTORS } from '../kernel/device.ts'; +import { PLATFORM_SELECTORS, publicPlatformString } from '../kernel/device.ts'; import { parseReplayOpenFlags } from './open-script.ts'; import { formatPortableActionLine } from './script-formatting.ts'; import type { SessionAction, SessionState } from '../daemon/types.ts'; @@ -15,14 +15,15 @@ import { } from './script-utils.ts'; import { REPLAY_VAR_KEY_RE } from './vars.ts'; -// Replay metadata `context platform=` lines only support concrete leaf -// platforms. 'apple' is an alias (resolved to a leaf), and 'web' is not yet a -// supported replay target, so both are excluded — keep the type and the runtime -// allow-list derived from the same canonical PLATFORM_SELECTORS source. -type ReplayScriptPlatform = Exclude; +// Replay metadata `context platform=` lines support every accepted `--platform` +// selector except 'web' (not yet a supported replay target). Legacy `ios`/`macos` +// and the collapsed `apple` selector all resolve through the same device-selection +// path — keep the type and the runtime allow-list derived from the canonical +// PLATFORM_SELECTORS source. +type ReplayScriptPlatform = Exclude; export const REPLAY_METADATA_PLATFORMS = new Set( - PLATFORM_SELECTORS.filter((p): p is ReplayScriptPlatform => p !== 'apple' && p !== 'web'), + PLATFORM_SELECTORS.filter((p): p is ReplayScriptPlatform => p !== 'web'), ); const REPLAY_METADATA_TARGETS = new Set(['mobile', 'tv', 'desktop']); @@ -457,8 +458,11 @@ export function writeReplayScript( if (session) { const kind = session.device.kind ? ` kind=${session.device.kind}` : ''; const target = session.device.target ? ` target=${session.device.target}` : ''; + // approach (b): heal-write the PUBLIC leaf platform (ios/macos), never the + // internal `apple` — keeps healed `.ad` scripts byte-compatible with checked-in + // fixtures and machine consumers. lines.push( - `context platform=${session.device.platform}${target} device=${formatScriptStringLiteral(session.device.name)}${kind} theme=unknown`, + `context platform=${publicPlatformString(session.device)}${target} device=${formatScriptStringLiteral(session.device.name)}${kind} theme=unknown`, ); } for (const action of actions) { diff --git a/src/snapshot/snapshot-processing.ts b/src/snapshot/snapshot-processing.ts index dac9c8959..67e15e5fc 100644 --- a/src/snapshot/snapshot-processing.ts +++ b/src/snapshot/snapshot-processing.ts @@ -1,4 +1,4 @@ -import type { Platform } from '../kernel/device.ts'; +import type { Platform, PublicPlatform } from '../kernel/device.ts'; import type { RawSnapshotNode, SnapshotNode, SnapshotState } from '../kernel/snapshot.ts'; import { extractReadableText, normalizeType } from '../utils/text-surface.ts'; @@ -81,7 +81,7 @@ export function pruneGroupNodes(nodes: RawSnapshotNode[]): RawSnapshotNode[] { return result; } -export function isFillableType(type: string, platform: Platform): boolean { +export function isFillableType(type: string, platform: Platform | PublicPlatform): boolean { const normalized = normalizeType(type); if (!normalized) return true; if (platform === 'android') { diff --git a/src/utils/__tests__/cli-flags.test.ts b/src/utils/__tests__/cli-flags.test.ts index e8a912abe..aca218258 100644 --- a/src/utils/__tests__/cli-flags.test.ts +++ b/src/utils/__tests__/cli-flags.test.ts @@ -8,7 +8,7 @@ test('--platform enumValues are derived from the canonical PLATFORM_SELECTORS tu assert.ok(platformFlag, 'expected a --platform flag definition'); assert.deepEqual(platformFlag.enumValues, [...PLATFORM_SELECTORS]); // Guard the exact membership that today's CLI accepts so the derivation cannot drift. - assert.deepEqual(platformFlag.enumValues, ['ios', 'macos', 'android', 'linux', 'web', 'apple']); + assert.deepEqual(platformFlag.enumValues, ['apple', 'android', 'linux', 'web', 'ios', 'macos']); }); test('--platform usageLabel lists the same selectors as enumValues', () => { diff --git a/src/utils/__tests__/device.test.ts b/src/utils/__tests__/device.test.ts index 6589d0621..f2898c6db 100644 --- a/src/utils/__tests__/device.test.ts +++ b/src/utils/__tests__/device.test.ts @@ -29,17 +29,19 @@ test('isTvOsDevice selects only the Apple tvOS leaf, not any TV target', () => { }); test('matchesPlatformSelector resolves apple selector across Apple platforms', () => { - assert.equal(matchesPlatformSelector('ios', 'apple'), true); - assert.equal(matchesPlatformSelector('macos', 'apple'), true); - assert.equal(matchesPlatformSelector('android', 'apple'), false); + assert.equal(matchesPlatformSelector({ platform: 'apple', appleOs: 'ios' }, 'apple'), true); + assert.equal(matchesPlatformSelector({ platform: 'apple', appleOs: 'macos' }, 'apple'), true); + assert.equal(matchesPlatformSelector({ platform: 'android' }, 'apple'), false); }); test('isPlatform accepts exactly the canonical PLATFORMS tuple', () => { for (const platform of PLATFORMS) { assert.equal(isPlatform(platform), true); } - // The `apple` selector is not a concrete leaf platform. - assert.equal(isPlatform('apple'), false); + // 'apple' is now the concrete Apple platform; the legacy leaf strings are not. + assert.equal(isPlatform('apple'), true); + assert.equal(isPlatform('ios'), false); + assert.equal(isPlatform('macos'), false); assert.equal(isPlatform('windows'), false); assert.equal(isPlatform(undefined), false); }); @@ -183,14 +185,14 @@ test('resolveDevice applies scoped set guidance when no platform selector specif test('resolveDevice prefers simulator over physical device when no explicit device selector', async () => { const physical: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'phys-1', name: 'My iPhone', kind: 'device', booted: true, }; const simulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 16', kind: 'simulator', @@ -203,21 +205,21 @@ test('resolveDevice prefers simulator over physical device when no explicit devi test('resolveDevice prefers booted simulator over physical device', async () => { const physical: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'phys-1', name: 'My iPhone', kind: 'device', booted: true, }; const sim1: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 16', kind: 'simulator', booted: true, }; const sim2: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-2', name: 'iPhone 15', kind: 'simulator', @@ -229,7 +231,7 @@ test('resolveDevice prefers booted simulator over physical device', async () => test('resolveDevice keeps Apple simulator family priority ahead of boot state', async () => { const tvSimulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'tv-sim', name: 'Apple TV 4K', kind: 'simulator', @@ -237,7 +239,7 @@ test('resolveDevice keeps Apple simulator family priority ahead of boot state', booted: true, }; const iphoneSimulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'iphone-sim', name: 'iPhone 16', kind: 'simulator', @@ -252,7 +254,7 @@ test('resolveDevice keeps Apple simulator family priority ahead of boot state', test('resolveDevice prefers booted Apple simulator within the same family', async () => { const shutdownIphone: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'iphone-shutdown', name: 'iPhone 16', kind: 'simulator', @@ -260,7 +262,7 @@ test('resolveDevice prefers booted Apple simulator within the same family', asyn booted: false, }; const bootedIphone: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'iphone-booted', name: 'iPhone 17', kind: 'simulator', @@ -275,14 +277,14 @@ test('resolveDevice prefers booted Apple simulator within the same family', asyn test('resolveDevice returns physical device when explicitly selected by deviceName', async () => { const physical: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'phys-1', name: 'My iPhone', kind: 'device', booted: true, }; const simulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 16', kind: 'simulator', diff --git a/src/utils/__tests__/interactors.test.ts b/src/utils/__tests__/interactors.test.ts index a26f148e5..a3f341a48 100644 --- a/src/utils/__tests__/interactors.test.ts +++ b/src/utils/__tests__/interactors.test.ts @@ -15,7 +15,7 @@ import { resolveAppleBackRunnerCommand } from '../../platforms/apple/interaction import { runAppleRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts'; const iosSimulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone Simulator', kind: 'simulator', @@ -23,7 +23,7 @@ const iosSimulator: DeviceInfo = { }; const tvOsSimulator: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'tv-sim-1', name: 'Apple TV', kind: 'simulator', diff --git a/src/utils/parsing.ts b/src/utils/parsing.ts index c44449166..f6bc5398b 100644 --- a/src/utils/parsing.ts +++ b/src/utils/parsing.ts @@ -1,5 +1,5 @@ import { AppError } from '../kernel/errors.ts'; -import type { DeviceKind, DeviceTarget, Platform } from '../kernel/device.ts'; +import type { DeviceKind, DeviceTarget, PublicPlatform } from '../kernel/device.ts'; import type { Point, Rect } from '../kernel/snapshot.ts'; function readRequired( @@ -59,7 +59,7 @@ export function readRequiredNumber(record: Record, key: string) ); } -export function readRequiredPlatform(record: Record, key: string): Platform { +export function readRequiredPlatform(record: Record, key: string): PublicPlatform { return readRequired(record, key, parsePlatform, `Daemon response has invalid "${key}".`); } @@ -106,7 +106,13 @@ function parseFiniteNumber(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } -function parsePlatform(value: unknown): Platform | undefined { +// Client-side parser for the PUBLIC leaf platform a daemon response carries. Under +// approach (b) the daemon always emits leaf strings (`ios`/`macos`), never the +// internal `apple`; the extra `apple` acceptance is forward-compat only, and — being +// unable to disambiguate the Apple OS from the bare token — maps to the dominant +// `ios` leaf (unreachable today, since output is never `apple`). +function parsePlatform(value: unknown): PublicPlatform | undefined { + if (value === 'apple') return 'ios'; return value === 'ios' || value === 'macos' || value === 'android' || diff --git a/src/utils/selector-build.ts b/src/utils/selector-build.ts index 6d63e1975..5f64f1b71 100644 --- a/src/utils/selector-build.ts +++ b/src/utils/selector-build.ts @@ -1,11 +1,11 @@ -import type { Platform } from '../kernel/device.ts'; +import type { Platform, PublicPlatform } from '../kernel/device.ts'; import type { SnapshotNode } from '../kernel/snapshot.ts'; import { isNodeVisible } from './selector-node.ts'; import { extractNodeText, normalizeType } from '../snapshot/snapshot-processing.ts'; export function buildSelectorChainForNode( node: SnapshotNode, - _platform: Platform, + _platform: Platform | PublicPlatform, options: { action?: 'click' | 'fill' | 'get' } = {}, ): string[] { const chain: string[] = []; diff --git a/src/utils/selector-is-predicates.ts b/src/utils/selector-is-predicates.ts index 00fccdc4b..ef5c7082d 100644 --- a/src/utils/selector-is-predicates.ts +++ b/src/utils/selector-is-predicates.ts @@ -1,4 +1,4 @@ -import type { Platform } from '../kernel/device.ts'; +import type { Platform, PublicPlatform } from '../kernel/device.ts'; import type { SnapshotState } from '../kernel/snapshot.ts'; import { isNodeVisibleInEffectiveViewport } from '../snapshot/mobile-snapshot-semantics.ts'; import { isNodeEditable, isNodeVisible } from './selector-node.ts'; @@ -20,7 +20,7 @@ export function evaluateIsPredicate(params: { node: SnapshotState['nodes'][number]; nodes: SnapshotState['nodes']; expectedText?: string; - platform: Platform; + platform: Platform | PublicPlatform; }): { pass: boolean; actualText: string; details: string } { const { predicate, node, nodes, expectedText, platform } = params; const actualText = extractNodeText(node); @@ -60,7 +60,7 @@ export function evaluateIsPredicate(params: { function isAssertionVisible( node: SnapshotState['nodes'][number], nodes: SnapshotState['nodes'], - platform: Platform, + platform: Platform | PublicPlatform, ): boolean { if (platform === 'android' && node.visibleToUser === false) return false; if (hasPositiveRect(node.rect)) return isRectVisibleInViewport(node, nodes); @@ -82,7 +82,7 @@ function isRectVisibleInViewport( function resolveVisibilityAnchor( node: SnapshotState['nodes'][number], nodes: SnapshotState['nodes'], - platform: Platform, + platform: Platform | PublicPlatform, ): SnapshotState['nodes'][number] | null { const nodesByIndex = buildSnapshotNodeByIndex(nodes); return findSnapshotAncestor(nodes, node, nodesByIndex, (parent) => @@ -93,7 +93,7 @@ function resolveVisibilityAnchor( // fallow-ignore-next-line complexity function isUsefulVisibilityAnchor( node: SnapshotState['nodes'][number], - platform: Platform, + platform: Platform | PublicPlatform, ): boolean { if (platform === 'android' && node.visibleToUser === false) return false; const type = normalizeType(node.type ?? ''); diff --git a/src/utils/selector-node.ts b/src/utils/selector-node.ts index 4aa96dcb1..d8234d9c3 100644 --- a/src/utils/selector-node.ts +++ b/src/utils/selector-node.ts @@ -1,4 +1,4 @@ -import type { Platform } from '../kernel/device.ts'; +import type { Platform, PublicPlatform } from '../kernel/device.ts'; import type { SnapshotNode } from '../kernel/snapshot.ts'; import { isFillableType } from '../snapshot/snapshot-processing.ts'; @@ -8,6 +8,6 @@ export function isNodeVisible(node: SnapshotNode): boolean { return node.rect.width > 0 && node.rect.height > 0; } -export function isNodeEditable(node: SnapshotNode, platform: Platform): boolean { +export function isNodeEditable(node: SnapshotNode, platform: Platform | PublicPlatform): boolean { return isFillableType(node.type ?? '', platform) && node.enabled !== false; } diff --git a/test/integration/provider-scenarios/fixtures.ts b/test/integration/provider-scenarios/fixtures.ts index c5c75bb42..b6d56e891 100644 --- a/test/integration/provider-scenarios/fixtures.ts +++ b/test/integration/provider-scenarios/fixtures.ts @@ -13,7 +13,7 @@ export const PROVIDER_SCENARIO_ANDROID: DeviceInfo = { }; export const PROVIDER_SCENARIO_IOS_SIMULATOR: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'sim-1', name: 'iPhone 15', kind: 'simulator', @@ -22,7 +22,7 @@ export const PROVIDER_SCENARIO_IOS_SIMULATOR: DeviceInfo = { }; export const PROVIDER_SCENARIO_IOS_DEVICE: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'ios-device-1', name: 'QA iPhone', kind: 'device', @@ -31,7 +31,7 @@ export const PROVIDER_SCENARIO_IOS_DEVICE: DeviceInfo = { }; export const PROVIDER_SCENARIO_IOS_REINSTALL_DEVICE: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'device-1', name: 'iPhone Device', kind: 'device', @@ -40,7 +40,7 @@ export const PROVIDER_SCENARIO_IOS_REINSTALL_DEVICE: DeviceInfo = { }; export const PROVIDER_SCENARIO_TVOS: DeviceInfo = { - platform: 'ios', + platform: 'apple', id: 'tv-sim-1', name: 'Apple TV', kind: 'simulator', @@ -49,7 +49,8 @@ export const PROVIDER_SCENARIO_TVOS: DeviceInfo = { }; export const PROVIDER_SCENARIO_MACOS: DeviceInfo = { - platform: 'macos', + platform: 'apple', + appleOs: 'macos', id: 'host-macos', name: 'Mac desktop', kind: 'device', diff --git a/test/integration/provider-scenarios/ios-alert-settings.test.ts b/test/integration/provider-scenarios/ios-alert-settings.test.ts index ba3b377a3..7818577b9 100644 --- a/test/integration/provider-scenarios/ios-alert-settings.test.ts +++ b/test/integration/provider-scenarios/ios-alert-settings.test.ts @@ -18,14 +18,14 @@ test('Provider-backed integration iOS Settings permission and alert flow uses pr { command: 'ios.runner.alert', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'alert', action: 'get', appBundleId: 'com.apple.Preferences' }, result: { title: 'Camera Access', message: 'Allow Settings to access Camera?' }, }, { command: 'ios.runner.alert', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'alert', action: 'accept', appBundleId: 'com.apple.Preferences' }, result: { action: 'accept', accepted: true }, }, diff --git a/test/integration/provider-scenarios/ios-record-trace.test.ts b/test/integration/provider-scenarios/ios-record-trace.test.ts index b98e134a7..b8dd4ff7b 100644 --- a/test/integration/provider-scenarios/ios-record-trace.test.ts +++ b/test/integration/provider-scenarios/ios-record-trace.test.ts @@ -33,13 +33,13 @@ test('Provider-backed integration iOS physical recording flow uses runner and de { command: 'ios.runner.recordStart', deviceId: PROVIDER_SCENARIO_IOS_DEVICE.id, - platform: 'ios', + platform: 'apple', result: {}, }, { command: 'ios.runner.recordStop', deviceId: PROVIDER_SCENARIO_IOS_DEVICE.id, - platform: 'ios', + platform: 'apple', request: { command: 'recordStop', appBundleId: 'com.apple.Preferences' }, result: {}, }, diff --git a/test/integration/provider-scenarios/ios-world.ts b/test/integration/provider-scenarios/ios-world.ts index 55139ba5d..32d1c74dc 100644 --- a/test/integration/provider-scenarios/ios-world.ts +++ b/test/integration/provider-scenarios/ios-world.ts @@ -33,7 +33,7 @@ export async function createIosSettingsWorld(): Promise { { command: 'ios.runner.uptime', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'uptime' }, result: { uptimeMs: 42 }, }, @@ -42,7 +42,7 @@ export async function createIosSettingsWorld(): Promise { { command: 'ios.runner.tap', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'tap', x: 196, @@ -55,7 +55,7 @@ export async function createIosSettingsWorld(): Promise { { command: 'ios.runner.pinch', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'pinch', scale: 0.8, @@ -68,7 +68,7 @@ export async function createIosSettingsWorld(): Promise { { command: 'ios.runner.drag', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'drag', x: 196, @@ -84,7 +84,7 @@ export async function createIosSettingsWorld(): Promise { { command: 'ios.runner.drag', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'drag', x: 196, @@ -100,7 +100,7 @@ export async function createIosSettingsWorld(): Promise { { command: 'ios.runner.rotateGesture', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'rotateGesture', degrees: 35, @@ -114,7 +114,7 @@ export async function createIosSettingsWorld(): Promise { { command: 'ios.runner.transformGesture', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'transformGesture', x: 196, @@ -132,7 +132,7 @@ export async function createIosSettingsWorld(): Promise { { command: 'ios.runner.querySelector', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'querySelector', selectorKey: 'label', @@ -157,7 +157,7 @@ export async function createIosSettingsWorld(): Promise { { command: 'ios.runner.findText', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'findText', text: 'General', @@ -168,14 +168,14 @@ export async function createIosSettingsWorld(): Promise { { command: 'ios.runner.backSystem', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'backSystem', appBundleId: 'com.apple.Preferences' }, result: { backed: true }, }, { command: 'ios.runner.keyboardDismiss', deviceId: PROVIDER_SCENARIO_IOS_SIMULATOR.id, - platform: 'ios', + platform: 'apple', request: { command: 'keyboardDismiss', appBundleId: 'com.apple.Preferences' }, result: { dismissed: true }, }, @@ -268,7 +268,7 @@ export async function createIosBottomTabsSnapshotWorld(): Promise Date: Wed, 1 Jul 2026 18:10:48 +0200 Subject: [PATCH 2/3] fix: project platform to the public leaf at open/perf response sites (#979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Platform collapse left two daemon response builders emitting the raw internal `device.platform` ('apple'), which the client normalizer rejects (isPublicPlatform excludes 'apple') — dropping the resolved device from the response: - session-open-surface.ts: `open` result `platform`/device projection. - session-perf.ts: the perf/frames/memory base response builders. Both now go through `publicPlatformString(device)`, so output stays the leaf `ios`/`macos` per approach (b). (The android/non-apple perf branches were already leaf-safe.) Also update macos-desktop provider test: the lifecycle mock observes the INTERNAL DeviceInfo, which is now `platform:'apple'` (+ appleOs:'macos'), so the recorded tag is `prepare:apple:desktop`. Fixes the provider-integration assertions that blocked both the Integration Tests and Coverage CI jobs (both run the provider-integration project). Verified: provider-integration 82/82, coverage passes, tsc/oxlint/oxfmt/layering/ fallow green. --- src/daemon/handlers/session-open-surface.ts | 9 +++++++-- src/daemon/handlers/session-perf.ts | 8 ++++---- .../integration/provider-scenarios/macos-desktop.test.ts | 4 +++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/daemon/handlers/session-open-surface.ts b/src/daemon/handlers/session-open-surface.ts index ddbfc9161..55e3de5cf 100644 --- a/src/daemon/handlers/session-open-surface.ts +++ b/src/daemon/handlers/session-open-surface.ts @@ -1,6 +1,11 @@ import { parseSessionSurface, type SessionSurface } from '../../core/session-surface.ts'; import { resolveFrontmostMacOsApp } from '../../platforms/apple/os/macos/helper.ts'; -import { isIosFamily, isMacOs, type DeviceInfo } from '../../kernel/device.ts'; +import { + isIosFamily, + isMacOs, + publicPlatformString, + type DeviceInfo, +} from '../../kernel/device.ts'; import type { SessionRuntimeHints, SessionState } from '../types.ts'; import { AppError } from '../../kernel/errors.ts'; import { successText } from '../../utils/success-text.ts'; @@ -49,7 +54,7 @@ export function buildOpenResult(params: { result.runtime = runtime; } if (device) { - result.platform = device.platform; + result.platform = publicPlatformString(device); result.target = device.target ?? 'mobile'; result.device = device.name; result.id = device.id; diff --git a/src/daemon/handlers/session-perf.ts b/src/daemon/handlers/session-perf.ts index 4f12532df..f40a49fa6 100644 --- a/src/daemon/handlers/session-perf.ts +++ b/src/daemon/handlers/session-perf.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import type { SessionAction, SessionState } from '../types.ts'; import { AppError, normalizeError } from '../../kernel/errors.ts'; -import { isApplePlatform } from '../../kernel/device.ts'; +import { isApplePlatform, publicPlatformString } from '../../kernel/device.ts'; import { tryGetPlugin } from '../../core/platform-plugin/plugin.ts'; import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-builtins.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; @@ -207,7 +207,7 @@ function buildBasePerfResponse(session: SessionState): PerfResponseData { }; return { session: session.name, - platform: session.device.platform, + platform: publicPlatformString(session.device), device: session.device.name, deviceId: session.device.id, metrics: { @@ -244,7 +244,7 @@ function buildDefaultUnavailableFrameMetric(): Record { function buildBasePerfFramesResponse(session: SessionState): PerfFramesResponseData { return { session: session.name, - platform: session.device.platform, + platform: publicPlatformString(session.device), device: session.device.name, deviceId: session.device.id, metrics: { @@ -259,7 +259,7 @@ function buildBasePerfFramesResponse(session: SessionState): PerfFramesResponseD function buildBasePerfMemoryResponse(session: SessionState): PerfMemoryResponseData { return { session: session.name, - platform: session.device.platform, + platform: publicPlatformString(session.device), device: session.device.name, deviceId: session.device.id, sampling: { diff --git a/test/integration/provider-scenarios/macos-desktop.test.ts b/test/integration/provider-scenarios/macos-desktop.test.ts index 5d6ec5c88..88245c1d8 100644 --- a/test/integration/provider-scenarios/macos-desktop.test.ts +++ b/test/integration/provider-scenarios/macos-desktop.test.ts @@ -51,7 +51,9 @@ test('Provider-backed integration prepare uses the Apple runner lifecycle provid }, ); - assert.deepEqual(lifecycleCalls, ['prepare:macos:desktop']); + // The provider receives the internal collapsed DeviceInfo (platform:'apple', + // appleOs:'macos'); the macOS distinction is carried by appleOs/target. + assert.deepEqual(lifecycleCalls, ['prepare:apple:desktop']); }); test('Provider-backed integration macOS desktop flow uses semantic host and helper providers', async () => { From 35e5d064209305d1ce6df29bc99320abd8e0b239 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 18:55:34 +0200 Subject: [PATCH 3/3] fix: project platform to the public leaf at nested output sites (#979) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Platform collapse (approach b) projects device.platform through publicPlatformString at emit sites so machine consumers keep seeing the leaf ios/macos and never the internal `apple`. Several nested output fields were missed. Project them and narrow their emitted types to PublicPlatform: - Apple perf memory snapshot support (buildAppleMemorySnapshotSupport) — response.support.platform / artifact.support.platform, plus the sibling sampleAppleFramePerf error data. - Apple xctrace perf capture/result platform surfaced in the perf cpu-profile started/stopped response data. - snapshotDiagnostics.stats.platform (recordSnapshotTiming) surfaced in snapshot/test response data and the slow-snapshot warning string. - doctor target-app evidence.platform + human summary, and doctor target-app-device evidence.booted[].platform. - provider/cloud UNSUPPORTED_OPERATION error.data.platform for cloud Apple devices (reachable via deviceFieldsFromPublicPlatform). Internal 'apple' emissions (selector-matching input, diagnostic emitDiagnostic telemetry, session appLog state, replay .ad flags) are left as-is. Adds focused tests pinning Apple perf memory support to the leaf and a guard asserting no emitted platform field equals 'apple'. --- src/cloud-webdriver/runtime.ts | 8 +++- src/commands/capture/index.test.ts | 2 +- src/core/interactors.ts | 4 +- .../__tests__/session-replay-vars.test.ts | 4 +- src/daemon/handlers/session-doctor-app.ts | 12 ++++-- src/daemon/handlers/session-doctor-device.ts | 4 +- src/daemon/handlers/snapshot-capture.ts | 5 ++- .../apple/core/__tests__/perf.test.ts | 43 +++++++++++++++++-- src/platforms/apple/core/perf-xctrace.ts | 16 +++++-- src/platforms/apple/core/perf.ts | 21 ++++++--- src/provider-device-runtime.ts | 4 +- src/snapshot-diagnostics.ts | 9 ++-- 12 files changed, 99 insertions(+), 33 deletions(-) diff --git a/src/cloud-webdriver/runtime.ts b/src/cloud-webdriver/runtime.ts index 768aef5ca..67de8df9f 100644 --- a/src/cloud-webdriver/runtime.ts +++ b/src/cloud-webdriver/runtime.ts @@ -13,7 +13,11 @@ import type { ProviderDeviceInstallResult, ProviderDeviceRuntime, } from '../provider-device-runtime.ts'; -import { deviceFieldsFromPublicPlatform, type DeviceInfo } from '../kernel/device.ts'; +import { + deviceFieldsFromPublicPlatform, + publicPlatformString, + type DeviceInfo, +} from '../kernel/device.ts'; import { AppError } from '../kernel/errors.ts'; import { unavailableCloudArtifactsResult } from './artifact-results.ts'; import { @@ -364,7 +368,7 @@ class CloudWebDriverRuntime implements ProviderDeviceRuntime { throw new AppError( 'UNSUPPORTED_OPERATION', unsupportedCapabilityMessage(session.capabilities, 'install'), - { provider: this.provider, deviceId: device.id, platform: device.platform }, + { provider: this.provider, deviceId: device.id, platform: publicPlatformString(device) }, ); } const uploadApp = session.prepared.uploadApp ?? this.options.uploadApp; diff --git a/src/commands/capture/index.test.ts b/src/commands/capture/index.test.ts index d3afdf204..a55c3fc7b 100644 --- a/src/commands/capture/index.test.ts +++ b/src/commands/capture/index.test.ts @@ -64,7 +64,7 @@ describe('capture command interface', () => { p95Ms: 1_900, maxMs: 1_900, slowThresholdMs: 1_500, - platform: 'apple', + platform: 'ios', }, warning: 'Warning: ios snapshots are slow in this run: p95 1900ms over 2 captures.', }, diff --git a/src/core/interactors.ts b/src/core/interactors.ts index dd23f9a04..6cf470452 100644 --- a/src/core/interactors.ts +++ b/src/core/interactors.ts @@ -1,4 +1,4 @@ -import type { DeviceInfo } from '../kernel/device.ts'; +import { publicPlatformString, 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'; @@ -19,7 +19,7 @@ export async function getInteractor( throw new AppError( 'UNSUPPORTED_OPERATION', 'Provider device runtime does not have an active interactor for this device.', - { deviceId: device.id, platform: device.platform }, + { deviceId: device.id, platform: publicPlatformString(device) }, ); } diff --git a/src/daemon/handlers/__tests__/session-replay-vars.test.ts b/src/daemon/handlers/__tests__/session-replay-vars.test.ts index 126e6acd1..7f5ec16de 100644 --- a/src/daemon/handlers/__tests__/session-replay-vars.test.ts +++ b/src/daemon/handlers/__tests__/session-replay-vars.test.ts @@ -492,7 +492,7 @@ test('runReplayScriptFile reports snapshot diagnostics from per-action session s session?.snapshotDiagnostics?.samples.push({ durationMs: captures === 1 ? 400 : 1_900, backend: 'xctest', - platform: 'apple', + platform: 'ios', }); return { ok: true, @@ -550,7 +550,7 @@ test('runReplayScriptFile reports snapshot diagnostics on replay failure', async session?.snapshotDiagnostics?.samples.push({ durationMs: captures === 1 ? 450 : 2_100, backend: 'xctest', - platform: 'apple', + platform: 'ios', }); if (captures === 1) return { ok: true, data: {} }; return { ok: false, error: { code: 'COMMAND_FAILED', message: 'button missing' } }; diff --git a/src/daemon/handlers/session-doctor-app.ts b/src/daemon/handlers/session-doctor-app.ts index 47ca46cc6..983cb07ca 100644 --- a/src/daemon/handlers/session-doctor-app.ts +++ b/src/daemon/handlers/session-doctor-app.ts @@ -1,4 +1,9 @@ -import { isIosFamily, isMacOs, type DeviceInfo } from '../../kernel/device.ts'; +import { + isIosFamily, + isMacOs, + publicPlatformString, + type DeviceInfo, +} from '../../kernel/device.ts'; import { AppError, normalizeError } from '../../kernel/errors.ts'; import type { SessionState } from '../types.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; @@ -19,8 +24,9 @@ export async function appendAppChecks( appendDoctorCheck(checks, { id: 'target-app', status: 'info', - summary: `Target app installation checks are not supported for ${device.platform}.`, - evidence: { requested: targetApp, platform: device.platform }, + // approach (b): emit the PUBLIC leaf platform (ios/macos), never the internal `apple`. + summary: `Target app installation checks are not supported for ${publicPlatformString(device)}.`, + evidence: { requested: targetApp, platform: publicPlatformString(device) }, }); return; } diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts index c49c1a17d..2aa118dc4 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -10,6 +10,7 @@ import { } from '../../core/platform-inventory.ts'; import { matchesDeviceSelector, + publicPlatformString, type DeviceInfo, type DeviceTarget, type Platform, @@ -94,7 +95,8 @@ export function resolveDoctorDeviceForAppCheck( evidence: { targetApp, booted: booted.map((device) => ({ - platform: device.platform, + // approach (b): emit the PUBLIC leaf platform (ios/macos), never the internal `apple`. + platform: publicPlatformString(device), id: device.id, name: device.name, })), diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index 9f06c435c..7cfff64ea 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -1,5 +1,5 @@ import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts'; -import { isMacOs, isMobilePlatform } from '../../kernel/device.ts'; +import { isMacOs, isMobilePlatform, publicPlatformString } from '../../kernel/device.ts'; import { sleep } from '../../utils/timeouts.ts'; import { runMacOsSnapshotAction } from '../../platforms/apple/os/macos/helper.ts'; import { snapshotLinux } from '../../platforms/linux/snapshot.ts'; @@ -312,7 +312,8 @@ async function captureSnapshotAttempt(params: CaptureSnapshotParams): Promise { }); import { + buildAppleMemorySnapshotSupport, captureAppleMemorySnapshot, parseApplePsOutput, sampleAppleFramePerf, @@ -67,6 +68,42 @@ beforeEach(() => { vi.useRealTimers(); }); +function collectPlatformValues(value: unknown): string[] { + if (Array.isArray(value)) return value.flatMap((entry) => collectPlatformValues(entry)); + if (value && typeof value === 'object') { + return Object.entries(value).flatMap(([key, entry]) => + key === 'platform' && typeof entry === 'string' + ? [entry, ...collectPlatformValues(entry)] + : collectPlatformValues(entry), + ); + } + return []; +} + +test('buildAppleMemorySnapshotSupport projects support.platform to the macOS leaf', () => { + // approach (b): response.support.platform must be the PUBLIC leaf, never the internal `apple`. + const support = buildAppleMemorySnapshotSupport(MACOS_DEVICE); + assert.equal(support.platform, 'macos'); + assert.equal(support.memgraph, true); +}); + +test('buildAppleMemorySnapshotSupport projects support.platform to the iOS leaf', () => { + assert.equal(buildAppleMemorySnapshotSupport(IOS_SIMULATOR).platform, 'ios'); + assert.equal(buildAppleMemorySnapshotSupport(IOS_DEVICE).platform, 'ios'); +}); + +test('buildAppleMemorySnapshotSupport never emits the internal apple platform', () => { + // Guard: no emitted `platform` field on a representative Apple perf response may equal `apple`. + for (const device of [MACOS_DEVICE, IOS_SIMULATOR, IOS_DEVICE]) { + const platforms = collectPlatformValues(buildAppleMemorySnapshotSupport(device)); + assert.ok(platforms.length > 0, 'expected at least one platform field'); + assert.ok( + !platforms.includes('apple'), + `apple leaked in support for ${device.name}: ${platforms.join(', ')}`, + ); + } +}); + test('parseApplePsOutput reads pid cpu rss and command columns', () => { const rows = parseApplePsOutput( ['123 12.5 45678 /Applications/Test.app/Contents/MacOS/Test --flag', '456 0.0 2048 Test'].join( @@ -728,7 +765,7 @@ test('stopAppleXctracePerfCapture returns compact artifact metadata', async () = outPath: tracePath, appBundleId: 'com.example.app', deviceId: 'sim-1', - platform: 'apple', + platform: 'ios', targetPids: [111], targetProcesses: ['Example'], startedAt: '2026-04-01T10:00:00.000Z', @@ -759,7 +796,7 @@ test('stopAppleXctracePerfCapture force-kills xctrace when graceful stop times o outPath: tracePath, appBundleId: 'com.example.app', deviceId: 'sim-1', - platform: 'apple', + platform: 'ios', targetPids: [111], targetProcesses: ['Example'], startedAt: '2026-04-01T10:00:00.000Z', @@ -810,7 +847,7 @@ test('stopAppleXctracePerfCapture reports confirmed cleanup after forced kill ex outPath: tracePath, appBundleId: 'com.example.app', deviceId: 'sim-1', - platform: 'apple', + platform: 'ios', targetPids: [111], targetProcesses: ['Example'], startedAt: '2026-04-01T10:00:00.000Z', diff --git a/src/platforms/apple/core/perf-xctrace.ts b/src/platforms/apple/core/perf-xctrace.ts index 77bace687..ab9955658 100644 --- a/src/platforms/apple/core/perf-xctrace.ts +++ b/src/platforms/apple/core/perf-xctrace.ts @@ -2,7 +2,13 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { isIosFamily, isApplePlatform, type DeviceInfo } from '../../../kernel/device.ts'; +import { + isIosFamily, + isApplePlatform, + publicPlatformString, + type DeviceInfo, + type PublicPlatform, +} from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; import { runCmdBackground, @@ -38,7 +44,8 @@ export type AppleXctracePerfCapture = { outPath: string; appBundleId: string; deviceId: string; - platform: DeviceInfo['platform']; + // approach (b): the PUBLIC leaf platform (ios/macos) surfaced in perf responses, never `apple`. + platform: PublicPlatform; targetPids: number[]; targetProcesses: string[]; startedAt: string; @@ -53,7 +60,8 @@ export type AppleXctracePerfResult = { outPath: string; appBundleId: string; deviceId: string; - platform: DeviceInfo['platform']; + // approach (b): the PUBLIC leaf platform (ios/macos) surfaced in perf responses, never `apple`. + platform: PublicPlatform; targetPids: number[]; targetProcesses: string[]; startedAt: string; @@ -102,7 +110,7 @@ export async function startAppleXctracePerfCapture(params: { outPath: params.outPath, appBundleId: params.appBundleId, deviceId: params.device.id, - platform: params.device.platform, + platform: publicPlatformString(params.device), targetPids: target.pids, targetProcesses: target.processNames, startedAt, diff --git a/src/platforms/apple/core/perf.ts b/src/platforms/apple/core/perf.ts index 390a8aa85..e4234695a 100644 --- a/src/platforms/apple/core/perf.ts +++ b/src/platforms/apple/core/perf.ts @@ -2,7 +2,13 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { isIosFamily, isMacOs, type DeviceInfo } from '../../../kernel/device.ts'; +import { + isIosFamily, + isMacOs, + publicPlatformString, + type DeviceInfo, + type PublicPlatform, +} from '../../../kernel/device.ts'; import { AppError } from '../../../kernel/errors.ts'; import type { ExecResult } from '../../../utils/exec.ts'; import { splitNonEmptyTrimmedLines } from '../../../utils/parsing.ts'; @@ -312,7 +318,7 @@ export async function sampleAppleFramePerf( 'Apple frame-health sampling is currently available only on connected iOS devices.', { metric: 'fps', - platform: device.platform, + platform: publicPlatformString(device), deviceKind: device.kind, }, ); @@ -392,7 +398,8 @@ export function buildAppleSamplingMetadata(device: DeviceInfo): Record; }; @@ -108,7 +109,7 @@ function percentileNearestRank(values: number[], percentile: number): number { function singlePlatform(samples: SnapshotTimingSample[]): Pick { const platforms = samples .map((sample) => sample.platform) - .filter((platform): platform is Platform => Boolean(platform)); + .filter((platform): platform is PublicPlatform => Boolean(platform)); const uniquePlatforms = new Set(platforms); return uniquePlatforms.size === 1 ? { platform: platforms[0] } : {}; }