|
| 1 | +import { expect, test, vi } from 'vitest'; |
| 2 | +import type { ScreenRecordingStartInput } from '@agent-device/contracts/screen-recording-runtime'; |
| 3 | +import { providerRuntimeOwner } from '@agent-device/contracts/platform-runtime'; |
| 4 | +import { createLimrunScreenRecordingOperations } from './recording-runtime.ts'; |
| 5 | +import { limrunIosSimulator } from './app-log-runtime.fixtures.ts'; |
| 6 | + |
| 7 | +const owner = providerRuntimeOwner('limrun', 'default'); |
| 8 | + |
| 9 | +function recorder(options: { finalize?: () => Promise<Record<string, never>> } = {}) { |
| 10 | + const calls: string[] = []; |
| 11 | + const session = { |
| 12 | + startRecording: vi.fn(async (start?: { quality?: number }) => { |
| 13 | + calls.push(`start:${start?.quality ?? 'default'}`); |
| 14 | + }), |
| 15 | + stopRecording: vi.fn(async ({ outPath }: { outPath: string }) => { |
| 16 | + calls.push(`stop:${outPath}`); |
| 17 | + return 'https://limrun.example/recording.mp4'; |
| 18 | + }), |
| 19 | + }; |
| 20 | + const host = { |
| 21 | + screenRecording: { |
| 22 | + outputs: { |
| 23 | + prepare: vi.fn(async () => { |
| 24 | + calls.push('prepare'); |
| 25 | + }), |
| 26 | + copy: vi.fn(async () => {}), |
| 27 | + remove: vi.fn(async () => { |
| 28 | + calls.push('remove'); |
| 29 | + return 'removed' as const; |
| 30 | + }), |
| 31 | + }, |
| 32 | + finalize: { |
| 33 | + sniff: vi.fn(async () => {}), |
| 34 | + complete: vi.fn(async () => { |
| 35 | + calls.push('finalize'); |
| 36 | + return await (options.finalize ?? (async () => ({})))(); |
| 37 | + }), |
| 38 | + }, |
| 39 | + }, |
| 40 | + }; |
| 41 | + const operations = createLimrunScreenRecordingOperations({ |
| 42 | + host, |
| 43 | + device: limrunIosSimulator, |
| 44 | + owner, |
| 45 | + signal: new AbortController().signal, |
| 46 | + getDeviceSession: () => session, |
| 47 | + }); |
| 48 | + return { operations, session, host, calls }; |
| 49 | +} |
| 50 | + |
| 51 | +function input(overrides: Partial<ScreenRecordingStartInput> = {}): ScreenRecordingStartInput { |
| 52 | + return { |
| 53 | + sessionId: 'lp-verify', |
| 54 | + outputPath: '/tmp/limrun-recording.mp4', |
| 55 | + scope: 'app', |
| 56 | + showTouches: false, |
| 57 | + hideTouchesRequested: false, |
| 58 | + recordOnlySession: false, |
| 59 | + fence: { token: 'fence', generation: 1 }, |
| 60 | + ...overrides, |
| 61 | + }; |
| 62 | +} |
| 63 | + |
| 64 | +test('start prepares the output, then asks the instance to record at the medium preset', async () => { |
| 65 | + const { operations, calls } = recorder(); |
| 66 | + |
| 67 | + const started = await operations.screenRecordingStart(input()); |
| 68 | + |
| 69 | + expect(calls).toEqual(['prepare', 'start:5']); |
| 70 | + expect(started.envelope.descriptor.body).toMatchObject({ |
| 71 | + backend: 'limrun-recorder', |
| 72 | + outputPath: '/tmp/limrun-recording.mp4', |
| 73 | + }); |
| 74 | + expect(started.pendingHandle.transfer().inspect()).toMatchObject({ |
| 75 | + backend: 'limrun-recorder', |
| 76 | + outPath: '/tmp/limrun-recording.mp4', |
| 77 | + scope: 'app', |
| 78 | + showTouches: false, |
| 79 | + }); |
| 80 | +}); |
| 81 | + |
| 82 | +test('the high preset maps onto the low end of the legacy high band', async () => { |
| 83 | + const { operations, session } = recorder(); |
| 84 | + |
| 85 | + await operations.screenRecordingStart(input({ exportQuality: 'high' })); |
| 86 | + |
| 87 | + expect(session.startRecording).toHaveBeenCalledWith({ quality: 8 }); |
| 88 | +}); |
| 89 | + |
| 90 | +test('stop downloads the MP4 to the output path, finalizes it, and confirms the recorder', async () => { |
| 91 | + const { operations, calls } = recorder(); |
| 92 | + const started = await operations.screenRecordingStart(input()); |
| 93 | + |
| 94 | + const outcome = await started.pendingHandle.transfer().finish(); |
| 95 | + |
| 96 | + expect(calls).toEqual(['prepare', 'start:5', 'stop:/tmp/limrun-recording.mp4', 'finalize']); |
| 97 | + expect(outcome).toMatchObject({ |
| 98 | + status: 'completed', |
| 99 | + result: { |
| 100 | + backend: 'limrun-recorder', |
| 101 | + outPath: '/tmp/limrun-recording.mp4', |
| 102 | + stopObservation: { recorder: 'confirmed' }, |
| 103 | + }, |
| 104 | + }); |
| 105 | +}); |
| 106 | + |
| 107 | +test.each([ |
| 108 | + [{ fps: 30 }, 'Limrun recordings do not support --fps'], |
| 109 | + [{ hideTouchesRequested: true }, 'Limrun recordings do not support --hide-touches'], |
| 110 | +])( |
| 111 | + 'refuses option %# before the output is touched or the instance records', |
| 112 | + async (override, message) => { |
| 113 | + const { operations, session, host } = recorder(); |
| 114 | + |
| 115 | + await expect(operations.screenRecordingStart(input(override))).rejects.toThrow(message); |
| 116 | + |
| 117 | + expect(host.screenRecording.outputs.prepare).not.toHaveBeenCalled(); |
| 118 | + expect(session.startRecording).not.toHaveBeenCalled(); |
| 119 | + }, |
| 120 | +); |
| 121 | + |
| 122 | +test('a device without a live provider session is refused before the output is prepared', async () => { |
| 123 | + const prepare = vi.fn(async () => {}); |
| 124 | + const operations = createLimrunScreenRecordingOperations({ |
| 125 | + host: { |
| 126 | + screenRecording: { |
| 127 | + outputs: { prepare, copy: async () => {}, remove: async () => 'removed' as const }, |
| 128 | + finalize: { sniff: async () => {}, complete: async () => ({}) }, |
| 129 | + }, |
| 130 | + }, |
| 131 | + device: limrunIosSimulator, |
| 132 | + owner, |
| 133 | + signal: new AbortController().signal, |
| 134 | + getDeviceSession: () => undefined, |
| 135 | + }); |
| 136 | + |
| 137 | + await expect(operations.screenRecordingStart(input())).rejects.toMatchObject({ |
| 138 | + code: 'UNSUPPORTED_OPERATION', |
| 139 | + }); |
| 140 | + expect(prepare).not.toHaveBeenCalled(); |
| 141 | +}); |
| 142 | + |
| 143 | +test('a finalizer failure is not a completion, and cleanup stops the instance recorder once', async () => { |
| 144 | + const { operations, session, calls } = recorder({ |
| 145 | + finalize: async () => { |
| 146 | + throw new Error('export is not a playable MP4'); |
| 147 | + }, |
| 148 | + }); |
| 149 | + const started = await operations.screenRecordingStart(input()); |
| 150 | + const handle = started.pendingHandle.transfer(); |
| 151 | + |
| 152 | + await expect(handle.finish()).rejects.toThrow('export is not a playable MP4'); |
| 153 | + await expect(handle.forceCleanup()).resolves.toEqual({ status: 'cleaned' }); |
| 154 | + |
| 155 | + expect(session.stopRecording).toHaveBeenCalledOnce(); |
| 156 | + expect(calls.at(-1)).toBe('remove'); |
| 157 | +}); |
| 158 | + |
| 159 | +test('recordings are neither reattachable nor cleanable after a daemon restart', async () => { |
| 160 | + const { operations } = recorder(); |
| 161 | + const envelope = (await operations.screenRecordingStart(input())).envelope; |
| 162 | + |
| 163 | + await expect(operations.screenRecordingReattach({ envelope })).resolves.toMatchObject({ |
| 164 | + status: 'unreattachable', |
| 165 | + reason: 'transport-not-reattachable', |
| 166 | + }); |
| 167 | + await expect(operations.screenRecordingCleanup({ envelope })).resolves.toMatchObject({ |
| 168 | + status: 'cleanup-pending', |
| 169 | + reason: 'manual-recovery-required', |
| 170 | + }); |
| 171 | +}); |
0 commit comments