Skip to content

Commit 07e6863

Browse files
committed
fix: chunk long Android recordings
1 parent 9a026bf commit 07e6863

11 files changed

Lines changed: 678 additions & 141 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: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1331,6 +1331,167 @@ 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+
startedAt: Date.now() - 172_000,
1428+
stoppedAt: Date.now() - 2_000,
1429+
},
1430+
{
1431+
index: 2,
1432+
path: path.resolve('./android-long.part-002.mp4'),
1433+
remotePath: '/sdcard/agent-device-recording-2.mp4',
1434+
startedAt: Date.now() - 2_000,
1435+
},
1436+
],
1437+
};
1438+
sessionStore.set(sessionName, session);
1439+
1440+
const adbCommands: string[] = [];
1441+
mockRunCmd.mockImplementation(async (_cmd, args) => {
1442+
const command = args.join(' ');
1443+
adbCommands.push(command);
1444+
if (command === '-s emulator-5554 shell ps -o pid= -p 4322') {
1445+
return adbCommands.includes('-s emulator-5554 shell kill -2 4322')
1446+
? { stdout: '', stderr: '', exitCode: 1 }
1447+
: { stdout: '4322\n', stderr: '', exitCode: 0 };
1448+
}
1449+
if (command === '-s emulator-5554 shell kill -2 4322') {
1450+
return { stdout: '', stderr: '', exitCode: 0 };
1451+
}
1452+
if (
1453+
/^-s emulator-5554 shell stat -c %s \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command)
1454+
) {
1455+
return { stdout: '2048\n', stderr: '', exitCode: 0 };
1456+
}
1457+
return { stdout: '', stderr: '', exitCode: 0 };
1458+
});
1459+
1460+
const response = await runRecordCommand({
1461+
sessionStore,
1462+
sessionName,
1463+
positionals: ['stop'],
1464+
});
1465+
1466+
expect(response?.ok).toBe(true);
1467+
if (response?.ok !== true) {
1468+
throw new Error('expected successful Android record stop response');
1469+
}
1470+
expect(response.data?.warning).toMatch(/split into multiple MP4 chunks/);
1471+
expect(response.data?.overlayWarning).toMatch(/skipped for chunked Android recordings/);
1472+
expect(response.data?.chunks).toEqual([
1473+
expect.objectContaining({ index: 1, path: path.resolve('./android-long.mp4') }),
1474+
expect.objectContaining({ index: 2, path: path.resolve('./android-long.part-002.mp4') }),
1475+
]);
1476+
expect(response.data?.artifacts).toEqual(
1477+
expect.arrayContaining([
1478+
expect.objectContaining({ field: 'outPath', path: path.resolve('./android-long.mp4') }),
1479+
expect.objectContaining({
1480+
field: 'chunkPath',
1481+
path: path.resolve('./android-long.part-002.mp4'),
1482+
}),
1483+
]),
1484+
);
1485+
expect(adbCommands).toEqual(
1486+
expect.arrayContaining([
1487+
'-s emulator-5554 pull /sdcard/agent-device-recording-1.mp4 ' +
1488+
path.resolve('./android-long.mp4'),
1489+
'-s emulator-5554 pull /sdcard/agent-device-recording-2.mp4 ' +
1490+
path.resolve('./android-long.part-002.mp4'),
1491+
]),
1492+
);
1493+
});
1494+
13341495
test('record stop keeps iOS simulator video when touch overlay recording was invalidated', async () => {
13351496
const sessionStore = makeSessionStore();
13361497
const sessionName = 'ios-invalidated-recording';
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
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+
export const ANDROID_SCREENRECORD_TIME_LIMIT_MS = 180_000;
8+
export 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+
clientPath: recording.clientOutPath,
39+
remotePath: recording.remotePath,
40+
startedAt: recording.startedAt,
41+
},
42+
];
43+
return recording.chunks;
44+
}
45+
46+
export function resolveAndroidScreenrecordLimitWarning(
47+
recording: AndroidRecording,
48+
): string | undefined {
49+
const elapsedMs = Date.now() - recording.startedAt;
50+
if (elapsedMs < ANDROID_SCREENRECORD_TIME_LIMIT_MS - ANDROID_SCREENRECORD_TIME_LIMIT_GRACE_MS) {
51+
return undefined;
52+
}
53+
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.';
54+
}
55+
56+
export function scheduleAndroidRecordingRotation(params: {
57+
recording: AndroidRecording;
58+
startNextChunk: (preferredRemoteDir: string) => Promise<AndroidScreenrecordChunk>;
59+
finishCurrentChunk: () => Promise<string | undefined>;
60+
}): void {
61+
const { recording, startNextChunk, finishCurrentChunk } = params;
62+
const timer = setTimeout(() => {
63+
recording.rotationPromise = rotateAndroidRecordingChunk({
64+
recording,
65+
startNextChunk,
66+
finishCurrentChunk,
67+
})
68+
.catch((error: unknown) => {
69+
recording.rotationFailedReason = error instanceof Error ? error.message : String(error);
70+
})
71+
.finally(() => {
72+
recording.rotationPromise = undefined;
73+
if (!recording.stopping && !recording.rotationFailedReason) {
74+
scheduleAndroidRecordingRotation({ recording, startNextChunk, finishCurrentChunk });
75+
}
76+
});
77+
}, ANDROID_SCREENRECORD_CHUNK_MS);
78+
timer.unref?.();
79+
recording.rotationTimer = timer;
80+
}
81+
82+
async function rotateAndroidRecordingChunk(params: {
83+
recording: AndroidRecording;
84+
startNextChunk: (preferredRemoteDir: string) => Promise<AndroidScreenrecordChunk>;
85+
finishCurrentChunk: () => Promise<string | undefined>;
86+
}): Promise<void> {
87+
const { recording, startNextChunk, finishCurrentChunk } = params;
88+
if (recording.stopping) return;
89+
const stopError = await finishCurrentChunk();
90+
if (stopError) {
91+
throw new Error(stopError);
92+
}
93+
if (recording.stopping) return;
94+
95+
const chunks = ensureAndroidRecordingChunks(recording);
96+
const nextIndex = chunks.length + 1;
97+
const nextChunk = await startNextChunk(path.posix.dirname(recording.remotePath));
98+
recording.remotePath = nextChunk.remotePath;
99+
recording.remotePid = nextChunk.remotePid;
100+
chunks.push({
101+
index: nextIndex,
102+
path: deriveAndroidChunkOutPath(recording.outPath, nextIndex),
103+
clientPath:
104+
recording.clientOutPath === undefined
105+
? undefined
106+
: deriveAndroidChunkOutPath(recording.clientOutPath, nextIndex),
107+
remotePath: nextChunk.remotePath,
108+
startedAt: nextChunk.startedAt,
109+
});
110+
recording.warning ??=
111+
'Android adb screenrecord is capped at 180s, so this recording was split into multiple MP4 chunks.';
112+
}
113+
114+
export async function finalizeAndroidRecordingOutput(params: {
115+
recording: AndroidRecording;
116+
deps: RecordTraceDeps;
117+
}): Promise<void> {
118+
const { recording, deps } = params;
119+
const chunks = ensureAndroidRecordingChunks(recording);
120+
if (chunks.length <= 1) {
121+
await finalizeRecordingOverlay({
122+
recording,
123+
deps,
124+
targetLabel: 'Android recording',
125+
});
126+
return;
127+
}
128+
129+
persistRecordingTelemetry({ recording });
130+
if (recording.showTouches && recording.gestureEvents.length > 0) {
131+
recording.overlayWarning ??=
132+
'touch overlay burn-in is skipped for chunked Android recordings; returning raw chunks plus gesture telemetry';
133+
}
134+
}

0 commit comments

Comments
 (0)