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
4 changes: 2 additions & 2 deletions .github/workflows/ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ jobs:
build-command: sh ./scripts/build-xcuitest-apple.sh
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 +57,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
4 changes: 2 additions & 2 deletions .github/workflows/replays-nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ jobs:
build-command: sh ./scripts/build-xcuitest-apple.sh
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 +80,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
72 changes: 67 additions & 5 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 @@ -146,9 +197,10 @@ function resolveSigningBuildSettings() {
const appBundleId = resolveRunnerAppBundleId();
const testBundleId = resolveRunnerTestBundleId();
const metadata = {
schemaVersion: 1,
schemaVersion: 2,
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);
}
6 changes: 3 additions & 3 deletions src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1159,11 +1159,11 @@ function resolveRequestTimeoutHint(params: {
if (!resetDaemon) {
const iosPrepareHint =
command === PUBLIC_COMMANDS.snapshot
? ' If this was the first iOS snapshot on the device, run agent-device prepare ios-runner --platform ios before snapshot/test so runner startup is handled explicitly.'
? ' If this was the first Apple-platform snapshot on the device, run agent-device prepare ios-runner with the same --platform before snapshot/test so runner startup is handled explicitly.'
: '';
return `Retry with --debug and check daemon diagnostics logs. The timed-out ${command ?? 'request'} request was canceled and iOS runner work was aborted when detected; the daemon was kept alive so the session can still be closed or inspected.${iosPrepareHint}`;
return `Retry with --debug and check daemon diagnostics logs. The timed-out ${command ?? 'request'} request was canceled and Apple runner work was aborted when detected; the daemon was kept alive so the session can still be closed or inspected.${iosPrepareHint}`;
}
return 'Retry with --debug and check daemon diagnostics logs. Timed-out iOS runner xcodebuild processes were terminated when detected.';
return 'Retry with --debug and check daemon diagnostics logs. Timed-out Apple runner xcodebuild processes were terminated when detected.';
}

function handleTransportError(
Expand Down
86 changes: 73 additions & 13 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,12 +2138,13 @@ 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({
cleanStaleBundles: true,
buildTimeoutMs: 240000,
healthTimeoutMs: 90000,
logPath: expect.stringMatching(/daemon\.log$/),
requestId: 'prepare-request',
startupTimeoutMs: 240000,
Expand All @@ -2147,13 +2156,62 @@ 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',
message: 'Prepared Apple runner: iPhone 17 Pro',
});
expect(sessionStore.get(sessionName)).toBeUndefined();
});

test('prepare ios-runner rejects non-iOS devices', async () => {
test('prepare ios-runner starts the XCTest runner on an explicit macOS selector', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'prepare-macos-runner';
mockResolveTargetDevice.mockResolvedValue({
platform: 'macos',
id: 'host-macos-local',
name: 'Host Mac',
kind: 'device',
target: 'desktop',
booted: true,
});

const response = await handleSessionCommands({
req: {
token: 't',
session: sessionName,
command: 'prepare',
positionals: ['ios-runner'],
flags: { platform: 'macos', timeoutMs: 240000 },
meta: { requestId: 'prepare-macos-request' },
},
sessionName,
logPath: path.join(os.tmpdir(), 'daemon.log'),
sessionStore,
invoke: noopInvoke,
});

expect(response).toBeTruthy();
expect(response?.ok).toBe(true);
expect(mockPrepareIosRunner).toHaveBeenCalledWith(
expect.objectContaining({ platform: 'macos', id: 'host-macos-local' }),
expect.objectContaining({
buildTimeoutMs: 240000,
healthTimeoutMs: 90000,
requestId: 'prepare-macos-request',
}),
);
expect((response as any).data).toMatchObject({
action: 'ios-runner',
platform: 'macos',
deviceId: 'host-macos-local',
deviceName: 'Host Mac',
kind: 'device',
message: 'Prepared Apple runner: Host Mac',
});
});

test('prepare ios-runner rejects non-Apple runner devices', async () => {
const sessionStore = makeSessionStore();
mockResolveTargetDevice.mockResolvedValue({
platform: 'android',
Expand Down Expand Up @@ -2181,9 +2239,11 @@ test('prepare ios-runner rejects non-iOS devices', async () => {
expect(response?.ok).toBe(false);
if (response && !response.ok) {
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
expect(response.error.message).toBe('prepare ios-runner is only supported on iOS');
expect(response.error.message).toBe(
'prepare ios-runner is only supported on Apple runner platforms',
);
}
expect(mockRunIosRunnerCommand).not.toHaveBeenCalled();
expect(mockPrepareIosRunner).not.toHaveBeenCalled();
});

test('prepare requires the ios-runner subcommand', async () => {
Expand All @@ -2210,7 +2270,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
Loading
Loading