Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/__tests__/platform-runtime-apple-application-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@agent-device/platform-apple/runner/operations')>();
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);
});
20 changes: 16 additions & 4 deletions src/platform-runtime-android-application-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,36 @@ 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<typeof import('./platform-runtime-open-target.ts')> | undefined;
const loadOpenTarget = () => (openTargetModule ??= import('./platform-runtime-open-target.ts'));

let runtimeHintsModule: Promise<typeof import('./platform-runtime-runtime-hints.ts')> | 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) => {
const { resetAndroidFramePerfStats } = await loadAndroidMechanics();
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) => {
Expand Down Expand Up @@ -85,7 +97,7 @@ async function resolveAndroidOpenTarget(
input: OpenTargetResolutionInput,
): Promise<OpenTargetResolution> {
const { resolveAndroidPackageForOpen, resolveSessionAppBundleIdForTarget } =
await import('./platform-runtime-open-target.ts');
await loadOpenTarget();
return {
appBundleId: await resolveSessionAppBundleIdForTarget(
device,
Expand Down
15 changes: 13 additions & 2 deletions src/platform-runtime-android-mechanics.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('@agent-device/platform-android/mechanics')> | 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;
}
76 changes: 53 additions & 23 deletions src/platform-runtime-apple-application-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('@agent-device/platform-apple/runner/operations')>
| undefined;
const loadRunnerOperations = () =>
(runnerOperationsModule ??= import('@agent-device/platform-apple/runner/operations'));

let runtimeHintsModule: Promise<typeof import('./platform-runtime-runtime-hints.ts')> | undefined;
const loadRuntimeHints = () =>
(runtimeHintsModule ??= import('./platform-runtime-runtime-hints.ts'));

let openTargetModule: Promise<typeof import('./platform-runtime-open-target.ts')> | undefined;
const loadOpenTarget = () => (openTargetModule ??= import('./platform-runtime-open-target.ts'));

let appResolutionModule:
| Promise<typeof import('@agent-device/platform-apple/app-resolution')>
| undefined;
const loadAppResolution = () =>
(appResolutionModule ??= import('@agent-device/platform-apple/app-resolution'));

let macOsModule: Promise<typeof import('@agent-device/platform-apple/macos')> | undefined;
const loadMacOs = () => (macOsModule ??= import('@agent-device/platform-apple/macos'));

let retryModule: Promise<typeof import('@agent-device/host-kit/retry')> | 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 (
Expand All @@ -25,32 +61,29 @@ 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,
...options,
});
},
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),
Expand All @@ -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();
},
});
Expand All @@ -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 ??
Expand All @@ -118,8 +149,7 @@ async function resolveAppleForegroundTarget(
device: DeviceInfo,
): Promise<OpenTargetResolution | undefined> {
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;
}
Expand All @@ -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 };
}
Expand All @@ -155,7 +185,7 @@ async function dismissMacOsCloseAlerts(
input: CloseApplicationFinalizationInput,
): Promise<void> {
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 }
Expand Down
Loading