Merge branch 'codex/2198-runner-demand' into codex/2198-proxy-parity

* codex/2198-runner-demand:
  fix(ios): keep observation on the bridge while app discovery is pending and no runner is live
  bench(ios): press an unambiguous target on the catalog and Settings screens
This commit is contained in:
Michał Pierzchała
2026-09-06 19:05:45 +02:00
5 changed files with 109 additions and 5 deletions
@@ -204,9 +204,10 @@ test('cancelled acquisition does not start a fallback after the request aborts',
expect(fallback).not.toHaveBeenCalled();
});
test('a slow app discovery yields to the XCTest fallback within its wait slice, then serves the bridge', async () => {
test('a slow app discovery yields to a live runner within its wait slice, then serves the bridge', async () => {
// The production resolver over a simctl whose `launchctl list` answers only when released,
// the shape of a loaded CI host: the first capture must not sit on that probe.
// the shape of a loaded CI host: with a runner that can answer at once, the first capture
// must not sit on that probe.
let release!: () => void;
const released = new Promise<void>((resolve) => {
release = resolve;
@@ -232,9 +233,11 @@ test('a slow app discovery yields to the XCTest fallback within its wait slice,
producer: 'simulator-ax-bridge' as const,
nodes: [{ index: 0, type: 'Application' }],
}));
const baseHost = platformRuntimeHostFixture();
const route = createAppleSnapshotRoute(
{
...platformRuntimeHostFixture(),
...baseHost,
appleApplications: { ...baseHost.appleApplications, hasLiveRunnerSession: async () => true },
snapshot: { captureSurface: vi.fn(), presentIosAcquisition },
},
{ source, resolveTarget: createSimulatorSnapshotTargetResolver() },
@@ -302,3 +305,65 @@ function runnerResult() {
function signal(): AbortSignal {
return new AbortController().signal;
}
test('a slow app discovery keeps observation on the bridge while no runner can answer', async () => {
// #2198: the open no longer awaits the runner, so right after a relaunch the fallback would
// wait for a cold runner start. A capture with no live runner rides the single-flight
// discovery instead, however many wait slices that takes.
let release!: () => void;
const released = new Promise<void>((resolve) => {
release = resolve;
});
const run = vi.fn(async (args: string[]) => {
if (args[0] === 'spawn') await released;
return {
stdout:
args[0] === 'spawn'
? `42\t0\tUIKitApplication:${input.options.appBundleId}[launch-a][rb-legacy]`
: JSON.stringify({
devices: { 'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [{ udid: ios.id }] },
}),
stderr: '',
exitCode: 0,
};
});
const runCommand = vi.fn(async () => ({ stdout: 'start-a', stderr: '', exitCode: 0 }));
const fallback = vi.fn(async () => runnerResult());
const source = sourceReturning(bridgeAcquisition());
const presentIosAcquisition = vi.fn(async () => ({
backend: 'xctest' as const,
producer: 'simulator-ax-bridge' as const,
nodes: [{ index: 0, type: 'Application' }],
}));
const hasLiveRunnerSession = vi.fn(async () => false);
const baseHost = platformRuntimeHostFixture();
const route = createAppleSnapshotRoute(
{
...baseHost,
appleApplications: { ...baseHost.appleApplications, hasLiveRunnerSession },
snapshot: { captureSurface: vi.fn(), presentIosAcquisition },
},
{ source, resolveTarget: createSimulatorSnapshotTargetResolver() },
);
vi.useFakeTimers();
try {
await withAppleToolProvider(
createLocalAppleToolProvider({ simctl: { run }, runCommand }),
async () => {
const capture = route.capture(ios, input, signal(), fallback);
await vi.advanceTimersByTimeAsync(4_500);
expect(fallback).not.toHaveBeenCalled();
expect(hasLiveRunnerSession).toHaveBeenCalled();
release();
await vi.advanceTimersByTimeAsync(0);
const result = await capture;
expect(result.producer).toBe('simulator-ax-bridge');
expect(fallback).not.toHaveBeenCalled();
expect(run.mock.calls.filter(([args]) => args[0] === 'spawn')).toHaveLength(1);
},
);
} finally {
vi.useRealTimers();
}
});
+33 -1
View File
@@ -66,7 +66,7 @@ export function createAppleSnapshotRoute(
if (!isEligible(device, input)) return await fallback(input);
let target: SimulatorSnapshotTarget;
try {
target = await resolveTarget(device, input.options!.appBundleId!, signal);
target = await resolveTargetForObservation(host, resolveTarget, device, input, signal);
} catch (error) {
signal.throwIfAborted();
emitRouteDiagnostic('target-resolution-failed', device, undefined, error);
@@ -143,6 +143,38 @@ export function createAppleSnapshotRoute(
});
}
/**
* A discovery still in flight is not a failure while no runner can answer instead. The XCTest
* fallback would first wait for a runner start, and #2198 keeps observation off that wait, so the
* capture stays on the single-flight discovery: each turn waits one discovery slice, and the
* discovery's own deadline or the request signal ends the loop. A runner that is already live
* answers at once, so there the fallback remains the cheaper route (#2331).
*/
async function resolveTargetForObservation(
host: PlatformRuntimeHost,
resolveTarget: SimulatorSnapshotTargetResolver,
device: DeviceInfo,
input: CaptureSnapshotInput,
signal: AbortSignal,
): Promise<SimulatorSnapshotTarget> {
const appBundleId = input.options!.appBundleId!;
for (;;) {
try {
return await resolveTarget(device, appBundleId, signal);
} catch (error) {
if (!isDiscoveryPending(error)) throw error;
const execution = { requestId: input.execution?.requestId };
if (await host.appleApplications.hasLiveRunnerSession(device, execution)) throw error;
}
}
}
function isDiscoveryPending(error: unknown): boolean {
return (
error instanceof AppError && error.details?.reason === 'simulator-target-discovery-pending'
);
}
function isEligible(device: DeviceInfo, input: CaptureSnapshotInput): boolean {
return (
device.platform === 'apple' &&
+1 -1
View File
@@ -80,7 +80,7 @@ export function firstInteractionAfterOpen(context: CliContext, fixture: ScreenFi
const selector =
fixture.launchUrl && deepLinkConfirmationShown(context, opened)
? 'label="Open"'
: `text=${JSON.stringify(fixture.anchorText)}`;
: (fixture.interactionTarget ?? `text=${JSON.stringify(fixture.anchorText)}`);
return pressFixtureTarget(context, selector);
}
@@ -29,6 +29,7 @@ const SCREEN_FIXTURES: readonly ScreenFixture[] = [
app: FIXTURE_APP_ID,
launchUrl: `${FIXTURE_SCHEME}/catalog`,
anchorText: 'Catalog',
interactionTarget: 'id="catalog-search"',
},
{
id: 'nested-scroll',
@@ -51,6 +52,7 @@ const SCREEN_FIXTURES: readonly ScreenFixture[] = [
label: 'iOS Settings system surface',
app: IOS_SETTINGS_APP_ID,
anchorText: 'Settings',
interactionTarget: 'text="General"',
},
{
id: 'xctest-stress',
+5
View File
@@ -39,6 +39,11 @@ export type ScreenFixture = {
anchorText: string;
postSetupAnchorText?: string;
setupAction?: 'open-alert';
/**
* What the first-interaction cell presses when the anchor text names more than one
* actionable element (a native tab and the screen title share it); the anchor otherwise.
*/
interactionTarget?: string;
};
export type Failure = {