mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
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:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user