diff --git a/src/__tests__/platform-runtime-apple-application-tools.test.ts b/src/__tests__/platform-runtime-apple-application-tools.test.ts new file mode 100644 index 000000000..0d8cdefb2 --- /dev/null +++ b/src/__tests__/platform-runtime-apple-application-tools.test.ts @@ -0,0 +1,31 @@ +import { expect, test, vi } from 'vitest'; +import { createAppleApplicationTools } from '../platform-runtime-apple-application-tools.ts'; + +const detachIosSimulatorRunnerSessionsForShutdown = vi.hoisted(() => vi.fn(async () => 0)); +const stopAllIosRunnerSessions = vi.hoisted(() => vi.fn(async () => {})); + +// The factory awaits the real Apple runner graph on purpose: that wait is what let a second, +// concurrent dynamic import of this specifier overtake the still-unresolved mock and hand the +// caller the UNMOCKED module (#2314). +vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, detachIosSimulatorRunnerSessionsForShutdown, stopAllIosRunnerSessions }; +}); + +// Two ports of these tools run at once whenever the open path leaves its runner prewarm +// unawaited, so every port must reach the runner module through the one memoized loader. A port +// that opens its own `import(...)` resolves the specifier a second time, and a unit test that +// loses the mock this way starts the real local XCTest runner — whose stale-process cleanup +// `pkill`s xcodebuild on the developer's host, failing whichever test is running when it lands. +test('concurrent runner ports share one module resolution, so the mock always applies', async () => { + const tools = createAppleApplicationTools(); + + await Promise.all([ + tools.detachRunnerSessionsForShutdown(), + tools.finalizeRunnerSessionsForShutdown(), + ]); + + expect(detachIosSimulatorRunnerSessionsForShutdown).toHaveBeenCalledTimes(1); + expect(stopAllIosRunnerSessions).toHaveBeenCalledTimes(1); +}); diff --git a/src/platform-runtime-android-application-tools.ts b/src/platform-runtime-android-application-tools.ts index 40b4a258a..e77c27e38 100644 --- a/src/platform-runtime-android-application-tools.ts +++ b/src/platform-runtime-android-application-tools.ts @@ -8,12 +8,24 @@ import { AppError } from '@agent-device/kernel/errors'; import { emitDiagnostic } from '@agent-device/host-kit/diagnostics'; import { loadAndroidMechanics } from './platform-runtime-android-mechanics.ts'; +/** + * One memoized loader per lazily imported module, and the only way the ports below reach one. + * See the same loaders in `platform-runtime-apple-application-tools.ts` for why a port never + * opens its own `import(...)` (#2314). + */ +let openTargetModule: Promise | undefined; +const loadOpenTarget = () => (openTargetModule ??= import('./platform-runtime-open-target.ts')); + +let runtimeHintsModule: Promise | undefined; +const loadRuntimeHints = () => + (runtimeHintsModule ??= import('./platform-runtime-runtime-hints.ts')); + /** Lazy Android tools; the Android package owns lifecycle sequencing and durable IME policy. */ export function createAndroidApplicationTools(): AndroidApplicationTools { return Object.freeze({ resolveOpenTarget: async (device, input) => await resolveAndroidOpenTarget(device, input), inferOpenedAppBundleId: async (device, target, currentAppBundleId) => { - const { inferAndroidPackageAfterOpen } = await import('./platform-runtime-open-target.ts'); + const { inferAndroidPackageAfterOpen } = await loadOpenTarget(); return await inferAndroidPackageAfterOpen(device, target, currentAppBundleId); }, resetFramePerfStats: async (device, appBundleId) => { @@ -21,11 +33,11 @@ export function createAndroidApplicationTools(): AndroidApplicationTools { await resetAndroidFramePerfStats(device, appBundleId); }, applyRuntimeHints: async (device, input) => { - const { applyRuntimeHintValues } = await import('./platform-runtime-runtime-hints.ts'); + const { applyRuntimeHintValues } = await loadRuntimeHints(); await applyRuntimeHintValues({ device, ...input }); }, clearRuntimeHints: async (device, input) => { - const { clearRuntimeHintValues } = await import('./platform-runtime-runtime-hints.ts'); + const { clearRuntimeHintValues } = await loadRuntimeHints(); await clearRuntimeHintValues({ device, ...input }); }, activateTestIme: async (device, input) => { @@ -85,7 +97,7 @@ async function resolveAndroidOpenTarget( input: OpenTargetResolutionInput, ): Promise { const { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } = - await import('./platform-runtime-open-target.ts'); + await loadOpenTarget(); return { appBundleId: await resolveSessionAppBundleIdForTarget( device, diff --git a/src/platform-runtime-android-mechanics.ts b/src/platform-runtime-android-mechanics.ts index 0271b6c4a..c33bdfb9e 100644 --- a/src/platform-runtime-android-mechanics.ts +++ b/src/platform-runtime-android-mechanics.ts @@ -1,4 +1,15 @@ +/** + * The Android mechanics module, resolved exactly once per process. + * + * Memoized for the same reason as the loaders in + * `platform-runtime-apple-application-tools.ts`: concurrent callers must share one resolution, + * or a `vi.mock` factory still in flight lets the second caller reach the unmocked module (#2314). + */ +let mechanicsModule: Promise | undefined; + export async function loadAndroidMechanics() { - await import('./platform-runtime-android-adb-host.ts'); - return await import('@agent-device/platform-android/mechanics'); + mechanicsModule ??= import('./platform-runtime-android-adb-host.ts').then( + async () => await import('@agent-device/platform-android/mechanics'), + ); + return await mechanicsModule; } diff --git a/src/platform-runtime-apple-application-tools.ts b/src/platform-runtime-apple-application-tools.ts index 82ef6be64..9ebfc7aec 100644 --- a/src/platform-runtime-apple-application-tools.ts +++ b/src/platform-runtime-apple-application-tools.ts @@ -9,13 +9,49 @@ import type { import { isIosFamily, isMacOs, type DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +/** + * One memoized loader per lazily imported module, and the only way the ports below reach one. + * + * A port must never open its own `import(...)`: the open path deliberately leaves the runner + * prewarm unawaited, so two ports routinely resolve the same specifier at the same time. In + * production those duplicates are equivalent — the loader caches — but under Vitest they are + * not: while a `vi.mock` factory is still awaiting `importOriginal()`, a second dynamic import + * of that id resolves to the UNMOCKED module. A unit test's mocked runner then escapes into the + * real local XCTest runner, whose stale-process cleanup `pkill`s xcodebuild on the developer's + * host and fails whichever test happens to be running when it lands (#2314). Resolving each + * specifier exactly once removes the second resolution that the escape needs. + */ +let runnerOperationsModule: + | Promise + | undefined; +const loadRunnerOperations = () => + (runnerOperationsModule ??= import('@agent-device/platform-apple/runner/operations')); + +let runtimeHintsModule: Promise | undefined; +const loadRuntimeHints = () => + (runtimeHintsModule ??= import('./platform-runtime-runtime-hints.ts')); + +let openTargetModule: Promise | undefined; +const loadOpenTarget = () => (openTargetModule ??= import('./platform-runtime-open-target.ts')); + +let appResolutionModule: + | Promise + | undefined; +const loadAppResolution = () => + (appResolutionModule ??= import('@agent-device/platform-apple/app-resolution')); + +let macOsModule: Promise | undefined; +const loadMacOs = () => (macOsModule ??= import('@agent-device/platform-apple/macos')); + +let retryModule: Promise | undefined; +const loadRetry = () => (retryModule ??= import('@agent-device/host-kit/retry')); + /** Lazy Apple tools; the Apple package owns lifecycle sequencing around these primitives. */ export function createAppleApplicationTools(): AppleApplicationTools { return Object.freeze({ resolveOpenTarget: async (device, input) => await resolveAppleOpenTarget(device, input), prewarmRunnerCache: async (device, execution, signal) => { - const { prewarmAppleRunnerCache } = - await import('@agent-device/platform-apple/runner/operations'); + const { prewarmAppleRunnerCache } = await loadRunnerOperations(); await prewarmAppleRunnerCache(device, appleRunnerOptions(execution, signal)); }, prewarmRunnerSession: async ( @@ -25,8 +61,7 @@ export function createAppleApplicationTools(): AppleApplicationTools { propagateError, options?: AppleRunnerSessionPrewarmOptions, ) => { - const { prewarmIosRunnerSession } = - await import('@agent-device/platform-apple/runner/operations'); + const { prewarmIosRunnerSession } = await loadRunnerOperations(); await prewarmIosRunnerSession(device, { ...appleRunnerOptions(execution, signal), propagateError, @@ -34,23 +69,21 @@ export function createAppleApplicationTools(): AppleApplicationTools { }); }, notifyRunnerAppRelaunched: async (device, execution, signal) => { - const { notifyIosRunnerAppRelaunched } = - await import('@agent-device/platform-apple/runner/operations'); + const { notifyIosRunnerAppRelaunched } = await loadRunnerOperations(); await notifyIosRunnerAppRelaunched(device, appleRunnerOptions(execution, signal)); }, stopRunnerSession: async (deviceId) => { - const { stopIosRunnerSession } = - await import('@agent-device/platform-apple/runner/operations'); + const { stopIosRunnerSession } = await loadRunnerOperations(); await stopIosRunnerSession(deviceId); }, scheduleRunnerIdleStop: (deviceId) => { - void import('@agent-device/platform-apple/runner/operations').then( - ({ scheduleIosRunnerIdleStop }) => scheduleIosRunnerIdleStop(deviceId), + void loadRunnerOperations().then(({ scheduleIosRunnerIdleStop }) => + scheduleIosRunnerIdleStop(deviceId), ); }, prepareRunner: async (device, input, signal) => { - const { Deadline } = await import('@agent-device/host-kit/retry'); - const { prepareIosRunner } = await import('@agent-device/platform-apple/runner/operations'); + const { Deadline } = await loadRetry(); + const { prepareIosRunner } = await loadRunnerOperations(); const startedAtMs = Date.now(); return await prepareIosRunner(device, { ...appleRunnerOptions(input.execution, signal), @@ -62,22 +95,20 @@ export function createAppleApplicationTools(): AppleApplicationTools { }); }, applyRuntimeHints: async (device, input) => { - const { applyRuntimeHintValues } = await import('./platform-runtime-runtime-hints.ts'); + const { applyRuntimeHintValues } = await loadRuntimeHints(); await applyRuntimeHintValues({ device, ...input }); }, clearRuntimeHints: async (device, input) => { - const { clearRuntimeHintValues } = await import('./platform-runtime-runtime-hints.ts'); + const { clearRuntimeHintValues } = await loadRuntimeHints(); await clearRuntimeHintValues({ device, ...input }); }, dismissCloseAlerts: async (device, input) => await dismissMacOsCloseAlerts(device, input), detachRunnerSessionsForShutdown: async () => { - const { detachIosSimulatorRunnerSessionsForShutdown } = - await import('@agent-device/platform-apple/runner/operations'); + const { detachIosSimulatorRunnerSessionsForShutdown } = await loadRunnerOperations(); await detachIosSimulatorRunnerSessionsForShutdown(); }, finalizeRunnerSessionsForShutdown: async () => { - const { stopAllIosRunnerSessions } = - await import('@agent-device/platform-apple/runner/operations'); + const { stopAllIosRunnerSessions } = await loadRunnerOperations(); await stopAllIosRunnerSessions(); }, }); @@ -100,7 +131,7 @@ async function resolveAppleOpenTarget( ); } const macOsSurface = await resolveMacOsSurface(device, input.surface); - const { resolveSessionAppBundleIdForTarget } = await import('./platform-runtime-open-target.ts'); + const { resolveSessionAppBundleIdForTarget } = await loadOpenTarget(); return { appBundleId: macOsSurface.appBundleId ?? @@ -118,8 +149,7 @@ async function resolveAppleForegroundTarget( device: DeviceInfo, ): Promise { if (!isIosFamily(device) || device.kind !== 'simulator') return undefined; - const { detectSoleRunningIosSimulatorApp } = - await import('@agent-device/platform-apple/app-resolution'); + const { detectSoleRunningIosSimulatorApp } = await loadAppResolution(); const app = await detectSoleRunningIosSimulatorApp(device); return app ? { appBundleId: app.bundleId, appName: app.name } : undefined; } @@ -131,7 +161,7 @@ async function resolveMacOsSurface( if (!isMacOs(device) || surface === 'app' || surface === 'desktop' || surface === 'menubar') { return {}; } - const { resolveFrontmostMacOsApp } = await import('@agent-device/platform-apple/macos'); + const { resolveFrontmostMacOsApp } = await loadMacOs(); const frontmost = await resolveFrontmostMacOsApp(); return { appBundleId: frontmost.bundleId, appName: frontmost.appName }; } @@ -155,7 +185,7 @@ async function dismissMacOsCloseAlerts( input: CloseApplicationFinalizationInput, ): Promise { if (!isMacOs(device)) return; - const { runMacOsAlertAction } = await import('@agent-device/platform-apple/macos'); + const { runMacOsAlertAction } = await loadMacOs(); const dismissOptions = input.surface === 'frontmost-app' ? { surface: 'frontmost-app' as const }