From 42539e9ea1defc4efc1a8ffeac773e785607033d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stanis=C5=82aw=20Chmiela?= Date: Fri, 10 Jul 2026 22:08:11 +0200 Subject: [PATCH] Add simulator details to screen recording metadata --- ...adDeviceRunSessionScreenRecordings.test.ts | 67 +++++++++++++++++++ .../uploadDeviceRunSessionScreenRecordings.ts | 12 ++-- .../steps/utils/IosSimulatorRecordingUtils.ts | 48 +++++++------ .../src/utils/IosSimulatorUtils.ts | 12 +++- .../utils/__tests__/IosSimulatorUtils.test.ts | 33 +++++++++ 5 files changed, 147 insertions(+), 25 deletions(-) create mode 100644 packages/build-tools/src/steps/functions/__tests__/uploadDeviceRunSessionScreenRecordings.test.ts diff --git a/packages/build-tools/src/steps/functions/__tests__/uploadDeviceRunSessionScreenRecordings.test.ts b/packages/build-tools/src/steps/functions/__tests__/uploadDeviceRunSessionScreenRecordings.test.ts new file mode 100644 index 0000000000..432bc1a2d9 --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/uploadDeviceRunSessionScreenRecordings.test.ts @@ -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 }); + } + }); +}); diff --git a/packages/build-tools/src/steps/functions/uploadDeviceRunSessionScreenRecordings.ts b/packages/build-tools/src/steps/functions/uploadDeviceRunSessionScreenRecordings.ts index 6eba5d3787..2b8ca298d9 100644 --- a/packages/build-tools/src/steps/functions/uploadDeviceRunSessionScreenRecordings.ts +++ b/packages/build-tools/src/steps/functions/uploadDeviceRunSessionScreenRecordings.ts @@ -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(), }) ); @@ -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')) @@ -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, @@ -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}.` ); } }) diff --git a/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts b/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts index ac7e55c9a0..dc7b2fbeb8 100644 --- a/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts +++ b/packages/build-tools/src/steps/utils/IosSimulatorRecordingUtils.ts @@ -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; @@ -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.'); @@ -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), @@ -110,9 +114,7 @@ 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), @@ -120,10 +122,10 @@ export namespace IosSimulatorRecordingUtils { ]); 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(), @@ -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, })); } @@ -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) { @@ -212,10 +216,12 @@ async function startIosSimulatorRecordingAsync( session: IosSimulatorRecordingSession, { udid, - displayName, + deviceName, + runtimeDisplayName, }: { udid: IosSimulatorUuid; - displayName: string; + deviceName: string; + runtimeDisplayName: string; } ): Promise { const startedAt = new Date(); @@ -223,7 +229,7 @@ async function startIosSimulatorRecordingAsync( 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'], @@ -241,7 +247,7 @@ 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(() => { @@ -249,7 +255,8 @@ async function startIosSimulatorRecordingAsync( session.completedRecordings.push({ id: recordingId, udid, - displayName, + deviceName, + runtimeDisplayName, outputDirectory, startedAt, getOutput, @@ -259,7 +266,8 @@ async function startIosSimulatorRecordingAsync( session.activeRecordings.set(udid, { id: recordingId, udid, - displayName, + deviceName, + runtimeDisplayName, outputDirectory, recordingProcess: recordingSpawn.child, completionPromise, diff --git a/packages/build-tools/src/utils/IosSimulatorUtils.ts b/packages/build-tools/src/utils/IosSimulatorUtils.ts index 9cfe82db03..4f3ada8d12 100644 --- a/packages/build-tools/src/utils/IosSimulatorUtils.ts +++ b/packages/build-tools/src/utils/IosSimulatorUtils.ts @@ -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: { @@ -60,6 +64,7 @@ export namespace IosSimulatorUtils { ...devices.map(device => ({ ...device, runtime, + runtimeDisplayName: formatRuntimeDisplayName(runtime), displayName: `${device.name} (${device.udid}) on ${runtime}`, })) ); @@ -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, diff --git a/packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts b/packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts index b9252c5368..74617c1256 100644 --- a/packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts +++ b/packages/build-tools/src/utils/__tests__/IosSimulatorUtils.test.ts @@ -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[]) => {