diff --git a/package.json b/package.json index e22743881c..a39877d3ea 100644 --- a/package.json +++ b/package.json @@ -340,7 +340,7 @@ "@agent-device/xml": "workspace:*", "@arethetypeswrong/cli": "^0.18.5", "@chenglou/freerange": "^0.0.4", - "@limrun/api": "^0.64.1", + "@limrun/api": "^0.68.1", "@nkzw/eslint-plugin": "^2.0.0", "@nkzw/oxlint-config": "^1.3.0", "@statelyai/graph": "2.4.0", diff --git a/packages/provider-limrun/package.json b/packages/provider-limrun/package.json index b83e398072..6bd2406c11 100644 --- a/packages/provider-limrun/package.json +++ b/packages/provider-limrun/package.json @@ -17,6 +17,6 @@ } }, "devDependencies": { - "@limrun/api": "^0.64.1" + "@limrun/api": "^0.68.1" } } diff --git a/packages/provider-limrun/src/device-session.test.ts b/packages/provider-limrun/src/device-session.test.ts index 51aa9a9d3b..48c61da4cc 100644 --- a/packages/provider-limrun/src/device-session.test.ts +++ b/packages/provider-limrun/src/device-session.test.ts @@ -73,8 +73,8 @@ const TEST_DEPENDENCIES = { test('iOS device session exposes reusable capabilities without its raw client', async () => { const pressKey = vi.fn(async () => undefined); const readLogs = vi.fn(async () => 'line one\nline two\n'); - const startRecording = vi.fn(async () => undefined); - const stopRecording = vi.fn(async () => 'https://ios.example/recording'); + const startRecordingSession = vi.fn(async () => 'recording-id'); + const stopRecordingSession = vi.fn(async () => 'https://ios.example/recording'); const simctlWait = vi.fn(async () => ({ code: 0, stdout: 'ok', stderr: '' })); const simctl = vi.fn(() => commandExecution(simctlWait)); const session = createLimrunDeviceSession( @@ -83,8 +83,8 @@ test('iOS device session exposes reusable capabilities without its raw client', listApps: vi.fn(async () => IOS_APPS), pressKey, appLogTail: readLogs, - startRecording, - stopRecording, + startRecordingSession, + stopRecordingSession, simctl, }), ); @@ -103,8 +103,10 @@ test('iOS device session exposes reusable capabilities without its raw client', assert.deepEqual(session.viewport, { width: 402, height: 874 }); await session.pressKey('enter', ['command']); assert.equal(await session.readLogs('com.example.ios', 200), 'line one\nline two\n'); - await session.startRecording({ quality: 8 }); - assert.deepEqual(await session.stopRecording(), { downloadUrl: 'https://ios.example/recording' }); + await session.startRecording({ recordingId: 'recording-id', quality: 8 }); + assert.deepEqual(await session.stopRecording('recording-id'), { + downloadUrl: 'https://ios.example/recording', + }); await session.downloadRecording({ downloadUrl: 'https://ios.example/recording', outPath: '/tmp/ios-recording.mp4', @@ -117,8 +119,10 @@ test('iOS device session exposes reusable capabilities without its raw client', assert.deepEqual(pressKey.mock.calls[0], ['enter', ['command']]); assert.deepEqual(readLogs.mock.calls[0], ['com.example.ios', 200]); - assert.deepEqual(startRecording.mock.calls[0], [{ quality: 8 }]); - assert.deepEqual(stopRecording.mock.calls[0], [{}]); + assert.deepEqual(startRecordingSession.mock.calls[0], [ + { recordingId: 'recording-id', quality: 8 }, + ]); + assert.deepEqual(stopRecordingSession.mock.calls[0], ['recording-id']); assert.deepEqual(downloadFile.mock.calls.at(-1), [ { url: 'https://ios.example/recording', @@ -175,11 +179,11 @@ test('Android device session exposes semantic ADB capabilities without its raw c }, }; const pressKey = vi.fn(async () => undefined); - const startRecording = vi.fn(async () => undefined); - const stopRecording = vi.fn(async () => 'https://android.example/recording'); + const startRecordingSession = vi.fn(async () => 'recording-id'); + const stopRecordingSession = vi.fn(async () => 'https://android.example/recording'); const sendAsset = vi.fn(async () => undefined); const session = createLimrunDeviceSession( - androidSession(provider, { pressKey, startRecording, stopRecording, sendAsset }), + androidSession(provider, { pressKey, startRecordingSession, stopRecordingSession, sendAsset }), ); assert.equal(session.platform, 'android'); @@ -195,8 +199,8 @@ test('Android device session exposes semantic ADB capabilities without its raw c assert.equal(await session.readLogs(20), 'log line\n'); assert.equal(await session.installRemoteApp('https://assets.example/android.apk'), undefined); await session.pressKey('KEYCODE_ENTER', ['shift']); - await session.startRecording({ quality: 7 }); - assert.deepEqual(await session.stopRecording(), { + await session.startRecording({ recordingId: 'recording-id', quality: 7 }); + assert.deepEqual(await session.stopRecording('recording-id'), { downloadUrl: 'https://android.example/recording', }); await session.downloadRecording({ @@ -207,8 +211,10 @@ test('Android device session exposes semantic ADB capabilities without its raw c assert.deepEqual(sendAsset.mock.calls[0], ['https://assets.example/android.apk']); assert.deepEqual(pressKey.mock.calls[0], ['KEYCODE_ENTER', ['shift']]); - assert.deepEqual(startRecording.mock.calls[0], [{ quality: 7 }]); - assert.deepEqual(stopRecording.mock.calls[0], [{}]); + assert.deepEqual(startRecordingSession.mock.calls[0], [ + { recordingId: 'recording-id', quality: 7 }, + ]); + assert.deepEqual(stopRecordingSession.mock.calls[0], ['recording-id']); assert.deepEqual(downloadFile.mock.calls.at(-1), [ { url: 'https://android.example/recording', diff --git a/packages/provider-limrun/src/device-session.ts b/packages/provider-limrun/src/device-session.ts index a563cd3af7..2a59addb15 100644 --- a/packages/provider-limrun/src/device-session.ts +++ b/packages/provider-limrun/src/device-session.ts @@ -59,16 +59,19 @@ type LimrunDeviceSessionBase = { readonly interactor: Interactor; listApps(filter?: AppsFilter): Promise; pressKey(key: string, modifiers?: string[]): Promise; - startRecording(options?: { quality?: LimrunRecordingQuality }): Promise; - /** Stops the instance recorder and answers where the finished file is served; nothing is downloaded. */ - stopRecording(): Promise<{ downloadUrl: string }>; + startRecording(options: { recordingId: string; quality?: LimrunRecordingQuality }): Promise; + /** Stops the identified recorder and answers where the finished file is served; nothing is downloaded. */ + stopRecording(recordingId: string): Promise<{ downloadUrl: string }>; /** Fetches a served recording to `outPath` within a fixed deadline. Retriable while the instance lives. */ downloadRecording(input: { downloadUrl: string; outPath: string }): Promise; }; type LimrunRecordingClient = { - startRecording(options?: { quality?: LimrunRecordingQuality }): Promise; - stopRecording(saveTo: { localPath?: string }): Promise; + startRecordingSession(options: { + recordingId: string; + quality?: LimrunRecordingQuality; + }): Promise; + stopRecordingSession(recordingId: string): Promise; }; /** @@ -170,12 +173,13 @@ function createRecordingOperations( host: Pick, ) { return { - startRecording: async (options?: { quality?: LimrunRecordingQuality }) => { - await client.startRecording(options); + startRecording: async (options: { recordingId: string; quality?: LimrunRecordingQuality }) => { + await client.startRecordingSession(options); }, - // Without `localPath` the SDK only stops the recorder and returns the served URL; a download - // tied to the one-shot stop could not be retried after a dropped transfer. - stopRecording: async () => ({ downloadUrl: await client.stopRecording({}) }), + // Keep downloads separate from stopping so a dropped transfer can be retried. + stopRecording: async (recordingId: string) => ({ + downloadUrl: await client.stopRecordingSession(recordingId), + }), downloadRecording: async (input: { downloadUrl: string; outPath: string }) => { await host.downloadFile({ url: input.downloadUrl, diff --git a/packages/provider-limrun/src/recording-runtime.test.ts b/packages/provider-limrun/src/recording-runtime.test.ts index 60e187f987..44f7740a7d 100644 --- a/packages/provider-limrun/src/recording-runtime.test.ts +++ b/packages/provider-limrun/src/recording-runtime.test.ts @@ -112,7 +112,10 @@ test('the high preset maps onto the low end of the legacy high band', async () = await operations.screenRecordingStart(input({ exportQuality: 'high' })); - expect(session.startRecording).toHaveBeenCalledWith({ quality: 8 }); + expect(session.startRecording).toHaveBeenCalledWith({ + recordingId: expect.any(String), + quality: 8, + }); }); test('stop asks the instance once, downloads the served MP4 to the output path, finalizes, and confirms the recorder', async () => { @@ -258,3 +261,38 @@ test('the bound owner serves record start and stop through the live device sessi 'finalize', ]); }); + +test('finishing or cleaning one recording leaves other recordings on the instance active', async () => { + const active = new Set(['external-recording']); + const calls: string[] = []; + const session: LimrunScreenRecordingSession = { + startRecording: async ({ recordingId }) => { + if (active.has(recordingId)) throw new Error('Recording ID already exists'); + active.add(recordingId); + }, + stopRecording: async (recordingId) => { + if (!active.delete(recordingId)) throw new Error('Recording does not exist'); + return { downloadUrl: `${DOWNLOAD_URL}&recordingId=${recordingId}` }; + }, + downloadRecording: async () => {}, + }; + const operations = createLimrunScreenRecordingOperations({ + host: fakeRecordingHost(calls), + device: limrunIosSimulator, + owner, + signal: new AbortController().signal, + getDeviceSession: () => session, + }); + const first = (await operations.screenRecordingStart(input())).pendingHandle.transfer(); + const second = ( + await operations.screenRecordingStart(input({ outputPath: '/tmp/second-recording.mp4' })) + ).pendingHandle.transfer(); + expect(active.size).toBe(3); + + await expect(first.finish()).resolves.toMatchObject({ status: 'completed' }); + expect(active.size).toBe(2); + expect(active.has('external-recording')).toBe(true); + + await expect(second.forceCleanup()).resolves.toEqual({ status: 'cleaned' }); + expect([...active]).toEqual(['external-recording']); +}); diff --git a/packages/provider-limrun/src/recording-runtime.ts b/packages/provider-limrun/src/recording-runtime.ts index 64cc54ed92..16d6326edc 100644 --- a/packages/provider-limrun/src/recording-runtime.ts +++ b/packages/provider-limrun/src/recording-runtime.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { RuntimeOwnerRef } from '@agent-device/contracts/platform-runtime'; @@ -67,6 +68,7 @@ async function startLimrunRecording( { deviceId: params.device.id }, ); } + const recordingId = randomUUID(); return await startTransportScreenRecording({ host: params.host, device: params.device, @@ -78,10 +80,11 @@ async function startLimrunRecording( targetLabel: 'Limrun recording', start: async () => { await session.startRecording({ + recordingId, quality: LIMRUN_QUALITY_BY_EXPORT[input.exportQuality ?? 'medium'], }); }, - stop: async () => (await session.stopRecording()).downloadUrl, + stop: async () => (await session.stopRecording(recordingId)).downloadUrl, collect: async (downloadUrl, outputPath) => { await session.downloadRecording({ downloadUrl, outPath: outputPath }); }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e4bdab090..5d38ac0838 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -149,8 +149,8 @@ importers: specifier: ^0.0.4 version: 0.0.4 '@limrun/api': - specifier: ^0.64.1 - version: 0.64.1(supports-color@7.2.0) + specifier: ^0.68.1 + version: 0.68.1(supports-color@7.2.0) '@nkzw/eslint-plugin': specifier: ^2.0.0 version: 2.0.0(eslint@10.9.1(supports-color@7.2.0)) @@ -868,8 +868,8 @@ importers: version: link:../platform-android devDependencies: '@limrun/api': - specifier: ^0.64.1 - version: 0.64.1(supports-color@7.2.0) + specifier: ^0.68.1 + version: 0.68.1(supports-color@7.2.0) packages/provider-revyl: devDependencies: @@ -2089,8 +2089,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@limrun/api@0.64.1': - resolution: {integrity: sha512-nrKFfalo3I+9wFPEoIs7kqlUxgih14qvaQqBr1OsUM7j0rjfxje2XiIHb/401PIccPEvuXSFbSJ4uru+asDbwg==} + '@limrun/api@0.68.1': + resolution: {integrity: sha512-BgE74RtV1BSbIJJlctYdWF10gxr90dU3AniO3ERhjb0Q5HN8Eth4DXvmmHDKCfjZRCIeft4m9WvZY98PW7LKoA==} '@limrun/xdelta3-wasm@0.2.0': resolution: {integrity: sha512-mEohO7CYj4Gdc0v1Gr/AjlBhfbXQnIz6+2wmMhRfwz8hXZrsIC/yqYQhfoDhu1ysqQwBnU5lDDLPUGdUu+8hPQ==} @@ -6689,7 +6689,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@limrun/api@0.64.1(supports-color@7.2.0)': + '@limrun/api@0.68.1(supports-color@7.2.0)': dependencies: '@limrun/xdelta3-wasm': 0.2.0 '@types/har-format': 1.2.16