Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/actions/setup-apple-replay/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ inputs:
description: "Optional AGENT_DEVICE_IOS_CLEAN_DERIVED value"
required: false
default: ""
build-on-miss:
description: "Whether this setup action should build replay artifacts on cache miss"
required: false
default: "true"

outputs:
agent-home-dir:
Expand Down Expand Up @@ -68,7 +72,7 @@ runs:
shell: bash

- name: Build replay artifacts
if: steps.restore-prebuilt.outputs.cache-hit != 'true'
if: inputs.build-on-miss == 'true' && steps.restore-prebuilt.outputs.cache-hit != 'true'
run: ${{ inputs.build-command }}
shell: bash
env:
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ jobs:
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
clean-derived: "1"
build-on-miss: "false"

- name: Boot iOS test simulator
uses: ./.github/actions/boot-ios-test-simulator
Expand All @@ -57,7 +58,7 @@ jobs:
- name: Prepare iOS runner
run: |
pnpm clean:daemon
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json

- name: Run iOS simulator smoke replay
run: |
Expand Down
3 changes: 2 additions & 1 deletion .github/workflows/replays-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ jobs:
xcuitest-platform: ios
xcuitest-destination: generic/platform=iOS Simulator
clean-derived: "1"
build-on-miss: "false"

- name: Boot iOS test simulator
uses: ./.github/actions/boot-ios-test-simulator
Expand All @@ -80,7 +81,7 @@ jobs:
- name: Prepare iOS runner
run: |
pnpm clean:daemon
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 240000
node --experimental-strip-types src/bin.ts prepare ios-runner --platform ios --timeout 300000 --json

- name: Run iOS simulator replay suite
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
Expand Down
70 changes: 66 additions & 4 deletions scripts/write-xcuitest-cache-metadata.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,57 @@ function resolveBuildDestinationFamily() {
return `generic/platform=${platformName}`;
}

function resolveRunnerSdkName() {
const platformName = resolvePlatformName();
if (platformName === 'macOS') return 'macosx';
if (platformName === 'tvOS') {
return resolveDeviceKind() === 'simulator' ? 'appletvsimulator' : 'appletvos';
}
return resolveDeviceKind() === 'simulator' ? 'iphonesimulator' : 'iphoneos';
}

function runAppleToolFingerprintCommand(command, args) {
try {
return execFileSync(command, args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
timeout: 5000,
maxBuffer: 128 * 1024,
}).trim() || 'unknown';
} catch {
return 'unknown';
}
}

function parseXcodeVersionOutput(output) {
return {
version: output.match(/^Xcode\s+(.+)$/m)?.[1]?.trim() || 'unknown',
buildVersion: output.match(/^Build version\s+(.+)$/m)?.[1]?.trim() || 'unknown',
};
}

function resolveRunnerToolchainFingerprint() {
const xcode = parseXcodeVersionOutput(
runAppleToolFingerprintCommand('xcodebuild', ['-version']),
);
const sdkName = resolveRunnerSdkName();
return {
xcodeVersion: xcode.version,
xcodeBuildVersion: xcode.buildVersion,
sdkName,
sdkVersion: runAppleToolFingerprintCommand('xcrun', [
'--sdk',
sdkName,
'--show-sdk-version',
]),
sdkBuildVersion: runAppleToolFingerprintCommand('xcrun', [
'--sdk',
sdkName,
'--show-sdk-build-version',
]),
};
}

function resolveSigningBuildSettings() {
if (platform !== 'macos') {
return [];
Expand All @@ -149,6 +200,7 @@ const metadata = {
schemaVersion: 1,
packageVersion: readPackageVersion(),
runnerSourceFingerprint: computeRunnerSourceFingerprint(),
...resolveRunnerToolchainFingerprint(),
platformName: resolvePlatformName(),
deviceKind: resolveDeviceKind(),
target: resolveTarget(),
Expand All @@ -175,14 +227,16 @@ function resolveRunnerCacheArtifacts() {
const productPaths = resolveExistingXctestrunProductPaths(xctestrunPath);
if (!productPaths || productPaths.length === 0) return null;
const xctestrunMtimeMs = readFileMtimeMs(xctestrunPath);
if (xctestrunMtimeMs === null) return null;
const xctestrunSize = readFileSize(xctestrunPath);
if (xctestrunMtimeMs === null || xctestrunSize === null) return null;
const productArtifacts = [];
for (const productPath of productPaths) {
const mtimeMs = readFileMtimeMs(productPath);
if (mtimeMs === null) return null;
productArtifacts.push({ path: productPath, mtimeMs });
const size = readFileSize(productPath);
if (mtimeMs === null || size === null) return null;
productArtifacts.push({ path: productPath, mtimeMs, size });
}
return { xctestrunPath, xctestrunMtimeMs, productPaths: productArtifacts };
return { xctestrunPath, xctestrunMtimeMs, xctestrunSize, productPaths: productArtifacts };
}

function findXctestrun(root) {
Expand Down Expand Up @@ -378,6 +432,14 @@ function readFileMtimeMs(filePath) {
}
}

function readFileSize(filePath) {
try {
return fs.statSync(filePath).size;
} catch {
return null;
}
}

function isRecord(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
31 changes: 21 additions & 10 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ vi.mock('../../../platforms/ios/runner-client.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../platforms/ios/runner-client.ts')>();
return {
...actual,
prepareIosRunner: vi.fn(async () => ({
runner: { currentUptimeMs: 42 },
connectMs: 3,
healthCheckMs: 3,
})),
prewarmIosRunnerSession: vi.fn(),
runIosRunnerCommand: vi.fn(async () => ({ currentUptimeMs: 42 })),
stopIosRunnerSession: vi.fn(async () => {}),
};
});
Expand Down Expand Up @@ -96,8 +100,8 @@ import { dispatchCommand, resolveTargetDevice } from '../../../core/dispatch.ts'
import { ensureDeviceReady } from '../../device-ready.ts';
import { applyRuntimeHintsToApp, clearRuntimeHintsFromApp } from '../../runtime-hints.ts';
import {
prepareIosRunner,
prewarmIosRunnerSession,
runIosRunnerCommand,
stopIosRunnerSession,
} from '../../../platforms/ios/runner-client.ts';
import { runMacOsAlertAction } from '../../../platforms/ios/macos-helper.ts';
Expand All @@ -120,7 +124,7 @@ const mockEnsureDeviceReady = vi.mocked(ensureDeviceReady);
const mockApplyRuntimeHints = vi.mocked(applyRuntimeHintsToApp);
const mockClearRuntimeHints = vi.mocked(clearRuntimeHintsFromApp);
const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession);
const mockRunIosRunnerCommand = vi.mocked(runIosRunnerCommand);
const mockPrepareIosRunner = vi.mocked(prepareIosRunner);
const mockStopIosRunner = vi.mocked(stopIosRunnerSession);
const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction);
const mockSettleSimulator = vi.mocked(settleIosSimulator);
Expand Down Expand Up @@ -151,8 +155,12 @@ beforeEach(() => {
mockClearRuntimeHints.mockReset();
mockClearRuntimeHints.mockResolvedValue(undefined);
mockPrewarmIosRunnerSession.mockReset();
mockRunIosRunnerCommand.mockReset();
mockRunIosRunnerCommand.mockResolvedValue({ currentUptimeMs: 42 });
mockPrepareIosRunner.mockReset();
mockPrepareIosRunner.mockResolvedValue({
runner: { currentUptimeMs: 42 },
connectMs: 3,
healthCheckMs: 3,
});
mockStopIosRunner.mockReset();
mockStopIosRunner.mockResolvedValue(undefined);
mockDismissMacOsAlert.mockReset();
Expand Down Expand Up @@ -2130,11 +2138,12 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector',
expect(mockEnsureDeviceReady).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'ios', id: 'sim-1' }),
);
expect(mockRunIosRunnerCommand).toHaveBeenCalledTimes(1);
expect(mockRunIosRunnerCommand).toHaveBeenCalledWith(
expect(mockPrepareIosRunner).toHaveBeenCalledTimes(1);
expect(mockPrepareIosRunner).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'ios', id: 'sim-1' }),
{ command: 'uptime' },
expect.objectContaining({
buildTimeoutMs: 240000,
healthTimeoutMs: 90000,
logPath: expect.stringMatching(/daemon\.log$/),
requestId: 'prepare-request',
}),
Expand All @@ -2145,6 +2154,8 @@ test('prepare ios-runner starts the XCTest runner on an explicit iOS selector',
deviceId: 'sim-1',
deviceName: 'iPhone 17 Pro',
kind: 'simulator',
connectMs: 3,
healthCheckMs: 3,
runner: { currentUptimeMs: 42 },
message: 'Prepared iOS runner: iPhone 17 Pro',
});
Expand Down Expand Up @@ -2181,7 +2192,7 @@ test('prepare ios-runner rejects non-iOS devices', async () => {
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
expect(response.error.message).toBe('prepare ios-runner is only supported on iOS');
}
expect(mockRunIosRunnerCommand).not.toHaveBeenCalled();
expect(mockPrepareIosRunner).not.toHaveBeenCalled();
});

test('prepare requires the ios-runner subcommand', async () => {
Expand All @@ -2208,7 +2219,7 @@ test('prepare requires the ios-runner subcommand', async () => {
expect(response.error.message).toBe('prepare requires a subcommand: ios-runner');
}
expect(mockResolveTargetDevice).not.toHaveBeenCalled();
expect(mockRunIosRunnerCommand).not.toHaveBeenCalled();
expect(mockPrepareIosRunner).not.toHaveBeenCalled();
});

test('open web URL on iOS device session without active app falls back to Safari', async () => {
Expand Down
26 changes: 20 additions & 6 deletions src/daemon/handlers/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {
} from '../../command-catalog.ts';
import { resolvePayloadInput } from '../../utils/payload-input.ts';
import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts';
import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts';
import {
prepareIosRunner,
type PrepareIosRunnerResult,
} from '../../platforms/ios/runner-client.ts';
import type { DeviceInfo } from '../../utils/device.ts';
import { normalizePlatformSelector } from '../../utils/device.ts';
import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts';
Expand Down Expand Up @@ -41,6 +44,8 @@ const INVENTORY_COMMANDS = DAEMON_COMMAND_GROUPS.inventory;
const STATE_COMMANDS = DAEMON_COMMAND_GROUPS.state;
const OBSERVABILITY_COMMANDS = DAEMON_COMMAND_GROUPS.observability;
const REPLAY_COMMANDS = DAEMON_COMMAND_GROUPS.replay;
const PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS = 5 * 60_000;
const PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS = 90_000;

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

const startedAtMs = Date.now();
const result = await runIosRunnerCommand(
const result = await prepareIosRunner(
device,
{ command: 'uptime' },
buildPrepareIosRunnerOptions(req, session, logPath),
);
const durationMs = Math.max(0, Date.now() - startedAtMs);
Expand All @@ -105,20 +109,30 @@ function buildPrepareIosRunnerOptions(
req: DaemonRequest,
session: SessionState | undefined,
logPath: string,
): Parameters<typeof runIosRunnerCommand>[2] {
): Parameters<typeof prepareIosRunner>[1] {
const buildTimeoutMs = readPrepareIosRunnerBuildTimeoutMs(req);
return {
verbose: req.flags?.verbose,
logPath,
traceLogPath: session?.trace?.outPath,
requestId: req.meta?.requestId,
buildTimeoutMs,
healthTimeoutMs: Math.min(buildTimeoutMs, PREPARE_IOS_RUNNER_HEALTH_TIMEOUT_MS),
};
}

function readPrepareIosRunnerBuildTimeoutMs(req: DaemonRequest): number {
const value = req.flags?.timeoutMs;
return typeof value === 'number' && Number.isFinite(value) && value > 0
? value
: PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS;
}

function prepareIosRunnerResponseData(
action: string,
device: DeviceInfo,
durationMs: number,
runner: Awaited<ReturnType<typeof runIosRunnerCommand>>,
result: PrepareIosRunnerResult,
): Record<string, unknown> {
return {
action,
Expand All @@ -127,7 +141,7 @@ function prepareIosRunnerResponseData(
deviceName: device.name,
kind: device.kind,
durationMs,
runner,
...result,
message: `Prepared iOS runner: ${device.name}`,
};
}
Expand Down
53 changes: 53 additions & 0 deletions src/platforms/ios/__tests__/runner-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ import {
} from '../runner-client.ts';
import {
acquireRunnerXctestrunCacheLock,
ensureXctestrunArtifact,
ensureXctestrun,
markRunnerXctestrunArtifactBadForRun,
resolveExpectedRunnerCacheMetadata,
resolveRunnerDerivedPath,
resolveRunnerCacheMetadataPath,
Expand Down Expand Up @@ -1132,6 +1134,57 @@ test('ensureXctestrun rebuilds cached runner when metadata package version misma
assert.equal(rebuiltMetadata.artifacts?.xctestrunPath, rebuiltXctestrunPath);
});

test('ensureXctestrunArtifact stress-recovers after a bad restored artifact', async () => {
const projectRoot = repoRoot;
const tmpDir = await makeProjectTmpDir();
const derivedPath = path.join(tmpDir, 'custom-derived');
const productPath = path.join(derivedPath, 'Runner.app');
const cachedXctestrunPath = path.join(derivedPath, 'cached.xctestrun');
await fs.promises.mkdir(productPath, { recursive: true });
writeXctestrunFixture(cachedXctestrunPath, {
projectRoot,
productRelativePaths: ['Runner.app'],
});
writeRunnerCacheMetadataWithArtifacts({
derivedPath,
device: macOsDevice,
xctestrunPath: cachedXctestrunPath,
productPaths: [productPath],
});
withRunnerDerivedPathEnv(derivedPath);

const hit = await ensureXctestrunArtifact(macOsDevice, {});

assert.equal(hit.xctestrunPath, cachedXctestrunPath);
assert.equal(hit.cache, 'exact');
assert.equal(hit.artifact, 'valid');
assert.equal(hit.buildMs, 0);
assert.equal(mockRunCmdStreaming.mock.calls.length, 0);

await markRunnerXctestrunArtifactBadForRun(hit, 'stress health failed');
assert.equal(fs.existsSync(cachedXctestrunPath), false);

const rebuiltXctestrunPath = path.join(derivedPath, 'rebuilt', 'rebuilt.xctestrun');
mockRunCmdStreaming.mockImplementationOnce(async () => {
await fs.promises.mkdir(path.join(derivedPath, 'rebuilt', 'Runner.app'), { recursive: true });
writeXctestrunFixture(rebuiltXctestrunPath, {
projectRoot,
productRelativePaths: ['Runner.app'],
});
});

const rebuilt = await ensureXctestrunArtifact(macOsDevice, {
buildTimeoutMs: 300_000,
});

assert.equal(rebuilt.xctestrunPath, rebuiltXctestrunPath);
assert.equal(rebuilt.cache, 'miss');
assert.equal(rebuilt.artifact, 'rebuilt');
assert.equal(rebuilt.reason, 'missing_xctestrun');
assert.equal(mockRunCmdStreaming.mock.calls.length, 1);
assert.equal(mockRunCmdStreaming.mock.calls[0]?.[2]?.timeoutMs, 300_000);
});

test('ensureXctestrun rethrows unexpected cached macOS runner repair errors', async () => {
const { derivedPath, existingXctestrunPath } = await makeCachedRunnerXctestrun();

Expand Down
Loading
Loading