Skip to content

Commit c73826e

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

14 files changed

Lines changed: 532 additions & 45 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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2135,7 +2135,11 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector',
21352135
expect.objectContaining({ platform: 'ios', id: 'sim-1' }),
21362136
{ command: 'uptime' },
21372137
expect.objectContaining({
2138+
buildTimeoutMs: 240000,
2139+
commandTimeoutMs: 90000,
21382140
logPath: expect.stringMatching(/daemon\.log$/),
2141+
prepareDiagnostics: true,
2142+
recoverBadCachedRunnerArtifact: true,
21392143
requestId: 'prepare-request',
21402144
}),
21412145
);

src/daemon/handlers/session.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ const INVENTORY_COMMANDS = DAEMON_COMMAND_GROUPS.inventory;
4141
const STATE_COMMANDS = DAEMON_COMMAND_GROUPS.state;
4242
const OBSERVABILITY_COMMANDS = DAEMON_COMMAND_GROUPS.observability;
4343
const REPLAY_COMMANDS = DAEMON_COMMAND_GROUPS.replay;
44+
const PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS = 5 * 60_000;
45+
const PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS = 90_000;
4446

4547
export const SESSION_COMMAND_HANDLERS = {
4648
...Object.fromEntries([...INVENTORY_COMMANDS].map((command) => [command, true] as const)),
@@ -106,28 +108,49 @@ function buildPrepareIosRunnerOptions(
106108
session: SessionState | undefined,
107109
logPath: string,
108110
): Parameters<typeof runIosRunnerCommand>[2] {
111+
const buildTimeoutMs = readPrepareIosRunnerBuildTimeoutMs(req);
112+
const commandTimeoutMs = Math.min(buildTimeoutMs, PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS);
109113
return {
110114
verbose: req.flags?.verbose,
111115
logPath,
112116
traceLogPath: session?.trace?.outPath,
113117
requestId: req.meta?.requestId,
118+
buildTimeoutMs,
119+
commandTimeoutMs,
120+
recoverBadCachedRunnerArtifact: true,
121+
prepareDiagnostics: true,
114122
};
115123
}
116124

125+
function readPrepareIosRunnerBuildTimeoutMs(req: DaemonRequest): number {
126+
const value = req.flags?.timeoutMs;
127+
return typeof value === 'number' && Number.isFinite(value) && value > 0
128+
? value
129+
: PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS;
130+
}
131+
117132
function prepareIosRunnerResponseData(
118133
action: string,
119134
device: DeviceInfo,
120135
durationMs: number,
121136
runner: Awaited<ReturnType<typeof runIosRunnerCommand>>,
122137
): Record<string, unknown> {
138+
const { prepareDiagnostics, ...runnerData } = runner;
139+
const diagnostics =
140+
prepareDiagnostics &&
141+
typeof prepareDiagnostics === 'object' &&
142+
!Array.isArray(prepareDiagnostics)
143+
? (prepareDiagnostics as Record<string, unknown>)
144+
: {};
123145
return {
124146
action,
125147
platform: device.platform,
126148
deviceId: device.id,
127149
deviceName: device.name,
128150
kind: device.kind,
129151
durationMs,
130-
runner,
152+
...diagnostics,
153+
runner: runnerData,
131154
message: `Prepared iOS runner: ${device.name}`,
132155
};
133156
}

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

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import type { RunnerSession } from '../runner-session-types.ts';
1212
const {
1313
mockAcquireXcodebuildSimulatorSetRedirect,
1414
mockCleanupTempFile,
15-
mockEnsureXctestrun,
15+
mockEnsureXctestrunArtifact,
1616
mockGetFreePort,
1717
mockIsProcessAlive,
1818
mockIsProcessGroupAlive,
@@ -26,7 +26,7 @@ const {
2626
} = vi.hoisted(() => ({
2727
mockAcquireXcodebuildSimulatorSetRedirect: vi.fn(),
2828
mockCleanupTempFile: vi.fn(),
29-
mockEnsureXctestrun: vi.fn(),
29+
mockEnsureXctestrunArtifact: vi.fn(),
3030
mockGetFreePort: vi.fn(),
3131
mockIsProcessAlive: vi.fn(),
3232
mockIsProcessGroupAlive: vi.fn(),
@@ -86,7 +86,7 @@ vi.mock('../runner-xctestrun.ts', async () => {
8686
return {
8787
...actual,
8888
acquireXcodebuildSimulatorSetRedirect: mockAcquireXcodebuildSimulatorSetRedirect,
89-
ensureXctestrun: mockEnsureXctestrun,
89+
ensureXctestrunArtifact: mockEnsureXctestrunArtifact,
9090
prepareXctestrunWithEnv: mockPrepareXctestrunWithEnv,
9191
};
9292
});
@@ -103,7 +103,14 @@ import {
103103
beforeEach(() => {
104104
vi.resetAllMocks();
105105
mockRunXcrun.mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
106-
mockEnsureXctestrun.mockResolvedValue('/tmp/base-runner.xctestrun');
106+
mockEnsureXctestrunArtifact.mockResolvedValue({
107+
xctestrunPath: '/tmp/base-runner.xctestrun',
108+
derived: '/tmp/derived',
109+
cache: 'miss',
110+
artifact: 'rebuilt',
111+
buildMs: 12,
112+
xctestrunPathSource: 'build',
113+
});
107114
mockGetFreePort.mockResolvedValue(8123);
108115
mockPrepareXctestrunWithEnv.mockResolvedValue({
109116
xctestrunPath: '/tmp/session-runner.xctestrun',

0 commit comments

Comments
 (0)