Skip to content

Commit 73713ab

Browse files
committed
feat: cache ios runner artifacts during prepare
1 parent 1881f6f commit 73713ab

14 files changed

Lines changed: 806 additions & 68 deletions

File tree

.github/actions/setup-apple-replay/action.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ inputs:
2727
description: "Optional AGENT_DEVICE_IOS_CLEAN_DERIVED value"
2828
required: false
2929
default: ""
30+
build-on-miss:
31+
description: "Whether this setup action should build replay artifacts on cache miss"
32+
required: false
33+
default: "true"
3034

3135
outputs:
3236
agent-home-dir:
@@ -68,7 +72,7 @@ runs:
6872
shell: bash
6973

7074
- name: Build replay artifacts
71-
if: steps.restore-prebuilt.outputs.cache-hit != 'true'
75+
if: inputs.build-on-miss == 'true' && steps.restore-prebuilt.outputs.cache-hit != 'true'
7276
run: ${{ inputs.build-command }}
7377
shell: bash
7478
env:

.github/workflows/ios.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ jobs:
4747
xcuitest-platform: ios
4848
xcuitest-destination: generic/platform=iOS Simulator
4949
clean-derived: "1"
50+
build-on-miss: "false"
5051

5152
- name: Boot iOS test simulator
5253
uses: ./.github/actions/boot-ios-test-simulator
@@ -57,7 +58,7 @@ jobs:
5758
- name: Prepare iOS runner
5859
run: |
5960
pnpm clean:daemon
60-
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000
61+
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json
6162
6263
- name: Run iOS simulator smoke replay
6364
run: |

.github/workflows/replays-nightly.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ jobs:
7070
xcuitest-platform: ios
7171
xcuitest-destination: generic/platform=iOS Simulator
7272
clean-derived: "1"
73+
build-on-miss: "false"
7374

7475
- name: Boot iOS test simulator
7576
uses: ./.github/actions/boot-ios-test-simulator
@@ -80,7 +81,7 @@ jobs:
8081
- name: Prepare iOS runner
8182
run: |
8283
pnpm clean:daemon
83-
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000
84+
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json
8485
8586
- name: Run iOS simulator replay suite
8687
run: node --experimental-strip-types src/bin.ts test test/integration/replays/ios/simulator --retries 2 --report-junit test/artifacts/replays-ios-simulator.junit.xml

scripts/write-xcuitest-cache-metadata.mjs

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,57 @@ function resolveBuildDestinationFamily() {
131131
return `generic/platform=${platformName}`;
132132
}
133133

134+
function resolveRunnerSdkName() {
135+
const platformName = resolvePlatformName();
136+
if (platformName === 'macOS') return 'macosx';
137+
if (platformName === 'tvOS') {
138+
return resolveDeviceKind() === 'simulator' ? 'appletvsimulator' : 'appletvos';
139+
}
140+
return resolveDeviceKind() === 'simulator' ? 'iphonesimulator' : 'iphoneos';
141+
}
142+
143+
function runAppleToolFingerprintCommand(command, args) {
144+
try {
145+
return execFileSync(command, args, {
146+
encoding: 'utf8',
147+
stdio: ['ignore', 'pipe', 'ignore'],
148+
timeout: 5000,
149+
maxBuffer: 128 * 1024,
150+
}).trim() || 'unknown';
151+
} catch {
152+
return 'unknown';
153+
}
154+
}
155+
156+
function parseXcodeVersionOutput(output) {
157+
return {
158+
version: output.match(/^Xcode\s+(.+)$/m)?.[1]?.trim() || 'unknown',
159+
buildVersion: output.match(/^Build version\s+(.+)$/m)?.[1]?.trim() || 'unknown',
160+
};
161+
}
162+
163+
function resolveRunnerToolchainFingerprint() {
164+
const xcode = parseXcodeVersionOutput(
165+
runAppleToolFingerprintCommand('xcodebuild', ['-version']),
166+
);
167+
const sdkName = resolveRunnerSdkName();
168+
return {
169+
xcodeVersion: xcode.version,
170+
xcodeBuildVersion: xcode.buildVersion,
171+
sdkName,
172+
sdkVersion: runAppleToolFingerprintCommand('xcrun', [
173+
'--sdk',
174+
sdkName,
175+
'--show-sdk-version',
176+
]),
177+
sdkBuildVersion: runAppleToolFingerprintCommand('xcrun', [
178+
'--sdk',
179+
sdkName,
180+
'--show-sdk-build-version',
181+
]),
182+
};
183+
}
184+
134185
function resolveSigningBuildSettings() {
135186
if (platform !== 'macos') {
136187
return [];
@@ -149,6 +200,7 @@ const metadata = {
149200
schemaVersion: 1,
150201
packageVersion: readPackageVersion(),
151202
runnerSourceFingerprint: computeRunnerSourceFingerprint(),
203+
...resolveRunnerToolchainFingerprint(),
152204
platformName: resolvePlatformName(),
153205
deviceKind: resolveDeviceKind(),
154206
target: resolveTarget(),
@@ -175,14 +227,16 @@ function resolveRunnerCacheArtifacts() {
175227
const productPaths = resolveExistingXctestrunProductPaths(xctestrunPath);
176228
if (!productPaths || productPaths.length === 0) return null;
177229
const xctestrunMtimeMs = readFileMtimeMs(xctestrunPath);
178-
if (xctestrunMtimeMs === null) return null;
230+
const xctestrunSize = readFileSize(xctestrunPath);
231+
if (xctestrunMtimeMs === null || xctestrunSize === null) return null;
179232
const productArtifacts = [];
180233
for (const productPath of productPaths) {
181234
const mtimeMs = readFileMtimeMs(productPath);
182-
if (mtimeMs === null) return null;
183-
productArtifacts.push({ path: productPath, mtimeMs });
235+
const size = readFileSize(productPath);
236+
if (mtimeMs === null || size === null) return null;
237+
productArtifacts.push({ path: productPath, mtimeMs, size });
184238
}
185-
return { xctestrunPath, xctestrunMtimeMs, productPaths: productArtifacts };
239+
return { xctestrunPath, xctestrunMtimeMs, xctestrunSize, productPaths: productArtifacts };
186240
}
187241

188242
function findXctestrun(root) {
@@ -378,6 +432,14 @@ function readFileMtimeMs(filePath) {
378432
}
379433
}
380434

435+
function readFileSize(filePath) {
436+
try {
437+
return fs.statSync(filePath).size;
438+
} catch {
439+
return null;
440+
}
441+
}
442+
381443
function isRecord(value) {
382444
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
383445
}

src/daemon/handlers/__tests__/session.test.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,12 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
1717
const actual = await importOriginal<typeof import('../../../platforms/ios/runner-client.ts')>();
1818
return {
1919
...actual,
20+
prepareIosRunner: vi.fn(async () => ({
21+
runner: { currentUptimeMs: 42 },
22+
connectMs: 3,
23+
healthCheckMs: 3,
24+
})),
2025
prewarmIosRunnerSession: vi.fn(),
21-
runIosRunnerCommand: vi.fn(async () => ({ currentUptimeMs: 42 })),
2226
stopIosRunnerSession: vi.fn(async () => {}),
2327
};
2428
});
@@ -96,8 +100,8 @@ import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'
96100
import { ensureDeviceReady } from '../../device-ready.ts';
97101
import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts';
98102
import {
103+
prepareIosRunner,
99104
prewarmIosRunnerSession,
100-
runIosRunnerCommand,
101105
stopIosRunnerSession,
102106
} from '../../../platforms/ios/runner-client.ts';
103107
import { runMacOsAlertAction } from '../../../platforms/ios/macos-helper.ts';
@@ -120,7 +124,7 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady);
120124
const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp);
121125
const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp);
122126
const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession);
123-
const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand);
127+
const mockPrepareIosRunner = vi.mocked(prepareIosRunner);
124128
const mockStopIosRunner = vi.mocked(stopIosRunnerSession);
125129
const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction);
126130
const mockSettleSimulator = vi.mocked(settleIosSimulator);
@@ -151,8 +155,12 @@ beforeEach(() => {
151155
mockClearRuntimeHints.mockReset();
152156
mockClearRuntimeHints.mockResolvedValue(undefined);
153157
mockPrewarmIosRunnerSession.mockReset();
154-
mockRunIosRunnerCommand.mockReset();
155-
mockRunIosRunnerCommand.mockResolvedValue({ currentUptimeMs: 42 });
158+
mockPrepareIosRunner.mockReset();
159+
mockPrepareIosRunner.mockResolvedValue({
160+
runner: { currentUptimeMs: 42 },
161+
connectMs: 3,
162+
healthCheckMs: 3,
163+
});
156164
mockStopIosRunner.mockReset();
157165
mockStopIosRunner.mockResolvedValue(undefined);
158166
mockDismissMacOsAlert.mockReset();
@@ -2130,11 +2138,12 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector',
21302138
expect(mockEnsureDeviceReady).toHaveBeenCalledWith(
21312139
expect.objectContaining({ platform: 'ios', id: 'sim-1' }),
21322140
);
2133-
expect(mockRunIosRunnerCommand).toHaveBeenCalledTimes(1);
2134-
expect(mockRunIosRunnerCommand).toHaveBeenCalledWith(
2141+
expect(mockPrepareIosRunner).toHaveBeenCalledTimes(1);
2142+
expect(mockPrepareIosRunner).toHaveBeenCalledWith(
21352143
expect.objectContaining({ platform: 'ios', id: 'sim-1' }),
2136-
{ command: 'uptime' },
21372144
expect.objectContaining({
2145+
buildTimeoutMs: 240000,
2146+
healthTimeoutMs: 90000,
21382147
logPath: expect.stringMatching(/daemon\.log$/),
21392148
requestId: 'prepare-request',
21402149
}),
@@ -2145,6 +2154,8 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector',
21452154
deviceId: 'sim-1',
21462155
deviceName: 'iPhone 17 Pro',
21472156
kind: 'simulator',
2157+
connectMs: 3,
2158+
healthCheckMs: 3,
21482159
runner: { currentUptimeMs: 42 },
21492160
message: 'Prepared iOS runner: iPhone 17 Pro',
21502161
});
@@ -2181,7 +2192,7 @@ test('prepare ios-runner rejects non-iOS devices', async () => {
21812192
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
21822193
expect(response.error.message).toBe('prepare ios-runner is only supported on iOS');
21832194
}
2184-
expect(mockRunIosRunnerCommand).not.toHaveBeenCalled();
2195+
expect(mockPrepareIosRunner).not.toHaveBeenCalled();
21852196
});
21862197

21872198
test('prepare requires the ios-runner subcommand', async () => {
@@ -2208,7 +2219,7 @@ test('prepare requires the ios-runner subcommand', async () => {
22082219
expect(response.error.message).toBe('prepare requires a subcommand: ios-runner');
22092220
}
22102221
expect(mockResolveTargetDevice).not.toHaveBeenCalled();
2211-
expect(mockRunIosRunnerCommand).not.toHaveBeenCalled();
2222+
expect(mockPrepareIosRunner).not.toHaveBeenCalled();
22122223
});
22132224

22142225
test('open web URL on iOS device session without active app falls back to Safari', async () => {

src/daemon/handlers/session.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import {
77
} from '../../command-catalog.ts';
88
import { resolvePayloadInput } from '../../utils/payload-input.ts';
99
import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts';
10-
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
10+
import {
11+
prepareIosRunner,
12+
type PrepareIosRunnerResult,
13+
} from '../../platforms/ios/runner-client.ts';
1114
import type { DeviceInfo } from '../../utils/device.ts';
1215
import { normalizePlatformSelector } from '../../utils/device.ts';
1316
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
@@ -41,6 +44,8 @@ const INVENTORY_COMMANDS = DAEMON_COMMAND_GROUPS.inventory;
4144
const STATE_COMMANDS = DAEMON_COMMAND_GROUPS.state;
4245
const OBSERVABILITY_COMMANDS = DAEMON_COMMAND_GROUPS.observability;
4346
const REPLAY_COMMANDS = DAEMON_COMMAND_GROUPS.replay;
47+
const PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS = 5 * 60_000;
48+
const PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS = 90_000;
4449

4550
export const SESSION_COMMAND_HANDLERS = {
4651
...Object.fromEntries([...INVENTORY_COMMANDS].map((command) => [command, true] as const)),
@@ -89,9 +94,8 @@ async function handlePrepareCommand(params: {
8994
}
9095

9196
const startedAtMs = Date.now();
92-
const result = await runIosRunnerCommand(
97+
const result = await prepareIosRunner(
9398
device,
94-
{ command: 'uptime' },
9599
buildPrepareIosRunnerOptions(req, session, logPath),
96100
);
97101
const durationMs = Math.max(0, Date.now() - startedAtMs);
@@ -105,20 +109,30 @@ function buildPrepareIosRunnerOptions(
105109
req: DaemonRequest,
106110
session: SessionState | undefined,
107111
logPath: string,
108-
): Parameters<typeof runIosRunnerCommand>[2] {
112+
): Parameters<typeof prepareIosRunner>[1] {
113+
const buildTimeoutMs = readPrepareIosRunnerBuildTimeoutMs(req);
109114
return {
110115
verbose: req.flags?.verbose,
111116
logPath,
112117
traceLogPath: session?.trace?.outPath,
113118
requestId: req.meta?.requestId,
119+
buildTimeoutMs,
120+
healthTimeoutMs: Math.min(buildTimeoutMs, PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS),
114121
};
115122
}
116123

124+
function readPrepareIosRunnerBuildTimeoutMs(req: DaemonRequest): number {
125+
const value = req.flags?.timeoutMs;
126+
return typeof value === 'number' && Number.isFinite(value) && value > 0
127+
? value
128+
: PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS;
129+
}
130+
117131
function prepareIosRunnerResponseData(
118132
action: string,
119133
device: DeviceInfo,
120134
durationMs: number,
121-
runner: Awaited<ReturnType<typeof runIosRunnerCommand>>,
135+
result: PrepareIosRunnerResult,
122136
): Record<string, unknown> {
123137
return {
124138
action,
@@ -127,7 +141,7 @@ function prepareIosRunnerResponseData(
127141
deviceName: device.name,
128142
kind: device.kind,
129143
durationMs,
130-
runner,
144+
...result,
131145
message: `Prepared iOS runner: ${device.name}`,
132146
};
133147
}

src/platforms/ios/__tests__/runner-client.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,9 @@ import {
4848
} from '../runner-client.ts';
4949
import {
5050
acquireRunnerXctestrunCacheLock,
51+
ensureXctestrunArtifact,
5152
ensureXctestrun,
53+
markRunnerXctestrunArtifactBadForRun,
5254
resolveExpectedRunnerCacheMetadata,
5355
resolveRunnerDerivedPath,
5456
resolveRunnerCacheMetadataPath,
@@ -1132,6 +1134,57 @@ test('ensureXctestrun rebuilds cached runner when metadata package version misma
11321134
assert.equal(rebuiltMetadata.artifacts?.xctestrunPath, rebuiltXctestrunPath);
11331135
});
11341136

1137+
test('ensureXctestrunArtifact stress-recovers after a bad restored artifact', async () => {
1138+
const projectRoot = repoRoot;
1139+
const tmpDir = await makeProjectTmpDir();
1140+
const derivedPath = path.join(tmpDir, 'custom-derived');
1141+
const productPath = path.join(derivedPath, 'Runner.app');
1142+
const cachedXctestrunPath = path.join(derivedPath, 'cached.xctestrun');
1143+
await fs.promises.mkdir(productPath, { recursive: true });
1144+
writeXctestrunFixture(cachedXctestrunPath, {
1145+
projectRoot,
1146+
productRelativePaths: ['Runner.app'],
1147+
});
1148+
writeRunnerCacheMetadataWithArtifacts({
1149+
derivedPath,
1150+
device: macOsDevice,
1151+
xctestrunPath: cachedXctestrunPath,
1152+
productPaths: [productPath],
1153+
});
1154+
withRunnerDerivedPathEnv(derivedPath);
1155+
1156+
const hit = await ensureXctestrunArtifact(macOsDevice, {});
1157+
1158+
assert.equal(hit.xctestrunPath, cachedXctestrunPath);
1159+
assert.equal(hit.cache, 'exact');
1160+
assert.equal(hit.artifact, 'valid');
1161+
assert.equal(hit.buildMs, 0);
1162+
assert.equal(mockRunCmdStreaming.mock.calls.length, 0);
1163+
1164+
await markRunnerXctestrunArtifactBadForRun(hit, 'stress health failed');
1165+
assert.equal(fs.existsSync(cachedXctestrunPath), false);
1166+
1167+
const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
1168+
mockRunCmdStreaming.mockImplementationOnce(async () => {
1169+
await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true });
1170+
writeXctestrunFixture(rebuiltXctestrunPath, {
1171+
projectRoot,
1172+
productRelativePaths: ['Runner.app'],
1173+
});
1174+
});
1175+
1176+
const rebuilt = await ensureXctestrunArtifact(macOsDevice, {
1177+
buildTimeoutMs: 300_000,
1178+
});
1179+
1180+
assert.equal(rebuilt.xctestrunPath, rebuiltXctestrunPath);
1181+
assert.equal(rebuilt.cache, 'miss');
1182+
assert.equal(rebuilt.artifact, 'rebuilt');
1183+
assert.equal(rebuilt.reason, 'missing_xctestrun');
1184+
assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
1185+
assert.equal(mockRunCmdStreaming.mock.calls[0]?.[2]?.timeoutMs, 300_000);
1186+
});
1187+
11351188
test('ensureXctestrun rethrows unexpected cached macOS runner repair errors', async () => {
11361189
const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun();
11371190

0 commit comments

Comments
 (0)