diff --git a/packages/capture-kit/src/recording/video.test.ts b/packages/capture-kit/src/recording/video.test.ts index 0c8dd214c1..03928bf0e0 100644 --- a/packages/capture-kit/src/recording/video.test.ts +++ b/packages/capture-kit/src/recording/video.test.ts @@ -3,13 +3,18 @@ import { createTestHost } from '../host-environment.fixtures.ts'; import path from 'node:path'; import { beforeEach, expect, test, vi } from 'vitest'; import { runCmd } from '@agent-device/host-kit/command'; -import { AppError } from '@agent-device/kernel/errors'; +import { AppError, normalizeError } from '@agent-device/kernel/errors'; import { likelyPlayableWebmContainer } from '../__tests__/test-utils/video-fixtures.ts'; import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts'; import { mp4Atom, mp4MovieHeader } from './mp4.fixtures.ts'; import { Deadline } from '@agent-device/host-kit/retry'; import { compileSwiftSourceText } from './swift-cache.ts'; -import { hasVideoContainer, isPlayableVideo, waitForPlayableVideo } from './video.ts'; +import { + hasVideoContainer, + isPlayableVideo, + prepareVideoValidator, + waitForPlayableVideo, +} from './video.ts'; vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ ...(await importOriginal()), @@ -104,54 +109,158 @@ test('callers share one validator compile, and each waits for it within its own expect(compileSwiftSourceText).toHaveBeenCalledOnce(); const [compile] = vi.mocked(compileSwiftSourceText).mock.calls[0]!; expect(compile.timeoutMs).toBeUndefined(); - expect(compile.signal?.aborted).toBe(false); + expect(compile.signal).toBeUndefined(); } finally { vi.useRealTimers(); } }); -test('the last caller to stop waiting cancels an unfinished compile, and the next caller compiles again', async () => { - // Nothing owns swiftc once its requests end: a daemon that exits while it runs leaves it - // reparented to launchd, and the compile's own temp output behind. - const mp4 = writeFixture('cancel-compile.mp4', Buffer.concat([ftyp, moov])); - const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/cancel' }, cwd: directory }; +test('an expired waiter leaves the compile running for the next stop', async () => { + const mp4 = writeFixture('pending-compile.mp4', Buffer.concat([ftyp, moov])); + const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/pending' }, cwd: directory }; + let resolveCompile!: (value: string) => void; + const pending = new Promise((resolve) => { + resolveCompile = resolve; + }); + vi.mocked(compileSwiftSourceText).mockClear().mockReturnValueOnce(pending); + void prepareVideoValidator(host); await hasVideoContainer(mp4); - let compileSignal: AbortSignal | undefined; vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); try { - vi.mocked(compileSwiftSourceText) - .mockClear() - .mockImplementationOnce(async ({ signal }) => { - compileSignal = signal; - return await new Promise((_, reject) => { - signal?.addEventListener('abort', () => reject(signal.reason)); - }); - }); - const timedOut = expect( isPlayableVideo(mp4, host, Deadline.fromTimeoutMs(5_000)), - ).rejects.toMatchObject({ details: { reason: 'video_validator_compile_timeout' } }); + ).rejects.toMatchObject({ + details: { reason: 'video_validator_compile_timeout', retriable: true }, + }); await vi.advanceTimersByTimeAsync(5_000); await timedOut; - expect(compileSignal?.aborted).toBe(true); + expect(runCmd).not.toHaveBeenCalled(); + resolveCompile('/bin/true'); + await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); + expect(compileSwiftSourceText).toHaveBeenCalledOnce(); } finally { + resolveCompile('/bin/true'); vi.useRealTimers(); } - - await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); - expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); }); -test('a failed compile is dropped, and the next caller compiles again', async () => { +test('a rejected compile is evicted so the next attempt compiles and verifies', async () => { const mp4 = writeFixture('failed-compile.mp4', Buffer.concat([ftyp, moov])); const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/failed' }, cwd: directory }; vi.mocked(compileSwiftSourceText) .mockClear() .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'xcrun timed out after 120000ms')); - await expect(isPlayableVideo(mp4, host)).rejects.toMatchObject({ code: 'COMMAND_FAILED' }); + await expect(prepareVideoValidator(host)).rejects.toMatchObject({ + details: { reason: 'video_validator_unavailable' }, + }); await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); expect(compileSwiftSourceText).toHaveBeenCalledTimes(2); + expect(runCmd).toHaveBeenCalledOnce(); +}); + +test('preserves compiler stderr and recovery hint without replacing the typed validator reason', async () => { + const mp4 = writeFixture('compiler-diagnostics.mp4', Buffer.concat([ftyp, moov])); + const host = { + env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/compiler-diagnostics' }, + cwd: directory, + }; + const stderr = 'xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH'; + const hint = + 'Export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer before running agent-device.'; + const failure = new AppError('COMMAND_FAILED', 'xcrun exited with code 72', { + stderr, + stdout: '', + exitCode: 72, + reason: 'xcrun-tool-not-found', + hint, + }); + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce(failure); + await expect(isPlayableVideo(mp4, host)).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'video_validator_unavailable', + retriable: false, + stderr, + stdout: '', + exitCode: 72, + hint, + }, + cause: failure, + }); +}); + +test('retains main container-only completion when the Swift compiler is missing', async () => { + const mp4 = writeFixture('missing-compiler.mp4', Buffer.concat([ftyp, moov])); + const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/missing-compiler' }, cwd: directory }; + vi.mocked(compileSwiftSourceText).mockRejectedValueOnce( + new AppError('TOOL_MISSING', 'xcrun not found in PATH'), + ); + await expect(isPlayableVideo(mp4, host)).resolves.toBe(true); + expect(runCmd).not.toHaveBeenCalled(); +}); + +test('a missing validator executable after compilation returns the typed unavailable error', async () => { + const mp4 = writeFixture('missing-validator.mp4', Buffer.concat([ftyp, moov])); + vi.mocked(runCmd).mockRejectedValueOnce( + new AppError('TOOL_MISSING', 'validator not found in PATH'), + ); + await expect(isPlayableVideo(mp4, createTestHost())).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { reason: 'video_validator_unavailable', retriable: false }, + }); +}); + +test.each([ + { + stderr: `xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH`, + stdout: '', + recoveryHint: 'DEVELOPER_DIR', + messageExcerpt: 'unable to find utility "swiftc"', + }, + { + stderr: `validator.swift:2:8: error: no such module 'AVFoundation'`, + stdout: '', + recoveryHint: 'AVFoundation', + messageExcerpt: "no such module 'AVFoundation'", + }, + { + stderr: '', + stdout: `validator.swift:2:8: error: no such module 'AVFoundation'`, + recoveryHint: 'AVFoundation', + messageExcerpt: 'MP4 playability could not be verified.', + }, +])( + 'preserves unavailable validator output and selects its recovery hint: %j', + async ({ stderr, stdout, recoveryHint, messageExcerpt }) => { + const mp4 = writeFixture('unavailable.mp4', Buffer.concat([ftyp, moov])); + vi.mocked(runCmd).mockResolvedValueOnce({ exitCode: 1, stderr, stdout }); + const playable = isPlayableVideo(mp4, createTestHost()); + await expect(playable).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'video_validator_unavailable', + retriable: false, + stderr, + stdout, + exitCode: 1, + hint: expect.stringContaining(recoveryHint), + }, + }); + await expect(playable.catch(normalizeError)).resolves.toMatchObject({ + message: expect.stringContaining(messageExcerpt), + }); + }, +); + +test('an ordinary validator refusal is unplayable, not unavailable', async () => { + const mp4 = writeFixture('refused.mp4', Buffer.concat([ftyp, moov])); + vi.mocked(runCmd).mockResolvedValueOnce({ + exitCode: 1, + stderr: "no such module 'OtherModule'", + stdout: '', + }); + await expect(isPlayableVideo(mp4, createTestHost())).resolves.toBe(false); }); test('a budget spent before the validator runs reads as not playable instead of a 1 ms run', async () => { diff --git a/packages/capture-kit/src/recording/video.ts b/packages/capture-kit/src/recording/video.ts index df9c38b350..49f92a6053 100644 --- a/packages/capture-kit/src/recording/video.ts +++ b/packages/capture-kit/src/recording/video.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import { AppError } from '@agent-device/kernel/errors'; -import { isCommandTimeoutError, runCmd } from '@agent-device/host-kit/command'; +import { execFailureDetails, isCommandTimeoutError, runCmd } from '@agent-device/host-kit/command'; import { type Deadline, sleep } from '@agent-device/host-kit/retry'; import { buildSwiftToolEnv, compileSwiftSourceText, getSwiftCacheRoot } from './swift-cache.ts'; import type { HostEnvironment } from '@agent-device/kernel/source-value'; @@ -40,19 +40,26 @@ exit(exitCode) const VALIDATOR_COMPILE_WAIT_MS = 30_000; const VALIDATOR_RUN_TIMEOUT_MS = 10_000; -type SharedValidatorCompile = { - readonly compiled: Promise; - readonly cancel: AbortController; - waiters: number; - settled: boolean; -}; +const videoValidatorCompiles = new Map>(); -// A compiled helper is valid for the Swift cache it was built into, so each cache root keeps its own. -// Callers share one compile, and each waits for it only within its own budget. The last caller to -// stop waiting cancels an unfinished compile: that kills this process's `swiftc`, or stops waiting -// for another process's compile. Nothing owns `swiftc` once its requests end, so a daemon that -// exits would leave it running. -const videoValidatorCompiles = new Map(); +/** Starts one cached compile per Swift cache root. Callers may warm it without awaiting it. */ +export function prepareVideoValidator(host: HostEnvironment): Promise { + const cacheRoot = getSwiftCacheRoot(host.env); + let compiled = videoValidatorCompiles.get(cacheRoot); + if (!compiled) { + compiled = compileSwiftSourceText({ + host, + source: VIDEO_VALIDATION_SCRIPT, + cacheName: 'video-validator', + }).catch((error: unknown) => { + if (videoValidatorCompiles.get(cacheRoot) === compiled) + videoValidatorCompiles.delete(cacheRoot); + throw unavailableVideoValidator(error); + }); + videoValidatorCompiles.set(cacheRoot, compiled); + } + return compiled; +} export async function waitForStableFile( filePath: string, @@ -107,7 +114,17 @@ export async function isPlayableVideo( const validatorPath = await waitForValidatorCompile( host, deadline?.remainingMs() ?? VALIDATOR_COMPILE_WAIT_MS, - ); + ).catch((error: unknown) => { + if ( + error instanceof AppError && + error.cause instanceof AppError && + error.cause.code === 'TOOL_MISSING' + ) + return undefined; + throw error; + }); + // Without compiler-launch tooling, MP4 completion relies on the container check. + if (!validatorPath) return true; const runTimeoutMs = Math.min( VALIDATOR_RUN_TIMEOUT_MS, deadline?.remainingMs() ?? VALIDATOR_RUN_TIMEOUT_MS, @@ -126,48 +143,22 @@ export async function isPlayableVideo( if (result.exitCode === 0) { return true; } - return isSwiftVideoValidatorUnavailable(result.stderr, result.stdout); + if (isSwiftVideoValidatorUnavailable(result.stderr, result.stdout)) { + throw unavailableVideoValidator( + new AppError('COMMAND_FAILED', result.stderr, execFailureDetails(result)), + ); + } + return false; } catch (error) { if (isSwiftVideoValidatorError(error)) { - return true; + throw unavailableVideoValidator(error); } throw error; } } -function joinValidatorCompile(host: HostEnvironment, cacheRoot: string): SharedValidatorCompile { - const existing = videoValidatorCompiles.get(cacheRoot); - if (existing) return existing; - const cancel = new AbortController(); - const shared: SharedValidatorCompile = { - compiled: compileSwiftSourceText({ - host, - source: VIDEO_VALIDATION_SCRIPT, - cacheName: 'video-validator', - signal: cancel.signal, - }), - cancel, - waiters: 0, - settled: false, - }; - videoValidatorCompiles.set(cacheRoot, shared); - shared.compiled.then( - () => { - shared.settled = true; - }, - () => { - shared.settled = true; - if (videoValidatorCompiles.get(cacheRoot) === shared) - videoValidatorCompiles.delete(cacheRoot); - }, - ); - return shared; -} - async function waitForValidatorCompile(host: HostEnvironment, waitMs: number): Promise { - const cacheRoot = getSwiftCacheRoot(host.env); - const shared = joinValidatorCompile(host, cacheRoot); - shared.waiters += 1; + const compiled = prepareVideoValidator(host); let timer: NodeJS.Timeout | undefined; const expired = new Promise((_, reject) => { timer = setTimeout( @@ -181,9 +172,8 @@ async function waitForValidatorCompile(host: HostEnvironment, waitMs: number): P timeoutMs: waitMs, retriable: true, hint: - 'Run record stop again: the Swift modules this compile already built stay ' + - 'cached, so the next compile finishes sooner, and the recording keeps its ' + - 'evidence either way.', + 'Run record stop again: the validator compile continues in the background, ' + + 'and the recording keeps its evidence.', }, ), ), @@ -191,18 +181,33 @@ async function waitForValidatorCompile(host: HostEnvironment, waitMs: number): P ); }); try { - return await Promise.race([shared.compiled, expired]); + return await Promise.race([compiled, expired]); } finally { clearTimeout(timer); - shared.waiters -= 1; - if (shared.waiters === 0 && !shared.settled) { - if (videoValidatorCompiles.get(cacheRoot) === shared) - videoValidatorCompiles.delete(cacheRoot); - shared.cancel.abort(); - } } } +function unavailableVideoValidator(cause: unknown): AppError { + if (cause instanceof AppError && cause.details?.reason === 'video_validator_unavailable') + return cause; + const details = cause instanceof AppError ? cause.details : undefined; + return new AppError( + 'COMMAND_FAILED', + 'The video validator is unavailable; MP4 playability could not be verified.', + { + ...details, + reason: 'video_validator_unavailable', + retriable: false, + hint: + details?.hint ?? + (isSwiftVideoValidatorUnavailable(details?.stderr ?? '', details?.stdout ?? '') + ? 'Ensure Swift and AVFoundation are available on the daemon host, then retry record stop.' + : 'Repair the Swift toolchain or validator executable on the daemon host, then retry record stop.'), + }, + cause, + ); +} + function isSwiftVideoValidatorError(error: unknown): boolean { if (!(error instanceof AppError)) { return false; @@ -240,7 +245,7 @@ export async function waitForPlayableVideo( function isSwiftVideoValidatorUnavailable(stderr: string, stdout: string): boolean { const combined = `${stderr}\n${stdout}`; - return /\b(no such module ['"]AVFoundation['"]|unable to find utility ["']swiftc?["']|xcrun: error: unable to find utility ["']swiftc?["'])\b/i.test( + return /\b(no such module ['"]AVFoundation['"]|unable to find utility ["']swiftc?["']|xcrun: error: unable to find utility ["']swiftc?["'])/i.test( combined, ); } diff --git a/packages/command-surface/src/recording/index.ts b/packages/command-surface/src/recording/index.ts index 4c9be5500c..1ec522891d 100644 --- a/packages/command-surface/src/recording/index.ts +++ b/packages/command-surface/src/recording/index.ts @@ -127,7 +127,7 @@ export const recordCommandFacet = defineCommandFacet({ text: { summary: 'Start or stop screen recording', cliDetail: - 'The default --scope app requires an active app session from open ; use --scope device/system to explicitly request whole-screen recording where the selected backend supports it. Android record start publishes a durable device manifest, recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks while the daemon stays alive, and daemon-restart recovery uses only manifest-owned chunks. Android screenrecord encodes a frame only when the screen changes, so a clip can end at the last frame the recorder encoded instead of at record stop; durationMs is host wall clock from record start until the export finished, and capturedDurationMs reports the video timeline when it can be measured, with a warning naming how much of the window that video covers. An Android manifest left by an unreachable recording is retired on the next start once its recorders are proven gone; one still owned refuses with non-retriable DEVICE_IN_USE and reason native_recovery_evidence_open naming the session to run record stop for, and a recorder that is still writing an artifact refuses with DEVICE_IN_USE and reason native_recording_artifact_claimed, which clears itself once that recorder ends at the 180s limit. HarmonyOS supports whole-screen recording on physical devices only: use --scope device/system; --fps, --quality, and --hide-touches are unsupported. Use --quality to choose medium or high export quality on supported backends. The touch overlay renders at most 30 frames a second, or at --fps when lower; a lower --fps makes record stop faster on a long recording, and an overlay export that runs past its budget returns the raw video with an overlayWarning. An iOS simulator host recording lock returns non-retriable DEVICE_IN_USE with reason apple_simulator_recording_busy. Stop the recording in its owning session; if a dead recorder left the host locked, ask the host operator to restart the CoreSimulator stream service. On any backend, record start first settles the open recording records on its device that it would refuse on and whose session this daemon no longer holds: it finishes each one as its own record stop would, so the export lands at the output path of that recording and a later record stop in that session returns it, and runs the exact-owner recovery a daemon restart runs, which discards what that recording left, only for a record that cannot finish. A record it cannot confirm cleaned, or one whose session is still held, refuses the start with reason cleanup-unconfirmed and names that session: stop or close it, then retry. A record that another device left under the same session name is never settled from this device: the start refuses and names that device, so stop it in that session or record under another session name. A record stop that cannot produce its export keeps what a retry reads back — on Android the device-side artifact and the native manifest — leaves the recording manifest of that session open, and returns its own error, so retry record stop in that session; while this daemon holds that session, only closing it disposes them, and once it does not, the next record start on that device settles them as described above. Every stopped recording also reports what became of its recorder and of the file that recorder writes to: recorder is confirmed, or lost when the session holding it died, and nativePathDisposition is retirable while that file still owes a removal or retired once its removal was verified. Both are optional disclosures, not failures; ADR 0024 declares further states (unconfirmed, pending) for the steps that gain those probes, and no stop reports them yet. record contact-sheet [--out ] is a local report over a file you already exported: it decodes frames out of that MP4 and writes one PNG holding the cells where the screen visibly changed, each labeled with its elapsed time on the clip timeline. It needs no session and no device, rebuilds any past recording, and works for every backend that exports MP4; a WebM recording is refused with reason contact_sheet_container_unsupported. The sample grid is bounded and spread across the whole clip, so it reports coverage rather than a review: a flash that opens and closes entirely between two sample times is not in the sheet, which is why the result also reports how many times it sampled and how many frames came back. Frame decoding is Apple AVFoundation tooling, so the command runs only on macOS hosts and refuses elsewhere with reason contact_sheet_unsupported_host.', + 'MP4 verification setup starts in the background before recording output preparation, without delaying capture. If setup exceeds the stop budget, details.reason is video_validator_compile_timeout (retriable); run record stop again while setup continues. Hosts without xcrun keep container-only MP4 completion. Other compilation failures, or an unavailable validator executable or AVFoundation during verification, return video_validator_unavailable. Repair the reported failure and retry record stop; a failed compile is retried on the next start or stop. The default --scope app requires an active app session from open ; use --scope device/system to explicitly request whole-screen recording where the selected backend supports it. Android record start publishes a durable device manifest, recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks while the daemon stays alive, and daemon-restart recovery uses only manifest-owned chunks. Android screenrecord encodes a frame only when the screen changes, so a clip can end at the last frame the recorder encoded instead of at record stop; durationMs is host wall clock from record start until the export finished, and capturedDurationMs reports the video timeline when it can be measured, with a warning naming how much of the window that video covers. An Android manifest left by an unreachable recording is retired on the next start once its recorders are proven gone; one still owned refuses with non-retriable DEVICE_IN_USE and reason native_recovery_evidence_open naming the session to run record stop for, and a recorder that is still writing an artifact refuses with DEVICE_IN_USE and reason native_recording_artifact_claimed, which clears itself once that recorder ends at the 180s limit. HarmonyOS supports whole-screen recording on physical devices only: use --scope device/system; --fps, --quality, and --hide-touches are unsupported. Use --quality to choose medium or high export quality on supported backends. The touch overlay renders at most 30 frames a second, or at --fps when lower; a lower --fps makes record stop faster on a long recording, and an overlay export that runs past its budget returns the raw video with an overlayWarning. An iOS simulator host recording lock returns non-retriable DEVICE_IN_USE with reason apple_simulator_recording_busy. Stop the recording in its owning session; if a dead recorder left the host locked, ask the host operator to restart the CoreSimulator stream service. On any backend, record start first settles the open recording records on its device that it would refuse on and whose session this daemon no longer holds: it finishes each one as its own record stop would, so the export lands at the output path of that recording and a later record stop in that session returns it, and runs the exact-owner recovery a daemon restart runs, which discards what that recording left, only for a record that cannot finish. A record it cannot confirm cleaned, or one whose session is still held, refuses the start with reason cleanup-unconfirmed and names that session: stop or close it, then retry. A record that another device left under the same session name is never settled from this device: the start refuses and names that device, so stop it in that session or record under another session name. A record stop that cannot produce its export keeps what a retry reads back — on Android the device-side artifact and the native manifest — leaves the recording manifest of that session open, and returns its own error, so retry record stop in that session; while this daemon holds that session, only closing it disposes them, and once it does not, the next record start on that device settles them as described above. Every stopped recording also reports what became of its recorder and of the file that recorder writes to: recorder is confirmed, or lost when the session holding it died, and nativePathDisposition is retirable while that file still owes a removal or retired once its removal was verified. Both are optional disclosures, not failures; ADR 0024 declares further states (unconfirmed, pending) for the steps that gain those probes, and no stop reports them yet. record contact-sheet [--out ] is a local report over a file you already exported: it decodes frames out of that MP4 and writes one PNG holding the cells where the screen visibly changed, each labeled with its elapsed time on the clip timeline. It needs no session and no device, rebuilds any past recording, and works for every backend that exports MP4; a WebM recording is refused with reason contact_sheet_container_unsupported. The sample grid is bounded and spread across the whole clip, so it reports coverage rather than a review: a flash that opens and closes entirely between two sample times is not in the sheet, which is why the result also reports how many times it sampled and how many frames came back. Frame decoding is Apple AVFoundation tooling, so the command runs only on macOS hosts and refuses elsewhere with reason contact_sheet_unsupported_host.', }, metadata: recordCommandMetadata, run: (client, input) => client.recording.record(input as RecordOptions), diff --git a/src/platform-runtime-screen-recording-host.test.ts b/src/platform-runtime-screen-recording-host.test.ts new file mode 100644 index 0000000000..44376838a1 --- /dev/null +++ b/src/platform-runtime-screen-recording-host.test.ts @@ -0,0 +1,188 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, expect, test, vi } from 'vitest'; +import { runCmd } from '@agent-device/host-kit/command'; +import { AppError } from '@agent-device/kernel/errors'; +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { createLiveTestHostEnvironment, mkdtempForTestSync } from '@agent-device/test-kit'; +import * as video from '@agent-device/capture-kit/recording-video'; +import { runtimeModule } from '@agent-device/platform-apple'; +import { createPlatformRuntimeHost } from './platform-runtime-operation-host.ts'; + +const devices: DeviceInfo[] = ['simulator', 'device'].map((kind) => ({ + platform: 'apple', + appleOs: 'ios', + id: kind, + name: kind, + kind: kind as 'simulator' | 'device', + target: 'mobile', + booted: true, + ...(kind === 'device' ? { iosPhysicalDeviceBackend: 'coredevice' as const } : {}), +})); + +vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({ + ...(await importOriginal()), + runCmd: vi.fn(), +})); +afterEach(() => vi.restoreAllMocks()); + +async function harness(device: DeviceInfo, launch = () => {}) { + const root = mkdtempForTestSync('warm-video-validator-'); + const unused = async (): Promise => { + throw new Error('unused'); + }; + const host = createPlatformRuntimeHost({ + host: { ...createLiveTestHostEnvironment(), env: { AGENT_DEVICE_SWIFT_CACHE_DIR: root } }, + sessionsDir: root, + resolveSessionArtifacts: () => { + throw new Error('unused'); + }, + snapshot: { captureSurface: unused, presentIosAcquisition: unused }, + shutdownLoaders: { apple: unused, android: unused }, + }); + const runtime = await runtimeModule.loadRuntime({ + ...host, + screenRecording: { + ...host.screenRecording, + apple: { + ...host.screenRecording.apple, + availability: async () => ({ available: true }), + isRunnerBundleId: async () => false, + captureClockAnchor: async () => undefined, + startSimulator: async () => { + launch(); + return { + markers: [{ pid: 42, startTime: 'start', command: 'recorder' }], + wait: new Promise(() => {}), + terminate: async () => {}, + }; + }, + runRunner: async (_device, request) => { + if (request.kind !== 'start') return {}; + launch(); + return { + runnerSessionId: 'runner', + runnerAuthority: 'local-lease', + remotePath: 'tmp/agent-device-recording-123.mp4', + }; + }, + retrieveRunnerRecording: async (_device, _remote, outputPath) => { + fs.copyFileSync('website/docs/public/agent-device-contacts.mp4', outputPath); + }, + }, + }, + }); + const binding = await runtime.bind({ + device, + intent: { kind: 'ordinary' }, + scope: { + signal: new AbortController().signal, + diagnostics: { emit: () => {} }, + progress: { report: () => {} }, + }, + }); + const input = { + sessionId: 'one', + outputPath: path.join(root, 'capture.mp4'), + scope: 'device' as const, + showTouches: false, + hideTouchesRequested: false, + recordOnlySession: false, + activeSessionApp: { bundleId: 'com.example.app' }, + fence: { token: 'fence', generation: 1 }, + }; + return { input, start: () => binding.operations.screenRecordingStart!(input) }; +} + +test.each(devices)( + 'warms before $kind capture and starts while preparation is pending', + async (device) => { + let resolvePreparation!: (value: string) => void; + const pending = new Promise((resolve) => { + resolvePreparation = resolve; + }); + const preparation = vi.spyOn(video, 'prepareVideoValidator').mockReturnValue(pending); + const launched = vi.fn(() => { + expect(preparation).toHaveBeenCalledOnce(); + }); + const { start } = await harness(device, launched); + const removeOutput = vi.spyOn(fs, 'rmSync'); + preparation.mockImplementation(() => { + expect(removeOutput).not.toHaveBeenCalled(); + return pending; + }); + let started = false; + const starting = start().then((result) => { + started = true; + return result; + }); + void starting.catch(() => {}); + try { + await expect.poll(() => started, { timeout: 1_000 }).toBe(true); + expect(launched).toHaveBeenCalledOnce(); + expect(removeOutput).toHaveBeenCalled(); + } finally { + resolvePreparation('/bin/true'); + await starting; + } + }, +); + +test.each(['COMMAND_FAILED', 'TOOL_MISSING'])( + 'a %s compiler failure preserves capture and main stop semantics', + async (code) => { + const { start, input } = await harness(devices[1]!); + const failure = new AppError(code, 'xcrun exited with code 72', { + stderr: + code === 'TOOL_MISSING' + ? 'xcrun: error: unable to find utility "swiftc", not a developer tool or in PATH' + : "video-validator.swift:2:8: error: no such module 'AVFoundation'", + }); + let rejectCompile!: (error: unknown) => void; + const compile = new Promise((_resolve, reject) => { + rejectCompile = reject; + }); + void compile.catch(() => {}); + vi.mocked(runCmd).mockClear().mockReturnValue(compile); + const preparation = vi.spyOn(video, 'prepareVideoValidator'); + let captureStarted = false; + const starting = start().then((result) => { + captureStarted = true; + return result; + }); + try { + await expect.poll(() => captureStarted, { timeout: 1_000 }).toBe(true); + expect(preparation).toHaveBeenCalledOnce(); + await expect.poll(() => vi.mocked(runCmd).mock.calls.length).toBe(1); + } finally { + rejectCompile(failure); + await starting; + } + const started = await starting; + await expect(preparation.mock.results[0]!.value).rejects.toMatchObject({ + details: { reason: 'video_validator_unavailable' }, + }); + const handle = started.pendingHandle.transfer(); + if (code === 'TOOL_MISSING') { + await expect(handle.finish()).resolves.toMatchObject({ status: 'completed' }); + } else { + await expect(handle.finish()).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'video_validator_unavailable', + retriable: false, + stderr: failure.details?.stderr, + hint: expect.stringContaining('AVFoundation'), + }, + cause: failure, + }); + } + expect(runCmd).toHaveBeenCalledTimes(2); + expect(runCmd).toHaveBeenCalledWith( + 'xcrun', + expect.arrayContaining(['swiftc']), + expect.any(Object), + ); + expect(fs.existsSync(input.outputPath)).toBe(true); + }, +); diff --git a/src/platform-runtime-screen-recording-host.ts b/src/platform-runtime-screen-recording-host.ts index 27080d469b..5068f04736 100644 --- a/src/platform-runtime-screen-recording-host.ts +++ b/src/platform-runtime-screen-recording-host.ts @@ -24,6 +24,10 @@ export function createScreenRecordingRuntimeHost(options: { }); const outputs: ScreenRecordingRuntimeHost['outputs'] = Object.freeze({ prepare: async (outputPath) => { + if (!outputPath.toLowerCase().endsWith('.webm')) { + const { prepareVideoValidator } = await import('@agent-device/capture-kit/recording-video'); + void prepareVideoValidator(host).catch(() => {}); + } const { createScreenRecordingOutputHost } = await import('./platform-runtime-screen-recording-output-host.ts'); await createScreenRecordingOutputHost().prepare(outputPath); diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index 0d73d55051..a0f86fd44c 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -30,8 +30,9 @@ import { type PlatformProviderResolvers, } from '../../../src/platform-runtime.ts'; import type { AppleSimulatorScreenRecordingProcess } from '../../../src/platform-runtime-screen-recording-apple-transport.ts'; -import { runCmdBackground } from '@agent-device/host-kit/command'; +import { runCmdBackground, withCommandExecutorOverride } from '@agent-device/host-kit/command'; import { createTestHostEnvironment } from '@agent-device/test-kit'; +import { prepareVideoValidator } from '@agent-device/capture-kit/recording-video'; import { withClientReplayScriptSources } from '../../../src/__tests__/test-utils/replay-script-source.fixtures.ts'; import type { DeviceInventoryProvider, @@ -112,6 +113,7 @@ export async function createProviderScenarioHarness( // A scenario that seeds claims for its daemon names the directory through the process. AGENT_DEVICE_CLAIMS_DIR: process.env.AGENT_DEVICE_CLAIMS_DIR ?? path.join(testRoot, 'claims'), AGENT_DEVICE_DAEMON_IDLE_TIMEOUT_MS: '3600000', + AGENT_DEVICE_SWIFT_CACHE_DIR: path.join(testRoot, 'swift-cache'), ...deps.env, // Set last: the harness reads its own state dir and transport back from these. AGENT_DEVICE_STATE_DIR: stateDir, @@ -239,6 +241,24 @@ export async function createProviderScenarioHarness( return { callCommand: async (command, positionals = [], flags = {}, options = {}) => { + if (command === 'record') { + await withCommandExecutorOverride( + (command, args) => { + if ( + command !== 'xcrun' || + args[0] !== 'swiftc' || + !args.some((arg) => path.basename(arg).startsWith('video-validator-')) + ) + return undefined; + const output = args[args.indexOf('-o') + 1]; + if (!args.includes('-o') || !output) + throw new Error('Missing validator fixture output'); + fs.writeFileSync(output, '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + return Promise.resolve({ exitCode: 0, stdout: '', stderr: '' }); + }, + async () => await prepareVideoValidator(createTestHostEnvironment({ env })), + ); + } const request = await withClientReplayScriptSources( commandRequest(runtime.token, command, positionals, flags, options), ); diff --git a/test/integration/provider-scenarios/macos-world.ts b/test/integration/provider-scenarios/macos-world.ts index be88cce6d8..0a36759c1f 100644 --- a/test/integration/provider-scenarios/macos-world.ts +++ b/test/integration/provider-scenarios/macos-world.ts @@ -48,10 +48,7 @@ export async function createMacOsDesktopWorld( }, }, }); - // Recording finalization runs the Swift validator; the process's warm cache keeps it from compiling. - const swiftCacheDir = process.env.AGENT_DEVICE_SWIFT_CACHE_DIR; const daemon = await createProviderScenarioHarness({ - ...(swiftCacheDir ? { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: swiftCacheDir } } : {}), appleRunnerProvider: options.appleRunnerProvider ? () => options.appleRunnerProvider : undefined, diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 3590e3b83f..a4231e5f7c 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -1075,6 +1075,7 @@ agent-device record stop # Stop active recording - Android uses `adb shell screenrecord` (180s limit per clip). Longer recordings are split into MP4 chunks while the daemon stays alive; after a daemon restart, `record stop` recovers only chunks listed in the device manifest. A clip can end before `record stop` when the screen stopped changing; `durationMs` is wall-clock time, and `capturedDurationMs` plus a warning report how much the video covers. - Limrun sessions always record the whole screen and refuse `--fps` and `--hide-touches` with `INVALID_ARGS`. A failed download is retried by the next `record stop`; nothing survives a daemon restart. - A busy iOS simulator recording slot returns non-retriable `DEVICE_IN_USE` with `details.reason: apple_simulator_recording_busy`. Run `record stop` in the session that owns the recording; if no recording is active, ask the host operator to restart the CoreSimulator stream service. +- MP4 verification setup runs in the background from `record start`; capture does not wait for it. If setup exceeds the stop budget, stop returns retriable `details.reason: video_validator_compile_timeout`. Run `record stop` again; setup continues and your recording is retained. Hosts without xcrun keep container-only MP4 completion. Other compilation failures, or an unavailable validator executable or AVFoundation during verification, return `details.reason: video_validator_unavailable`. Repair the reported failure and retry `record stop`; a failed compile is retried on the next start or stop. - `record stop` is safe to repeat. If the request window ends during export, a second `record stop` in the same session returns the finished recording. A recording whose video file is gone reports `no active recording`. - `record start --scope device` on a session that does not exist yet creates one, and `record stop` ends that session. If anything else takes over the session meanwhile — an `open` or an app event that publishes an app, or an audio probe, perf capture, trace, app log, device claim, or lease it holds — `record stop` ends only the recording; the session stays and `close` releases it. - On a remote daemon, if a leased session ends while a recording runs (lease expiry, release, or `close`), the daemon finishes the recording. It keeps the video and telemetry downloadable by the lease's tenant: `GET /artifacts` lists them with `sessionName`, and `GET /artifacts/` downloads them. Each file is deleted after its download, or after 15 minutes.