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
Expand Up @@ -12,7 +12,7 @@ vi.mock('@agent-device/host-kit/command', async () => {
}),
};
});
vi.mock('../video.ts', () => ({ waitForPlayableVideo: vi.fn(async () => {}) }));
vi.mock('../video.ts', () => ({ waitForPlayableVideo: vi.fn(async () => true) }));
vi.mock('../swift-cache.ts', () => ({
buildSwiftToolEnv: () => ({}),
compileSwiftSourceFile: vi.fn(async () => '/bin/true'),
Expand Down
24 changes: 23 additions & 1 deletion packages/capture-kit/src/recording/__tests__/overlay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ vi.mock('@agent-device/host-kit/command', async (importOriginal) => {
});

vi.mock('../video.ts', () => ({
waitForPlayableVideo: vi.fn(async () => {}),
waitForPlayableVideo: vi.fn(async () => true),
}));

import {
Expand Down Expand Up @@ -322,6 +322,27 @@ test('overlay keeps the plain failure when the export failed with budget to spar
});
});

test('overlay keeps the video as recorded when the export never plays within its budget', async () => {
const videoPath = path.join(tmpDir, 'recording.mp4');
const telemetryPath = path.join(tmpDir, 'recording.gesture-telemetry.json');
fs.writeFileSync(videoPath, 'original');
fs.writeFileSync(telemetryPath, '{"events":[]}');
vi.mocked(waitForPlayableVideo).mockResolvedValueOnce(false);

await expect(
overlayRecordingTouches({
host: createTestHost(),
videoPath,
telemetryPath,
deadline: startRecordingOverlayBudget(),
}),
).rejects.toMatchObject({
code: 'COMMAND_FAILED',
message: 'Failed to add touch overlays to the recording',
});
expect(fs.readFileSync(videoPath, 'utf8')).toBe('original');
});

test('overlay keeps the video as recorded when verifying the export spent its budget', async () => {
vi.useFakeTimers({ toFake: ['Date'] });
const videoPath = path.join(tmpDir, 'recording.mp4');
Expand All @@ -331,6 +352,7 @@ test('overlay keeps the video as recorded when verifying the export spent its bu
const deadline = startRecordingOverlayBudget();
vi.mocked(waitForPlayableVideo).mockImplementationOnce(async () => {
vi.setSystemTime(Date.now() + OVERLAY_BUDGET_MS);
return false;
});

await expect(
Expand Down
10 changes: 3 additions & 7 deletions packages/capture-kit/src/recording/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,19 +117,15 @@
exportOverran = deadline.remainingMs() <= HELPER_EXIT_GRACE_MS;

@apex-sentinel-staging apex-sentinel-staging Bot Nov 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No longer raised on a1af133.

🆕 🟡 Minor · Post-export playability check is now clamped to the 5 s exit grace, so a slow validator discards a good composite

Passing deadline into waitForPlayableVideo (overlay.ts:120) now propagates it into isPlayableVideo, where the validator run is clamped to Math.min(10_000, deadline.remainingMs()) and a run that times out reads as not playable (video.ts:111-125). By construction the helper exits with about HELPER_EXIT_GRACE_MS = 5 000 ms left (overlay.ts:94-95, 114), so the very first validation attempt gets ≤5 s instead of the fixed 10 s the predecessor gave it, and every later poll gets less. On a loaded host where AVFoundation needs ~5.5 s to load the composited file, the attempt times out → false → 150 ms sleep → the deadline expires → waitForPlayableVideo returns false → exportOverran = deadline.isExpired() is true → the verified-composite rename at overlay.ts:124 never happens and the user gets "the export did not finish within its 70000ms budget" with the touch overlay silently dropped, even though the composite was fine. The predecessor ran the same check with the full 10 s. Either give the post-export check its own floor (e.g. Math.max(1, deadline.remainingMs()) with a reserved validation slice inside HELPER_EXIT_GRACE_MS), or document that the exit grace must cover the validator run.

Suggested change

Reserve part of HELPER_EXIT_GRACE_MS for the post-export playability check (or keep that one check on the fixed VALIDATOR_RUN_TIMEOUT_MS) so a composite that is playable but slow to validate is not discarded at the end of the budget.

Details

persona: senior-developer

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

throw error;
}
await waitForPlayableVideo(outputPath, { host, deadline });
if (deadline.isExpired()) {
exportOverran = true;
throw new AppError(
'COMMAND_FAILED',
'The composited video was not verified within the budget',
);
if (!(await waitForPlayableVideo(outputPath, { host, deadline }))) {

@apex-sentinel-staging apex-sentinel-staging Bot Nov 2, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🆕 🟡 Minor · exportOverran is not set when the composited video fails playability verification before the budget expires

The old code set exportOverran = true whenever the verification step found the deadline expired. The new branch sets exportOverran = deadline.isExpired(), so when waitForPlayableVideo returns false with budget still on the clock — which is now a reachable state, because a clamped runTimeoutMs that times out, or a validator that exits nonzero, both read as not playable instead of throwing — the operator gets the bare commandDescription ("Failed to add touch overlays to the recording") with no budget hint. Worse, the details still carry the copied reason: 'video_validator_compile_timeout', retriable: true and the "Run record stop again" hint from the wrapped cause, so a run that had most of the 70 s left is described both as a plain overlay failure and as a compile timeout. Reproduce: overlay export succeeds but the composited file's validator run times out within a clamped runTimeoutMs while the deadline still has seconds left.

Suggested change

Pick one description for the not-verified path (either set exportOverran for it or drop the copied compile-timeout details) so the message and the details describe the same failure.

Details

Also raised here

Also raised at this location by javascript: Overlay's budget-overrun message now depends on the wait's false verdict coinciding with isExpired() — exportProcessedVideo replaced the two-step check (await waitForPlayableVideo(...) then deadline.isExpired()) with exportOverran = deadline.isExpired() read only after the wait resolves false (overlay.ts:120-122). Because waitForPlayableVideo now also returns false for reasons other than the budget — attempts exhausted (12 polls of 150 ms at the default, video.ts:228-236), or the validator run timing out inside isPlayableVideo (video.ts:117-125) — the exportOverran flag silently relies on 'expired' and 'not playable' being the same event. When they diverge (a composited export that fails 12 validator polls with budget left, or a validator run killed by runTimeoutMs), the user loses the the export did not finish within its 70000ms budget; a lower --fps renders faster advice and gets only the plain command description, even though the budget is what made the run too short. Say which condition sets the flag instead of inferring it from the clock.

Also raised at this location by senior-developer: New video_validator_compile_timeout reason is thrown from record stop outside the recording-runtime reason contract — waitForValidatorCompile rejects with a new reason: 'video_validator_compile_timeout' (video.ts:176-188), and finalizeScreenRecording lets it propagate: the earlier unconditional waitForPlayableVideo + isPlayableVideo pair that always threw unplayableRecording() with the contract-owned RECORDING_OUTPUT_UNPLAYABLE_REASON is replaced by one if (!(await waitForPlayableVideo(...))) (src/platform-runtime-screen-recording-finalizer-host.ts:38-40). So a cold compile that outlasts the 70 s finalize budget now fails record stop with a reason string that is defined inline in capture-kit and not part of the @agent-device/contracts/screen-recording-runtime vocabulary the other stop failures use; a client or doc that branches on the recording reasons sees a value it has never seen, and the same stop can fail with either reason depending on whether the compile or the file is at fault. …

persona: senior-developer, javascript

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

exportOverran = deadline.isExpired();
throw new AppError('COMMAND_FAILED', 'The composited video was not verified as playable');
}
fs.renameSync(outputPath, videoPath);
} catch (error) {
const description = exportOverran
? `${commandDescription}: the export did not finish within its ${OVERLAY_BUDGET_MS}ms budget; a lower --fps renders faster`
: commandDescription;

Check warning on line 128 in packages/capture-kit/src/recording/overlay.ts

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

minor: `exportOverran` is not set when the composited video fails playability verification before the budget expires

The old code set `exportOverran = true` whenever the verification step found the deadline expired. The new branch sets `exportOverran = deadline.isExpired()`, so when `waitForPlayableVideo` returns `false` with budget still on the clock — which is now a reachable state, because a clamped `runTimeoutMs` that times out, or a validator that exits nonzero, both read as not playable instead of throwing — the operator gets the bare `commandDescription` ("Failed to add touch overlays to the recording") with no budget hint. Worse, the details still carry the copied `reason: 'video_validator_compile_timeout'`, `retriable: true` and the "Run record stop again" hint from the wrapped cause, so a run that had most of the 70 s left is described both as a plain overlay failure and as a compile timeout. Reproduce: overlay export succeeds but the composited file's validator run times out within a clamped `runTimeoutMs` while the deadline still has seconds left. ### Also raised here Also raised at this location by javascript: Overlay's budget-overrun message now depends on the wait's false verdict coinciding with isExpired() — `exportProcessedVideo` replaced the two-step check (`await waitForPlayableVideo(...)` then `deadline.isExpired()`) with `exportOverran = deadline.isExpired()` read only after the wait resolves false (overlay.ts:120-122). Because `waitForPlayableVideo` now also returns false for reasons other than the budget — `attempts` exhausted (12 polls of 150 ms at the default, video.ts:228-236), or the validator run timing out inside `isPlayableVideo` (video.ts:117-125) — the `exportOverran` flag silently relies on 'expired' and 'not playable' being the same event. When they diverge (a composited export that fails 12 validator polls with budget left, or a validator run killed by `runTimeoutMs`), the user loses the `the export did not finish within its 70000ms budget; a lower --fps renders faster` advice and gets only the plain command description, even though the budget…
const cause =
error instanceof AppError
? error
Expand Down
57 changes: 57 additions & 0 deletions packages/capture-kit/src/recording/swift-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createTestHost } from '../host-environment.fixtures.ts';
import fs from 'node:fs';
import path from 'node:path';
import { mkdtempForTestSync } from '../tmp-dir.fixtures.ts';
import { createRequestCanceledError, isRequestCanceledError } from '@agent-device/kernel/errors';

vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({
...(await importOriginal()),
Expand Down Expand Up @@ -170,6 +171,62 @@ test('compileSwiftSourceText resolves a cache name with a long interior dash run
expect(elapsedMs).toBeLessThan(1_000);
});

test('a canceled compile kills swiftc through its signal and leaves no temp output', async () => {
const cancel = new AbortController();
mockRunCmd.mockImplementationOnce(
async (_cmd, _args, options) =>
await new Promise((_, reject) => {
options?.signal?.addEventListener('abort', () => reject(createRequestCanceledError()));
}),
);

const compiled = compileSwiftSourceText({
host: createTestHost(),
source: 'print(1)',
cacheName: 'canceled',
signal: cancel.signal,
});
await vi.waitFor(() => expect(mockRunCmd).toHaveBeenCalledOnce());
cancel.abort();

await expect(compiled).rejects.toSatisfy(isRequestCanceledError);
expect(mockRunCmd.mock.calls[0]![2]).toMatchObject({
signal: cancel.signal,
kill: { signal: 'SIGINT' },
});
expect(fs.readdirSync(path.join(tmpDir, 'swift-cache', 'bin'))).toEqual([]);
});

test('a canceled wait on another process compile stops waiting and leaves its lock alone', async () => {
const compile = (signal?: AbortSignal) =>
compileSwiftSourceText({
host: createTestHost(),
source: 'print(1)',
cacheName: 'held-elsewhere',
signal,
});
const executablePath = await compile();
fs.rmSync(executablePath);
const lockDir = `${executablePath}.lock`;
fs.mkdirSync(lockDir);
vi.clearAllMocks();
// The parent process stands in for another live process that holds the lock while it compiles.
fs.writeFileSync(
path.join(lockDir, 'owner.json'),
JSON.stringify({ pid: process.ppid, startTime: null, acquiredAtMs: Date.now() }),
);
const cancel = new AbortController();

const compiled = compile(cancel.signal);
// Let the wait poll the held lock a few times before it is canceled.
await new Promise((resolve) => setTimeout(resolve, 100));
cancel.abort();

await expect(compiled).rejects.toSatisfy(isRequestCanceledError);
expect(mockRunCmd).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(lockDir, 'owner.json'))).toBe(true);
});

test('compileSwiftSourceText falls back to swift-helper when the cache name sanitizes to nothing', async () => {
const executablePath = await compileSwiftSourceText({
host: createTestHost(),
Expand Down
13 changes: 12 additions & 1 deletion packages/capture-kit/src/recording/swift-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

const SWIFT_CACHE_VERSION = '2';
const LOCK_RETRY_DELAY_MS = 25;
// swift-driver runs each frontend job in its own process group. It stops them on SIGINT, but

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🆕 🔵 Nit · Comment on kill describes only the timeout path, but SWIFTC_STOP now also serves request cancellation

ExecOptions.kill is documented as how 'a deadline or a cancelled request stops the child' (packages/host-kit/src/internal/exec.ts:32-38), and this PR is the first use of the field for a cancellation: SWIFTC_STOP is passed unconditionally with signal: params.signal (swift-cache.ts:201-205). The new comment above the constant explains only the swift-driver process-group reason for SIGINT and never says that SIGINT is load-bearing for the abort path too, so a reader who later passes a request-scoped signal into compileSwiftSourceFile/compileSwiftSourceText (which compileSwiftSourceFile cannot do today) would keep the default SIGKILL and re-create the orphaned swift-frontend processes this PR set out to avoid.

Suggested change

Extend the comment to state that the option applies to both the compile deadline and a caller's cancel signal, so any caller that forwards a signal keeps SIGINT.

Details

persona: javascript

Reply @apex-sentinel-staging note ... to add context, or @apex-sentinel-staging pivot ... if this is wrong — I will re-check the cited code.

Useful? Reply @apex-sentinel-staging like, or @apex-sentinel-staging dislike <why> if it is not.

// SIGKILL or SIGTERM leaves them compiling with no parent.
const SWIFTC_STOP = { signal: 'SIGINT', graceMs: 2_000 } as const;

Check notice on line 21 in packages/capture-kit/src/recording/swift-cache.ts

View check run for this annotation

apex-sentinel-staging / Apex Sentinel

nit: Comment on `kill` describes only the timeout path, but SWIFTC\_STOP now also serves request cancellation

`ExecOptions.kill` is documented as how 'a deadline or a cancelled request stops the child' (packages/host-kit/src/internal/exec.ts:32-38), and this PR is the first use of the field for a cancellation: `SWIFTC_STOP` is passed unconditionally with `signal: params.signal` (swift-cache.ts:201-205). The new comment above the constant explains only the swift-driver process-group reason for SIGINT and never says that SIGINT is load-bearing for the abort path too, so a reader who later passes a request-scoped signal into `compileSwiftSourceFile`/`compileSwiftSourceText` (which `compileSwiftSourceFile` cannot do today) would keep the default SIGKILL and re-create the orphaned `swift-frontend` processes this PR set out to avoid.
const RECORDING_SCRIPT_SUBDIRECTORY = 'apple/runner/AgentDeviceRunner/RecordingScripts';

/**
Expand Down Expand Up @@ -130,6 +133,8 @@
source: string;
cacheName: string;
timeoutMs?: number;
/** Cancels the compile: kills `swiftc`, or stops waiting for another process's compile. */
signal?: AbortSignal;
}): Promise<string> {
const cacheName = sanitizeCacheName(params.cacheName);
const key = hashParts([SWIFT_CACHE_VERSION, process.platform, process.arch, params.source]);
Expand All @@ -143,6 +148,7 @@
executablePath,
sourceText: params.source,
timeoutMs: params.timeoutMs,
signal: params.signal,
});
return executablePath;
}
Expand All @@ -162,6 +168,7 @@
executablePath: string;
sourceText?: string;
timeoutMs?: number;
signal?: AbortSignal;
}): Promise<void> {
if (isExecutableFile(params.executablePath)) {
return;
Expand All @@ -171,7 +178,7 @@
fs.mkdirSync(executableDir, { recursive: true });
const timeoutMs = params.timeoutMs ?? 120_000;
await withProcessLock({
acquire: () => acquireSwiftCacheLock(`${params.executablePath}.lock`, timeoutMs),
acquire: () => acquireSwiftCacheLock(`${params.executablePath}.lock`, timeoutMs, params.signal),
task: async () => {
// Another process may have published the executable while this one waited for the lock.
if (isExecutableFile(params.executablePath)) {
Expand All @@ -193,6 +200,8 @@
}
await runCmd('xcrun', ['swiftc', ...params.sourcePaths, '-o', tempExecutablePath], {
timeoutMs,
signal: params.signal,
kill: SWIFTC_STOP,
env: buildSwiftToolEnv(params.host.env),
});
fs.renameSync(tempExecutablePath, params.executablePath);
Expand All @@ -206,6 +215,7 @@
async function acquireSwiftCacheLock(
lockDir: string,
timeoutMs: number,
signal: AbortSignal | undefined,
): Promise<ProcessLockRelease> {
try {
return await acquireProcessLock({
Expand All @@ -217,6 +227,7 @@
},
timeoutMs,
pollMs: LOCK_RETRY_DELAY_MS,
signal,
description: `Swift cache lock: ${lockDir} (${timeoutMs}ms)`,
});
} catch (error) {
Expand Down
153 changes: 151 additions & 2 deletions packages/capture-kit/src/recording/video.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ 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 { 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 { isPlayableVideo, waitForPlayableVideo } from './video.ts';
import { hasVideoContainer, isPlayableVideo, waitForPlayableVideo } from './video.ts';

vi.mock('@agent-device/host-kit/command', () => ({
vi.mock(import('@agent-device/host-kit/command'), async (importOriginal) => ({
...(await importOriginal()),
runCmd: vi.fn(async () => ({ exitCode: 0, stdout: '', stderr: '' })),
}));
vi.mock('./swift-cache.ts', () => ({
Expand Down Expand Up @@ -69,6 +71,153 @@ test('each Swift cache root compiles its own validator, and a root reuses the on
).toEqual(['/cache/one', '/cache/two']);
});

test('callers share one validator compile, and each waits for it within its own budget', async () => {
// A cold compile on a loaded CI runner outlasted a fixed 30 s wait while record stop had most of
// its 70 s finalization budget left. A caller without a deadline that starts the compile first must
// not impose its 30 s wait on a concurrent caller that has a deadline.
const mp4 = writeFixture('shared-compile.mp4', Buffer.concat([ftyp, moov]));
const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/shared' }, cwd: directory };
await hasVideoContainer(mp4);
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] });
try {
vi.mocked(compileSwiftSourceText)
.mockClear()
.mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve('/bin/true'), 45_000)),
);

const withoutDeadline = isPlayableVideo(mp4, host);
const withDeadline = isPlayableVideo(mp4, host, Deadline.fromTimeoutMs(70_000));
const withoutDeadlineRejected = expect(withoutDeadline).rejects.toMatchObject({
code: 'COMMAND_FAILED',
details: {
reason: 'video_validator_compile_timeout',
timeoutMs: 30_000,
retriable: true,
hint: expect.stringContaining('Run record stop again'),
},
});
await vi.advanceTimersByTimeAsync(45_000);

await withoutDeadlineRejected;
await expect(withDeadline).resolves.toBe(true);
expect(compileSwiftSourceText).toHaveBeenCalledOnce();
const [compile] = vi.mocked(compileSwiftSourceText).mock.calls[0]!;
expect(compile.timeoutMs).toBeUndefined();
expect(compile.signal?.aborted).toBe(false);
} 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 };
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<string>((_, 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' } });
await vi.advanceTimersByTimeAsync(5_000);
await timedOut;
expect(compileSignal?.aborted).toBe(true);
} finally {
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 () => {
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(isPlayableVideo(mp4, host)).resolves.toBe(true);
expect(compileSwiftSourceText).toHaveBeenCalledTimes(2);
});

test('a budget spent before the validator runs reads as not playable instead of a 1 ms run', async () => {
const mp4 = writeFixture('spent-budget.mp4', Buffer.concat([ftyp, moov]));
const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/spent' }, cwd: directory };
await isPlayableVideo(mp4, host);
vi.mocked(runCmd).mockClear();

await expect(isPlayableVideo(mp4, host, Deadline.fromTimeoutMs(0))).resolves.toBe(false);
await expect(
waitForPlayableVideo(mp4, { host, pollMs: 0, deadline: Deadline.fromTimeoutMs(0) }),
).resolves.toBe(false);
expect(runCmd).not.toHaveBeenCalled();
});

test('the validator run after a compile stays inside the caller deadline', async () => {
const mp4 = writeFixture('run-clamp.mp4', Buffer.concat([ftyp, moov]));
const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/run-clamp' }, cwd: directory };

await isPlayableVideo(mp4, host, Deadline.fromTimeoutMs(5_000));
await isPlayableVideo(mp4, host);

const [withDeadline, withoutDeadline] = vi
.mocked(runCmd)
.mock.calls.map(([, , options]) => options?.timeoutMs);
expect(withDeadline).toBeGreaterThan(0);
expect(withDeadline).toBeLessThanOrEqual(5_000);
expect(withoutDeadline).toBe(10_000);
});

test('a validator run that times out reads as not playable, whatever bound stopped it', async () => {
const mp4 = writeFixture('run-cut-short.mp4', Buffer.concat([ftyp, moov]));
const host = { env: { AGENT_DEVICE_SWIFT_CACHE_DIR: '/cache/run-cut-short' }, cwd: directory };
const timedOut = (timeoutMs: number) =>
new AppError('COMMAND_FAILED', `validator timed out after ${timeoutMs}ms`, { timeoutMs });
vi.mocked(runCmd).mockImplementation(async (_cmd, _args, options) => {
throw timedOut(options!.timeoutMs!);
});
// A frozen clock keeps the 5 ms budget from passing before the validator runs.
vi.useFakeTimers({ toFake: ['Date'] });
try {
await expect(isPlayableVideo(mp4, host, Deadline.fromTimeoutMs(5))).resolves.toBe(false);
await expect(isPlayableVideo(mp4, host)).resolves.toBe(false);
expect(vi.mocked(runCmd).mock.calls.map(([, , options]) => options?.timeoutMs)).toEqual([
5, 10_000,
]);
} finally {
vi.useRealTimers();
vi.mocked(runCmd).mockReset();
vi.mocked(runCmd).mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
}
});

test('waiting for a playable video checks it once per attempt', async () => {
const mp4 = writeFixture('never-plays.mp4', Buffer.concat([ftyp, moov]));
vi.mocked(runCmd).mockResolvedValue({ exitCode: 1, stdout: '', stderr: '' });
try {
await expect(
waitForPlayableVideo(mp4, { host: createTestHost(), pollMs: 0, attempts: 2 }),
).resolves.toBe(false);
expect(runCmd).toHaveBeenCalledTimes(2);
} finally {
vi.mocked(runCmd).mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' });
}
});

test('reaches the semantic validator with an MP4 container whose last box runs past the file', async () => {
const truncated = Buffer.concat([ftyp, moov]).subarray(0, ftyp.length + moov.length - 4);
await expect(
Expand Down
Loading
Loading