Skip to content
2 changes: 1 addition & 1 deletion src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ Validation and evidence:
agent-device press 124 817
agent-device snapshot -i
Startup/CPU/memory/frame first pass: perf metrics --json (bare perf and metrics are aliases). Focused frame/jank health: perf frames --json. Memory-only sample: perf memory sample --json returns compact JSON with bounded top offenders. Heap/memgraph artifact escalation: perf memory snapshot --out heap.artifact; use --kind android-hprof on Android or --kind memgraph on supported Apple simulator/macOS app sessions. Android native profiling: perf cpu profile start|stop|report --kind simpleperf --out <path>; Android native traces: perf trace start|stop --kind perfetto --out <path>. Artifact collectors return compact state/path/size metadata only; raw heap/profile/trace files stay on disk. Treat native perf output as the agent evidence: for example, a Perfetto stop can return state=stopped, outPath=/tmp/app.perfetto-trace, sizeBytes=5392410, and method=adb-shell-perfetto while the 5.3 MB raw trace stays in the artifact. This is better than raw dumps for agents because it is stable, bounded, and keeps large artifacts out of context. heapprofd is deferred until Perfetto plumbing is available. Replay maintenance: replay -u ./flow.ad.
Recording: record start/stop. Use --max-size to cap the longest edge and --quality medium|high to choose output quality across Android and Apple targets. By default, stop burns touch overlays into the video; use record start --hide-touches for the fastest raw recording. Android adb screenrecord has a 180s platform limit, so longer Android recordings are returned as multiple MP4 chunks. For gesture-heavy iOS simulator proof videos, prefer --hide-touches because overlay timing depends on a stable runner session while gestures are executing. Tracing: trace start ./trace.log, trace stop ./trace.log. Paths are positional.
Recording: record start/stop. Use --max-size to cap the longest edge and --quality medium|high to choose output quality across Android and Apple targets. By default, stop burns touch overlays into the video; use record start --hide-touches for the fastest raw recording. Android record start publishes a durable device manifest. Android adb screenrecord has a 180s platform limit, so longer Android recordings are returned as multiple MP4 chunks while the daemon stays alive; after daemon restart, record stop recovers only manifest-owned chunks and warns when gesture overlays are unavailable. For gesture-heavy iOS simulator proof videos, prefer --hide-touches because overlay timing depends on a stable runner session while gestures are executing. Tracing: trace start ./trace.log, trace stop ./trace.log. Paths are positional.
Stable known flow: batch ./steps.json, not workflow batch.
Inline batch JSON example:
agent-device batch --steps '[{"command":"open","input":{"app":"settings"}},{"command":"wait","input":{"kind":"duration","durationMs":100}}]'
Expand Down
2 changes: 1 addition & 1 deletion src/commands/recording/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const recordCliSchema = {
'record start [path] [--fps <n>] [--max-size <px>] [--quality <medium|high>] [--hide-touches] | record stop',
listUsageOverride: 'record start [path] | record stop',
helpDescription:
'Start/stop screen recording; Android recordings longer than the 180s adb screenrecord limit are returned as multiple MP4 chunks. Use --max-size to limit dimensions and --quality to choose medium or high export quality',
'Start/stop screen recording; 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. Use --max-size to limit dimensions and --quality to choose medium or high export quality',
summary: 'Start or stop screen recording',
positionalArgs: ['start|stop', 'path?'],
allowedFlags: ['fps', 'screenshotMaxSize', 'quality', 'hideTouches'],
Expand Down
161 changes: 161 additions & 0 deletions src/daemon/handlers/__tests__/record-trace.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ function makeIosSimulatorRecordingSession(
return session;
}

function isAndroidScreenrecordStartCommand(command: string): boolean {
return /^-s emulator-5554 shell screenrecord (?:--size 756x1344 )?--bit-rate (?:8000000|20000000) \/(?:sdcard|data\/local\/tmp)\/agent-device-recording-\d+\.mp4 >\/dev\/null 2>&1 & echo \$!$/.test(
command,
);
}

async function runRecordCommand(params: {
sessionStore: SessionStore;
sessionName: string;
Expand Down Expand Up @@ -2064,6 +2070,161 @@ test('record stop returns multiple Android recording chunks', async () => {
);
});

test('Android recording rotation retries sequentially when concurrent screenrecord start fails', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const sessionStore = makeSessionStore();
const sessionName = 'android-screenrecord-sequential-rotation';
sessionStore.set(
sessionName,
makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Android',
kind: 'device',
booted: true,
}),
);

const adbCommands: string[] = [];
let startAttempt = 0;
let firstFailedStartIndex = -1;
let oldPidStopped = false;
mockRunCmd.mockImplementation(async (_cmd, args) => {
const command = args.join(' ');
adbCommands.push(command);
if (isAndroidScreenrecordStartCommand(command)) {
startAttempt += 1;
if (startAttempt === 1) return { stdout: '4321\n', stderr: '', exitCode: 0 };
if (startAttempt <= 3) {
if (firstFailedStartIndex === -1) firstFailedStartIndex = adbCommands.length - 1;
return { stdout: '', stderr: 'encoder busy', exitCode: 1 };
}
return { stdout: '4322\n', stderr: '', exitCode: 0 };
}
if (
/^-s emulator-5554 shell stat -c %s \/(?:sdcard|data\/local\/tmp)\/agent-device-recording-\d+\.mp4$/.test(
command,
)
) {
return { stdout: '2048\n', stderr: '', exitCode: 0 };
}
if (command === '-s emulator-5554 shell ps -o pid= -p 4321') {
return oldPidStopped
? { stdout: '', stderr: '', exitCode: 1 }
: { stdout: '4321\n', stderr: '', exitCode: 0 };
}
if (command === '-s emulator-5554 shell kill -2 4321') {
oldPidStopped = true;
return { stdout: '', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 0 };
});

const response = await runRecordCommand({
sessionStore,
sessionName,
positionals: ['start', './android-sequential-rotation.mp4'],
});
expect(response?.ok).toBe(true);

await vi.advanceTimersByTimeAsync(170_000);

const recording = sessionStore.get(sessionName)?.recording;
expect(recording?.platform).toBe('android');
if (recording?.platform !== 'android') {
throw new Error('expected Android recording');
}
expect(recording.remotePid).toBe('4322');
expect(recording.chunks).toHaveLength(2);
const stopOldChunk = adbCommands.findIndex(
(command) => command === '-s emulator-5554 shell kill -2 4321',
);
const sequentialStart = adbCommands
.slice(stopOldChunk + 1)
.findIndex((command) => isAndroidScreenrecordStartCommand(command));
expect(firstFailedStartIndex).toBeGreaterThan(-1);
expect(stopOldChunk).toBeGreaterThan(firstFailedStartIndex);
expect(sequentialStart).toBeGreaterThan(-1);
});

test('Android recording rotation discards next chunk when manifest commit fails', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
const sessionStore = makeSessionStore();
const sessionName = 'android-screenrecord-rotation-manifest-failure';
sessionStore.set(
sessionName,
makeSession(sessionName, {
platform: 'android',
id: 'emulator-5554',
name: 'Android',
kind: 'device',
booted: true,
}),
);

const adbCommands: string[] = [];
let startAttempt = 0;
let manifestWriteCount = 0;
let nextPidStopped = false;
mockRunCmd.mockImplementation(async (_cmd, args) => {
const command = args.join(' ');
adbCommands.push(command);
if (command.includes('agent-device-recording-active.json.tmp')) {
manifestWriteCount += 1;
return manifestWriteCount === 4
? { stdout: '', stderr: 'manifest write failed', exitCode: 1 }
: { stdout: '', stderr: '', exitCode: 0 };
}
if (isAndroidScreenrecordStartCommand(command)) {
startAttempt += 1;
return { stdout: `${4320 + startAttempt}\n`, 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 };
}
if (command === '-s emulator-5554 shell ps -o pid= -p 4322') {
return nextPidStopped
? { stdout: '', stderr: '', exitCode: 1 }
: { stdout: '4322\n', stderr: '', exitCode: 0 };
}
if (command === '-s emulator-5554 shell kill -2 4322') {
nextPidStopped = true;
return { stdout: '', stderr: '', exitCode: 0 };
}
return { stdout: '', stderr: '', exitCode: 0 };
});

const response = await runRecordCommand({
sessionStore,
sessionName,
positionals: ['start', './android-rotation-manifest-failure.mp4'],
});
expect(response?.ok).toBe(true);

await vi.advanceTimersByTimeAsync(170_000);

const recording = sessionStore.get(sessionName)?.recording;
expect(recording?.platform).toBe('android');
if (recording?.platform !== 'android') {
throw new Error('expected Android recording');
}
expect(recording.remotePid).toBe('4321');
expect(recording.chunks).toHaveLength(1);
expect(recording.rotationFailedReason).toMatch(
/failed to write Android recording recovery manifest/,
);
expect(adbCommands).toContain('-s emulator-5554 shell kill -2 4322');
expect(
adbCommands.some((command) =>
/^-s emulator-5554 shell rm -f \/sdcard\/agent-device-recording-\d+\.mp4$/.test(command),
),
).toBe(true);
});

test('record stop keeps iOS simulator video when touch overlay recording was invalidated', async () => {
const sessionStore = makeSessionStore();
const sessionName = 'ios-invalidated-recording';
Expand Down
Loading
Loading