Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { BuildRuntimePlatform } from '@expo/steps';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';

import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../../utils/deviceRunSessionArtifacts';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from '../uploadDeviceRunSessionScreenRecordings';

jest.mock('../../utils/deviceRunSessionArtifacts', () => ({
uploadDeviceRunSessionArtifactAsync: jest.fn(),
}));

describe(createUploadDeviceRunSessionScreenRecordingsBuildFunction, () => {
it('uploads simulator device metadata', async () => {
const recordingDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'ios-recording-test-'));
const recordingPath = path.join(recordingDirectory, 'recording.mp4');
await fs.writeFile(recordingPath, 'recording');
await fs.writeFile(
path.join(recordingDirectory, 'session.json'),
JSON.stringify({
recording: path.basename(recordingPath),
firstFrameWallClock: { iso8601: '2026-07-10T10:00:00.000Z' },
})
);

try {
const globalContext = createGlobalContextMock({
runtimePlatform: BuildRuntimePlatform.DARWIN,
});
const buildStep = createUploadDeviceRunSessionScreenRecordingsBuildFunction(
{} as CustomBuildContext
).createBuildStepFromFunctionCall(globalContext, {
env: { DEVICE_RUN_SESSION_ID: 'device-run-session-id' },
callInputs: {
recordings_json: [
{
udid: 'simulator-udid',
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
directory: recordingDirectory,
},
],
},
});

await buildStep.executeAsync();

expect(uploadDeviceRunSessionArtifactAsync).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
name: 'iPhone 16 screen recording',
metadata: {
__eas_screen_recording: '1',
udid: 'simulator-udid',
deviceName: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
firstFrameAt: '2026-07-10T10:00:00.000Z',
},
})
);
} finally {
await fs.rm(recordingDirectory, { recursive: true, force: true });
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { getDeviceRunSessionIdOrThrow } from '../utils/remoteDeviceRunSession';
const RecordingsSchema = z.array(
z.object({
udid: z.string(),
displayName: z.string(),
deviceName: z.string(),
runtimeDisplayName: z.string(),
directory: z.string(),
})
);
Expand Down Expand Up @@ -67,6 +68,7 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
await Promise.all(
recordings.map(recording =>
limit(async () => {
const displayName = `${recording.deviceName} screen recording`;
try {
const metadata = RecordingManifestSchema.parse(
JSON.parse(await readFile(path.join(recording.directory, 'session.json'), 'utf-8'))
Expand All @@ -75,17 +77,19 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
const { size } = await stat(recordingPath);
const recordingId = path.basename(recording.directory);
logger.info(
`Uploading screen recording for ${recording.displayName} (${formatBytes(size)}).`
`Uploading screen recording for ${recording.deviceName} (${formatBytes(size)}).`
);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: recordingId,
name: recording.displayName,
name: displayName,
filename: `${recordingId}.mp4`,
kind: 'screen-recording',
metadata: {
__eas_screen_recording: '1',
udid: recording.udid,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
firstFrameAt: metadata.firstFrameWallClock.iso8601,
},
size,
Expand All @@ -96,7 +100,7 @@ export function createUploadDeviceRunSessionScreenRecordingsBuildFunction(
Sentry.capture('Could not upload iOS Simulator screen recording', error);
logger.warn(
{ err: error },
`Could not upload screen recording for ${recording.displayName}.`
`Could not upload screen recording for ${recording.deviceName}.`
);
}
})
Expand Down
48 changes: 28 additions & 20 deletions packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const RECORD_SIM_COMMAND = 'record-sim';
type IosSimulatorRecording = {
id: string;
udid: IosSimulatorUuid;
displayName: string;
deviceName: string;
runtimeDisplayName: string;
outputDirectory: string;
startedAt: Date;
getOutput: () => string;
Expand Down Expand Up @@ -84,11 +85,14 @@ export namespace IosSimulatorRecordingUtils {
});
}

export async function finishAsync({
logger,
}: {
logger: bunyan;
}): Promise<{ udid: IosSimulatorUuid; displayName: string; directory: string }[]> {
export async function finishAsync({ logger }: { logger: bunyan }): Promise<
{
udid: IosSimulatorUuid;
deviceName: string;
runtimeDisplayName: string;
directory: string;
}[]
> {
const session = activeIosSimulatorRecordingSession;
if (!session) {
logger.info('No iOS Simulator screen recordings are running.');
Expand All @@ -100,7 +104,7 @@ export namespace IosSimulatorRecordingUtils {
await session.pollingPromise;
await Promise.all(
[...session.activeRecordings.values()].map(async recording => {
logger.info(`Stopping screen recording for ${recording.displayName}.`);
logger.info(`Stopping screen recording for ${recording.deviceName}.`);
recording.recordingProcess.kill('SIGINT');
const finished = await Promise.race([
recording.completionPromise.then(() => true),
Expand All @@ -110,20 +114,18 @@ export namespace IosSimulatorRecordingUtils {
return;
}

logger.warn(
`Forcing iOS Simulator recording process for ${recording.displayName} to stop.`
);
logger.warn(`Forcing iOS Simulator recording process for ${recording.deviceName} to stop.`);
recording.recordingProcess.kill('SIGKILL');
const killed = await Promise.race([
recording.completionPromise.then(() => true),
setTimeout(RECORD_SIM_FORCE_STOP_TIMEOUT_MS, false, { ref: false }),
]);
if (!killed) {
logger.warn(
`iOS Simulator recording process for ${recording.displayName} did not exit after SIGKILL.`
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`
);
Sentry.capture(
`iOS Simulator recording process for ${recording.displayName} did not exit after SIGKILL.`,
`iOS Simulator recording process for ${recording.deviceName} did not exit after SIGKILL.`,
{
extras: {
output: recording.getOutput(),
Expand All @@ -139,7 +141,8 @@ export namespace IosSimulatorRecordingUtils {
);
return completedRecordings.map(recording => ({
udid: recording.udid,
displayName: recording.displayName,
deviceName: recording.deviceName,
runtimeDisplayName: recording.runtimeDisplayName,
directory: recording.outputDirectory,
}));
}
Expand Down Expand Up @@ -179,7 +182,8 @@ async function pollIosSimulatorRecordingsAsync(
}
await startIosSimulatorRecordingAsync(session, {
udid: device.udid,
displayName: `${device.name} screen recording`,
deviceName: device.name,
runtimeDisplayName: device.runtimeDisplayName,
});
}
} catch (err) {
Expand Down Expand Up @@ -212,18 +216,20 @@ async function startIosSimulatorRecordingAsync(
session: IosSimulatorRecordingSession,
{
udid,
displayName,
deviceName,
runtimeDisplayName,
}: {
udid: IosSimulatorUuid;
displayName: string;
deviceName: string;
runtimeDisplayName: string;
}
): Promise<void> {
const startedAt = new Date();
const recordingId = randomUUID();
const outputDirectory = path.join(session.recordingsRootDirectory, recordingId);
await mkdir(outputDirectory, { recursive: true });

session.logger.info(`Starting screen recording for ${displayName}.`);
session.logger.info(`Starting screen recording for ${deviceName}.`);
const recordingSpawn = spawn(
session.recordSimCommand,
['--udid', udid, '--output', outputDirectory, '--segment-duration', '0'],
Expand All @@ -241,15 +247,16 @@ async function startIosSimulatorRecordingAsync(
Sentry.capture('iOS Simulator screen recording process failed', error);
session.logger.warn(
{ err: error, recordSimOutput: getOutput() },
`Screen recording process failed for ${displayName}.`
`Screen recording process failed for ${deviceName}.`
);
})
.finally(() => {
session.activeRecordings.delete(udid);
session.completedRecordings.push({
id: recordingId,
udid,
displayName,
deviceName,
runtimeDisplayName,
outputDirectory,
startedAt,
getOutput,
Expand All @@ -259,7 +266,8 @@ async function startIosSimulatorRecordingAsync(
session.activeRecordings.set(udid, {
id: recordingId,
udid,
displayName,
deviceName,
runtimeDisplayName,
outputDirectory,
recordingProcess: recordingSpawn.child,
completionPromise,
Expand Down
12 changes: 11 additions & 1 deletion packages/build-tools/src/utils/IosSimulatorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ export namespace IosSimulatorUtils {
lastBootedAt?: string;
};

type SimulatorDevice = XcrunSimctlDevice & { runtime: string; displayName: string };
type SimulatorDevice = XcrunSimctlDevice & {
runtime: string;
runtimeDisplayName: string;
displayName: string;
};

type XcrunSimctlListDevicesJsonOutput = {
devices: {
Expand Down Expand Up @@ -60,6 +64,7 @@ export namespace IosSimulatorUtils {
...devices.map(device => ({
...device,
runtime,
runtimeDisplayName: formatRuntimeDisplayName(runtime),
displayName: `${device.name} (${device.udid}) on ${runtime}`,
}))
);
Expand Down Expand Up @@ -374,6 +379,11 @@ export namespace IosSimulatorUtils {
}
}

function formatRuntimeDisplayName(runtimeIdentifier: string): string {
const match = /^com\.apple\.CoreSimulator\.SimRuntime\.([^-]+)-(.+)$/.exec(runtimeIdentifier);
return match ? `${match[1]} ${match[2].replaceAll('-', '.')}` : runtimeIdentifier;
}

async function isLaunchctlServiceLoadedAsync({
udid,
env,
Expand Down
33 changes: 33 additions & 0 deletions packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,39 @@ describe('IosSimulatorUtils', () => {
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
});

describe(IosSimulatorUtils.getAvailableDevicesAsync, () => {
it('adds a human-readable runtime display name', async () => {
mockedSpawn.mockResolvedValue({
stdout: JSON.stringify({
devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-18-6': [
{
dataPath: '/tmp/eas-test-simulator-data',
dataPathSize: 1,
logPath: '/tmp/eas-test-simulator-logs',
udid: 'test-udid',
isAvailable: true,
deviceTypeIdentifier: 'com.apple.CoreSimulator.SimDeviceType.iPhone-16',
state: 'Booted',
name: 'iPhone 16',
},
],
},
}),
stderr: '',
} as any);

await expect(
IosSimulatorUtils.getAvailableDevicesAsync({ env: process.env, filter: 'booted' })
).resolves.toEqual([
expect.objectContaining({
name: 'iPhone 16',
runtimeDisplayName: 'iOS 18.6',
}),
]);
});
});

describe(IosSimulatorUtils.startAsync, () => {
it('waits for boot completion without writing accessibility prefs', async () => {
mockedSpawn.mockImplementation((async (command: string, args: string[]) => {
Expand Down
Loading