Skip to content

Commit 3522aa3

Browse files
authored
fix: chunk long Android recordings (#617)
* fix: chunk long Android recordings * refactor: simplify Android recording chunks * fix: satisfy Android recording fallow audit
1 parent 3283e5e commit 3522aa3

11 files changed

Lines changed: 697 additions & 151 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { recordCliOutput } from '../client-output.ts';
3+
4+
describe('recordCliOutput', () => {
5+
test('prints chunked Android recording paths clearly for human stdout', () => {
6+
const output = recordCliOutput({
7+
recording: 'stopped',
8+
outPath: '/tmp/recording.mp4',
9+
telemetryPath: '/tmp/recording.gesture-telemetry.json',
10+
warning:
11+
'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.',
12+
overlayWarning:
13+
'touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry',
14+
chunks: [
15+
{ index: 1, path: '/tmp/recording.mp4' },
16+
{ index: 2, path: '/tmp/recording.part-002.mp4' },
17+
],
18+
});
19+
20+
expect(output.text).toBe(
21+
[
22+
'Recording chunks:',
23+
' 1: /tmp/recording.mp4',
24+
' 2: /tmp/recording.part-002.mp4',
25+
'Telemetry: /tmp/recording.gesture-telemetry.json',
26+
'Warning: Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.',
27+
'Overlay warning: touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry',
28+
].join('\n'),
29+
);
30+
expect(output.data).toMatchObject({
31+
chunks: [
32+
{ index: 1, path: '/tmp/recording.mp4' },
33+
{ index: 2, path: '/tmp/recording.part-002.mp4' },
34+
],
35+
});
36+
});
37+
});

src/commands/client-output.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,53 @@ export function tapCliOutput(result: CommandRequestResult): CliOutput {
179179
export function recordCliOutput(result: CommandRequestResult): CliOutput {
180180
const data = result as Record<string, unknown>;
181181
const outPath = typeof data.outPath === 'string' ? data.outPath : '';
182-
return { data, text: outPath };
182+
const chunks = readRecordingChunks(data);
183+
if (chunks.length <= 1) {
184+
return { data, text: formatRecordSingleOutput(data, outPath) };
185+
}
186+
187+
const lines = ['Recording chunks:'];
188+
for (const chunk of chunks) {
189+
lines.push(` ${chunk.index}: ${chunk.path}`);
190+
}
191+
if (typeof data.telemetryPath === 'string') {
192+
lines.push(`Telemetry: ${data.telemetryPath}`);
193+
}
194+
if (typeof data.warning === 'string') {
195+
lines.push(`Warning: ${data.warning}`);
196+
}
197+
if (typeof data.overlayWarning === 'string') {
198+
lines.push(`Overlay warning: ${data.overlayWarning}`);
199+
}
200+
return { data, text: lines.join('\n') };
183201
}
184202

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

207+
function formatRecordSingleOutput(data: Record<string, unknown>, outPath: string): string {
208+
const lines: string[] = [];
209+
if (outPath) lines.push(outPath);
210+
if (typeof data.warning === 'string') lines.push(`Warning: ${data.warning}`);
211+
if (typeof data.overlayWarning === 'string')
212+
lines.push(`Overlay warning: ${data.overlayWarning}`);
213+
return lines.join('\n');
214+
}
215+
216+
function readRecordingChunks(
217+
data: Record<string, unknown>,
218+
): Array<{ index: number; path: string }> {
219+
const rawChunks = data.chunks;
220+
if (!Array.isArray(rawChunks)) return [];
221+
return rawChunks.flatMap((chunk) => {
222+
if (!chunk || typeof chunk !== 'object') return [];
223+
const candidate = chunk as Record<string, unknown>;
224+
if (typeof candidate.index !== 'number' || typeof candidate.path !== 'string') return [];
225+
return [{ index: candidate.index, path: candidate.path }];
226+
});
227+
}
228+
189229
function messageOutput(data: Record<string, unknown>): CliOutput {
190230
return { data, text: readCommandMessage(data) };
191231
}

src/daemon/handlers/__tests__/record-trace.test.ts

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,164 @@ test('record stop force-kills Android screenrecord when SIGINT fails but process
13311331
).toBe(true);
13321332
});
13331333

1334+
test('record stop warns when Android screenrecord hit the 180s platform limit', async () => {
1335+
const sessionStore = makeSessionStore();
1336+
const sessionName = 'android-screenrecord-limit';
1337+
sessionStore.set(
1338+
sessionName,
1339+
makeSession(sessionName, {
1340+
platform: 'android',
1341+
id: 'emulator-5554',
1342+
name: 'Android',
1343+
kind: 'device',
1344+
booted: true,
1345+
}),
1346+
);
1347+
1348+
mockRunCmd.mockImplementation(async (_cmd, args) => {
1349+
const command = args.join(' ');
1350+
if (
1351+
/^-s emulator-5554 shell screenrecord \/sdcard\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test(
1352+
command,
1353+
)
1354+
) {
1355+
return { stdout: '4321\n', stderr: '', exitCode: 0 };
1356+
}
1357+
if (
1358+
/^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)
1359+
) {
1360+
return { stdout: '1024\n', stderr: '', exitCode: 0 };
1361+
}
1362+
return { stdout: '', stderr: '', exitCode: 0 };
1363+
});
1364+
1365+
await runRecordCommand({
1366+
sessionStore,
1367+
sessionName,
1368+
positionals: ['start', './android-limit.mp4'],
1369+
});
1370+
1371+
const recording = sessionStore.get(sessionName)?.recording;
1372+
if (recording) {
1373+
recording.startedAt = Date.now() - 181_000;
1374+
}
1375+
1376+
mockRunCmd.mockImplementation(async (_cmd, args) => {
1377+
const command = args.join(' ');
1378+
if (command === '-s emulator-5554 shell ps -o pid= -p 4321') {
1379+
return { stdout: '', stderr: '', exitCode: 1 };
1380+
}
1381+
if (command === '-s emulator-5554 shell kill -2 4321') {
1382+
return { stdout: '', stderr: 'No such process', exitCode: 1 };
1383+
}
1384+
if (
1385+
/^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)
1386+
) {
1387+
return { stdout: '2048\n', stderr: '', exitCode: 0 };
1388+
}
1389+
return { stdout: '', stderr: '', exitCode: 0 };
1390+
});
1391+
1392+
const response = await runRecordCommand({
1393+
sessionStore,
1394+
sessionName,
1395+
positionals: ['stop'],
1396+
});
1397+
1398+
expect(response?.ok).toBe(true);
1399+
expect((response as any).data?.warning).toMatch(/180s platform limit/);
1400+
});
1401+
1402+
test('record stop returns multiple Android recording chunks', async () => {
1403+
const sessionStore = makeSessionStore();
1404+
const sessionName = 'android-screenrecord-chunks';
1405+
const session = makeSession(sessionName, {
1406+
platform: 'android',
1407+
id: 'emulator-5554',
1408+
name: 'Android',
1409+
kind: 'device',
1410+
booted: true,
1411+
});
1412+
session.recording = {
1413+
platform: 'android',
1414+
outPath: path.resolve('./android-long.mp4'),
1415+
startedAt: Date.now() - 172_000,
1416+
showTouches: true,
1417+
gestureEvents: [{ kind: 'tap', tMs: 120, x: 90, y: 180 }],
1418+
remotePath: '/sdcard/agent-device-recording-2.mp4',
1419+
remotePid: '4322',
1420+
warning:
1421+
'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.',
1422+
chunks: [
1423+
{
1424+
index: 1,
1425+
path: path.resolve('./android-long.mp4'),
1426+
remotePath: '/sdcard/agent-device-recording-1.mp4',
1427+
},
1428+
{
1429+
index: 2,
1430+
path: path.resolve('./android-long.part-002.mp4'),
1431+
remotePath: '/sdcard/agent-device-recording-2.mp4',
1432+
},
1433+
],
1434+
};
1435+
sessionStore.set(sessionName, session);
1436+
1437+
const adbCommands: string[] = [];
1438+
mockRunCmd.mockImplementation(async (_cmd, args) => {
1439+
const command = args.join(' ');
1440+
adbCommands.push(command);
1441+
if (command === '-s emulator-5554 shell ps -o pid= -p 4322') {
1442+
return adbCommands.includes('-s emulator-5554 shell kill -2 4322')
1443+
? { stdout: '', stderr: '', exitCode: 1 }
1444+
: { stdout: '4322\n', stderr: '', exitCode: 0 };
1445+
}
1446+
if (command === '-s emulator-5554 shell kill -2 4322') {
1447+
return { stdout: '', stderr: '', exitCode: 0 };
1448+
}
1449+
if (
1450+
/^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)
1451+
) {
1452+
return { stdout: '2048\n', stderr: '', exitCode: 0 };
1453+
}
1454+
return { stdout: '', stderr: '', exitCode: 0 };
1455+
});
1456+
1457+
const response = await runRecordCommand({
1458+
sessionStore,
1459+
sessionName,
1460+
positionals: ['stop'],
1461+
});
1462+
1463+
expect(response?.ok).toBe(true);
1464+
if (response?.ok !== true) {
1465+
throw new Error('expected successful Android record stop response');
1466+
}
1467+
expect(response.data?.warning).toMatch(/split into multiple MP4 chunks/);
1468+
expect(response.data?.overlayWarning).toMatch(/skipped for chunked Android recordings/);
1469+
expect(response.data?.chunks).toEqual([
1470+
expect.objectContaining({ index: 1, path: path.resolve('./android-long.mp4') }),
1471+
expect.objectContaining({ index: 2, path: path.resolve('./android-long.part-002.mp4') }),
1472+
]);
1473+
expect(response.data?.artifacts).toEqual(
1474+
expect.arrayContaining([
1475+
expect.objectContaining({ field: 'outPath', path: path.resolve('./android-long.mp4') }),
1476+
expect.objectContaining({
1477+
field: 'chunkPath',
1478+
path: path.resolve('./android-long.part-002.mp4'),
1479+
}),
1480+
]),
1481+
);
1482+
expect(adbCommands).toEqual(
1483+
expect.arrayContaining([
1484+
'-s emulator-5554 pull /sdcard/agent-device-recording-1.mp4 ' +
1485+
path.resolve('./android-long.mp4'),
1486+
'-s emulator-5554 pull /sdcard/agent-device-recording-2.mp4 ' +
1487+
path.resolve('./android-long.part-002.mp4'),
1488+
]),
1489+
);
1490+
});
1491+
13341492
test('record stop keeps iOS simulator video when touch overlay recording was invalidated', async () => {
13351493
const sessionStore = makeSessionStore();
13361494
const sessionName = 'ios-invalidated-recording';
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import path from 'node:path';
2+
import type { SessionState } from '../types.ts';
3+
import type { RecordTraceDeps } from './record-trace-types.ts';
4+
import { finalizeRecordingOverlay } from './record-trace-finalize.ts';
5+
import { persistRecordingTelemetry } from '../recording-telemetry.ts';
6+
7+
const ANDROID_SCREENRECORD_TIME_LIMIT_MS = 180_000;
8+
const ANDROID_SCREENRECORD_TIME_LIMIT_GRACE_MS = 2_000;
9+
const ANDROID_SCREENRECORD_CHUNK_MS = 170_000;
10+
11+
type AndroidRecording = Extract<NonNullable<SessionState['recording']>, { platform: 'android' }>;
12+
13+
type AndroidScreenrecordChunk = {
14+
remotePath: string;
15+
remotePid: string;
16+
startedAt: number;
17+
};
18+
19+
export function deriveAndroidChunkOutPath(outPath: string, chunkIndex: number): string {
20+
if (chunkIndex === 1) {
21+
return outPath;
22+
}
23+
const parsed = path.parse(outPath);
24+
const extension = parsed.ext || '.mp4';
25+
return path.join(
26+
parsed.dir,
27+
`${parsed.name}.part-${String(chunkIndex).padStart(3, '0')}${extension}`,
28+
);
29+
}
30+
31+
export function ensureAndroidRecordingChunks(
32+
recording: AndroidRecording,
33+
): NonNullable<AndroidRecording['chunks']> {
34+
recording.chunks ??= [
35+
{
36+
index: 1,
37+
path: recording.outPath,
38+
remotePath: recording.remotePath,
39+
},
40+
];
41+
return recording.chunks;
42+
}
43+
44+
export function resolveAndroidScreenrecordLimitWarning(
45+
recording: AndroidRecording,
46+
): string | undefined {
47+
const elapsedMs = Date.now() - recording.startedAt;
48+
if (elapsedMs < ANDROID_SCREENRECORD_TIME_LIMIT_MS - ANDROID_SCREENRECORD_TIME_LIMIT_GRACE_MS) {
49+
return undefined;
50+
}
51+
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.';
52+
}
53+
54+
export function scheduleAndroidRecordingRotation(params: {
55+
recording: AndroidRecording;
56+
startNextChunk: (preferredRemoteDir: string) => Promise<AndroidScreenrecordChunk>;
57+
finishCurrentChunk: () => Promise<string | undefined>;
58+
}): void {
59+
const { recording, startNextChunk, finishCurrentChunk } = params;
60+
const timer = setTimeout(() => {
61+
recording.rotationPromise = rotateAndroidRecordingChunk({
62+
recording,
63+
startNextChunk,
64+
finishCurrentChunk,
65+
})
66+
.catch((error: unknown) => {
67+
recording.rotationFailedReason = error instanceof Error ? error.message : String(error);
68+
})
69+
.finally(() => {
70+
recording.rotationPromise = undefined;
71+
if (!recording.stopping && !recording.rotationFailedReason) {
72+
scheduleAndroidRecordingRotation({ recording, startNextChunk, finishCurrentChunk });
73+
}
74+
});
75+
}, ANDROID_SCREENRECORD_CHUNK_MS);
76+
timer.unref?.();
77+
recording.rotationTimer = timer;
78+
}
79+
80+
async function rotateAndroidRecordingChunk(params: {
81+
recording: AndroidRecording;
82+
startNextChunk: (preferredRemoteDir: string) => Promise<AndroidScreenrecordChunk>;
83+
finishCurrentChunk: () => Promise<string | undefined>;
84+
}): Promise<void> {
85+
const { recording, startNextChunk, finishCurrentChunk } = params;
86+
if (recording.stopping) return;
87+
const stopError = await finishCurrentChunk();
88+
if (stopError) {
89+
throw new Error(stopError);
90+
}
91+
if (recording.stopping) return;
92+
93+
const chunks = ensureAndroidRecordingChunks(recording);
94+
const nextIndex = chunks.length + 1;
95+
const nextChunk = await startNextChunk(path.posix.dirname(recording.remotePath));
96+
recording.remotePath = nextChunk.remotePath;
97+
recording.remotePid = nextChunk.remotePid;
98+
chunks.push({
99+
index: nextIndex,
100+
path: deriveAndroidChunkOutPath(recording.outPath, nextIndex),
101+
remotePath: nextChunk.remotePath,
102+
});
103+
recording.warning ??=
104+
'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.';
105+
}
106+
107+
export async function finalizeAndroidRecordingOutput(params: {
108+
recording: AndroidRecording;
109+
deps: RecordTraceDeps;
110+
}): Promise<void> {
111+
const { recording, deps } = params;
112+
const chunks = ensureAndroidRecordingChunks(recording);
113+
if (chunks.length <= 1) {
114+
await finalizeRecordingOverlay({
115+
recording,
116+
deps,
117+
targetLabel: 'Android recording',
118+
});
119+
return;
120+
}
121+
122+
persistRecordingTelemetry({ recording });
123+
if (recording.showTouches && recording.gestureEvents.length > 0) {
124+
recording.overlayWarning ??=
125+
'touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry';
126+
}
127+
}

0 commit comments

Comments
 (0)