Skip to content

Commit 856ff38

Browse files
fix(ios): preserve final-probe xcodebuild diagnostics (#1776)
* fix(ios): preserve final-probe xcodebuild diagnostics * refactor(ios): split runner startup transport
1 parent f378050 commit 856ff38

13 files changed

Lines changed: 819 additions & 706 deletions

fallow-baselines/health.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@
379379
"count": 1
380380
}
381381
},
382-
"src/platforms/apple/core/runner/runner-transport.ts": {
382+
"src/platforms/apple/core/runner/runner-startup-transport.ts": {
383383
"crap_high": {
384384
"count": 1
385385
}

src/platforms/apple/core/__tests__/runner-disposal.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ vi.mock('../../../../utils/host-process.ts', async (importOriginal) => {
3333
};
3434
});
3535

36-
vi.mock('../runner/runner-transport.ts', async (importOriginal) => {
37-
const actual = await importOriginal<typeof import('../runner/runner-transport.ts')>();
36+
vi.mock('../runner/runner-io.ts', async (importOriginal) => {
37+
const actual = await importOriginal<typeof import('../runner/runner-io.ts')>();
3838
return { ...actual, cleanupTempFile: mockCleanupTempFile };
3939
});
4040

src/platforms/apple/core/__tests__/runner-request-cancellation.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,21 @@ vi.mock('../tool-provider.ts', async () => {
6666
};
6767
});
6868

69-
vi.mock('../runner/runner-transport.ts', async () => {
70-
const actual = await vi.importActual<typeof import('../runner/runner-transport.ts')>(
71-
'../runner/runner-transport.ts',
72-
);
69+
vi.mock('../runner/runner-io.ts', async () => {
70+
const actual =
71+
await vi.importActual<typeof import('../runner/runner-io.ts')>('../runner/runner-io.ts');
7372
return {
7473
...actual,
7574
getFreePort: mockGetFreePort,
75+
};
76+
});
77+
78+
vi.mock('../runner/runner-startup-transport.ts', async () => {
79+
const actual = await vi.importActual<typeof import('../runner/runner-startup-transport.ts')>(
80+
'../runner/runner-startup-transport.ts',
81+
);
82+
return {
83+
...actual,
7684
waitForRunner: mockWaitForRunner,
7785
};
7886
});

src/platforms/apple/core/__tests__/runner-session.test.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,33 @@ vi.mock('../tool-provider.ts', async () => {
8989
};
9090
});
9191

92+
vi.mock('../runner/runner-io.ts', async () => {
93+
const actual =
94+
await vi.importActual<typeof import('../runner/runner-io.ts')>('../runner/runner-io.ts');
95+
return {
96+
...actual,
97+
cleanupTempFile: mockCleanupTempFile,
98+
getFreePort: mockGetFreePort,
99+
};
100+
});
101+
102+
vi.mock('../runner/runner-startup-transport.ts', async () => {
103+
const actual = await vi.importActual<typeof import('../runner/runner-startup-transport.ts')>(
104+
'../runner/runner-startup-transport.ts',
105+
);
106+
return {
107+
...actual,
108+
waitForRunner: mockWaitForRunner,
109+
};
110+
});
111+
92112
vi.mock('../runner/runner-transport.ts', async () => {
93113
const actual = await vi.importActual<typeof import('../runner/runner-transport.ts')>(
94114
'../runner/runner-transport.ts',
95115
);
96116
return {
97117
...actual,
98-
cleanupTempFile: mockCleanupTempFile,
99-
getFreePort: mockGetFreePort,
100118
sendRunnerCommandOnce: mockSendRunnerCommandOnce,
101-
waitForRunner: mockWaitForRunner,
102119
};
103120
});
104121

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { afterEach, beforeEach, test, vi } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import type { ExecBackgroundResult } from '../../../../utils/exec.ts';
4+
import { AppError } from '@agent-device/kernel/errors';
5+
import type { RunnerSession } from '../runner/runner-session-types.ts';
6+
import {
7+
iosDevice,
8+
iosSimulator,
9+
makeTunnelIpLookup,
10+
makeTunnelIpLookupSequence,
11+
stubSuccessfulFetch,
12+
usbmuxDeviceUnattachedError,
13+
xctestIosDevice,
14+
} from './runner-transport.fixtures.ts';
15+
16+
const { mockRunCmd, mockUsbmuxPostCommand } = vi.hoisted(() => ({
17+
mockRunCmd: vi.fn(),
18+
mockUsbmuxPostCommand: vi.fn(),
19+
}));
20+
21+
vi.mock('../../../../utils/exec.ts', async () => {
22+
const actual = await vi.importActual<typeof import('../../../../utils/exec.ts')>(
23+
'../../../../utils/exec.ts',
24+
);
25+
return {
26+
...actual,
27+
runCmd: mockRunCmd,
28+
};
29+
});
30+
31+
vi.mock('../runner/runner-usbmux.ts', async (importOriginal) => {
32+
const actual = await importOriginal<typeof import('../runner/runner-usbmux.ts')>();
33+
return {
34+
...actual,
35+
usbmuxRunnerTransport: {
36+
postCommand: mockUsbmuxPostCommand,
37+
},
38+
};
39+
});
40+
41+
import { clearDeviceTunnelIpCache } from '../runner/runner-command-route.ts';
42+
import { waitForRunner } from '../runner/runner-startup-transport.ts';
43+
44+
beforeEach(() => {
45+
clearDeviceTunnelIpCache();
46+
mockRunCmd.mockReset();
47+
mockUsbmuxPostCommand.mockReset();
48+
mockUsbmuxPostCommand.mockResolvedValue(new Response('{}'));
49+
});
50+
51+
afterEach(() => {
52+
vi.unstubAllGlobals();
53+
vi.unstubAllEnvs();
54+
});
55+
56+
test('waitForRunner propagates request cancellation without fallback', async () => {
57+
const signal = AbortSignal.abort();
58+
await assert.rejects(
59+
() =>
60+
waitForRunner(
61+
iosSimulator,
62+
8100,
63+
{ command: 'snapshot' },
64+
undefined,
65+
5_000,
66+
undefined,
67+
signal,
68+
),
69+
(error: unknown) => {
70+
assert.equal(error instanceof AppError, true);
71+
const appError = error as AppError;
72+
assert.equal(appError.code, 'COMMAND_FAILED');
73+
assert.equal(appError.message, 'request canceled');
74+
assert.equal(appError.message.includes('Runner did not accept connection'), false);
75+
return true;
76+
},
77+
);
78+
});
79+
80+
test('waitForRunner reuses cached physical-device tunnel IP across commands', async () => {
81+
mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError());
82+
stubSuccessfulFetch();
83+
mockRunCmd.mockImplementation(makeTunnelIpLookup('fd00::123'));
84+
85+
await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000);
86+
await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000);
87+
88+
assert.equal(mockRunCmd.mock.calls.length, 1);
89+
const fetchCalls = vi.mocked(fetch).mock.calls;
90+
assert.equal(fetchCalls.length, 2);
91+
assert.equal(fetchCalls[0]?.[0], 'http://[fd00::123]:8100/command');
92+
assert.equal(fetchCalls[1]?.[0], 'http://[fd00::123]:8100/command');
93+
});
94+
95+
test('waitForRunner keeps tunnel IP lookup request-local when no tunnel IP is available', async () => {
96+
mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError());
97+
stubSuccessfulFetch();
98+
mockRunCmd.mockImplementation(async () => ({ exitCode: 1, stdout: '', stderr: '' }));
99+
100+
await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000);
101+
102+
assert.equal(mockRunCmd.mock.calls.length, 1);
103+
assert.equal(vi.mocked(fetch).mock.calls[0]?.[0], 'http://127.0.0.1:8100/command');
104+
});
105+
106+
test('waitForRunner uses simulator fallback within the attempt for ready sessions', async () => {
107+
vi.stubGlobal(
108+
'fetch',
109+
vi.fn(async () => {
110+
throw new Error('ECONNREFUSED');
111+
}),
112+
);
113+
mockRunCmd.mockResolvedValue({ exitCode: 0, stdout: '{"ok":true}', stderr: '' });
114+
115+
const response = await waitForRunner(
116+
iosSimulator,
117+
8100,
118+
{ command: 'uptime' },
119+
undefined,
120+
5_000,
121+
makeReadyRunnerSession(),
122+
);
123+
124+
assert.equal(await response.text(), '{"ok":true}');
125+
assert.equal(vi.mocked(fetch).mock.calls.length, 1);
126+
assert.equal(mockRunCmd.mock.calls.length, 1);
127+
assert.equal(mockRunCmd.mock.calls[0]?.[0], 'xcrun');
128+
assert.deepEqual(mockRunCmd.mock.calls[0]?.[1]?.slice(0, 5), [
129+
'simctl',
130+
'spawn',
131+
iosSimulator.id,
132+
'/usr/bin/curl',
133+
'-s',
134+
]);
135+
});
136+
137+
test('waitForRunner invalidates cached tunnel IP when localhost fallback succeeds', async () => {
138+
mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError());
139+
mockRunCmd.mockImplementation(makeTunnelIpLookupSequence(['fd00::123', 'fd00::456']));
140+
let staleTunnelFailed = false;
141+
vi.stubGlobal(
142+
'fetch',
143+
vi.fn(async (input: string | URL | Request) => {
144+
const url = String(input);
145+
if (url === 'http://[fd00::123]:8100/command' && staleTunnelFailed) {
146+
throw new Error('stale tunnel');
147+
}
148+
if (url === 'http://[fd00::123]:8100/command') {
149+
staleTunnelFailed = true;
150+
}
151+
return new Response('{}');
152+
}),
153+
);
154+
155+
await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000);
156+
await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000);
157+
await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000);
158+
159+
const fetchCalls = vi.mocked(fetch).mock.calls.map(([input]) => String(input));
160+
assert.equal(mockRunCmd.mock.calls.length, 2);
161+
assert.deepEqual(fetchCalls, [
162+
'http://[fd00::123]:8100/command',
163+
'http://[fd00::123]:8100/command',
164+
'http://127.0.0.1:8100/command',
165+
'http://[fd00::456]:8100/command',
166+
]);
167+
});
168+
169+
test('waitForRunner preserves xcodebuild diagnostics when the runner exits during the final probe', async () => {
170+
const session: RunnerSession = {
171+
sessionId: 'starting-device-session',
172+
device: xctestIosDevice,
173+
deviceId: xctestIosDevice.id,
174+
port: 8100,
175+
xctestrunPath: '/tmp/runner.xctestrun',
176+
jsonPath: '/tmp/runner.json',
177+
testPromise: Promise.resolve({
178+
exitCode: 65,
179+
stdout: '',
180+
stderr:
181+
'The application could not be launched because the Developer App Certificate is not trusted.',
182+
}),
183+
child: { pid: 1234, exitCode: null } as ExecBackgroundResult['child'],
184+
ready: false,
185+
};
186+
mockUsbmuxPostCommand.mockImplementation(async () => {
187+
(session.child as { exitCode: number | null }).exitCode = 65;
188+
throw new Error('ECONNREFUSED');
189+
});
190+
191+
await assert.rejects(
192+
() =>
193+
waitForRunner(xctestIosDevice, 8100, { command: 'uptime' }, '/tmp/runner.log', 100, session),
194+
(error: unknown) => {
195+
const appError = error as AppError;
196+
assert.equal(appError.message, 'Runner did not accept connection (xcodebuild exited early)');
197+
assert.equal(
198+
(appError.details?.xcodebuild as { exitCode?: number } | undefined)?.exitCode,
199+
65,
200+
);
201+
assert.match(
202+
String((appError.details?.xcodebuild as { stderr?: string } | undefined)?.stderr),
203+
/Developer App Certificate is not trusted/,
204+
);
205+
return true;
206+
},
207+
);
208+
209+
assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1);
210+
});
211+
212+
test('waitForRunner reports the usbmux verdict for xctest devices without retrying', async () => {
213+
// Regression: an XCTest device has no tunnel, so retrying cannot attach a
214+
// cable. Before this was terminal, readiness preflight and read-only
215+
// commands burned the whole connect budget and lost the recovery hint.
216+
mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError());
217+
stubSuccessfulFetch();
218+
219+
await assert.rejects(
220+
() => waitForRunner(xctestIosDevice, 8100, { command: 'snapshot' }, undefined, 5_000),
221+
(error: unknown) => {
222+
const appError = error as AppError;
223+
assert.equal(appError.code, 'DEVICE_NOT_FOUND');
224+
assert.match(String(appError.details?.hint), /Connect the device by cable/);
225+
assert.equal(appError.message.includes('Runner did not accept connection'), false);
226+
return true;
227+
},
228+
);
229+
230+
assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1);
231+
assert.equal(vi.mocked(fetch).mock.calls.length, 0);
232+
assert.equal(mockRunCmd.mock.calls.length, 0);
233+
});
234+
235+
test('waitForRunner routes coredevice physical devices through usbmux first', async () => {
236+
const fetchMock = vi.fn();
237+
vi.stubGlobal('fetch', fetchMock);
238+
239+
const response = await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000);
240+
241+
assert.equal(response.status, 200);
242+
assert.equal(fetchMock.mock.calls.length, 0);
243+
assert.equal(mockRunCmd.mock.calls.length, 0);
244+
assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1);
245+
});
246+
247+
test('waitForRunner falls back to the tunnel route inside the same attempt', async () => {
248+
mockUsbmuxPostCommand.mockRejectedValue(usbmuxDeviceUnattachedError());
249+
stubSuccessfulFetch();
250+
mockRunCmd.mockImplementation(makeTunnelIpLookup('fd00::123'));
251+
252+
const response = await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000);
253+
254+
assert.equal(response.status, 200);
255+
// One usbmux probe, then the tunnel endpoint — no retry round trip in between.
256+
assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1);
257+
assert.equal(vi.mocked(fetch).mock.calls[0]?.[0], 'http://[fd00::123]:8100/command');
258+
});
259+
260+
function makeReadyRunnerSession(): RunnerSession {
261+
return {
262+
sessionId: 'ready-session',
263+
device: iosSimulator,
264+
deviceId: iosSimulator.id,
265+
port: 8100,
266+
xctestrunPath: '/tmp/runner.xctestrun',
267+
jsonPath: '/tmp/runner.json',
268+
testPromise: Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }),
269+
child: { pid: 1234, exitCode: null } as ExecBackgroundResult['child'],
270+
ready: true,
271+
};
272+
}

0 commit comments

Comments
 (0)