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
37 changes: 37 additions & 0 deletions src/commands/__tests__/client-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'vitest';
import { recordCliOutput } from '../client-output.ts';

describe('recordCliOutput', () => {
test('prints chunked Android recording paths clearly for human stdout', () => {
const output = recordCliOutput({
recording: 'stopped',
outPath: '/tmp/recording.mp4',
telemetryPath: '/tmp/recording.gesture-telemetry.json',
warning:
'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.',
overlayWarning:
'touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry',
chunks: [
{ index: 1, path: '/tmp/recording.mp4' },
{ index: 2, path: '/tmp/recording.part-002.mp4' },
],
});

expect(output.text).toBe(
[
'Recording chunks:',
' 1: /tmp/recording.mp4',
' 2: /tmp/recording.part-002.mp4',
'Telemetry: /tmp/recording.gesture-telemetry.json',
'Warning: Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.',
'Overlay warning: touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry',
].join('\n'),
);
expect(output.data).toMatchObject({
chunks: [
{ index: 1, path: '/tmp/recording.mp4' },
{ index: 2, path: '/tmp/recording.part-002.mp4' },
],
});
});
});
42 changes: 41 additions & 1 deletion src/commands/client-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,53 @@ export function tapCliOutput(result: CommandRequestResult): CliOutput {
export function recordCliOutput(result: CommandRequestResult): CliOutput {
const data = result as Record<string, unknown>;
const outPath = typeof data.outPath === 'string' ? data.outPath : '';
return { data, text: outPath };
const chunks = readRecordingChunks(data);
if (chunks.length <= 1) {
return { data, text: formatRecordSingleOutput(data, outPath) };
}

const lines = ['Recording chunks:'];
for (const chunk of chunks) {
lines.push(` ${chunk.index}: ${chunk.path}`);
}
if (typeof data.telemetryPath === 'string') {
lines.push(`Telemetry: ${data.telemetryPath}`);
}
if (typeof data.warning === 'string') {
lines.push(`Warning: ${data.warning}`);
}
if (typeof data.overlayWarning === 'string') {
lines.push(`Overlay warning: ${data.overlayWarning}`);
}
return { data, text: lines.join('\n') };
}

function defaultCommandCliOutput(result: CommandRequestResult): CliOutput {
return messageOutput(result as Record<string, unknown>);
}

function formatRecordSingleOutput(data: Record<string, unknown>, outPath: string): string {
const lines: string[] = [];
if (outPath) lines.push(outPath);
if (typeof data.warning === 'string') lines.push(`Warning: ${data.warning}`);
if (typeof data.overlayWarning === 'string')
lines.push(`Overlay warning: ${data.overlayWarning}`);
return lines.join('\n');
}

function readRecordingChunks(
data: Record<string, unknown>,
): Array<{ index: number; path: string }> {
const rawChunks = data.chunks;
if (!Array.isArray(rawChunks)) return [];
return rawChunks.flatMap((chunk) => {
if (!chunk || typeof chunk !== 'object') return [];
const candidate = chunk as Record<string, unknown>;
if (typeof candidate.index !== 'number' || typeof candidate.path !== 'string') return [];
return [{ index: candidate.index, path: candidate.path }];
});
}

function messageOutput(data: Record<string, unknown>): CliOutput {
return { data, text: readCommandMessage(data) };
}
Expand Down
158 changes: 158 additions & 0 deletions src/daemon/handlers/__tests__/record-trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1331,6 +1331,164 @@ test('record stop force-kills Android screenrecord when SIGINT fails but process
).toBe(true);
});

test('record stop warns when Android screenrecord hit the 180s platform limit', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-screenrecord-limit';
sessionStore.set(
sessionName,
makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Android',
kind: 'device',
booted: true,
}),
);

mockRunCmd.mockImplementation(async (_cmd, args) => {
const command = args.join(' ');
if (
/^-s emulator-5554 shell screenrecord \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test(
command,
)
) {
return { stdout: '4321\n', stderr: '', exitCode: 0 };
}
if (
/^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)
) {
return { stdout: '1024\n', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 0 };
});

await runRecordCommand({
sessionStore,
sessionName,
positionals: ['start', './android-limit.mp4'],
});

const recording = sessionStore.get(sessionName)?.recording;
if (recording) {
recording.startedAt = Date.now() - 181_000;
}

mockRunCmd.mockImplementation(async (_cmd, args) => {
const command = args.join(' ');
if (command === '-s emulator-5554 shell ps -o pid= -p 4321') {
return { stdout: '', stderr: '', exitCode: 1 };
}
if (command === '-s emulator-5554 shell kill -2 4321') {
return { stdout: '', stderr: 'No such process', exitCode: 1 };
}
if (
/^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)
) {
return { stdout: '2048\n', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 0 };
});

const response = await runRecordCommand({
sessionStore,
sessionName,
positionals: ['stop'],
});

expect(response?.ok).toBe(true);
expect((response as any).data?.warning).toMatch(/180s platform limit/);
});

test('record stop returns multiple Android recording chunks', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'android-screenrecord-chunks';
const session = makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Android',
kind: 'device',
booted: true,
});
session.recording = {
platform: 'android',
outPath: path.resolve('./android-long.mp4'),
startedAt: Date.now() - 172_000,
showTouches: true,
gestureEvents: [{ kind: 'tap', tMs: 120, x: 90, y: 180 }],
remotePath: '/sdcard/agent-device-recording-2.mp4',
remotePid: '4322',
warning:
'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.',
chunks: [
{
index: 1,
path: path.resolve('./android-long.mp4'),
remotePath: '/sdcard/agent-device-recording-1.mp4',
},
{
index: 2,
path: path.resolve('./android-long.part-002.mp4'),
remotePath: '/sdcard/agent-device-recording-2.mp4',
},
],
};
sessionStore.set(sessionName, session);

const adbCommands: string[] = [];
mockRunCmd.mockImplementation(async (_cmd, args) => {
const command = args.join(' ');
adbCommands.push(command);
if (command === '-s emulator-5554 shell ps -o pid= -p 4322') {
return adbCommands.includes('-s emulator-5554 shell kill -2 4322')
? { stdout: '', stderr: '', exitCode: 1 }
: { stdout: '4322\n', stderr: '', exitCode: 0 };
}
if (command === '-s emulator-5554 shell kill -2 4322') {
return { stdout: '', stderr: '', exitCode: 0 };
}
if (
/^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)
) {
return { stdout: '2048\n', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 0 };
});

const response = await runRecordCommand({
sessionStore,
sessionName,
positionals: ['stop'],
});

expect(response?.ok).toBe(true);
if (response?.ok !== true) {
throw new Error('expected successful Android record stop response');
}
expect(response.data?.warning).toMatch(/split into multiple MP4 chunks/);
expect(response.data?.overlayWarning).toMatch(/skipped for chunked Android recordings/);
expect(response.data?.chunks).toEqual([
expect.objectContaining({ index: 1, path: path.resolve('./android-long.mp4') }),
expect.objectContaining({ index: 2, path: path.resolve('./android-long.part-002.mp4') }),
]);
expect(response.data?.artifacts).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: 'outPath', path: path.resolve('./android-long.mp4') }),
expect.objectContaining({
field: 'chunkPath',
path: path.resolve('./android-long.part-002.mp4'),
}),
]),
);
expect(adbCommands).toEqual(
expect.arrayContaining([
'-s emulator-5554 pull /sdcard/agent-device-recording-1.mp4 ' +
path.resolve('./android-long.mp4'),
'-s emulator-5554 pull /sdcard/agent-device-recording-2.mp4 ' +
path.resolve('./android-long.part-002.mp4'),
]),
);
});

test('record stop keeps iOS simulator video when touch overlay recording was invalidated', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-invalidated-recording';
Expand Down
127 changes: 127 additions & 0 deletions src/daemon/handlers/record-trace-android-chunks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import path from 'node:path';
import type { SessionState } from '../types.ts';
import type { RecordTraceDeps } from './record-trace-types.ts';
import { finalizeRecordingOverlay } from './record-trace-finalize.ts';
import { persistRecordingTelemetry } from '../recording-telemetry.ts';

const ANDROID_SCREENRECORD_TIME_LIMIT_MS = 180_000;
const ANDROID_SCREENRECORD_TIME_LIMIT_GRACE_MS = 2_000;
const ANDROID_SCREENRECORD_CHUNK_MS = 170_000;

type AndroidRecording = Extract<NonNullable<SessionState['recording']>, { platform: 'android' }>;

type AndroidScreenrecordChunk = {
remotePath: string;
remotePid: string;
startedAt: number;
};

export function deriveAndroidChunkOutPath(outPath: string, chunkIndex: number): string {
if (chunkIndex === 1) {
return outPath;
}
const parsed = path.parse(outPath);
const extension = parsed.ext || '.mp4';
return path.join(
parsed.dir,
`${parsed.name}.part-${String(chunkIndex).padStart(3, '0')}${extension}`,
);
}

export function ensureAndroidRecordingChunks(
recording: AndroidRecording,
): NonNullable<AndroidRecording['chunks']> {
recording.chunks ??= [
{
index: 1,
path: recording.outPath,
remotePath: recording.remotePath,
},
];
return recording.chunks;
}

export function resolveAndroidScreenrecordLimitWarning(
recording: AndroidRecording,
): string | undefined {
const elapsedMs = Date.now() - recording.startedAt;
if (elapsedMs < ANDROID_SCREENRECORD_TIME_LIMIT_MS - ANDROID_SCREENRECORD_TIME_LIMIT_GRACE_MS) {
return undefined;
}
return 'Android adb screenrecord stopped before record stop, likely after reaching the 180s platform limit. The MP4 may be truncated; final interactions after the limit are not in the video.';
}

export function scheduleAndroidRecordingRotation(params: {
recording: AndroidRecording;
startNextChunk: (preferredRemoteDir: string) => Promise<AndroidScreenrecordChunk>;
finishCurrentChunk: () => Promise<string | undefined>;
}): void {
const { recording, startNextChunk, finishCurrentChunk } = params;
const timer = setTimeout(() => {
recording.rotationPromise = rotateAndroidRecordingChunk({
recording,
startNextChunk,
finishCurrentChunk,
})
.catch((error: unknown) => {
recording.rotationFailedReason = error instanceof Error ? error.message : String(error);
})
.finally(() => {
recording.rotationPromise = undefined;
if (!recording.stopping && !recording.rotationFailedReason) {
scheduleAndroidRecordingRotation({ recording, startNextChunk, finishCurrentChunk });
}
});
}, ANDROID_SCREENRECORD_CHUNK_MS);
timer.unref?.();
recording.rotationTimer = timer;
}

async function rotateAndroidRecordingChunk(params: {
recording: AndroidRecording;
startNextChunk: (preferredRemoteDir: string) => Promise<AndroidScreenrecordChunk>;
finishCurrentChunk: () => Promise<string | undefined>;
}): Promise<void> {
const { recording, startNextChunk, finishCurrentChunk } = params;
if (recording.stopping) return;
const stopError = await finishCurrentChunk();
if (stopError) {
throw new Error(stopError);
}
if (recording.stopping) return;

const chunks = ensureAndroidRecordingChunks(recording);
const nextIndex = chunks.length + 1;
const nextChunk = await startNextChunk(path.posix.dirname(recording.remotePath));
recording.remotePath = nextChunk.remotePath;
recording.remotePid = nextChunk.remotePid;
chunks.push({
index: nextIndex,
path: deriveAndroidChunkOutPath(recording.outPath, nextIndex),
remotePath: nextChunk.remotePath,
});
recording.warning ??=
'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.';
}

export async function finalizeAndroidRecordingOutput(params: {
recording: AndroidRecording;
deps: RecordTraceDeps;
}): Promise<void> {
const { recording, deps } = params;
const chunks = ensureAndroidRecordingChunks(recording);
if (chunks.length <= 1) {
await finalizeRecordingOverlay({
recording,
deps,
targetLabel: 'Android recording',
});
return;
}

persistRecordingTelemetry({ recording });
if (recording.showTouches && recording.gestureEvents.length > 0) {
recording.overlayWarning ??=
'touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry';
}
}
Loading
Loading