refactor: consolidate Apple platform internals (#968)

This commit is contained in:
Michał Pierzchała
2026-06-30 21:30:46 +02:00
committed by GitHub
parent f4882bc706
commit 26ac865c63
164 changed files with 857 additions and 1512 deletions
+22
View File
@@ -68,6 +68,28 @@
{
"file": "src/platforms/ios/index.ts",
"exports": ["listSimulatorApps", "uninstallIosApp"]
},
{
"file": "src/platforms/apple/core/apps.ts",
"exports": ["listSimulatorApps", "uninstallIosApp"]
},
{
"file": "src/platforms/apple/core/runner/runner-contract.ts",
"exports": ["resolveRunnerEarlyExitHint"]
},
{
"file": "src/platforms/apple/core/runner/runner-session.ts",
"exports": ["stopAllIosRunnerSessions"]
},
{
"file": "src/platforms/apple/core/runner/runner-xctestrun.ts",
"exports": [
"resolveRunnerBuildDestination",
"resolveRunnerAppBundleId",
"resolveRunnerSigningBuildSettings",
"resolveRunnerBundleBuildSettings",
"assertSafeDerivedCleanup"
]
}
],
"usedClassMembers": ["name", "listActiveLeases", "delete", "values", "elapsedMs", "isExpired"],
-10
View File
@@ -66,16 +66,6 @@ jobs:
run: |
node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator/01-settings.ad --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --retries 2 --artifacts-dir test/artifacts/replays-ios-simulator-smoke --report-junit test/artifacts/replays-ios-simulator-smoke.junit.xml
# Gate for runner refactors (Phase 3 step c): assert the iOS runner request
# count is unchanged vs. the committed baseline. Runs in its own isolated
# daemon (temp --state-dir) so it never contends with the smoke daemon's
# lease; `clean:daemon` first releases the shared daemon's UDID lease. A
# real count drift fails loudly; an infra hiccup is tolerated (see harness).
- name: Assert iOS runner request count
run: |
pnpm clean:daemon
node --experimental-strip-types scripts/runner-request-count/run.ts --udid "${{ steps.ios-simulator.outputs.simulator-udid }}" --prepare-timeout-ms "$AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS" --artifacts-dir test/artifacts/runner-request-count
- name: Run iOS physical device smoke replay
if: env.IOS_UDID != ''
env:
+13 -2
View File
@@ -49,5 +49,16 @@ preserved). The tvOS focus-only interaction contract (no coordinate `tap`) must
and snapshot fidelity is uneven (the deep-RN AX-server fallback is iOS-simulator-only). The final
`Platform` collapse of `ios`+`macos` into `apple` is the last, highest-diff step.
This composes with ADR 0008 (the descriptor's capability facet) and ADR 0003. The phased sequencing and
per-OS readiness live in `plans/apple-platform-consolidation.md`; this ADR owns the decision.
This composes with ADR 0008 (the descriptor's capability facet) and ADR 0003.
Implementation status as of 2026-06:
- Shipped: additive `appleOs` groundwork, the shared Apple engine under `src/platforms/apple/core`, macOS leaf
files under `src/platforms/apple/os/macos`, direct internal imports to the Apple modules, and visionOS
profile/build/discovery groundwork.
- Deferred: the public `Platform` collapse from `ios`/`macos` to `apple`, a dedicated tvOS leaf, per-`AppleOS`
capability tables, and any watchOS unsupported sentinel. watchOS remains out of scope for the current
consolidation.
This ADR owns the architectural decision; implementation progress for the remaining platform-plugin work lives
in `plans/phase3-platform-plugin-progress.md`.
+10 -10
View File
@@ -434,7 +434,7 @@
"count": 1
}
},
"src/platforms/ios/apps.ts": {
"src/platforms/apple/core/apps.ts": {
"complexity_high": {
"count": 1
},
@@ -442,7 +442,7 @@
"count": 2
}
},
"src/platforms/ios/devices.ts": {
"src/platforms/apple/core/devices.ts": {
"crap_moderate": {
"count": 1
}
@@ -455,12 +455,12 @@
"count": 1
}
},
"src/platforms/ios/macos-helper.ts": {
"src/platforms/apple/os/macos/helper.ts": {
"crap_high": {
"count": 1
}
},
"src/platforms/ios/perf.ts": {
"src/platforms/apple/core/perf.ts": {
"complexity_high": {
"count": 1
},
@@ -468,17 +468,17 @@
"count": 1
}
},
"src/platforms/ios/runner-transport.ts": {
"src/platforms/apple/core/runner/runner-transport.ts": {
"crap_high": {
"count": 1
}
},
"src/platforms/ios/runner-xctestrun.ts": {
"src/platforms/apple/core/runner/runner-xctestrun.ts": {
"complexity_moderate": {
"count": 1
}
},
"src/platforms/ios/screenshot-status-bar.ts": {
"src/platforms/apple/core/screenshot-status-bar.ts": {
"complexity_moderate": {
"count": 1
},
@@ -486,12 +486,12 @@
"count": 2
}
},
"src/platforms/ios/screenshot.ts": {
"src/platforms/apple/core/screenshot.ts": {
"complexity_moderate": {
"count": 1
}
},
"src/platforms/ios/simulator.ts": {
"src/platforms/apple/core/simulator.ts": {
"crap_moderate": {
"count": 1
}
@@ -701,7 +701,7 @@
"src/cli/commands/connection-runtime.ts:complexity",
"src/daemon/context.ts:high impact",
"src/utils/success-text.ts:high impact",
"src/platforms/ios/apps.ts:complexity",
"src/platforms/apple/core/apps.ts:complexity",
"src/compat/maestro/runtime-targets.ts:high impact",
"src/utils/timeouts.ts:high impact",
"src/replay/script-utils.ts:high impact",
@@ -353,8 +353,8 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator";
TARGETED_DEVICE_FAMILY = "1,2,3";
SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator xros xrsimulator";
TARGETED_DEVICE_FAMILY = "1,2,3,7";
TVOS_DEPLOYMENT_TARGET = 15.6;
};
name = Debug;
@@ -387,8 +387,8 @@
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator";
TARGETED_DEVICE_FAMILY = "1,2,3";
SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator xros xrsimulator";
TARGETED_DEVICE_FAMILY = "1,2,3,7";
TVOS_DEPLOYMENT_TARGET = 15.6;
};
name = Release;
@@ -412,8 +412,8 @@
SWIFT_OBJC_BRIDGING_HEADER = "AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator";
TARGETED_DEVICE_FAMILY = "1,2,3";
SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator xros xrsimulator";
TARGETED_DEVICE_FAMILY = "1,2,3,7";
TVOS_DEPLOYMENT_TARGET = 15.6;
TEST_TARGET_NAME = AgentDeviceRunner;
};
@@ -438,8 +438,8 @@
SWIFT_OBJC_BRIDGING_HEADER = "AgentDeviceRunnerUITests/AgentDeviceRunnerUITests-Bridging-Header.h";
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
SWIFT_VERSION = 5.0;
SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator";
TARGETED_DEVICE_FAMILY = "1,2,3";
SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator appletvos appletvsimulator xros xrsimulator";
TARGETED_DEVICE_FAMILY = "1,2,3,7";
TVOS_DEPLOYMENT_TARGET = 15.6;
TEST_TARGET_NAME = AgentDeviceRunner;
};
@@ -82,6 +82,31 @@ int main(int argc, const char *argv[]) {
@end
#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
@interface AgentDeviceRunnerSceneDelegate : UIResponder <UIWindowSceneDelegate>
@property(nonatomic, strong) UIWindow *window;
@end
@implementation AgentDeviceRunnerSceneDelegate
- (void)scene:(UIScene *)scene
willConnectToSession:(UISceneSession *)session
options:(UISceneConnectionOptions *)connectionOptions {
(void)session;
(void)connectionOptions;
if (![scene isKindOfClass:UIWindowScene.class]) {
return;
}
self.window = [[UIWindow alloc] initWithWindowScene:(UIWindowScene *)scene];
self.window.rootViewController = [[AgentDeviceRunnerViewController alloc] init];
[self.window makeKeyAndVisible];
}
@end
#endif
@interface AgentDeviceRunnerAppDelegate : UIResponder <UIApplicationDelegate>
@property(nonatomic, strong) UIWindow *window;
@end
@@ -93,13 +118,33 @@ int main(int argc, const char *argv[]) {
(void)application;
(void)launchOptions;
#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
return YES;
#else
self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds];
self.window.rootViewController = [[AgentDeviceRunnerViewController alloc] init];
[self.window makeKeyAndVisible];
return YES;
#endif
}
#if defined(TARGET_OS_VISION) && TARGET_OS_VISION
- (UISceneConfiguration *)application:(UIApplication *)application
configurationForConnectingSceneSession:(UISceneSession *)connectingSceneSession
options:(UISceneConnectionOptions *)options {
(void)application;
(void)connectingSceneSession;
(void)options;
UISceneConfiguration *configuration =
[[UISceneConfiguration alloc] initWithName:@"Default Configuration"
sessionRole:UIWindowSceneSessionRoleApplication];
configuration.delegateClass = AgentDeviceRunnerSceneDelegate.class;
return configuration;
}
#endif
@end
int main(int argc, char *argv[]) {
@@ -122,7 +122,7 @@ extension RunnerTests {
}
func rotateDevice(to orientationName: String) -> Bool {
#if os(macOS) || os(tvOS)
#if os(macOS) || os(tvOS) || os(visionOS)
return false
#else
switch orientationName {
+3 -1
View File
@@ -77,6 +77,7 @@
"clean:xcuitest:ios": "node scripts/clean-xcuitest-derived.mjs ios",
"clean:xcuitest:macos": "node scripts/clean-xcuitest-derived.mjs macos",
"clean:xcuitest:tvos": "node scripts/clean-xcuitest-derived.mjs tvos",
"clean:xcuitest:visionos": "node scripts/clean-xcuitest-derived.mjs visionos",
"build:node": "pnpm build && pnpm clean:daemon",
"build:xcuitest": "pnpm build:xcuitest:ios && pnpm build:xcuitest:macos",
"build:xcuitest:ios": "AGENT_DEVICE_XCUITEST_PLATFORM=ios sh ./scripts/build-xcuitest-apple.sh",
@@ -84,6 +85,8 @@
"build:xcuitest:macos": "AGENT_DEVICE_XCUITEST_PLATFORM=macos sh ./scripts/build-xcuitest-apple.sh",
"build:xcuitest:tvos": "AGENT_DEVICE_XCUITEST_PLATFORM=tvos sh ./scripts/build-xcuitest-apple.sh",
"build:xcuitest:tvos:clean": "pnpm clean:xcuitest:tvos && pnpm build:xcuitest:tvos",
"build:xcuitest:visionos": "AGENT_DEVICE_XCUITEST_PLATFORM=visionos sh ./scripts/build-xcuitest-apple.sh",
"build:xcuitest:visionos:clean": "pnpm clean:xcuitest:visionos && pnpm build:xcuitest:visionos",
"build:android-snapshot-helper": "sh ./scripts/build-android-snapshot-helper.sh $(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper",
"package:android-snapshot-helper": "sh ./scripts/package-android-snapshot-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") .tmp/android-snapshot-helper",
"package:android-snapshot-helper:npm": "rm -rf android-snapshot-helper/dist && sh ./scripts/package-android-snapshot-helper.sh $(node -p \"require('./package.json').version\") v$(node -p \"require('./package.json').version\") android-snapshot-helper/dist",
@@ -98,7 +101,6 @@
"perf": "node --experimental-strip-types scripts/perf/run.ts",
"perf:ios": "node --experimental-strip-types scripts/perf/run.ts --platform ios",
"perf:android": "node --experimental-strip-types scripts/perf/run.ts --platform android",
"validate:runner-count": "node --experimental-strip-types scripts/runner-request-count/run.ts",
"lint": "oxlint . --deny-warnings",
"format": "node ./node_modules/oxfmt/bin/oxfmt --write src test skills package.json tsconfig.json tsconfig.lib.json rslib.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
"format:check": "node ./node_modules/oxfmt/bin/oxfmt --check src test skills package.json tsconfig.json tsconfig.lib.json rslib.config.ts vitest.config.ts .github/actions/setup-node-pnpm/action.yml .oxlintrc.json .oxfmtrc.json '!test/skillgym/.skillgym-results/**'",
-177
View File
@@ -1,177 +0,0 @@
# Apple Platform Consolidation — `platforms/apple` with an `AppleOS` leaf axis
> The platform-axis half of the [perfect-shape roadmap](./perfect-shape.md): make the Apple plugin own
> **iOS / iPadOS / tvOS / macOS** today and **visionOS / watchOS** as honest future leaves — a single
> `apple` platform with an `AppleOS` discriminant. Grounded in a 4-investigator survey of the real code.
## TL;DR
`src/platforms/ios/` is **~85% an OS-agnostic Apple-XCTest engine that is merely misfiled and misnamed.**
Of ~14.2k LOC, ~12k is OS-agnostic (the 6,136-LOC runner stack, tool-provider, discovery, snapshot/AX,
screenshot, perf, debug-symbols), and `apple-runner-platform.ts` already models iOS/tvOS/macOS as
first-class runner profiles. The XCTest runner **already builds `ios|macos|tvos` from one Xcode project**,
and one `createAppleInteractor` already serves both `ios` and `macos`. So consolidation is overwhelmingly
**relocate-and-rename, not rewrite** — for iOS/iPadOS/tvOS/macOS. visionOS is real-but-scoped net-new
work; watchOS is **externally blocked** by Apple (no XCUITest UI automation).
**The taxonomy decision:** add an **`AppleOS` discriminant under one `apple` Platform** — do **not** promote
each OS to its own `Platform` literal. Reasons from the code:
- `DeviceTarget` (`mobile|tv|desktop`) is already **cross-platform** — Android TV uses `target:'tv'`, so a
`tvos` Platform literal would collide with the form-factor axis. (tvOS is *currently* hacked onto
`target:'tv'`; the fix is a dedicated `AppleOS` leaf, **not** more overloading of `target`.)
- Promoting to literals explodes the ~15 `isApplePlatform` and ~52 `platform==='macos'` sites to enumerate
six literals each, and breaks the single-bucket `apple` capability/selector model that already works.
## Before / after
```
BEFORE — Apple support is smeared across an "iOS" folder that is really the Apple engine
─────────────────────────────────────────────────────────────────────────────────────────
DeviceInfo (src/kernel/device.ts)
platform: ios | macos | android | linux | web ← macOS is its OWN literal …
kind: simulator | emulator | device
target?: mobile | tv | desktop ← … but tvOS = ios + target:'tv' (asymmetric!)
'tv'/'desktop' also used by Android (cross-platform)
resolveApplePlatformName(target) ──► 'iOS' | 'tvOS' | 'macOS' ← OS name INFERRED late & lossily
core/interactors.ts: ios ─┐
macos ─┴─► createAppleInteractor (one Apple owner already exists!)
┌─ src/platforms/ios/ (~14.2k LOC — the "iOS" name is a lie: ~85% is the Apple engine) ─────────┐
│ ░ APPLE-SHARED ENGINE (OS-agnostic, ~12k) ░ │
│ runner/ stack ........ 6,136 LOC / 17 files (speaks JSON to a Swift host w/ #if os()) │
│ apple-runner-platform.ts ► RUNNER_PROFILES = { iOS, tvOS, macOS } (3 rows) │
│ discovery · tool-provider · snapshot/xml · screenshot · perf · debug-symbols │
│ ▓ iOS leaf ▓ touch synthesis · status-bar override · xctrace perf │
│ ▓ tvOS leaf ▓ XCUIRemote focus / remotePress │
│ ▒ macOS leaf — MISLABELED HERE (~797 LOC) ▒ macos-helper · macos-apps · host-provider · scroll │
└───────────────────────────────▲─────────────────────────────────────────────────────────────────┘
│ imports (dependency arrow points BACKWARD)
src/platforms/macos/devices.ts = 19-LOC stub
Discovery: xcrun simctl list ─► filter admits only {ios, tvos} ─► watchOS, visionOS SILENTLY DROPPED
Capabilities: ONE 'apple' bucket + scattered re-derivation
isNotMacOs ×5 · isIosMobileSimulator · synthesisGestureUnsupportedHint · dispatch hard-throws ×3
('macos' string in 52 files · 'tv' in 15)
```
```
AFTER — one 'apple' platform, an AppleOS leaf axis, the engine named for what it actually is
─────────────────────────────────────────────────────────────────────────────────────────────
DeviceInfo
platform: apple | android | linux | web ← ios + macos collapse into 'apple'
appleOs: ios | ipados | tvos | watchos | visionos | macos ← NEW discriminant, stored at discovery
kind: simulator | device
target?: mobile | tv | desktop ← UNCHANGED, stays orthogonal (shared w/ Android)
resolveAppleOs(device) ──► reads appleOs (fallback: legacy target inference) ← single seam
Apple plugin = one instance of the PlatformPlugin registry (perfect-shape.md §5.1),
owning every leaf OS via appleOs
┌─ src/platforms/apple/ ──────────────────────────────────────────────────────────────────────────┐
│ core/ ░ OS-agnostic Apple engine — MOVED VERBATIM from platforms/ios ░ │
│ runner/ (6,136 LOC) · tool-provider · discovery (absorbs the old stub, filter widened) │
│ snapshot · screenshot · perf · debug-symbols │
│ os-profiles.ts ► RUNNER_PROFILES = { iOS, iPadOS, tvOS, macOS, visionOS, watchOS✗ } (3 → 6) │
│ interactor.ts (from core/interactors/apple.ts) │
│ os/ ← leaf code ONLY (genuinely per-OS) │
│ ios/ touch synthesis · status-bar · xctrace │
│ ipados/ aliases ios (only if iPad-specific features are modeled) │
│ tvos/ XCUIRemote focus — NO coordinate tap (contract differs; NOT flattened) │
│ macos/ AppKit: helper binary · host-provider · desktop-scroll · menubar/desktop surfaces │
│ visionos/ NEW — feasible: profile + build case + #if os(visionOS) + real spatial-input QA │
│ watchos/ ⛔ unsupported sentinel — XCUITest can't drive watchOS (declared, gated at admission) │
└────────────────────────────────────────────────────────────────────────────────────────────────────┘
Capabilities: per-AppleOS DATA TABLE (mirrors os-profiles + the Swift #if os() guards 1:1)
{ inputModel, multiTouch, gestures{pinch,rotate,transform}, surfaces, keyboard, orientation }
⇒ scattered isNotMacOs / target!=='tv' predicates collapse into one lookup
```
## Per-OS readiness (honest)
| OS | Status | Reality |
|---|---|---|
| **iOS** | works | Reference path. |
| **iPadOS** | works | Rides iOS *identically* (matched by `/ipad/`). Zero runner work; splitting it is a naming/label concern — only worth it if Stage Manager / pointer / Pencil are actually modeled. |
| **tvOS** | works | Functional but modeled as `ios + target:'tv'`. Promotion = **rename to a leaf**; XCUIRemote focus + no-coordinate-tap behavior already exists. |
| **macOS** | works | Same XCUITest project (`build:xcuitest:macos`) **plus** a separate `agent-device-macos-helper` Swift binary for AX surfaces. ~797 LOC already (mis)lives in `platforms/ios`. AppKit, not UIKit — kept as a distinct leaf, not folded into the touch model. |
| **visionOS** | feasible, net-new | XCUITest *does* support visionOS. Needs `xros` in `SUPPORTED_PLATFORMS`, a profile row, a build case, `#if os(visionOS)`, a widened discovery filter, **and real QA** of spatial input (look+pinch, no flat coordinates) + multi-window snapshot. Good first net-new OS to validate the leaf pattern. |
| **watchOS** | blocked by Apple | **XCUITest cannot drive watchOS UI** (no `XCUIApplication`). Not a code gap. Model as an explicit *unsupported sentinel* — do not promise it from this runner. |
## On macOS (the one to think about)
macOS is **AppKit**, the odd one out at the UI-framework level — so it's reasonable to ask whether it
belongs. The code says **include it, as a distinct leaf**, for two reasons:
1. It's already the **same XCUITest project** the iOS/tvOS runner uses (`build:xcuitest:macos`) — it is
already in the Apple runner, not a separate harness.
2. **~797 LOC of macOS code already lives *inside* `platforms/ios`** (`macos-helper`, `macos-apps`,
`macos-host-provider`, `desktop-scroll`), and `platforms/macos/devices.ts` is a 19-LOC stub the iOS
discovery imports — the dependency already points backwards.
So macOS is *already entangled* in the Apple stack; **excluding it would leave the mislabel in place**,
which is worse. Consolidation **normalizes** macOS (today it's the only Apple OS with its own `Platform`
literal while tvOS rides `target`) without **homogenizing** it: its AppKit specifics — the macos-helper
backend, the menubar/desktop/frontmost-app surface model, coordinate-pinch, no multi-touch — stay in the
`apple/os/macos/` leaf. The leaf boundary is exactly what protects the AppKit difference.
## Target shape
```
src/platforms/apple/
core/ ← the ~12k OS-agnostic engine, moved verbatim from platforms/ios
runner/ (6,136 LOC, 17 files — never needed to know which Apple OS it drives)
os-profiles.ts (apple-runner-platform.ts RUNNER_PROFILES, 3 → 6 rows)
discovery.ts tool-provider/ snapshot/ screenshot/ perf/ debug-symbols/ apps.ts
interactor.ts (from core/interactors/apple.ts)
os/
ios/ ipados/ tvos/ macos/ ← leaf code only (synthesis / focus / AppKit helper / surfaces)
visionos/ (new, when pursued) watchos/ (unsupported sentinel)
```
This is the Apple plugin from the roadmap, now owning *N OS leaves* via `AppleOS`. Capabilities become a
**per-`AppleOS` data table** mirroring `RUNNER_PROFILES` and the Swift `#if os()` guards 1:1, replacing the
scattered `target!=='tv'` / `platform!=='macos'` predicates.
## Sequencing (strangler-fig, low-risk first)
1. **Additive `appleOs`** (non-breaking): add `appleOs?: AppleOS` to `DeviceInfo`, populate it at discovery
(the runtime/productType is already known there), and make `resolveApplePlatformName` /
`resolveRunnerPlatformName` prefer it with the existing target inference as fallback. Extend
`RUNNER_PROFILES` 3 → 6. Instantly makes iOS/iPadOS/tvOS/macOS first-class and unambiguous without
touching the ~50 `macos`/`isApplePlatform` call sites. *(Aligns with AGENTS.md's "Apple-family target
changes must keep device.ts, capabilities.ts, dispatch-resolve.ts, ios/devices.ts, ios/runner-xctestrun.ts
in sync" rule — this step is what makes that rule a single seam instead of a five-file checklist.)*
2. **Relocate macOS** out of `platforms/ios` into `apple/os/macos/` and invert the `macos/devices.ts` stub.
Self-contained de-scatter; the single biggest mislabel removed.
3. **Move the OS-agnostic core** (`runner/` stack, tool-provider, discovery, snapshot, screenshot, perf,
debug-symbols, `os-profiles.ts`) `platforms/ios` → `platforms/apple/core` — pure move + re-export.
Rename `ios-runner/` → `apple-runner/` (cosmetic).
4. **Promote tvOS** from `ios + target:'tv'` to an `apple/os/tvos/` leaf (rename; behavior already exists).
5. **visionOS** as the first net-new OS: profile row + `SUPPORTED_PLATFORMS += xros` + build case +
`#if os(visionOS)` + widened discovery + **budgeted spatial-input/snapshot QA**.
6. **watchOS** = explicit unsupported sentinel (declared, gated at admission), no runner work.
7. **Per-`AppleOS` capability table** replaces the scattered predicates (after the leaves exist).
Steps 1–4 compose with the roadmap's **Phase 3 (platform plugin)** — the Apple plugin is the first real
`PlatformPlugin`, and it owns the `AppleOS` leaves. Step 1 can land early as additive groundwork.
## Risks / do-not-flatten
- **tvOS has a different interaction contract** (focus-only; `tap(x,y)` returns `UNSUPPORTED` off the
focused element). A uniform tap across Apple OSes is wrong by design — keep the per-OS capability gates.
- **macOS is a hybrid backend** (XCTest runner *and* the macos-helper binary). Don't fold the helper into
the runner.
- **visionOS is feasible but unvalidated** — spatial windowing, ornaments, multi-window viewport inference,
and look+pinch synthesis need real device/sim QA. "Just add a profile row" under-counts it.
- **Snapshot fidelity is uneven** — the deep-RN-tree AX-server fallback is iOS-simulator-only; macOS / tvOS
/ visionOS rely on public XCTest snapshots, so a unified "apple snapshot" has materially different
reliability per OS.
- **`watchos` must be gated unsupported**, or it surfaces a selectable device with no runner backend.
- The relocation touches ~52 `macos` + ~15 `tv` references — mechanical (move + re-export) but high diff;
stage as create-re-export → move-leaves → flip-the-stub-inversion-last to keep each step shippable.
+8 -8
View File
@@ -35,8 +35,8 @@ reduction is real but modest (~**-1k to -3k LOC**, dominated by deleting the `cl
(command descriptor, composing with ADR 0003) and
[ADR 0009](../docs/adr/0009-apple-platform-consolidation.md) (Apple / `AppleOS`).
**Status (2026-06):** Phase 0 (type-safety, parse-at-boundary, derived allow-lists, `AppleOS` groundwork,
replay derivation) and the Tier-A dedup sweep are merged. The next gateway is the command-descriptor spine
(§5.2, ADR 0008); everything substantive cascades from it.
replay derivation), the Tier-A dedup sweep, and the Apple filesystem consolidation are merged. The next gateway
is the command-descriptor spine (§5.2, ADR 0008); everything substantive cascades from it.
---
@@ -292,8 +292,8 @@ but "add a platform" still touches the `device.ts` union line.
**not** six `Platform` literals (which would collide with the cross-platform `target` axis). The XCTest runner
already builds `ios|macos|tvos` and ~85% of `platforms/ios` is already the OS-agnostic Apple engine, so this is
mostly relocate-and-rename for iOS/iPadOS/tvOS/macOS; visionOS is scoped net-new work and watchOS is an
explicit unsupported sentinel (XCUITest can't drive it). Full plan:
[apple-platform-consolidation.md](./apple-platform-consolidation.md).
explicit unsupported sentinel (XCUITest can't drive it). ADR 0009 owns the AppleOS decision; remaining
implementation state is tracked in [phase3-platform-plugin-progress.md](./phase3-platform-plugin-progress.md).
### 5.2 `CommandDescriptor` (the command axis) — *facet composition*, honoring ADR 0003
@@ -398,7 +398,7 @@ a step can't ship alone, the plan has failed.
| **0 · confidence builders** (behaviorless) | **(b) ✅ shipped** — exhaustive capability platform selection; **(c) ✅ shipped** — generic `RecordingBackend<P>` (5 casts deleted); (a) parse-at-boundary on MCP/HTTP edge; (d) collapse the 3 platform allow-lists + 5-layer batch validation | low | correctness/security; builds muscle memory; touches no identity table |
| **1 · command spine** | (a) **invert the import graph** — `commandRegistry` becomes root, `command-catalog`/`capabilities`/`daemon-registry`/`batch-policy` derive (parity-tested, no deletion yet); (b) promote each family's facet → `CommandDescriptor` additively; (c) replace the 24-arm switch with the total map, arm-by-arm | **med** (the import-cycle inversion is the real first-week blocker: `command-catalog` has ~95 importers and the facet imports `AgentDeviceClient` today) | finishes a proven seam; add-command → ~2 files; enables everything below |
| **2 · typed results** (the parity oracle) | (a) `CommandResultMap` with `Record` default, migrate per-command from real runner payloads; (b) graft `TypedError`; fold the disowned 'generic' family into `handlers/` **last**; (c) kill the `client-types.ts` mirror (~550 LOC) | med (must be per-command, never a big-bang retype of 203 files) | the safety net the platform unwind needs; biggest single LOC win |
| **3 · platform plugin** (now safe) | (a) define `PlatformPlugin`, **lazy** factories (cold-start benchmark guards latency); (b) move capability columns onto plugin grants, **porting every `supports()` closure verbatim**, pinned by the table-equivalence test before deletion; (c) **last & most-gated:** unwind macOS out of iOS (keep an `apple-shared/` runner byte-identical), gated behind the sim-validation request-counting harness. The **Apple plugin is the first instance** and owns the `AppleOS` leaves — see [apple-platform-consolidation.md](./apple-platform-consolidation.md) | **high** (touches the shared XCTest runner) | add-platform wiring → ~3 files; kills the 231-branch smear |
| **3 · platform plugin** (now safe) | (a) define `PlatformPlugin`, **lazy** factories (cold-start benchmark guards latency); (b) move capability columns onto plugin grants, **porting every `supports()` closure verbatim**, pinned by the table-equivalence test before deletion; (c) ✅ unwind macOS and the OS-agnostic Apple engine out of `platforms/ios`; (d) finish Apple plugin facets, tvOS leaf, final `Platform` collapse, and watchOS sentinel last. The **Apple plugin is the first instance** and owns the `AppleOS` leaves — see ADR 0009 and [phase3-platform-plugin-progress.md](./phase3-platform-plugin-progress.md) | **high** (touches shared platform routing and the XCTest runner) | add-platform wiring → ~3 files; kills the 231-branch smear |
| **4 · agent-cost** (opt-in) | (a) `ResponseView.toView` with `default`==today; `responseLevel` knob defaulting to `default`; (b) typed `BatchStepResult` → intermediate steps digest; per-command MCP `outputSchema`; generalize zero-load fast-paths | med (wire-shape risk vs Maestro — strictly opt-in) | the north-star-#2 token/latency wins |
| **5 · layering + legacy** (quiet windows) | intent-folder moves + utils extraction as pure path codemods; at next major drop the ~175 LOC of legacy aliases/barrels | low-per-step, high-diff | scoped ownership + import lint; merge-pain risk → land fast, small |
@@ -432,8 +432,8 @@ grants; bundling the folder reorg with the registry work (maximizes diff-noise a
## 8. Before / after — the command axis
(The platform-axis before/after diagrams live in
[apple-platform-consolidation.md](./apple-platform-consolidation.md), since Apple is its first instance.)
(The platform-axis decision lives in ADR 0009; current implementation status lives in
[phase3-platform-plugin-progress.md](./phase3-platform-plugin-progress.md).)
```
BEFORE — a command's identity is RESTATED in ~10 hand-synced tables, aligned "by convention"
@@ -493,7 +493,7 @@ registration → everything else derives or looks up"*:
PlatformPlugin registry (§5.1) CommandDescriptor registry (§5.2)
└─ Apple plugin ──owns──► appleOs └─ defineCommand(...) ──derives──► catalog,
{ ios … macos … visionos } capability, daemon-registry, batch, dispatch,
(apple-platform-consolidation.md) client-types (daemonFacet honors ADR 0003)
(ADR 0009 / phase3 progress) client-types (daemonFacet honors ADR 0003)
```
---
+33 -22
View File
@@ -1,7 +1,7 @@
# Phase 3 — PlatformPlugin: progress + plan for the risky remainder
> Tracks the platform-axis work from [perfect-shape.md](./perfect-shape.md) §5.1 / §6 (row "3 · platform
> plugin") and [apple-platform-consolidation.md](./apple-platform-consolidation.md) / ADR-0009.
> plugin") and ADR-0009.
## Status
@@ -9,7 +9,8 @@
|---|---|---|
| **(a)** | `PlatformPlugin` registry + exhaustiveness + parity tests; route `getInteractor` through it | **✅ shipped (this PR — behaviorless)** |
| **(b)** | Move capability columns + daemon columns onto plugin grants; port `supports()`/`unsupportedHint()` closures verbatim | ⛔ planned — **HUMAN REVIEW ONLY, DO NOT AUTO-MERGE** |
| **(c)** | Unwind macOS out of `platforms/ios` into an `apple/` family, runner byte-identical | ⛔ planned — **HUMAN REVIEW ONLY, DO NOT AUTO-MERGE** |
| **(c)** | Unwind macOS and the OS-agnostic Apple engine out of `platforms/ios` into `platforms/apple` | **✅ shipped in #968** |
| **(d)** | Finish the public Apple leaf model: plugin facets, tvOS leaf, final `Platform` collapse, watchOS sentinel | ⛔ planned — **HUMAN REVIEW ONLY, DO NOT AUTO-MERGE** |
## Step (a) — what shipped (behaviorless foundation)
@@ -108,28 +109,38 @@ the daemon branches stay the source of truth and the facet is NOT added to the c
---
## Step (c) — unwind macOS out of `platforms/ios` ⛔ DO NOT AUTO-MERGE (touches the shared XCTest runner)
## Step (c) — Apple filesystem consolidation ✅ shipped in #968
Per [apple-platform-consolidation.md](./apple-platform-consolidation.md) §"Sequencing" and ADR-0009. **Gated
behind the sim-validation request-counting harness** (count iOS runner requests via `--debug` per-request
ndjson; isolate daemons with `--state-dir`) — the runner request count must be unchanged before/after each
relocation commit.
This step retired the stale standalone Apple plan by landing the low-risk relocation it described:
1. **macOS leaf relocation** — move `src/platforms/ios/{macos-helper.ts, macos-apps.ts, macos-host-provider.ts,
desktop-scroll.ts}` (~797 LOC) into `src/platforms/apple/os/macos/`, and invert the
`src/platforms/macos/devices.ts` 19-LOC stub (today `platform-inventory.ts:34` imports it). Pure
move + re-export; the AppKit specifics (helper binary, coordinate-pinch, menubar/desktop surfaces) stay in
the leaf — NOT flattened into the touch model.
2. **OS-agnostic engine relocation** — move the `runner/` stack (6,136 LOC, 17 files), `tool-provider`,
discovery, snapshot, screenshot, perf, debug-symbols, and `apple-runner-platform.ts` (`RUNNER_PROFILES`)
from `platforms/ios` → `platforms/apple/core`. **Byte-identical move** — the runner never needed to know
which Apple OS it drives. The runner request-counting harness is the gate here.
3. **Relocate the plugin + interactor** — `core/platform-plugin/` → `src/platforms/apple/` (plus
`core/interactors/apple.ts` → `apple/interactor.ts`), making `getInteractor`'s `core→platforms` routing the
final shape. Populate the `providers`/`recording`/`appLog`/`perf` facets from step (b.3) here.
4. **tvOS promotion** — rename `ios + target:'tv'` to an `apple/os/tvos/` leaf; behavior (XCUIRemote focus,
no coordinate tap) already exists. Keep the focus-only interaction contract — do NOT flatten a uniform tap.
5. (Future) visionOS net-new leaf; watchOS unsupported sentinel; per-`AppleOS` capability data table.
1. **macOS leaf relocation** — `macos-helper`, macOS app discovery, host provider, desktop scrolling, and the
macOS device stub now live under `src/platforms/apple/os/macos/`. AppKit-specific behavior stays isolated
and is not flattened into the iOS/tvOS touch model.
2. **OS-agnostic engine relocation** — the runner stack, tool provider, discovery, snapshot, screenshot, perf,
debug-symbols, and runner profile modules now live under `src/platforms/apple/core/`, and internal imports
point directly at those Apple modules instead of legacy iOS re-export shims.
3. **visionOS groundwork** — the runner profile, SDK/platform metadata, Xcode supported-platform list, build
script case, discovery tagging, and Swift interaction guard now recognize visionOS. Live spatial-input QA is
still future work.
4. **request-count gate removal** — the runner request-count CI gate and `cost.runnerRoundTrips` runtime surface
were removed because successful `main` runs captured zero runner events, so the signal did not prove runner
behavior. Apple runner regressions are now guarded by the normal unit/build gates plus live smoke replay.
## Step (d) — remaining Apple leaf/plugin work ⛔ DO NOT AUTO-MERGE
These items are still real work and should not be inferred as done from the filesystem move:
1. **Plugin + interactor placement/facets** — decide whether `core/platform-plugin/` and
`core/interactors/apple.ts` should move under `src/platforms/apple/` only when the platform-neutral
`providers` / `recording` / `appLog` / `perf` facets from step (b.3) are ready and parity-tested.
2. **tvOS promotion** — split `ios + target:'tv'` into an explicit tvOS leaf only with tests that preserve the
XCUIRemote focus-only contract and unsupported coordinate tap behavior.
3. **Final public platform collapse** — changing public `Platform` from `ios`/`macos` to `apple` is the
highest-diff compatibility step and should remain last.
4. **watchOS sentinel** — watchOS stays out of scope for now; when modeled, it must be an explicit unsupported
sentinel because XCUITest cannot drive watchOS UI.
5. **Per-`AppleOS` capability tables** — replace scattered Apple predicates only after table-equivalence tests
prove byte-for-byte behavior for iOS/iPadOS/tvOS/macOS/visionOS sample devices.
**Do-not-flatten (perfect-shape §7):** the iOS XCTest two-finger synthesis (`RunnerSynthesizedGesture`) and
adb/idb leaf code stay untouched; the plugin's job is to stop core/daemon BRANCHING on platform, not to
+8 -2
View File
@@ -7,7 +7,7 @@ SCHEME="AgentDeviceRunner"
DEFAULT_IOS_RUNNER_APP_BUNDLE_ID="com.callstack.agentdevice.runner"
if [ -z "$PLATFORM" ]; then
echo "AGENT_DEVICE_XCUITEST_PLATFORM is required (ios, macos, tvos)" >&2
echo "AGENT_DEVICE_XCUITEST_PLATFORM is required (ios, macos, tvos, visionos)" >&2
exit 1
fi
@@ -33,6 +33,9 @@ resolve_default_destination() {
tvos)
resolve_simulator_destination 'tvOS' 'Apple TV' || printf '%s\n' 'generic/platform=tvOS Simulator'
;;
visionos)
resolve_simulator_destination 'visionOS' 'Apple Vision' || printf '%s\n' 'generic/platform=visionOS Simulator'
;;
*)
echo "Unsupported AGENT_DEVICE_XCUITEST_PLATFORM: $PLATFORM" >&2
exit 1
@@ -85,6 +88,9 @@ resolve_default_derived_path() {
tvos)
printf '%s\n' "$HOME/.agent-device/ios-runner/derived/tvos"
;;
visionos)
printf '%s\n' "$HOME/.agent-device/ios-runner/derived/visionos"
;;
*)
echo "Unsupported AGENT_DEVICE_XCUITEST_PLATFORM: $PLATFORM" >&2
exit 1
@@ -102,7 +108,7 @@ resolve_clean_path() {
ios)
printf '%s\n' "$DERIVED_PATH/device"
;;
macos|tvos)
macos|tvos|visionos)
printf '%s\n' "$DERIVED_PATH"
;;
*)
+2 -2
View File
@@ -6,8 +6,8 @@ import {
isAgentDeviceDaemonProcess,
stopProcessForTakeover,
} from '../src/utils/process-identity.ts';
import { cleanupRunnerLeasesForOwner } from '../src/platforms/ios/runner-lease.ts';
import { runnerLeaseCleanupAdapter } from '../src/platforms/ios/runner-disposal.ts';
import { cleanupRunnerLeasesForOwner } from '../src/platforms/apple/core/runner/runner-lease.ts';
import { runnerLeaseCleanupAdapter } from '../src/platforms/apple/core/runner/runner-disposal.ts';
const DAEMON_TERM_TIMEOUT_MS = 15_000;
const DAEMON_KILL_TIMEOUT_MS = 2_000;
+12 -13
View File
@@ -17,13 +17,19 @@ const ROOT_TRANSIENT_ENTRY_NAMES = new Set([
'info.plist',
]);
const platforms = process.argv.slice(2);
const requested = platforms.length > 0 ? platforms : ['ios', 'macos', 'tvos'];
const supported = new Set(['ios', 'macos', 'tvos']);
const requested = platforms.length > 0 ? platforms : ['ios', 'macos', 'tvos', 'visionos'];
const supported = new Set(['ios', 'macos', 'tvos', 'visionos']);
const DERIVED_PATHS = new Map([
['ios', DERIVED_ROOT],
['macos', path.join(DERIVED_ROOT, 'macos')],
['tvos', path.join(DERIVED_ROOT, 'tvos')],
['visionos', path.join(DERIVED_ROOT, 'visionos')],
]);
for (const platform of requested) {
if (!supported.has(platform)) {
console.error(`Unsupported XCTest cache platform: ${platform}`);
console.error('Supported platforms: ios, macos, tvos');
console.error('Supported platforms: ios, macos, tvos, visionos');
process.exitCode = 1;
continue;
}
@@ -48,14 +54,7 @@ function cleanDerivedPath(platform, targetPath) {
}
function resolveDerivedPath(platform) {
switch (platform) {
case 'ios':
return DERIVED_ROOT;
case 'macos':
return path.join(DERIVED_ROOT, 'macos');
case 'tvos':
return path.join(DERIVED_ROOT, 'tvos');
default:
throw new Error(`Unsupported platform: ${platform}`);
}
const targetPath = DERIVED_PATHS.get(platform);
if (targetPath) return targetPath;
throw new Error(`Unsupported platform: ${platform}`);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { applyXctestRunnerAppIconFromDerivedPath } from '../src/platforms/ios/runner-icon.ts';
import { applyXctestRunnerAppIconFromDerivedPath } from '../src/platforms/apple/core/runner/runner-icon.ts';
const [derivedPath] = process.argv.slice(2);
@@ -1,10 +0,0 @@
{
"$comment": "Expected iOS runner request-count baseline for the smoke-ios scenario. This gate proves a runner refactor (e.g. Phase 3 step c) does not add/drop runner round-trips. `established: false` means the gate is NOT armed yet: the harness records observed counts instead of asserting. To arm/regenerate, run on a host with a booted iOS simulator: `node --experimental-strip-types scripts/runner-request-count/run.ts --udid <UDID> --update` (or read the values from the smoke-ios CI step log / uploaded artifact and edit this file), then commit it. Counts: runnerRoundTrips = ios_runner_command_send + ios_runner_readiness_preflight.",
"scenario": "test/integration/replays/ios/simulator/01-settings.ad",
"established": false,
"runnerRoundTrips": 0,
"byPhase": {
"ios_runner_command_send": 0,
"ios_runner_readiness_preflight": 0
}
}
-352
View File
@@ -1,352 +0,0 @@
#!/usr/bin/env node
/**
* iOS runner request-count gate.
*
* Drives a small, representative iOS replay scenario against a booted simulator
* with `--debug`, reads the per-request diagnostics ndjson the daemon appends to
* `<state-dir>/daemon.log`, counts the iOS-runner round-trip phases, and asserts
* the total is unchanged versus the committed baseline
* (`scripts/runner-request-count/expected-counts.json`). This proves a runner
* refactor (e.g. Phase 3 step c — relocating the shared Apple XCTest runner)
* adds or drops zero runner requests.
*
* Counting/assertion logic is the pure, unit-tested module
* `src/daemon/runner-request-count.ts`; this script is only orchestration + I/O.
*
* Usage:
* node --experimental-strip-types scripts/runner-request-count/run.ts \
* --udid <UDID> [--scenario <path>] [--artifacts-dir <dir>] \
* [--prepare-timeout-ms <ms>] [--state-dir <dir>] [--keep] [--strict] [--update]
*
* Modes:
* (default) assert observed counts == committed baseline (skips when unarmed).
* --update record observed counts into the committed baseline (arm/regenerate).
*
* Robustness: an infra hiccup (scenario fails to run, or zero round-trips
* captured) is reported as INCONCLUSIVE and does NOT fail the build unless
* `--strict` is passed; only a real count drift against an armed baseline fails.
*
* The CLI invocation defaults to running from source
* (`node --experimental-strip-types src/bin.ts`), matching the iOS workflow.
* Override with AGENT_DEVICE_RUNNER_COUNT_CLI (e.g. the built dist binary path).
*/
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runCmdSync } from '../../src/utils/exec.ts';
import {
buildRunnerRequestCountBaseline,
compareRunnerCounts,
countRunnerRequests,
parseRunnerRequestCountBaseline,
RUNNER_ROUND_TRIP_PHASES,
type RunnerRequestCountBaseline,
type RunnerRequestCounts,
} from '../../src/daemon/runner-request-count.ts';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, '..', '..');
const BASELINE_PATH = path.join(HERE, 'expected-counts.json');
const DEFAULT_SCENARIO = 'test/integration/replays/ios/simulator/01-settings.ad';
const DEFAULT_PREPARE_TIMEOUT_MS = 420_000;
const SCENARIO_TIMEOUT_MS = 600_000;
const MAX_BUFFER = 64 * 1024 * 1024;
type HarnessConfig = {
mode: 'assert' | 'update';
udid?: string;
scenario: string;
stateDir?: string;
artifactsDir?: string;
prepareTimeoutMs: number;
strict: boolean;
keep: boolean;
};
function log(msg: string): void {
process.stderr.write(`[runner-count] ${msg}\n`);
}
function readValue(argv: string[], index: number, flag: string): string {
const value = argv[index];
if (value === undefined) throw new Error(`Missing value for ${flag}`);
return value;
}
function envPrepareTimeoutMs(): number {
const raw = process.env.AGENT_DEVICE_IOS_PREPARE_TIMEOUT_MS?.trim();
const parsed = raw ? Number(raw) : NaN;
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_PREPARE_TIMEOUT_MS;
}
// Uncovered CLI arg parser: fallow's CRAP score is inflated for scripts (no test
// coverage feeds the audit), so suppress the complexity finding here.
// fallow-ignore-next-line complexity
function parseArgs(argv: string[]): HarnessConfig {
const cfg: HarnessConfig = {
mode: 'assert',
scenario: DEFAULT_SCENARIO,
prepareTimeoutMs: envPrepareTimeoutMs(),
strict: false,
keep: false,
};
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
if (a === '--update' || a === '--save') cfg.mode = 'update';
else if (a === '--strict') cfg.strict = true;
else if (a === '--keep') cfg.keep = true;
else if (a === '--help' || a === '-h') {
process.stdout.write(HELP);
process.exit(0);
} else i = applyValueFlag(cfg, a, argv, i);
}
return cfg;
}
// Apply a flag that consumes the next argv token; returns the advanced index.
// fallow-ignore-next-line complexity
function applyValueFlag(cfg: HarnessConfig, flag: string, argv: string[], i: number): number {
const value = readValue(argv, i + 1, flag);
switch (flag) {
case '--udid':
cfg.udid = value;
break;
case '--scenario':
cfg.scenario = value;
break;
case '--state-dir':
cfg.stateDir = path.resolve(value);
break;
case '--artifacts-dir':
cfg.artifactsDir = path.resolve(value);
break;
case '--prepare-timeout-ms':
cfg.prepareTimeoutMs = Number(value);
break;
default:
throw new Error(`Unknown flag: ${flag}`);
}
return i + 1;
}
const HELP = `iOS runner request-count gate
--udid <UDID> Simulator UDID to drive (required for a real run).
--scenario <path> Replay scenario (default: ${DEFAULT_SCENARIO}).
--artifacts-dir <dir> Where to write the replay + observed-count artifacts.
--state-dir <dir> Reuse an existing daemon state dir instead of a temp one.
--prepare-timeout-ms <n> Runner prepare timeout (default ${DEFAULT_PREPARE_TIMEOUT_MS}).
--update | --save Record observed counts into the committed baseline.
--strict Fail (not warn) on inconclusive/infra outcomes.
--keep Keep the temp state dir after running.
-h, --help Show this help.
`;
function cliArgv(): string[] {
const override = process.env.AGENT_DEVICE_RUNNER_COUNT_CLI?.trim();
if (override) return override.split(/\s+/);
return ['--experimental-strip-types', path.join(REPO_ROOT, 'src', 'bin.ts')];
}
function runCli(args: string[], timeoutMs: number): { exitCode: number; stderr: string } {
const full = [...cliArgv(), ...args];
try {
const result = runCmdSync(process.execPath, full, {
cwd: REPO_ROOT,
maxBuffer: MAX_BUFFER,
allowFailure: true,
timeoutMs,
});
return { exitCode: result.exitCode, stderr: result.stderr };
} catch (error) {
return { exitCode: -1, stderr: error instanceof Error ? error.message : String(error) };
}
}
function loadBaseline(): RunnerRequestCountBaseline {
const raw = fs.readFileSync(BASELINE_PATH, 'utf8');
return parseRunnerRequestCountBaseline(JSON.parse(raw) as unknown);
}
function writeBaseline(scenario: string, counts: RunnerRequestCounts): void {
const baseline = buildRunnerRequestCountBaseline(scenario, counts);
const doc = {
$comment:
'Expected iOS runner request-count baseline for the smoke-ios scenario. ' +
'Regenerate with: node --experimental-strip-types scripts/runner-request-count/run.ts --udid <UDID> --update. ' +
'runnerRoundTrips = ios_runner_command_send + ios_runner_readiness_preflight.',
...baseline,
};
fs.writeFileSync(BASELINE_PATH, `${JSON.stringify(doc, null, 2)}\n`);
log(`baseline updated: ${BASELINE_PATH}`);
}
function recordObserved(cfg: HarnessConfig, counts: RunnerRequestCounts): void {
const doc = buildRunnerRequestCountBaseline(cfg.scenario, counts);
process.stdout.write(`${JSON.stringify(doc, null, 2)}\n`);
if (!cfg.artifactsDir) return;
try {
fs.mkdirSync(cfg.artifactsDir, { recursive: true });
fs.writeFileSync(
path.join(cfg.artifactsDir, 'expected-counts.observed.json'),
`${JSON.stringify(doc, null, 2)}\n`,
);
} catch (error) {
log(`warning: could not write observed-count artifact: ${String(error)}`);
}
}
function describeCounts(counts: RunnerRequestCounts): string {
const phases = RUNNER_ROUND_TRIP_PHASES.map((p) => `${p}=${counts.byPhase[p]}`).join(', ');
return `runnerRoundTrips=${counts.runnerRoundTrips} (${phases})`;
}
function inconclusive(cfg: HarnessConfig, reason: string): number {
log(`INCONCLUSIVE: ${reason}`);
if (cfg.strict) {
log('exiting non-zero because --strict was set');
return 1;
}
log('treating as an infra hiccup (not a count drift); not failing the build');
return 0;
}
// Warm the runner WITHOUT --debug so prepare diagnostics never pollute the count.
function prepareRunner(cfg: HarnessConfig, udid: string, stateDir: string): boolean {
log('preparing iOS runner (no --debug)…');
const prepare = runCli(
[
'prepare',
'ios-runner',
'--platform',
'ios',
'--udid',
udid,
'--timeout',
String(cfg.prepareTimeoutMs),
'--json',
'--state-dir',
stateDir,
],
cfg.prepareTimeoutMs + 120_000,
);
return prepare.exitCode === 0;
}
// Drive the scenario with --debug (single attempt for a deterministic count) and
// return the run's exit code + the round-trip counts read from the daemon log.
function runScenario(
cfg: HarnessConfig,
udid: string,
stateDir: string,
): { exitCode: number; observed: RunnerRequestCounts } {
// Truncate daemon.log so we count ONLY the scenario's --debug round-trips.
const logPath = path.join(stateDir, 'daemon.log');
try {
fs.writeFileSync(logPath, '');
} catch {
/* fresh state dir may not have a log yet; the daemon recreates it */
}
log('running scenario with --debug (single attempt)…');
const args = [
'test',
cfg.scenario,
'--udid',
udid,
'--debug',
'--retries',
'0',
'--json',
'--state-dir',
stateDir,
];
if (cfg.artifactsDir) args.push('--artifacts-dir', path.join(cfg.artifactsDir, 'replay'));
const exitCode = runCli(args, SCENARIO_TIMEOUT_MS).exitCode;
let logText = '';
try {
logText = fs.readFileSync(logPath, 'utf8');
} catch {
/* no log => zero counts, handled as inconclusive downstream */
}
return { exitCode, observed: countRunnerRequests(logText) };
}
// Assert the observed counts against the committed baseline. Infra hiccups
// (failed run / zero captures) are inconclusive; only a real drift fails.
// fallow-ignore-next-line complexity
function assertObserved(
cfg: HarnessConfig,
exitCode: number,
observed: RunnerRequestCounts,
): number {
if (exitCode !== 0) {
return inconclusive(cfg, `scenario run failed (exit ${exitCode}); likely simulator/infra`);
}
if (observed.runnerRoundTrips === 0) {
return inconclusive(
cfg,
'scenario passed but zero runner round-trips were captured (likely a capture/infra issue, not a real drift)',
);
}
const comparison = compareRunnerCounts(loadBaseline(), observed);
if (comparison.status === 'unarmed') {
log('GATE NOT ARMED: committed baseline has established=false.');
log('Arm it by committing the observed counts above (or re-run with --update).');
return 0;
}
if (comparison.status === 'match') {
log(`MATCH: ${describeCounts(observed)} == committed baseline. No runner request drift.`);
return 0;
}
log('MISMATCH: iOS runner request count drifted from the committed baseline:');
for (const diff of comparison.differences) {
log(` ${diff.key}: expected ${diff.expected}, got ${diff.actual}`);
}
log('If this drift is intentional, regenerate the baseline with --update and commit it.');
return 1;
}
function runGate(cfg: HarnessConfig, stateDir: string): number {
if (!cfg.udid) return inconclusive(cfg, 'no --udid provided; cannot drive a simulator scenario');
if (!prepareRunner(cfg, cfg.udid, stateDir)) {
return inconclusive(cfg, 'prepare ios-runner failed');
}
const { exitCode, observed } = runScenario(cfg, cfg.udid, stateDir);
log(`observed: ${describeCounts(observed)}`);
recordObserved(cfg, observed);
if (cfg.mode === 'update') {
writeBaseline(cfg.scenario, observed);
log('OK: baseline recorded (update mode)');
return 0;
}
return assertObserved(cfg, exitCode, observed);
}
function teardown(cfg: HarnessConfig, stateDir: string, createdStateDir: boolean): void {
runCli(['close', '--shutdown', '--state-dir', stateDir], 60_000);
if (!createdStateDir || cfg.keep) return;
try {
fs.rmSync(stateDir, { recursive: true, force: true });
} catch {
/* best-effort */
}
}
function main(): number {
const cfg = parseArgs(process.argv.slice(2));
const createdStateDir = !cfg.stateDir;
const stateDir =
cfg.stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-count-'));
log(`scenario: ${cfg.scenario}`);
log(`state-dir: ${stateDir}${createdStateDir ? ' (temp)' : ''}`);
try {
return runGate(cfg, stateDir);
} finally {
teardown(cfg, stateDir, createdStateDir);
}
}
process.exit(main());
+13 -1
View File
@@ -9,7 +9,7 @@ const [platform, derivedPath, destination] = args;
if (!platform || !derivedPath || !destination) {
console.error(
'Usage: write-xcuitest-cache-metadata.mjs <ios|macos|tvos> <derived> <destination>',
'Usage: write-xcuitest-cache-metadata.mjs <ios|macos|tvos|visionos> <derived> <destination>',
);
process.exit(1);
}
@@ -111,6 +111,7 @@ function resolvePlatformName() {
if (platform === 'ios') return 'iOS';
if (platform === 'tvos') return 'tvOS';
if (platform === 'macos') return 'macOS';
if (platform === 'visionos') return 'visionOS';
throw new Error(`Unsupported platform: ${platform}`);
}
@@ -146,6 +147,9 @@ function resolveRunnerSdkName() {
if (platformName === 'tvOS') {
return resolveDeviceKind() === 'simulator' ? 'appletvsimulator' : 'appletvos';
}
if (platformName === 'visionOS') {
return resolveDeviceKind() === 'simulator' ? 'xrsimulator' : 'xros';
}
return resolveDeviceKind() === 'simulator' ? 'iphonesimulator' : 'iphoneos';
}
@@ -314,6 +318,14 @@ function scoreXctestrunCandidate(candidatePath) {
} else if (platform === 'macos') {
score +=
basename.includes('macos') || candidatePath.includes(`${path.sep}macos${path.sep}`) ? 100 : 0;
} else if (platform === 'visionos') {
score += destination.includes('Simulator')
? basename.includes('xrsimulator')
? 100
: 0
: basename.includes('xros')
? 100
: 0;
}
return score;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { sendToDaemon } from '../daemon/client/daemon-client.ts';
import { prepareMetroRuntime, reloadMetro } from '../metro/client-metro.ts';
import { resolveDaemonPaths } from '../daemon/config.ts';
import { symbolicateCrashArtifact } from '../platforms/ios/debug-symbols.ts';
import { symbolicateCrashArtifact } from '../platforms/apple/core/debug-symbols.ts';
import { INTERNAL_COMMANDS } from '../command-catalog.ts';
import {
prepareDaemonCommandRequest,
@@ -5,8 +5,9 @@ const { mockRunIosRunnerCommand } = vi.hoisted(() => ({
mockRunIosRunnerCommand: vi.fn(),
}));
vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../platforms/apple/core/runner/runner-client.ts')>();
return { ...actual, runIosRunnerCommand: mockRunIosRunnerCommand };
});
@@ -19,12 +20,12 @@ import {
handleTransformGestureCommand,
} from '../dispatch-interactions.ts';
import type { Interactor } from '../interactor-types.ts';
import type { RunnerCommand } from '../../platforms/ios/runner-contract.ts';
import type { RunnerCommand } from '../../platforms/apple/core/runner/runner-contract.ts';
import { AppError } from '../../kernel/errors.ts';
import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';
vi.mock('../../platforms/ios/macos-helper.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/macos-helper.ts')>();
vi.mock('../../platforms/apple/os/macos/helper.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/apple/os/macos/helper.ts')>();
return {
...actual,
runMacOsPressAction: vi.fn(async () => ({})),
+4 -3
View File
@@ -2,13 +2,14 @@ import { beforeEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../platforms/apple/core/runner/runner-client.ts')>();
return { ...actual, runIosRunnerCommand: vi.fn() };
});
import { dispatchCommand } from '../dispatch.ts';
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
import { runIosRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts';
import { ANDROID_EMULATOR, IOS_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts';
import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts';
+3 -3
View File
@@ -3,13 +3,13 @@ import assert from 'node:assert/strict';
import { dispatchCommand } from '../dispatch.ts';
import { AppError } from '../../kernel/errors.ts';
import type { DeviceInfo } from '../../kernel/device.ts';
import { openIosApp, setIosSetting } from '../../platforms/ios/apps.ts';
import { openIosApp, setIosSetting } from '../../platforms/apple/core/apps.ts';
import { openAndroidApp } from '../../platforms/android/app-lifecycle.ts';
import { setAndroidSetting } from '../../platforms/android/settings.ts';
import { IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts';
vi.mock('../../platforms/ios/apps.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/apps.ts')>();
vi.mock('../../platforms/apple/core/apps.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/apple/core/apps.ts')>();
return {
...actual,
openIosApp: vi.fn(async () => {}),
+2 -2
View File
@@ -6,8 +6,8 @@ const { mockFindBootableIosSimulator, mockListAppleDevices } = vi.hoisted(() =>
mockListAppleDevices: vi.fn(),
}));
vi.mock('../../platforms/ios/devices.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/devices.ts')>();
vi.mock('../../platforms/apple/core/devices.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/apple/core/devices.ts')>();
return {
...actual,
findBootableIosSimulator: mockFindBootableIosSimulator,
+4 -3
View File
@@ -2,13 +2,14 @@ import { beforeEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import { promises as fs } from 'node:fs';
vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../platforms/apple/core/runner/runner-client.ts')>();
return { ...actual, runIosRunnerCommand: vi.fn() };
});
import { dispatchCommand } from '../dispatch.ts';
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
import { runIosRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts';
import { ANDROID_EMULATOR, IOS_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts';
import { withMockedAdb } from '../../__tests__/test-utils/mocked-binaries.ts';
+7 -7
View File
@@ -45,8 +45,8 @@ import {
MAX_RUNNER_SEQUENCE_STEPS,
buildRunnerSequenceCommand,
parseRunnerSequenceResult,
} from '../platforms/ios/runner-sequence.ts';
import type { RunnerSequenceStep } from '../platforms/ios/runner-contract.ts';
} from '../platforms/apple/core/runner/runner-sequence.ts';
import type { RunnerSequenceStep } from '../platforms/apple/core/runner/runner-contract.ts';
import type { DispatchContext } from './dispatch-context.ts';
import type { Interactor, RunnerCallOptions } from './interactor-types.ts';
@@ -226,7 +226,7 @@ async function handleMacOsSurfacePress(
`${clickButton} click is not supported on macOS ${context.surface} sessions.`,
);
}
const { runMacOsPressAction } = await import('../platforms/ios/macos-helper.ts');
const { runMacOsPressAction } = await import('../platforms/apple/os/macos/helper.ts');
await runMacOsPressAction(x, y, {
bundleId: context.appBundleId,
surface: context.surface,
@@ -245,7 +245,7 @@ async function handleAlternateClick(
if (device.platform === 'linux') {
return await runLinuxAlternateClick(x, y, button);
}
const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts');
const { runIosRunnerCommand } = await import('../platforms/apple/core/runner/runner-client.ts');
await runIosRunnerCommand(
device,
{
@@ -346,7 +346,7 @@ async function runIosSequenceChunks(
steps: RunnerSequenceStep[],
context: DispatchContext | undefined,
): Promise<Record<string, unknown>> {
const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts');
const { runIosRunnerCommand } = await import('../platforms/apple/core/runner/runner-client.ts');
const chunks = chunkRunnerSequenceStepsByBudget(steps, MAX_RUNNER_SEQUENCE_STEPS);
let firstChunkRunnerResult: Record<string, unknown> | undefined;
@@ -1049,7 +1049,7 @@ export async function handleReadCommand(
return { action: 'read', text };
}
if (device.platform === 'macos' && context?.surface && context.surface !== 'app') {
const { runMacOsReadTextAction } = await import('../platforms/ios/macos-helper.ts');
const { runMacOsReadTextAction } = await import('../platforms/apple/os/macos/helper.ts');
const result = await runMacOsReadTextAction(x, y, {
bundleId: context.appBundleId,
surface: context.surface,
@@ -1057,7 +1057,7 @@ export async function handleReadCommand(
return { action: 'read', text: result.text };
}
// macOS app sessions run through the XCUITest runner; only desktop/menubar surfaces use the helper.
const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts');
const { runIosRunnerCommand } = await import('../platforms/apple/core/runner/runner-client.ts');
const result = await runIosRunnerCommand(
device,
{
+1 -1
View File
@@ -63,7 +63,7 @@ async function resolveAppleDevice(
const selected = await resolveAppleDeviceCandidate(devices, selector, context);
if (shouldUseAppleSimulatorFallback(selector, selected)) {
const { findBootableIosSimulator } = await import('../platforms/ios/devices.ts');
const { findBootableIosSimulator } = await import('../platforms/apple/core/devices.ts');
const simulator = await findBootableIosSimulator({
simulatorSetPath: context.simulatorSetPath,
target: selector.target,
+3 -3
View File
@@ -473,7 +473,7 @@ async function handleIosKeyboardCommand(
);
}
if (action === 'enter' || action === 'return') {
const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts');
const { runIosRunnerCommand } = await import('../platforms/apple/core/runner/runner-client.ts');
const result = await runIosRunnerCommand(
device,
{ command: 'keyboardReturn', appBundleId: context?.appBundleId },
@@ -487,7 +487,7 @@ async function handleIosKeyboardCommand(
...successText('Keyboard enter pressed'),
};
}
const { runIosRunnerCommand } = await import('../platforms/ios/runner-client.ts');
const { runIosRunnerCommand } = await import('../platforms/apple/core/runner/runner-client.ts');
const result = await runIosRunnerCommand(
device,
{ command: 'keyboardDismiss', appBundleId: context?.appBundleId },
@@ -590,7 +590,7 @@ async function handlePushCommand(
}
const payload = await readNotificationPayload(payloadArg);
if (device.platform === 'ios') {
const { pushIosNotification } = await import('../platforms/ios/apps.ts');
const { pushIosNotification } = await import('../platforms/apple/core/apps.ts');
await pushIosNotification(device, target, payload);
return {
platform: 'ios',
+3 -3
View File
@@ -6,14 +6,14 @@ import {
screenshotIos,
setIosSetting,
writeIosClipboardText,
} from '../../platforms/ios/apps.ts';
} from '../../platforms/apple/core/apps.ts';
import {
appleRemotePressCommand,
iosRunnerOverrides,
resolveAppleBackRunnerCommand,
} from '../../platforms/ios/interactions.ts';
import { runMacOsScreenshotAction } from '../../platforms/ios/macos-helper.ts';
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
import { runMacOsScreenshotAction } from '../../platforms/apple/os/macos/helper.ts';
import { runIosRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts';
import { withDiagnosticTimer } from '../../utils/diagnostics.ts';
import type { DeviceInfo } from '../../kernel/device.ts';
import { AppError } from '../../kernel/errors.ts';
+3 -3
View File
@@ -33,7 +33,7 @@ export async function listLocalDeviceInventory(
}
if (shouldUseHostMacFastPath(request)) {
const { listMacosDevices } = await import('../platforms/macos/devices.ts');
const { listMacosDevices } = await import('../platforms/apple/os/macos/devices.ts');
return await listMacosDevices();
}
@@ -52,7 +52,7 @@ export async function listLocalDeviceInventory(
}
if (request.platform) {
const { listAppleDevices } = await import('../platforms/ios/devices.ts');
const { listAppleDevices } = await import('../platforms/apple/core/devices.ts');
return await listAppleDevices({
simulatorSetPath: request.iosSimulatorSetPath,
udid: request.udid,
@@ -71,7 +71,7 @@ export async function listLocalDeviceInventory(
);
} catch {}
try {
const { listAppleDevices } = await import('../platforms/ios/devices.ts');
const { listAppleDevices } = await import('../platforms/apple/core/devices.ts');
devices.push(
...(await listAppleDevices({
simulatorSetPath: request.iosSimulatorSetPath,
@@ -26,10 +26,10 @@ const applePlugin = {
// inventory if-chain, reusing the SAME predicate (no divergent copy).
discoverDevices: async (request: DeviceInventoryRequest) => {
if (shouldUseHostMacFastPath(request)) {
const { listMacosDevices } = await import('../../platforms/macos/devices.ts');
const { listMacosDevices } = await import('../../platforms/apple/os/macos/devices.ts');
return await listMacosDevices();
}
const { listAppleDevices } = await import('../../platforms/ios/devices.ts');
const { listAppleDevices } = await import('../../platforms/apple/core/devices.ts');
return await listAppleDevices({
simulatorSetPath: request.iosSimulatorSetPath,
udid: request.udid,
+3 -2
View File
@@ -33,7 +33,7 @@ import {
} from './daemon/transport.ts';
import { prewarmPngWorker, terminatePngWorker } from './utils/png-worker-client.ts';
import { sleep } from './utils/timeouts.ts';
import { setRunnerLeaseOwnerStateDir } from './platforms/ios/runner-lease.ts';
import { setRunnerLeaseOwnerStateDir } from './platforms/apple/core/runner/runner-lease.ts';
const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000;
const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000;
@@ -223,7 +223,8 @@ export async function startDaemonRuntime(
}
await closeDaemonServers(servers);
await teardownDaemonSessions();
const { stopAllIosRunnerSessions } = await import('./platforms/ios/runner-client.ts');
const { stopAllIosRunnerSessions } =
await import('./platforms/apple/core/runner/runner-client.ts');
await stopAllIosRunnerSessions();
// Best effort: stop the PNG worker so an in-flight job cannot delay exit.
await Promise.race([
+2 -2
View File
@@ -8,7 +8,7 @@ vi.mock('../../utils/exec.ts', () => ({
runCmdSync: vi.fn(),
whichCmd: vi.fn(async () => true),
}));
vi.mock('../../platforms/ios/simulator.ts', () => ({
vi.mock('../../platforms/apple/core/simulator.ts', () => ({
ensureBootedSimulator: vi.fn(async () => {}),
}));
vi.mock('../../platforms/android/devices.ts', () => ({
@@ -17,7 +17,7 @@ vi.mock('../../platforms/android/devices.ts', () => ({
import { runCmd } from '../../utils/exec.ts';
import { waitForAndroidBoot } from '../../platforms/android/devices.ts';
import { ensureBootedSimulator } from '../../platforms/ios/simulator.ts';
import { ensureBootedSimulator } from '../../platforms/apple/core/simulator.ts';
import { ANDROID_EMULATOR, IOS_DEVICE, IOS_SIMULATOR } from '../../__tests__/test-utils/index.ts';
import {
clearDeviceReadyCacheForTests,
@@ -9,7 +9,10 @@ import {
makeSession,
} from '../../__tests__/test-utils/index.ts';
import { withTargetDeviceResolutionScope } from '../../core/dispatch-resolve.ts';
import { createLocalAppleToolProvider, runXcrun } from '../../platforms/ios/tool-provider.ts';
import {
createLocalAppleToolProvider,
runXcrun,
} from '../../platforms/apple/core/tool-provider.ts';
import { resolveWebProvider, type WebProvider } from '../../platforms/web/provider.ts';
import type { DeviceInfo } from '../../kernel/device.ts';
import { withRequestPlatformProviderScope } from '../request-platform-providers.ts';
@@ -1,11 +1,11 @@
import { test, expect, vi, beforeEach } from 'vitest';
import type { SessionState } from '../types.ts';
vi.mock('../../platforms/ios/runner-client.ts', () => ({
vi.mock('../../platforms/apple/core/runner/runner-client.ts', () => ({
getRunnerSessionSnapshot: vi.fn(),
}));
import { getRunnerSessionSnapshot } from '../../platforms/ios/runner-client.ts';
import { getRunnerSessionSnapshot } from '../../platforms/apple/core/runner/runner-client.ts';
import { refreshRecordingHealth } from '../request-recording-health.ts';
const mockGetRunnerSessionSnapshot = vi.mocked(getRunnerSessionSnapshot);
@@ -7,8 +7,9 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => {
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
});
vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../platforms/apple/core/runner/runner-client.ts')>();
return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) };
});
@@ -20,7 +21,6 @@ import type { DaemonRequest, SessionState } from '../types.ts';
import { LeaseRegistry } from '../lease-registry.ts';
import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts';
import { daemonCommandRequestSchema } from '../../kernel/contracts.ts';
import { emitDiagnostic } from '../../utils/diagnostics.ts';
const mockDispatch = vi.mocked(dispatchCommand);
@@ -110,12 +110,9 @@ test('(b) flag-on additive-only: cost block is the ONLY delta vs flag-off', asyn
expect(respFlagOn.ok).toBe(true);
if (!respFlagOff.ok || !respFlagOn.ok) return;
// The cost block now carries BOTH wallClockMs and runnerRoundTrips, both
// numbers ≥ 0. A request that never touches the iOS runner reports 0 — honest.
const cost = respFlagOn.data?.cost;
expect(cost).toMatchObject({
wallClockMs: expect.any(Number),
runnerRoundTrips: 0,
});
expect(cost?.wallClockMs).toBeGreaterThanOrEqual(0);
// This payload has no node tree, so nodeCount is omitted entirely.
@@ -126,30 +123,7 @@ test('(b) flag-on additive-only: cost block is the ONLY delta vs flag-off', asyn
expect(respFlagOn.data).toEqual(respFlagOff.data);
});
test('(c) runnerRoundTrips counts real iOS-runner round-trip diagnostics in scope', async () => {
const { sessionStore, handler } = makeHandler();
sessionStore.set('cost-session', makeIosSession('cost-session'));
// The mocked dispatch runs inside the request's diagnostics scope, so emitting
// here is equivalent to the runner-session emitting these phases per round-trip.
mockDispatch.mockImplementation(async () => {
emitDiagnostic({ phase: 'ios_runner_readiness_preflight' }); // real round-trip
emitDiagnostic({ phase: 'ios_runner_command_send' }); // real round-trip
emitDiagnostic({ phase: 'ios_runner_command_send' }); // real round-trip
emitDiagnostic({ level: 'debug', phase: 'ios_runner_readiness_preflight_skipped' }); // NOT
emitDiagnostic({ phase: 'some_other_phase' }); // NOT
return { ...REPRESENTATIVE_PAYLOAD };
});
const resp = await handler(baseRequest({ meta: { includeCost: true } }));
expect(resp.ok).toBe(true);
if (!resp.ok) return;
// 1 preflight + 2 command_send = 3; the _skipped marker and unrelated phases
// are excluded.
expect(resp.data?.cost?.runnerRoundTrips).toBe(3);
});
test('(c2) nodeCount reports the node-tree size whenever data carries a nodes array, additive-only', async () => {
test('(c) nodeCount reports the node-tree size whenever data carries a nodes array, additive-only', async () => {
const { sessionStore, handler } = makeHandler();
sessionStore.set('cost-session', makeIosSession('cost-session'));
@@ -7,8 +7,9 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => {
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
});
vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../platforms/apple/core/runner/runner-client.ts')>();
return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) };
});
@@ -7,12 +7,12 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => {
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
});
vi.mock('../../platforms/ios/runner-client.ts', () => ({
vi.mock('../../platforms/apple/core/runner/runner-client.ts', () => ({
getRunnerSessionSnapshot: vi.fn(),
}));
import { dispatchCommand } from '../../core/dispatch.ts';
import { getRunnerSessionSnapshot } from '../../platforms/ios/runner-client.ts';
import { getRunnerSessionSnapshot } from '../../platforms/apple/core/runner/runner-client.ts';
import { createRequestHandler } from '../request-router.ts';
import type { SessionState } from '../types.ts';
import { LeaseRegistry } from '../lease-registry.ts';
@@ -7,8 +7,9 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => {
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
});
vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../platforms/apple/core/runner/runner-client.ts')>();
return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) };
});
@@ -125,7 +126,6 @@ test('(d) digest composes with --cost: viewed data plus an additive cost block',
if (!resp.ok) return;
expect(resp.data).toMatchObject({ homeDigest: true, hadItems: true });
expect(typeof resp.data?.cost?.wallClockMs).toBe('number');
expect(resp.data?.cost?.runnerRoundTrips).toBe(0);
});
test('(e) digest on a command with no registered view is byte-identical to default', async () => {
@@ -7,8 +7,9 @@ vi.mock('../../core/dispatch.ts', async (importOriginal) => {
return { ...actual, dispatchCommand: vi.fn(async () => ({})) };
});
vi.mock('../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/runner-client.ts')>();
vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../platforms/apple/core/runner/runner-client.ts')>();
return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) };
});
@@ -1,191 +0,0 @@
import { test, expect } from 'vitest';
import {
buildRunnerRequestCountBaseline,
compareRunnerCounts,
countRunnerRequests,
emptyRunnerRequestCounts,
parseDiagnosticNdjson,
parseRunnerRequestCountBaseline,
RUNNER_ROUND_TRIP_PHASES,
type RunnerRequestCountBaseline,
} from '../runner-request-count.ts';
// A representative daemon `--debug` daemon.log capture: plain (non-JSON) daemon
// log lines interleaved with diagnostic ndjson, plus a stderr-prefixed line, a
// blank line, and a malformed JSON line — every one of which the tolerant parser
// must skip without throwing.
function ndjsonLine(phase: string, extra: Record<string, unknown> = {}): string {
return JSON.stringify({
ts: '2026-06-30T00:00:00.000Z',
level: 'info',
phase,
session: 'gate',
requestId: 'req-1',
command: 'click',
durationMs: 12,
...extra,
});
}
const SAMPLE_LOG = [
'[daemon] started, pid 4242',
ndjsonLine('ios_runner_readiness_preflight'),
ndjsonLine('ios_runner_command_send', { command: 'open' }),
'',
ndjsonLine('ios_runner_command_send', { command: 'click' }),
// Not a round-trip — the daemon excludes these explicitly.
ndjsonLine('ios_runner_readiness_preflight_skipped'),
// Unrelated phase from another subsystem.
ndjsonLine('android_adb_shell'),
'{ this is not valid json',
`[agent-device][diag] ${ndjsonLine('ios_runner_command_send', { command: 'back' })}`,
'[daemon] request complete',
].join('\n');
test('parseDiagnosticNdjson skips non-JSON, blank, malformed, and phaseless lines', () => {
const events = parseDiagnosticNdjson(SAMPLE_LOG);
// 5 well-formed diagnostic events (4 runner phases + 1 unrelated + 1 skipped = 6),
// including the stderr-prefixed one; the plain log lines and bad JSON are dropped.
expect(events.map((e) => e.phase)).toEqual([
'ios_runner_readiness_preflight',
'ios_runner_command_send',
'ios_runner_command_send',
'ios_runner_readiness_preflight_skipped',
'android_adb_shell',
'ios_runner_command_send',
]);
});
test('parseDiagnosticNdjson strips the stderr diagnostic prefix', () => {
const events = parseDiagnosticNdjson(
`[agent-device][diag] ${ndjsonLine('ios_runner_command_send')}`,
);
expect(events).toHaveLength(1);
expect(events[0]?.phase).toBe('ios_runner_command_send');
expect(events[0]?.command).toBe('click');
});
test('countRunnerRequests counts only the two round-trip phases (from text)', () => {
const counts = countRunnerRequests(SAMPLE_LOG);
expect(counts.runnerRoundTrips).toBe(4);
expect(counts.byPhase).toEqual({
ios_runner_command_send: 3,
ios_runner_readiness_preflight: 1,
});
});
test('countRunnerRequests matches the in-process counting semantics (3 round-trips)', () => {
// Mirrors request-router-cost.test.ts: 1 preflight + 2 command_send + a skipped
// marker + an unrelated phase => 3 runner round-trips.
const events = parseDiagnosticNdjson(
[
ndjsonLine('ios_runner_readiness_preflight'),
ndjsonLine('ios_runner_command_send'),
ndjsonLine('ios_runner_command_send'),
ndjsonLine('ios_runner_readiness_preflight_skipped'),
ndjsonLine('snapshot_capture'),
].join('\n'),
);
expect(countRunnerRequests(events).runnerRoundTrips).toBe(3);
});
test('countRunnerRequests on empty input is zeroed', () => {
expect(countRunnerRequests('')).toEqual(emptyRunnerRequestCounts());
expect(emptyRunnerRequestCounts().runnerRoundTrips).toBe(0);
});
test('RUNNER_ROUND_TRIP_PHASES is the documented pair', () => {
expect([...RUNNER_ROUND_TRIP_PHASES]).toEqual([
'ios_runner_command_send',
'ios_runner_readiness_preflight',
]);
});
// --- baseline parse + compare ------------------------------------------------
const ARMED_BASELINE: RunnerRequestCountBaseline = {
scenario: 'test/integration/replays/ios/simulator/01-settings.ad',
established: true,
runnerRoundTrips: 4,
byPhase: { ios_runner_command_send: 3, ios_runner_readiness_preflight: 1 },
};
test('parseRunnerRequestCountBaseline validates and ignores unknown keys', () => {
const parsed = parseRunnerRequestCountBaseline({
$comment: 'regenerate with --update',
scenario: ARMED_BASELINE.scenario,
established: true,
runnerRoundTrips: 4,
byPhase: { ios_runner_command_send: 3, ios_runner_readiness_preflight: 1 },
});
expect(parsed).toEqual(ARMED_BASELINE);
});
test('parseRunnerRequestCountBaseline treats missing/false established as unarmed', () => {
const parsed = parseRunnerRequestCountBaseline({
scenario: ARMED_BASELINE.scenario,
runnerRoundTrips: 0,
byPhase: { ios_runner_command_send: 0, ios_runner_readiness_preflight: 0 },
});
expect(parsed.established).toBe(false);
});
test('parseRunnerRequestCountBaseline rejects malformed payloads', () => {
expect(() => parseRunnerRequestCountBaseline(null)).toThrow(/must be a JSON object/);
expect(() => parseRunnerRequestCountBaseline({ byPhase: {} })).toThrow(/scenario/);
expect(() => parseRunnerRequestCountBaseline({ scenario: 'x', runnerRoundTrips: 1 })).toThrow(
/byPhase/,
);
expect(() =>
parseRunnerRequestCountBaseline({
scenario: 'x',
runnerRoundTrips: -1,
byPhase: { ios_runner_command_send: 0, ios_runner_readiness_preflight: 0 },
}),
).toThrow(/non-negative integer/);
});
test('compareRunnerCounts skips assertion when the baseline is unarmed', () => {
const unarmed = parseRunnerRequestCountBaseline({
scenario: ARMED_BASELINE.scenario,
established: false,
runnerRoundTrips: 0,
byPhase: { ios_runner_command_send: 0, ios_runner_readiness_preflight: 0 },
});
expect(compareRunnerCounts(unarmed, countRunnerRequests(SAMPLE_LOG))).toEqual({
status: 'unarmed',
});
});
test('compareRunnerCounts matches identical counts', () => {
expect(compareRunnerCounts(ARMED_BASELINE, countRunnerRequests(SAMPLE_LOG))).toEqual({
status: 'match',
});
});
test('compareRunnerCounts reports per-key differences on drift', () => {
// Drop one command_send (a runner refactor that removed a request).
const drifted = countRunnerRequests(
[
ndjsonLine('ios_runner_readiness_preflight'),
ndjsonLine('ios_runner_command_send'),
ndjsonLine('ios_runner_command_send'),
].join('\n'),
);
const result = compareRunnerCounts(ARMED_BASELINE, drifted);
expect(result).toEqual({
status: 'mismatch',
differences: [
{ key: 'runnerRoundTrips', expected: 4, actual: 3 },
{ key: 'ios_runner_command_send', expected: 3, actual: 2 },
],
});
});
test('buildRunnerRequestCountBaseline arms a baseline from observed counts', () => {
const baseline = buildRunnerRequestCountBaseline(
ARMED_BASELINE.scenario,
countRunnerRequests(SAMPLE_LOG),
);
expect(baseline).toEqual(ARMED_BASELINE);
});
+3 -3
View File
@@ -1,8 +1,8 @@
import { beforeEach, expect, test, vi } from 'vitest';
import type { DeviceInfo } from '../../kernel/device.ts';
vi.mock('../../platforms/ios/simulator.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/ios/simulator.ts')>();
vi.mock('../../platforms/apple/core/simulator.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../platforms/apple/core/simulator.ts')>();
return {
...actual,
getSimulatorState: vi.fn(),
@@ -16,7 +16,7 @@ vi.mock('../../utils/exec.ts', async (importOriginal) => {
});
import { shutdownDeviceTarget } from '../target-shutdown.ts';
import { getSimulatorState, shutdownSimulator } from '../../platforms/ios/simulator.ts';
import { getSimulatorState, shutdownSimulator } from '../../platforms/apple/core/simulator.ts';
import { runCmd } from '../../utils/exec.ts';
const mockGetSimulatorState = vi.mocked(getSimulatorState);
+2 -2
View File
@@ -1,8 +1,8 @@
import fs from 'node:fs';
import path from 'node:path';
import { buildSimctlArgs } from '../platforms/ios/simctl.ts';
import { buildSimctlArgs } from '../platforms/apple/core/simctl.ts';
import { runCmd, runCmdBackground } from '../utils/exec.ts';
import { runXcrun } from '../platforms/ios/tool-provider.ts';
import { runXcrun } from '../platforms/apple/core/tool-provider.ts';
import { clearPidFile, writePidFile, type AppLogResult } from './app-log-process.ts';
import { attachChildToStream, createLineWriter, waitForChildExit } from './app-log-stream.ts';
+1 -1
View File
@@ -3,7 +3,7 @@ import path from 'node:path';
import type { DeviceInfo } from '../kernel/device.ts';
import { AppError } from '../kernel/errors.ts';
import { runCmd } from '../utils/exec.ts';
import { runXcrun } from '../platforms/ios/tool-provider.ts';
import { runXcrun } from '../platforms/apple/core/tool-provider.ts';
import { runAndroidAdb } from '../platforms/android/adb.ts';
import { createScopedProvider } from '../utils/scoped-provider.ts';
import {
+2 -2
View File
@@ -1,7 +1,7 @@
import { isDeepLinkTarget } from '../core/open-target.ts';
import type { SessionSurface } from '../core/session-surface.ts';
import type { AppleRunnerLifecycleOptions } from '../platforms/ios/runner-provider.ts';
import { prewarmIosRunnerCache } from '../platforms/ios/runner-client.ts';
import type { AppleRunnerLifecycleOptions } from '../platforms/apple/core/runner/runner-provider.ts';
import { prewarmIosRunnerCache } from '../platforms/apple/core/runner/runner-client.ts';
import type { DeviceInfo } from '../kernel/device.ts';
import { contextFromFlags } from './context.ts';
import type { DaemonRequest } from './types.ts';
+1 -1
View File
@@ -6,7 +6,7 @@ import {
resolveTarArchiveRootName,
} from './artifact-archive.ts';
import { createArtifactTempDir, downloadArtifactToTempDir } from './artifact-download.ts';
import { readInfoPlistString } from '../platforms/ios/plist.ts';
import { readInfoPlistString } from '../platforms/apple/core/plist.ts';
import { AppError } from '../kernel/errors.ts';
export type MaterializeArtifactParams = {
+6 -3
View File
@@ -3,8 +3,11 @@ import os from 'node:os';
import path from 'node:path';
import { promises as fs } from 'node:fs';
import { AppError } from '../kernel/errors.ts';
import { resolveIosDevicectlHint, IOS_DEVICECTL_DEFAULT_HINT } from '../platforms/ios/devicectl.ts';
import { runXcrun } from '../platforms/ios/tool-provider.ts';
import {
resolveIosDevicectlHint,
IOS_DEVICECTL_DEFAULT_HINT,
} from '../platforms/apple/core/devicectl.ts';
import { runXcrun } from '../platforms/apple/core/tool-provider.ts';
import { isActiveProviderDevice } from '../provider-device-runtime.ts';
const IOS_DEVICE_READY_TIMEOUT_MS = 15_000;
@@ -38,7 +41,7 @@ export async function ensureDeviceReady(
if (device.platform === 'ios') {
if (device.kind === 'simulator') {
const { ensureBootedSimulator } = await import('../platforms/ios/simulator.ts');
const { ensureBootedSimulator } = await import('../platforms/apple/core/simulator.ts');
await ensureBootedSimulator(device, {
deviceHub: options.deviceHub,
focusExisting: options.focusExisting,
@@ -57,8 +57,9 @@ vi.mock('../interaction-snapshot.ts', async (importOriginal) => {
};
});
vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/runner-client.ts')>();
vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/core/runner/runner-client.ts')>();
return {
...actual,
runIosRunnerCommand: mockRunIosRunnerCommand,
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { test } from 'vitest';
import { IOS_DEVICE } from '../../../__tests__/test-utils/device-fixtures.ts';
import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts';
import type { RunnerCommand } from '../../../platforms/ios/runner-contract.ts';
import type { RunnerCommand } from '../../../platforms/apple/core/runner/runner-contract.ts';
import type { RecordTraceDeps } from '../record-trace-types.ts';
import { startIosDeviceRecording } from '../record-trace-ios.ts';
@@ -15,8 +15,9 @@ vi.mock('../../../utils/exec.ts', async (importOriginal) => {
};
});
vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/runner-client.ts')>();
vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/core/runner/runner-client.ts')>();
return {
...actual,
runIosRunnerCommand: vi.fn(async () => ({})),
@@ -56,7 +57,7 @@ import { handleRecordTraceCommands } from '../record-trace.ts';
import { deriveRecordingTelemetryPath } from '../../recording-telemetry.ts';
import { SessionStore } from '../../session-store.ts';
import type { SessionState } from '../../types.ts';
import { runIosRunnerCommand } from '../../../platforms/ios/runner-client.ts';
import { runIosRunnerCommand } from '../../../platforms/apple/core/runner/runner-client.ts';
import {
getRecordingOverlaySupportWarning,
resizeRecording,
@@ -6,20 +6,23 @@ import { SessionStore } from '../../session-store.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts';
import { AppError } from '../../../kernel/errors.ts';
vi.mock('../../../platforms/ios/simulator.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/simulator.ts')>();
vi.mock('../../../platforms/apple/core/simulator.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/core/simulator.ts')>();
return { ...actual, shutdownSimulator: vi.fn() };
});
vi.mock('../../../utils/exec.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../utils/exec.ts')>();
return { ...actual, runCmd: vi.fn() };
});
vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/runner-client.ts')>();
vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/core/runner/runner-client.ts')>();
return { ...actual, stopIosRunnerSession: vi.fn(async () => {}) };
});
vi.mock('../../../platforms/ios/perf-xctrace.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/perf-xctrace.ts')>();
vi.mock('../../../platforms/apple/core/perf-xctrace.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/core/perf-xctrace.ts')>();
return { ...actual, cleanupAppleXctracePerfCapture: vi.fn(async () => ({})) };
});
vi.mock('../../../platforms/android/perf.ts', async (importOriginal) => {
@@ -31,8 +34,9 @@ vi.mock('../../../platforms/android/snapshot-helper.ts', async (importOriginal)
await importOriginal<typeof import('../../../platforms/android/snapshot-helper.ts')>();
return { ...actual, stopAndroidSnapshotHelperSessionForDevice: vi.fn(async () => {}) };
});
vi.mock('../../../platforms/ios/macos-helper.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/macos-helper.ts')>();
vi.mock('../../../platforms/apple/os/macos/helper.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/os/macos/helper.ts')>();
return { ...actual, runMacOsAlertAction: vi.fn(async () => {}) };
});
vi.mock('../../runtime-hints.ts', async (importOriginal) => {
@@ -50,10 +54,10 @@ vi.mock('../session-device-utils.ts', async (importOriginal) => {
import { handleSessionCommands } from '../session.ts';
import { teardownSessionResources } from '../../session-teardown.ts';
import { shutdownSimulator } from '../../../platforms/ios/simulator.ts';
import { shutdownSimulator } from '../../../platforms/apple/core/simulator.ts';
import { runCmd } from '../../../utils/exec.ts';
import { dispatchCommand } from '../../../core/dispatch.ts';
import { cleanupAppleXctracePerfCapture } from '../../../platforms/ios/perf-xctrace.ts';
import { cleanupAppleXctracePerfCapture } from '../../../platforms/apple/core/perf-xctrace.ts';
import { cleanupAndroidNativePerfSession } from '../../../platforms/android/perf.ts';
import { stopAndroidSnapshotHelperSessionForDevice } from '../../../platforms/android/snapshot-helper.ts';
import { WEB_DESKTOP_DEVICE } from '../../../__tests__/test-utils/index.ts';
@@ -7,7 +7,7 @@ import type { AndroidAdbExecutor } from '../../../platforms/android/adb-executor
import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts';
import { makeAndroidSession, makeIosSession } from '../../../__tests__/test-utils/index.ts';
import { AppError } from '../../../kernel/errors.ts';
import type { AppleXctracePerfCapture } from '../../../platforms/ios/perf-xctrace.ts';
import type { AppleXctracePerfCapture } from '../../../platforms/apple/core/perf-xctrace.ts';
import type { DaemonResponse } from '../../types.ts';
const applePerfMocks = vi.hoisted(() => ({
@@ -16,8 +16,9 @@ const applePerfMocks = vi.hoisted(() => ({
writeAppleXctracePerfReport: vi.fn(),
}));
vi.mock('../../../platforms/ios/perf-xctrace.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/perf-xctrace.ts')>();
vi.mock('../../../platforms/apple/core/perf-xctrace.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/core/perf-xctrace.ts')>();
return {
...actual,
startAppleXctracePerfCapture: applePerfMocks.startAppleXctracePerfCapture,
@@ -19,16 +19,17 @@ vi.mock('../../runtime-hints.ts', async (importOriginal) => {
clearRuntimeHintsFromApp: vi.fn(async () => {}),
};
});
vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/runner-client.ts')>();
vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/core/runner/runner-client.ts')>();
return {
...actual,
prewarmIosRunnerSession: vi.fn(),
stopIosRunnerSession: vi.fn(async () => {}),
};
});
vi.mock('../../../platforms/ios/apps.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/apps.ts')>();
vi.mock('../../../platforms/apple/core/apps.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/apple/core/apps.ts')>();
return {
...actual,
resolveIosApp: vi.fn(async () => 'com.example.demo'),
+21 -15
View File
@@ -13,8 +13,9 @@ vi.mock('../../runtime-hints.ts', async (importOriginal) => {
clearRuntimeHintsFromApp: vi.fn(async () => {}),
};
});
vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/runner-client.ts')>();
vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/core/runner/runner-client.ts')>();
return {
...actual,
prepareIosRunner: vi.fn(async () => ({
@@ -27,8 +28,9 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
stopIosRunnerSession: vi.fn(async () => {}),
};
});
vi.mock('../../../platforms/ios/macos-helper.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/macos-helper.ts')>();
vi.mock('../../../platforms/apple/os/macos/helper.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/os/macos/helper.ts')>();
return { ...actual, runMacOsAlertAction: vi.fn(async () => {}) };
});
vi.mock('../session-device-utils.ts', async (importOriginal) => {
@@ -39,8 +41,9 @@ vi.mock('../session-open-target.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../session-open-target.ts')>();
return { ...actual, resolveAndroidPackageForOpen: vi.fn(async () => undefined) };
});
vi.mock('../../../platforms/ios/simulator.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/simulator.ts')>();
vi.mock('../../../platforms/apple/core/simulator.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/core/simulator.ts')>();
return { ...actual, getSimulatorState: vi.fn(async () => null), shutdownSimulator: vi.fn() };
});
vi.mock('../../../utils/exec.ts', async (importOriginal) => {
@@ -59,12 +62,12 @@ vi.mock('../../../platforms/android/devices.ts', async (importOriginal) => {
ensureAndroidEmulatorBooted: vi.fn(),
};
});
vi.mock('../../../platforms/ios/devices.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/devices.ts')>();
vi.mock('../../../platforms/apple/core/devices.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/apple/core/devices.ts')>();
return { ...actual, listAppleDevices: vi.fn(async () => []) };
});
vi.mock('../../../platforms/ios/apps.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/apps.ts')>();
vi.mock('../../../platforms/apple/core/apps.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/apple/core/apps.ts')>();
return {
...actual,
listIosApps: vi.fn(async () => []),
@@ -105,18 +108,21 @@ import {
prewarmIosRunnerCache,
prewarmIosRunnerSession,
stopIosRunnerSession,
} from '../../../platforms/ios/runner-client.ts';
import { runMacOsAlertAction } from '../../../platforms/ios/macos-helper.ts';
} from '../../../platforms/apple/core/runner/runner-client.ts';
import { runMacOsAlertAction } from '../../../platforms/apple/os/macos/helper.ts';
import { settleIosSimulator } from '../session-device-utils.ts';
import { resolveAndroidPackageForOpen } from '../session-open-target.ts';
import { runCmd } from '../../../utils/exec.ts';
import { shutdownSimulator } from '../../../platforms/ios/simulator.ts';
import { shutdownSimulator } from '../../../platforms/apple/core/simulator.ts';
import {
listAndroidDevices,
ensureAndroidEmulatorBooted,
} from '../../../platforms/android/devices.ts';
import { listAppleDevices } from '../../../platforms/ios/devices.ts';
import { resolveIosApp, resolveIosSimulatorDeepLinkBundleId } from '../../../platforms/ios/apps.ts';
import { listAppleDevices } from '../../../platforms/apple/core/devices.ts';
import {
resolveIosApp,
resolveIosSimulatorDeepLinkBundleId,
} from '../../../platforms/apple/core/apps.ts';
import { startAppLog, stopAppLog } from '../../app-log.ts';
import { defaultInstallOps, defaultReinstallOps } from '../session-deploy.ts';
import { clearRequestCanceled, markRequestCanceled } from '../../request-cancel.ts';
@@ -23,8 +23,9 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => {
};
});
vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/runner-client.ts')>();
vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../platforms/apple/core/runner/runner-client.ts')>();
return {
...actual,
runIosRunnerCommand: vi.fn(async () => ({})),
@@ -32,8 +33,8 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
};
});
vi.mock('../../../platforms/ios/apps.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/apps.ts')>();
vi.mock('../../../platforms/apple/core/apps.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/apple/core/apps.ts')>();
return {
...actual,
closeIosApp: vi.fn(async () => {}),
@@ -41,8 +42,11 @@ vi.mock('../../../platforms/ios/apps.ts', async (importOriginal) => {
});
import { dispatchCommand } from '../../../core/dispatch.ts';
import { runIosRunnerCommand, stopIosRunnerSession } from '../../../platforms/ios/runner-client.ts';
import { closeIosApp } from '../../../platforms/ios/apps.ts';
import {
runIosRunnerCommand,
stopIosRunnerSession,
} from '../../../platforms/apple/core/runner/runner-client.ts';
import { closeIosApp } from '../../../platforms/apple/core/apps.ts';
const mockDispatch = vi.mocked(dispatchCommand);
const mockRunnerCommand = vi.mocked(runIosRunnerCommand);
+3 -2
View File
@@ -160,7 +160,8 @@ export async function handleInstallFromSourceCommand(params: {
const requestSignal = getRequestSignal(req.meta?.requestId);
if (device.platform === 'ios') {
const { prepareIosInstallArtifact } = await import('../../platforms/ios/install-artifact.ts');
const { prepareIosInstallArtifact } =
await import('../../platforms/apple/core/install-artifact.ts');
const prepared = await prepareIosInstallArtifact(resolvedSource.source, {
signal: requestSignal,
});
@@ -239,7 +240,7 @@ async function installPreparedIosArtifact(
{ appIdentifierHint: prepared.bundleId },
);
if (!providerResult) {
const { installIosInstallablePath } = await import('../../platforms/ios/apps.ts');
const { installIosInstallablePath } = await import('../../platforms/apple/core/apps.ts');
await installIosInstallablePath(device, prepared.installablePath);
}
return buildIosInstallFromSourceResult(prepared, providerResult, retained);
+1 -1
View File
@@ -1,7 +1,7 @@
import { SessionStore } from '../session-store.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
import { emitDiagnostic } from '../../utils/diagnostics.ts';
import { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../../platforms/ios/runner-client.ts';
import { IOS_RUNNER_CONTAINER_BUNDLE_IDS } from '../../platforms/apple/core/runner/runner-client.ts';
import { formatRecordTraceError } from '../record-trace-errors.ts';
import { buildAppleRunnerRequestOptions } from '../apple-runner-options.ts';
import type { RecordTraceDeps, RecordingBase } from './record-trace-types.ts';
@@ -8,8 +8,8 @@ import type { DaemonArtifact, DaemonRequest, DaemonResponse, SessionState } from
import { runCmd } from '../../utils/exec.ts';
import { isPlayableVideo, waitForStableFile } from '../../utils/video.ts';
import { deriveRecordingTelemetryPath } from '../recording-telemetry.ts';
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
import { runXcrun } from '../../platforms/ios/tool-provider.ts';
import { runIosRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts';
import { runXcrun } from '../../platforms/apple/core/tool-provider.ts';
import {
overlayRecordingTouches,
resizeRecording,
+1 -1
View File
@@ -1,7 +1,7 @@
import type { RecordingProvider } from '../recording-provider.ts';
import type { runCmd } from '../../utils/exec.ts';
import type { isPlayableVideo, waitForStableFile } from '../../utils/video.ts';
import type { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
import type { runIosRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts';
import type {
overlayRecordingTouches,
resizeRecording,
+2 -2
View File
@@ -62,7 +62,7 @@ export const defaultReinstallOps: ReinstallOps = {
return { bundleId: providerResult.bundleId ?? providerResult.launchTarget ?? app };
}
const { reinstallIosApp } = await import('../../platforms/ios/apps.ts');
const { reinstallIosApp } = await import('../../platforms/apple/core/apps.ts');
return await reinstallIosApp(device, app, appPath);
},
android: async (device, app, appPath) => {
@@ -92,7 +92,7 @@ export const defaultInstallOps: InstallOps = {
};
}
const { installIosApp } = await import('../../platforms/ios/apps.ts');
const { installIosApp } = await import('../../platforms/apple/core/apps.ts');
const result = await installIosApp(device, appPath, { appIdentifierHint: app });
return {
bundleId: result.bundleId,
+1 -1
View File
@@ -14,7 +14,7 @@ import {
import type { DaemonRequest, DaemonResponse } from '../types.ts';
import { resolveSessionRunnerLogPath, SessionStore } from '../session-store.ts';
import { listAndroidApps } from '../../platforms/android/app-lifecycle.ts';
import { listIosApps } from '../../platforms/ios/apps.ts';
import { listIosApps } from '../../platforms/apple/core/apps.ts';
import { requireSessionOrExplicitSelector, resolveCommandDevice } from './session-device-utils.ts';
import { errorResponse, requireCommandSupported } from './response.ts';
import { resolveImplicitSessionScope, sessionMatchesScope } from '../session-routing.ts';
+1 -1
View File
@@ -1,5 +1,5 @@
import { parseSessionSurface, type SessionSurface } from '../../core/session-surface.ts';
import { resolveFrontmostMacOsApp } from '../../platforms/ios/macos-helper.ts';
import { resolveFrontmostMacOsApp } from '../../platforms/apple/os/macos/helper.ts';
import type { DeviceInfo } from '../../kernel/device.ts';
import type { SessionRuntimeHints, SessionState } from '../types.ts';
import { AppError } from '../../kernel/errors.ts';
+3 -2
View File
@@ -31,7 +31,8 @@ async function tryResolveIosSimulatorDeepLinkBundleId(
openTarget: string,
): Promise<string | undefined> {
try {
const { resolveIosSimulatorDeepLinkBundleId } = await import('../../platforms/ios/apps.ts');
const { resolveIosSimulatorDeepLinkBundleId } =
await import('../../platforms/apple/core/apps.ts');
return await resolveIosSimulatorDeepLinkBundleId(device, openTarget);
} catch {
return undefined;
@@ -43,7 +44,7 @@ async function tryResolveIosAppBundleId(
openTarget: string,
): Promise<string | undefined> {
try {
const { resolveIosApp } = await import('../../platforms/ios/apps.ts');
const { resolveIosApp } = await import('../../platforms/apple/core/apps.ts');
return await resolveIosApp(device, openTarget);
} catch {
return undefined;
+1 -1
View File
@@ -6,7 +6,7 @@ import { createRequestCanceledError, isRequestCanceled } from '../request-cancel
import {
prewarmIosRunnerSession,
stopIosRunnerSession,
} from '../../platforms/ios/runner-client.ts';
} from '../../platforms/apple/core/runner/runner-client.ts';
import {
buildAppleRunnerSessionOptions,
createIosRunnerCacheColdBootPrewarmForOpen,
+1 -1
View File
@@ -8,7 +8,7 @@ import {
writeAppleXctracePerfReport,
type AppleXctracePerfMode,
type AppleXctracePerfResult,
} from '../../platforms/ios/perf-xctrace.ts';
} from '../../platforms/apple/core/perf-xctrace.ts';
import { PERF_AREA_ERROR_MESSAGE } from '../../contracts/perf.ts';
import { errorResponse, type DaemonFailureResponse } from './response.ts';
import { recordSessionAction } from './handler-utils.ts';
+1 -1
View File
@@ -26,7 +26,7 @@ import {
captureAppleMemorySnapshot,
sampleAppleFramePerf,
sampleApplePerfMetrics,
} from '../../platforms/ios/perf.ts';
} from '../../platforms/apple/core/perf.ts';
import type { PerfKind } from '../../contracts/perf.ts';
import { SessionStore } from '../session-store.ts';
import {
+1 -1
View File
@@ -5,7 +5,7 @@ import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts
import {
prepareIosRunner,
type PrepareIosRunnerResult,
} from '../../platforms/ios/runner-client.ts';
} from '../../platforms/apple/core/runner/runner-client.ts';
import type { DeviceInfo } from '../../kernel/device.ts';
import { isApplePlatform } from '../../kernel/device.ts';
import type { DaemonInvokeFn, DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
+2 -2
View File
@@ -5,8 +5,8 @@ import {
type AlertAction,
} from '../../alert-contract.ts';
import { sleep } from '../../utils/timeouts.ts';
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
import { runMacOsAlertAction } from '../../platforms/ios/macos-helper.ts';
import { runIosRunnerCommand } from '../../platforms/apple/core/runner/runner-client.ts';
import { runMacOsAlertAction } from '../../platforms/apple/os/macos/helper.ts';
import { handleAndroidAlert } from '../../platforms/android/alert.ts';
import { AppError } from '../../kernel/errors.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
+1 -1
View File
@@ -1,7 +1,7 @@
import { dispatchCommand, type CommandFlags } from '../../core/dispatch.ts';
import { isMobilePlatform } from '../../kernel/device.ts';
import { sleep } from '../../utils/timeouts.ts';
import { runMacOsSnapshotAction } from '../../platforms/ios/macos-helper.ts';
import { runMacOsSnapshotAction } from '../../platforms/apple/os/macos/helper.ts';
import { snapshotLinux } from '../../platforms/linux/snapshot.ts';
import {
attachRefs,
+2 -2
View File
@@ -2,8 +2,8 @@ import { resolveTargetDevice } from '../../core/dispatch.ts';
import {
resolveRunnerAppBundleId,
stopIosRunnerSession,
} from '../../platforms/ios/runner-client.ts';
import { closeIosApp } from '../../platforms/ios/apps.ts';
} from '../../platforms/apple/core/runner/runner-client.ts';
import { closeIosApp } from '../../platforms/apple/core/apps.ts';
import { emitDiagnostic } from '../../utils/diagnostics.ts';
import type { DaemonRequest, SessionState } from '../types.ts';
import { ensureDeviceReady } from '../device-ready.ts';
+2 -1
View File
@@ -794,7 +794,8 @@ async function abortInFlightIosRunnerSessionsWhileDisconnected(
try {
const deadline = Date.now() + CLIENT_DISCONNECT_ABORT_MAX_WINDOW_MS;
while (isRequestCanceled(requestId) && Date.now() < deadline) {
const { abortAllIosRunnerSessions } = await import('../platforms/ios/runner-client.ts');
const { abortAllIosRunnerSessions } =
await import('../platforms/apple/core/runner/runner-client.ts');
await abortAllIosRunnerSessions();
if (!isRequestCanceled(requestId)) break;
await sleep(CLIENT_DISCONNECT_ABORT_POLL_INTERVAL_MS);
+1 -1
View File
@@ -1,4 +1,4 @@
import { buildSimctlArgsForDevice } from '../platforms/ios/simctl.ts';
import { buildSimctlArgsForDevice } from '../platforms/apple/core/simctl.ts';
import type { DeviceInfo } from '../kernel/device.ts';
import { runCmdBackground, type ExecBackgroundResult, type ExecResult } from '../utils/exec.ts';
import { createScopedProvider } from '../utils/scoped-provider.ts';
+5 -4
View File
@@ -3,11 +3,11 @@ import type { AndroidAdbExecutor, AndroidAdbProvider } from '../platforms/androi
import type {
AppleRunnerCommandExecutor,
AppleRunnerProvider,
} from '../platforms/ios/runner-provider.ts';
} from '../platforms/apple/core/runner/runner-provider.ts';
import type {
AppleToolCommandExecutor,
AppleToolProvider,
} from '../platforms/ios/tool-provider.ts';
} from '../platforms/apple/core/tool-provider.ts';
import type { LinuxToolProvider } from '../platforms/linux/tool-provider.ts';
import type { WebProvider } from '../platforms/web/provider.ts';
import { isApplePlatform, type DeviceInfo } from '../kernel/device.ts';
@@ -152,7 +152,8 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [
},
async appendWrapper(scopedProviders, wrappers) {
if (!scopedProviders.appleRunner?.provider) return;
const { withAppleRunnerProvider } = await import('../platforms/ios/runner-provider.ts');
const { withAppleRunnerProvider } =
await import('../platforms/apple/core/runner/runner-provider.ts');
appendRequestProviderWrapper(wrappers, scopedProviders.appleRunner, (provider, task) =>
withAppleRunnerProvider(
provider,
@@ -174,7 +175,7 @@ const REQUEST_PLATFORM_PROVIDER_DESCRIPTORS = [
},
async appendWrapper(scopedProviders, wrappers) {
if (!scopedProviders.appleTool?.provider) return;
const { withAppleToolProvider } = await import('../platforms/ios/tool-provider.ts');
const { withAppleToolProvider } = await import('../platforms/apple/core/tool-provider.ts');
appendRequestProviderWrapper(wrappers, scopedProviders.appleTool, withAppleToolProvider);
},
},
+1 -1
View File
@@ -1,4 +1,4 @@
import { getRunnerSessionSnapshot } from '../platforms/ios/runner-client.ts';
import { getRunnerSessionSnapshot } from '../platforms/apple/core/runner/runner-client.ts';
import type { SessionState } from './types.ts';
export function refreshRecordingHealth(session: SessionState): void {
-6
View File
@@ -22,7 +22,6 @@ import {
withRequestPlatformProviderScope,
} from './request-platform-providers.ts';
import {
countDiagnosticEventsByPhase,
emitDiagnostic,
flushDiagnosticsToSessionFile,
getDiagnosticsMeta,
@@ -40,10 +39,6 @@ import {
import { canRunReplayScopedAction } from './daemon-command-registry.ts';
import { createAgentBrowserWebProvider } from '../platforms/web/agent-browser-provider.ts';
import type { LeaseLifecycleProvider } from './handlers/lease.ts';
// Single source of truth for which diagnostic phases count as a real iOS-runner
// round-trip — shared with the external ndjson counter used by the
// runner-request-count CI gate (`scripts/runner-request-count/`).
import { RUNNER_ROUND_TRIP_PHASES } from './runner-request-count.ts';
// ---------------------------------------------------------------------------
// Request handler API
@@ -355,7 +350,6 @@ function buildResponseCost(
): ResponseCost {
const cost: ResponseCost = {
wallClockMs: Date.now() - startedAt,
runnerRoundTrips: countDiagnosticEventsByPhase(RUNNER_ROUND_TRIP_PHASES),
};
// nodeCount reads the ORIGINAL node tree (the digest view may have already
// collapsed `data.nodes`), so the count stays accurate.
-220
View File
@@ -1,220 +0,0 @@
/**
* Hardware-free counter for "iOS runner requests" in the daemon `--debug`
* diagnostics ndjson stream.
*
* The daemon emits one diagnostic event per real iOS-runner round-trip
* (`emitDiagnostic` in `../utils/diagnostics.ts`). When a request is in debug
* mode those events are streamed as one JSON object per line into the daemon
* log (`<state-dir>/daemon.log`). This module parses that stream and counts the
* round-trip phases, so the runner request count can be asserted in CI without
* re-implementing the hand-counting an operator used to do by reading the
* ndjson by eye.
*
* `RUNNER_ROUND_TRIP_PHASES` is the single source of truth shared by the
* in-process cost graft (`request-router.ts` `buildResponseCost`) and this
* external ndjson counter, so the two can never drift on which phases count.
*/
// Diagnostic phases emitted once per real iOS-runner round-trip. `..._command_send`
// is the command itself; `..._readiness_preflight` is the pre-command uptime probe
// (a real network round-trip). The `..._skipped` / `..._recovered` markers do NOT
// hit the runner and are intentionally excluded.
export const RUNNER_ROUND_TRIP_PHASES = [
'ios_runner_command_send',
'ios_runner_readiness_preflight',
] as const;
export type RunnerRoundTripPhase = (typeof RUNNER_ROUND_TRIP_PHASES)[number];
/**
* A single parsed line of the daemon `--debug` diagnostics ndjson stream. Only
* the fields the counter and its drift reporting need are retained; the full
* record carries more (ts/level/requestId/session/durationMs).
*/
export type ParsedDiagnosticEvent = {
phase: string;
command?: string;
};
export type RunnerRequestCounts = {
runnerRoundTrips: number;
byPhase: Record<RunnerRoundTripPhase, number>;
};
// The stderr fallback path in `emitDiagnostic` prefixes each ndjson line with
// this tag. The daemon-log path does not, but we tolerate both so the counter
// works against captured stderr too.
const STDERR_DIAGNOSTIC_PREFIX = '[agent-device][diag] ';
/**
* Tolerant ndjson parser: the daemon log interleaves plain log text with the
* diagnostic ndjson lines, so non-JSON lines, blank lines, malformed JSON, and
* objects without a `phase` are skipped rather than throwing.
*/
export function parseDiagnosticNdjson(text: string): ParsedDiagnosticEvent[] {
const events: ParsedDiagnosticEvent[] = [];
for (const rawLine of text.split(/\r?\n/)) {
let line = rawLine.trim();
if (line.length === 0) continue;
if (line.startsWith(STDERR_DIAGNOSTIC_PREFIX)) {
line = line.slice(STDERR_DIAGNOSTIC_PREFIX.length).trim();
}
// Fast-skip plain daemon log lines that are not JSON objects.
if (!line.startsWith('{')) continue;
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
const event = toDiagnosticEvent(parsed);
if (event) events.push(event);
}
return events;
}
function toDiagnosticEvent(value: unknown): ParsedDiagnosticEvent | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const record = value as Record<string, unknown>;
const phase = record.phase;
if (typeof phase !== 'string') return null;
const command = record.command;
return typeof command === 'string' ? { phase, command } : { phase };
}
export function emptyRunnerRequestCounts(): RunnerRequestCounts {
return {
runnerRoundTrips: 0,
byPhase: { ios_runner_command_send: 0, ios_runner_readiness_preflight: 0 },
};
}
/**
* Count iOS-runner round-trips the way the daemon itself does: tally events
* whose phase is one of `RUNNER_ROUND_TRIP_PHASES`. Accepts raw ndjson text or
* already-parsed events.
*/
export function countRunnerRequests(
input: string | readonly ParsedDiagnosticEvent[],
): RunnerRequestCounts {
const events = typeof input === 'string' ? parseDiagnosticNdjson(input) : input;
const counts = emptyRunnerRequestCounts();
for (const event of events) {
if (isRunnerRoundTripPhase(event.phase)) {
counts.byPhase[event.phase] += 1;
counts.runnerRoundTrips += 1;
}
}
return counts;
}
function isRunnerRoundTripPhase(phase: string): phase is RunnerRoundTripPhase {
return (RUNNER_ROUND_TRIP_PHASES as readonly string[]).includes(phase);
}
// ---------------------------------------------------------------------------
// Committed baseline + assertion logic (pure, so the CI harness only does I/O)
// ---------------------------------------------------------------------------
/**
* The committed expected-count baseline. `established: false` means the gate has
* not been armed yet (no real simulator run has recorded the counts), so the
* harness records the observed counts instead of asserting.
*/
export type RunnerRequestCountBaseline = RunnerRequestCounts & {
scenario: string;
established: boolean;
};
export type RunnerCountDifference = {
key: 'runnerRoundTrips' | RunnerRoundTripPhase;
expected: number;
actual: number;
};
export type RunnerCountComparison =
| { status: 'unarmed' }
| { status: 'match' }
| { status: 'mismatch'; differences: RunnerCountDifference[] };
export function buildRunnerRequestCountBaseline(
scenario: string,
counts: RunnerRequestCounts,
): RunnerRequestCountBaseline {
return {
scenario,
established: true,
runnerRoundTrips: counts.runnerRoundTrips,
byPhase: { ...counts.byPhase },
};
}
/**
* Validate an untrusted baseline payload (read from disk) into a typed baseline.
* Unknown keys (e.g. a documentation `$comment`) are ignored.
*/
export function parseRunnerRequestCountBaseline(value: unknown): RunnerRequestCountBaseline {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Runner request-count baseline must be a JSON object.');
}
const record = value as Record<string, unknown>;
const scenario = record.scenario;
if (typeof scenario !== 'string' || scenario.length === 0) {
throw new Error('Runner request-count baseline is missing a "scenario" string.');
}
const byPhaseRaw = record.byPhase;
if (!byPhaseRaw || typeof byPhaseRaw !== 'object' || Array.isArray(byPhaseRaw)) {
throw new Error('Runner request-count baseline is missing a "byPhase" object.');
}
const byPhaseRecord = byPhaseRaw as Record<string, unknown>;
const byPhase = emptyRunnerRequestCounts().byPhase;
for (const phase of RUNNER_ROUND_TRIP_PHASES) {
byPhase[phase] = asCount(byPhaseRecord[phase], `byPhase.${phase}`);
}
return {
scenario,
established: record.established === true,
runnerRoundTrips: asCount(record.runnerRoundTrips, 'runnerRoundTrips'),
byPhase,
};
}
function asCount(value: unknown, field: string): number {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
throw new Error(
`Runner request-count baseline field "${field}" must be a non-negative integer.`,
);
}
return value;
}
/**
* Compare observed counts against the committed baseline. Returns `unarmed`
* when the baseline has not been established yet (the caller should record, not
* fail), `match` when every count is identical, or `mismatch` with the exact
* per-key differences a runner refactor introduced.
*/
export function compareRunnerCounts(
baseline: RunnerRequestCountBaseline,
observed: RunnerRequestCounts,
): RunnerCountComparison {
if (!baseline.established) return { status: 'unarmed' };
const differences: RunnerCountDifference[] = [];
if (baseline.runnerRoundTrips !== observed.runnerRoundTrips) {
differences.push({
key: 'runnerRoundTrips',
expected: baseline.runnerRoundTrips,
actual: observed.runnerRoundTrips,
});
}
for (const phase of RUNNER_ROUND_TRIP_PHASES) {
if (baseline.byPhase[phase] !== observed.byPhase[phase]) {
differences.push({
key: phase,
expected: baseline.byPhase[phase],
actual: observed.byPhase[phase],
});
}
}
return differences.length === 0 ? { status: 'match' } : { status: 'mismatch', differences };
}
+2 -2
View File
@@ -10,8 +10,8 @@ import {
classifyAndroidAppTarget,
formatAndroidInstalledPackageRequiredMessage,
} from '../platforms/android/open-target.ts';
import { buildSimctlArgsForDevice } from '../platforms/ios/simctl.ts';
import { runXcrun } from '../platforms/ios/tool-provider.ts';
import { buildSimctlArgsForDevice } from '../platforms/apple/core/simctl.ts';
import { runXcrun } from '../platforms/apple/core/tool-provider.ts';
import { isActiveProviderDevice } from '../provider-device-runtime.ts';
const ANDROID_DEV_PREFS_PATH = 'shared_prefs/ReactNativeDevPrefs.xml';
+1 -1
View File
@@ -9,7 +9,7 @@ import { isApplePlatform } 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';
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
import { runIosRunnerCommand } from '../platforms/apple/core/runner/runner-client.ts';
import { buildAppleRunnerRequestOptions } from './apple-runner-options.ts';
import { createDaemonRuntimePolicy } from './runtime-policy.ts';
import { createDaemonRuntimeSessionStore } from './runtime-session.ts';
+1 -1
View File
@@ -2,7 +2,7 @@ import { parseWaitPositionals } from '../core/wait-positionals.ts';
import type { WaitParsed } from '../core/wait-positionals.ts';
import { AppError, asAppError, normalizeError } from '../kernel/errors.ts';
import type { SnapshotNode } from '../kernel/snapshot.ts';
import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
import { runIosRunnerCommand } from '../platforms/apple/core/runner/runner-client.ts';
import { buildAppleRunnerRequestOptions } from './apple-runner-options.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts';
import { errorResponse, requireCommandSupported } from './handlers/response.ts';
+3 -3
View File
@@ -1,9 +1,9 @@
import { emitDiagnostic } from '../utils/diagnostics.ts';
import { isApplePlatform } from '../kernel/device.ts';
import { runMacOsAlertAction } from '../platforms/ios/macos-helper.ts';
import { runMacOsAlertAction } from '../platforms/apple/os/macos/helper.ts';
import { stopAppLog } from './app-log.ts';
import { stopIosRunnerSession } from '../platforms/ios/runner-client.ts';
import { cleanupAppleXctracePerfCapture } from '../platforms/ios/perf-xctrace.ts';
import { stopIosRunnerSession } from '../platforms/apple/core/runner/runner-client.ts';
import { cleanupAppleXctracePerfCapture } from '../platforms/apple/core/perf-xctrace.ts';
import { cleanupAndroidNativePerfSession } from '../platforms/android/perf.ts';
import { stopAndroidSnapshotHelperSessionForDevice } from '../platforms/android/snapshot-helper.ts';
import { cleanupRetainedMaterializedPathsForSession } from './materialized-path-registry.ts';
+1 -1
View File
@@ -1,5 +1,5 @@
import { runAndroidAdb } from '../platforms/android/adb.ts';
import { getSimulatorState, shutdownSimulator } from '../platforms/ios/simulator.ts';
import { getSimulatorState, shutdownSimulator } from '../platforms/apple/core/simulator.ts';
import type { TargetShutdownResult } from '../target-shutdown-contract.ts';
import type { DeviceInfo } from '../kernel/device.ts';
import { normalizeError } from '../kernel/errors.ts';
+2 -1
View File
@@ -53,7 +53,8 @@ export function createSocketServer(handleRequest: DaemonInvokeFn): DaemonServer
try {
const deadline = Date.now() + disconnectAbortMaxWindowMs;
while (inFlightRequests > 0 && Date.now() < deadline) {
const { abortAllIosRunnerSessions } = await import('../platforms/ios/runner-client.ts');
const { abortAllIosRunnerSessions } =
await import('../platforms/apple/core/runner/runner-client.ts');
await abortAllIosRunnerSessions();
if (inFlightRequests <= 0) break;
await sleep(disconnectAbortPollIntervalMs);
+1 -1
View File
@@ -24,7 +24,7 @@ import type { AndroidNativePerfSession } from '../platforms/android/perf.ts';
import type {
AppleXctracePerfCapture,
AppleXctracePerfMode,
} from '../platforms/ios/perf-xctrace.ts';
} from '../platforms/apple/core/perf-xctrace.ts';
import type {
SnapshotDiagnosticsState,
SnapshotDiagnosticsSummary,
-1
View File
@@ -120,7 +120,6 @@ export type DaemonArtifact = {
export type ResponseCost = {
wallClockMs: number;
runnerRoundTrips: number;
// Number of UI/accessibility nodes in the response, when the command returns a
// node tree (e.g. snapshot). Absent for commands that produce no nodes, so an
// agent can size a snapshot before re-fetching at a different depth/scope.
+10 -6
View File
@@ -68,7 +68,7 @@ export function matchesPlatformSelector(
export function resolveApplePlatformName(
platformOrTarget: ApplePlatform | DeviceTarget | undefined,
appleOs?: AppleOS,
): 'iOS' | 'tvOS' | 'macOS' {
): 'iOS' | 'tvOS' | 'macOS' | 'visionOS' {
// Prefer the explicit, stored Apple OS when present; legacy records without
// it keep resolving through the existing target-based inference below.
if (appleOs) return resolveRunnerPlatformNameForAppleOs(appleOs);
@@ -77,16 +77,20 @@ export function resolveApplePlatformName(
return 'iOS';
}
function resolveRunnerPlatformNameForAppleOs(appleOs: AppleOS): 'iOS' | 'tvOS' | 'macOS' {
function resolveRunnerPlatformNameForAppleOs(
appleOs: AppleOS,
): 'iOS' | 'tvOS' | 'macOS' | 'visionOS' {
switch (appleOs) {
case 'tvos':
return 'tvOS';
case 'macos':
return 'macOS';
// iOS and iPadOS share the single iOS runner profile/SDK. watchOS/visionOS
// are reserved in the type but never produced by discovery; defaulting them
// to the iOS profile keeps any future record on a valid runner profile
// without introducing a new one.
case 'visionos':
return 'visionOS';
// iOS and iPadOS share the single iOS runner profile/SDK. watchOS remains
// reserved in the type but is never produced by discovery; defaulting it to
// iOS keeps any future record on a valid runner profile without introducing
// watchOS support.
default:
return 'iOS';
}
@@ -16,7 +16,7 @@ import {
} from '../install-source.ts';
import * as androidManifest from '../android/manifest.ts';
import { prepareAndroidInstallArtifact } from '../android/install-artifact.ts';
import { prepareIosInstallArtifact } from '../ios/install-artifact.ts';
import { prepareIosInstallArtifact } from '../apple/core/install-artifact.ts';
test('validateDownloadSourceUrl rejects localhost and private literal addresses by default', async () => {
await assert.rejects(
@@ -1,7 +1,11 @@
import { AppError } from '../../kernel/errors.ts';
import { isApplePlatform, resolveApplePlatformName, type DeviceInfo } from '../../kernel/device.ts';
import { AppError } from '../../../kernel/errors.ts';
import {
isApplePlatform,
resolveApplePlatformName,
type DeviceInfo,
} from '../../../kernel/device.ts';
export type RunnerApplePlatformName = 'iOS' | 'tvOS' | 'macOS';
export type RunnerApplePlatformName = 'iOS' | 'tvOS' | 'macOS' | 'visionOS';
type RunnerPlatformDeviceKind = 'simulator' | 'device';
@@ -72,6 +76,40 @@ const RUNNER_PLATFORM_PROFILES: Record<RunnerApplePlatformName, RunnerPlatformPr
},
},
},
visionOS: {
sdkName: {
simulator: 'xrsimulator',
device: 'xros',
},
derivedBaseName: {
simulator: 'visionos-simulator',
device: 'visionos-device',
},
xctestrunHints: {
simulator: {
preferred: ['xrsimulator'],
disallowed: [
'xros',
'iphoneos',
'iphonesimulator',
'appletvos',
'appletvsimulator',
'macos',
],
},
device: {
preferred: ['xros'],
disallowed: [
'xrsimulator',
'iphoneos',
'iphonesimulator',
'appletvos',
'appletvsimulator',
'macos',
],
},
},
},
};
export function resolveRunnerPlatformName(device: DeviceInfo): RunnerApplePlatformName {
@@ -2,35 +2,38 @@ 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 { AppError } from '../../kernel/errors.ts';
import { emitDiagnostic } from '../../utils/diagnostics.ts';
import type { AppsFilter } from '../../contracts/app-inventory.ts';
import 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';
import {
LAUNCH_CONSOLE_DIRECT_APP_ONLY_MESSAGE,
LAUNCH_CONSOLE_IOS_SIMULATOR_ONLY_MESSAGE,
} from '../../core/launch-console.ts';
import { requireLocationCoordinates } from '../../utils/location-coordinates.ts';
import { resolveIosSimulatorDeviceSetPath } from '../../utils/device-isolation.ts';
import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
} from '../../../core/launch-console.ts';
import { requireLocationCoordinates } from '../../../utils/location-coordinates.ts';
import { resolveIosSimulatorDeviceSetPath } from '../../../utils/device-isolation.ts';
import { Deadline, retryWithPolicy } from '../../../utils/retry.ts';
import {
isDeepLinkTarget,
isWebUrl,
resolveIosDeviceDeepLinkBundleId,
} from '../../core/open-target.ts';
import { getUnsupportedMacOsSettingMessage } from '../../core/settings-contract.ts';
} from '../../../core/open-target.ts';
import { getUnsupportedMacOsSettingMessage } from '../../../core/settings-contract.ts';
import {
parsePermissionAction,
parsePermissionTarget,
type SettingOptions,
} from '../permission-utils.ts';
import { parseAppearanceAction } from '../appearance.ts';
import { parseSettingState } from '../setting-state.ts';
import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts';
} from '../../permission-utils.ts';
import { parseAppearanceAction } from '../../appearance.ts';
import { parseSettingState } from '../../setting-state.ts';
import {
createAppResolutionCache,
type AppResolutionCacheScope,
} from '../../app-resolution-cache.ts';
import {
summarizeCommandAttemptFailures,
type CommandAttemptFailure,
} from '../command-attempts.ts';
} from '../../command-attempts.ts';
import {
IOS_APP_LAUNCH_TIMEOUT_MS,
@@ -64,8 +67,8 @@ import {
resolveMacOsApp,
setMacOsAppearance,
writeMacOsClipboardText,
} from './macos-apps.ts';
import { runMacOsPermissionAction, type MacOsPermissionTarget } from './macos-helper.ts';
} from '../os/macos/apps.ts';
import { runMacOsPermissionAction, type MacOsPermissionTarget } from '../os/macos/helper.ts';
import {
invalidateSimulatorStatusBarOverrideCache,
rememberClearedStatusBarOverrides,
@@ -4,8 +4,8 @@ import { readAppleCrashArtifact } from './debug-symbols/crash-artifact.ts';
import { matchImagesToDsyms, readDsymPaths, readDsymSlices } from './debug-symbols/dsym.ts';
import { summarizeCrashArtifact } from './debug-symbols/report.ts';
import { resolveAppleTools, symbolicateAddresses } from './debug-symbols/symbolication.ts';
import type { DebugSymbolsOptions, DebugSymbolsResult } from '../../contracts/debug-symbols.ts';
import { AppError } from '../../kernel/errors.ts';
import type { DebugSymbolsOptions, DebugSymbolsResult } from '../../../contracts/debug-symbols.ts';
import { AppError } from '../../../kernel/errors.ts';
export type {
DebugSymbolsCrashFrame,
@@ -1,5 +1,5 @@
import path from 'node:path';
import { isRecord } from '../../../utils/parsing.ts';
import { isRecord } from '../../../../utils/parsing.ts';
import type {
AppleImage,
CrashArtifact,
@@ -2,8 +2,8 @@ import fs from 'node:fs/promises';
import path from 'node:path';
import type { AppleImage, DsymMatch, DsymSlice } from './types.ts';
import { normalizeUuid, unique } from './utils.ts';
import { runCmd } from '../../../utils/exec.ts';
import { AppError } from '../../../kernel/errors.ts';
import { runCmd } from '../../../../utils/exec.ts';
import { AppError } from '../../../../kernel/errors.ts';
const MAX_SEARCH_ENTRIES = 10_000;
const MAX_DSYM_CANDIDATES = 200;
@@ -7,7 +7,7 @@ import type {
SymbolicatedAddress,
TextFrameMatch,
} from './types.ts';
import { isRecord } from '../../../utils/parsing.ts';
import { isRecord } from '../../../../utils/parsing.ts';
import {
addressKey,
compactJoin,
@@ -1,7 +1,7 @@
import type { DsymMatch, SymbolicatedAddress, SymbolicationGroup } from './types.ts';
import { addressKey, hex, unique } from './utils.ts';
import { runCmd } from '../../../utils/exec.ts';
import { AppError } from '../../../kernel/errors.ts';
import { runCmd } from '../../../../utils/exec.ts';
import { AppError } from '../../../../kernel/errors.ts';
export async function symbolicateAddresses(
addresses: SymbolicatedAddress[],
@@ -4,7 +4,7 @@ export type {
DebugSymbolsImage,
DebugSymbolsOptions,
DebugSymbolsResult,
} from '../../../contracts/debug-symbols.ts';
} from '../../../../contracts/debug-symbols.ts';
export type AppleImage = {
index?: number;
@@ -1,5 +1,5 @@
import { AppError } from '../../../kernel/errors.ts';
import { isRecord } from '../../../utils/parsing.ts';
import { AppError } from '../../../../kernel/errors.ts';
import { isRecord } from '../../../../utils/parsing.ts';
const UUID_RE = /^[0-9a-fA-F-]{32,36}$/;
@@ -2,8 +2,8 @@ import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { DeviceInfo } from '../../kernel/device.ts';
import { AppError } from '../../kernel/errors.ts';
import type { DeviceInfo } from '../../../kernel/device.ts';
import { AppError } from '../../../kernel/errors.ts';
import { IOS_DEVICECTL_TIMEOUT_MS } from './config.ts';
import { runXcrun } from './tool-provider.ts';
@@ -1,18 +1,19 @@
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { AppError } from '../../kernel/errors.ts';
import type { AppleOS, DeviceInfo, DeviceTarget } from '../../kernel/device.ts';
import { resolveIosSimulatorDeviceSetPath } from '../../utils/device-isolation.ts';
import { buildHostMacDevice } from '../macos/devices.ts';
import { AppError } from '../../../kernel/errors.ts';
import type { AppleOS, DeviceInfo, DeviceTarget } from '../../../kernel/device.ts';
import { resolveIosSimulatorDeviceSetPath } from '../../../utils/device-isolation.ts';
import { buildHostMacDevice } from '../os/macos/devices.ts';
import { buildSimctlArgs } from './simctl.ts';
import { resolveAppleToolProvider, runXcrun } from './tool-provider.ts';
export { createLocalAppleToolProvider, withAppleToolProvider } from './tool-provider.ts';
const IOS_DEVICECTL_LIST_TIMEOUT_MS = 8_000;
const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv)/i;
const APPLE_PRODUCT_TYPE_PATTERN = /^(iphone|ipad|ipod|appletv|realitydevice)/i;
const APPLE_IPAD_PATTERN = /ipad/i;
const APPLE_VISION_PATTERN = /\b(apple vision|vision pro|xros|visionos|realitydevice)\b/i;
const APPLE_MOBILE_LABEL_PATTERN = /\b(iphone|ipad|ipod)\b/i;
const APPLE_TV_PRODUCT_TYPE_PATTERN = /^appletv/i;
const APPLE_TV_LABEL_HINTS = ['apple tv', 'appletv', 'tvos'] as const;
@@ -70,13 +71,22 @@ function isAppleTvPlatform(platform: string): boolean {
return platform.includes('tvos');
}
function isAppleVisionPlatform(platform: string): boolean {
return platform.includes('xros') || platform.includes('visionos');
}
function resolveAppleTargetFromRuntime(runtime: string): DeviceTarget {
return isAppleTvPlatform(resolveAppleRuntime(runtime)) ? 'tv' : 'mobile';
}
function isSupportedAppleRuntime(runtime: string): boolean {
const normalized = resolveAppleRuntime(runtime);
return normalized.includes('ios') || normalized.includes('tvos');
return (
normalized.includes('ios') ||
normalized.includes('tvos') ||
normalized.includes('xros') ||
normalized.includes('visionos')
);
}
function isAppleTvLabel(value: string): boolean {
@@ -90,7 +100,7 @@ function isAppleMobileLabel(value: string): boolean {
function resolveAppleTargetFromLabel(value: string): DeviceTarget | null {
if (isAppleTvLabel(value)) return 'tv';
if (isAppleMobileLabel(value)) return 'mobile';
if (isAppleMobileLabel(value) || APPLE_VISION_PATTERN.test(value)) return 'mobile';
return null;
}
@@ -105,6 +115,7 @@ function resolveAppleTargetFromLabel(value: string): DeviceTarget | null {
*/
function resolveAppleOs(target: DeviceTarget, descriptors: string[]): AppleOS {
if (target === 'tv') return 'tvos';
if (descriptors.some((descriptor) => APPLE_VISION_PATTERN.test(descriptor))) return 'visionos';
if (descriptors.some((descriptor) => APPLE_IPAD_PATTERN.test(descriptor))) return 'ipados';
return 'ios';
}
@@ -139,7 +150,9 @@ export function resolveAppleTargetFromDevicectlDevice(device: DevicectlAppleDevi
export function isSupportedAppleDevicectlDevice(device: DevicectlAppleDevice): boolean {
const platform = resolveDevicectlApplePlatform(device);
if (platform.includes('ios') || platform.includes('tvos')) return true;
if (platform.includes('ios') || platform.includes('tvos') || isAppleVisionPlatform(platform)) {
return true;
}
const productType = resolveDevicectlAppleProductType(device);
if (isAppleProductType(productType)) return true;
return resolveDevicectlAppleLabels(device).some(isAppleTvLabel);
@@ -2,13 +2,13 @@ import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { readInfoPlistString } from './plist.ts';
import { AppError } from '../../kernel/errors.ts';
import { runCmd } from '../../utils/exec.ts';
import { AppError } from '../../../kernel/errors.ts';
import { runCmd } from '../../../utils/exec.ts';
import {
isTrustedInstallSourceUrl,
materializeInstallablePath,
type MaterializeInstallSource,
} from '../install-source.ts';
} from '../../install-source.ts';
type InstallIosArtifactOptions = {
appIdentifierHint?: string;

Some files were not shown because too many files have changed in this diff Show More