Skip to content

Commit 0285d59

Browse files
committed
fix: clean up recording stop and snapshot traversal
1 parent 2068f60 commit 0285d59

8 files changed

Lines changed: 204 additions & 53 deletions

File tree

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,41 @@ test('record stop leaves a short visual tail after iOS simulator gestures', asyn
596596
expect(kill).toHaveBeenCalledWith('SIGINT');
597597
});
598598

599+
test('record stop reports too-short iOS simulator recordings without leaving invalid output', async () => {
600+
const sessionStore = makeSessionStore();
601+
const sessionName = 'ios-sim-too-short';
602+
const outPath = path.join(os.tmpdir(), `agent-device-too-short-${Date.now()}.mp4`);
603+
fs.writeFileSync(outPath, 'not-a-video');
604+
const session = makeSession(sessionName, {
605+
platform: 'ios',
606+
id: 'sim-1',
607+
name: 'Simulator',
608+
kind: 'simulator',
609+
booted: true,
610+
});
611+
session.recording = {
612+
platform: 'ios',
613+
outPath,
614+
startedAt: Date.now(),
615+
showTouches: false,
616+
gestureEvents: [],
617+
child: { kill: vi.fn() },
618+
wait: Promise.resolve({ stdout: '', stderr: 'failed to finalize', exitCode: 1 }),
619+
};
620+
sessionStore.set(sessionName, session);
621+
622+
const response = await runRecordCommand({
623+
sessionStore,
624+
sessionName,
625+
positionals: ['stop'],
626+
});
627+
628+
expect(response?.ok).toBe(false);
629+
expect((response as any).error?.message).toMatch(/wait at least 1000ms/i);
630+
expect((response as any).error?.message).toMatch(/failed to finalize/i);
631+
expect(fs.existsSync(outPath)).toBe(false);
632+
});
633+
599634
test('record start stores iOS simulator recorder pid for scoped cleanup', async () => {
600635
const sessionStore = makeSessionStore();
601636
const sessionName = 'ios-sim-recorder-pid';

src/daemon/handlers/record-trace-android-copy.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,7 @@ async function copyAndroidRecordingWithValidation(params: {
4343
let lastCopyError: string | undefined;
4444

4545
for (let attempt = 0; attempt < ANDROID_LOCAL_VIDEO_ATTEMPTS; attempt += 1) {
46-
try {
47-
fs.rmSync(outPath, { force: true });
48-
} catch {
49-
// Ignore stale local file cleanup issues and let adb pull report the real failure.
50-
}
46+
removeLocalRecordingCandidate(outPath);
5147

5248
const device = androidDeviceForSerial(deviceId);
5349
const pullResult = await pullAndroidAdbFile(remotePath, outPath, {
@@ -98,6 +94,7 @@ async function copyAndroidRecordingWithValidation(params: {
9894
if (lastCopyError) {
9995
return `failed to copy recording from device: ${lastCopyError}`;
10096
}
97+
removeLocalRecordingCandidate(outPath);
10198
return 'failed to copy recording from device: pulled file is not a playable MP4';
10299
}
103100

@@ -108,3 +105,11 @@ function readFileSize(filePath: string): number {
108105
return 0;
109106
}
110107
}
108+
109+
function removeLocalRecordingCandidate(filePath: string): void {
110+
try {
111+
fs.rmSync(filePath, { force: true });
112+
} catch {
113+
// Ignore local cleanup issues and let the caller report the validation failure.
114+
}
115+
}

src/daemon/handlers/record-trace-android.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts';
22
import { sleep } from '../../utils/timeouts.ts';
33
import { androidDeviceForSerial, runAndroidAdb } from '../../platforms/android/adb.ts';
44
import type { DaemonResponse, SessionState } from '../types.ts';
5-
import { formatRecordTraceExecFailure } from '../record-trace-errors.ts';
5+
import { buildRecordStopFailure, formatRecordTraceExecFailure } from '../record-trace-errors.ts';
66
import type { RecordTraceDeps } from './record-trace-types.ts';
77
import { errorResponse } from './response.ts';
88
import type {
@@ -444,7 +444,7 @@ export async function stopAndroidRecording(params: {
444444
});
445445
if (copyError) {
446446
await cleanupRemoteRecording();
447-
return errorResponse('COMMAND_FAILED', copyError);
447+
return errorResponse('COMMAND_FAILED', formatAndroidStopFailure(copyError, recording));
448448
}
449449

450450
await finalizeAndroidRecordingOutput({ recording, deps });
@@ -453,7 +453,7 @@ export async function stopAndroidRecording(params: {
453453
await cleanupRemoteRecording();
454454

455455
if (stopError) {
456-
return errorResponse('COMMAND_FAILED', stopError);
456+
return errorResponse('COMMAND_FAILED', formatAndroidStopFailure(stopError, recording));
457457
}
458458

459459
if (cleanupError) {
@@ -488,3 +488,7 @@ export async function stopAndroidRecording(params: {
488488
}
489489
}
490490
}
491+
492+
function formatAndroidStopFailure(error: string, recording: AndroidRecording): string {
493+
return buildRecordStopFailure(error, recording).message;
494+
}

src/daemon/handlers/record-trace-recording.ts

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import {
1616
resizeRecording,
1717
trimRecordingStart,
1818
} from '../../recording/overlay.ts';
19-
import { formatRecordTraceError, formatRecordTraceExecFailure } from '../record-trace-errors.ts';
19+
import {
20+
buildRecordStopFailure,
21+
formatRecordTraceError,
22+
formatRecordTraceExecFailure,
23+
} from '../record-trace-errors.ts';
2024
import { resolveRecordingProvider } from '../recording-provider.ts';
2125
import { finalizeRecordingOverlay } from './record-trace-finalize.ts';
2226
import { errorResponse } from './response.ts';
@@ -328,9 +332,9 @@ async function stopNonRunnerRecording(params: {
328332
);
329333
}
330334
if (stopResult.exitCode !== 0) {
331-
return errorResponse(
332-
'COMMAND_FAILED',
335+
return buildLocalRecordingStopFailure(
333336
`failed to stop recording: ${formatRecordTraceExecFailure(stopResult, 'simctl recordVideo')}`,
337+
recording,
334338
);
335339
}
336340

@@ -353,9 +357,9 @@ async function stopNonRunnerRecording(params: {
353357
},
354358
);
355359
if (!playable) {
356-
return errorResponse(
357-
'COMMAND_FAILED',
360+
return buildLocalRecordingStopFailure(
358361
`failed to stop recording: ${recording.outPath} was not finalized into a playable video`,
362+
recording,
359363
);
360364
}
361365

@@ -398,6 +402,25 @@ async function stopNonRunnerRecording(params: {
398402
return null;
399403
}
400404

405+
function buildLocalRecordingStopFailure(
406+
message: string,
407+
recording: Extract<NonNullable<SessionState['recording']>, { platform: 'ios' | 'android' }>,
408+
): DaemonResponse {
409+
const failure = buildRecordStopFailure(message, recording);
410+
if (failure.tooShort) {
411+
removeInvalidRecordingOutput(recording.outPath);
412+
}
413+
return errorResponse('COMMAND_FAILED', failure.message);
414+
}
415+
416+
function removeInvalidRecordingOutput(outPath: string): void {
417+
try {
418+
fs.rmSync(outPath, { force: true });
419+
} catch {
420+
// Best effort: the error response still reports the failed finalization.
421+
}
422+
}
423+
401424
async function stopRecording(params: {
402425
req: DaemonRequest;
403426
activeSession: SessionState;

src/daemon/record-trace-errors.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,17 @@ export function formatRecordTraceError(error: unknown): string {
22
return error instanceof Error ? error.message : String(error);
33
}
44

5+
const MIN_PLAYABLE_RECORDING_DURATION_MS = 1_000;
6+
7+
type RecordingStartedAt = {
8+
startedAt: number;
9+
};
10+
11+
type RecordStopFailure = {
12+
message: string;
13+
tooShort: boolean;
14+
};
15+
516
export function formatRecordTraceExecFailure(
617
result: { stdout: string; stderr: string; exitCode: number },
718
command: string,
@@ -10,3 +21,18 @@ export function formatRecordTraceExecFailure(
1021
result.stderr.trim() || result.stdout.trim() || `${command} exited with code ${result.exitCode}`
1122
);
1223
}
24+
25+
export function buildRecordStopFailure(
26+
message: string,
27+
recording: RecordingStartedAt,
28+
now = Date.now(),
29+
): RecordStopFailure {
30+
const elapsedMs = Math.max(0, now - recording.startedAt);
31+
if (elapsedMs >= MIN_PLAYABLE_RECORDING_DURATION_MS) {
32+
return { message, tooShort: false };
33+
}
34+
return {
35+
message: `${message}. Recording stopped after ${Math.round(elapsedMs)}ms; wait at least ${MIN_PLAYABLE_RECORDING_DURATION_MS}ms between record start and record stop so the recorder can finalize a playable MP4`,
36+
tooShort: true,
37+
};
38+
}

src/daemon/snapshot-presentation/ios/noise.ts

Lines changed: 43 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@ import { normalizeType } from '../../snapshot-processing.ts';
44
import { collectIosScrollIndicatorPresentation } from './scroll.ts';
55
import {
66
areRectsApproximatelyEqual,
7-
collectDescendants,
7+
findDescendant,
88
findLargestViewportRect,
9+
forEachDescendant,
910
isRepeatedStaticNode,
1011
isScrollableSnapshotType,
1112
isSemanticActionNode,
@@ -35,9 +36,13 @@ function collectIosReactNativeOverlayWrapperSuppression(
3536
return;
3637
}
3738

38-
const hasVisibleBannerDescendant = collectDescendants(nodes, position).some(
39-
(descendant) =>
40-
descendant.label?.trim() === nodeLabel && isReactNativeCollapsedWarningBanner(descendant),
39+
const hasVisibleBannerDescendant = Boolean(
40+
findDescendant(
41+
nodes,
42+
position,
43+
(descendant) =>
44+
descendant.label?.trim() === nodeLabel && isReactNativeCollapsedWarningBanner(descendant),
45+
),
4146
);
4247
if (hasVisibleBannerDescendant) {
4348
suppressedIndexes.add(node.index);
@@ -81,59 +86,66 @@ function collectRepeatedStaticSuppressionForNode(
8186
nodeLabel: string,
8287
suppressedIndexes: Set<number>,
8388
): void {
84-
const descendants = collectDescendants(nodes, position);
8589
const type = normalizeType(node.type ?? '');
8690
if (type === 'statictext' || type === 'link') {
87-
suppressRepeatedStaticDescendants(descendants, nodeLabel, suppressedIndexes);
91+
suppressRepeatedStaticDescendants(nodes, position, nodeLabel, suppressedIndexes);
8892
return;
8993
}
9094
if (type !== 'other') {
9195
return;
9296
}
93-
if (hasEquivalentSemanticDescendant(descendants, nodeLabel)) {
97+
if (hasEquivalentSemanticDescendant(nodes, position, nodeLabel)) {
9498
suppressedIndexes.add(node.index);
9599
return;
96100
}
97-
suppressRepeatedStaticDescendants(descendants, nodeLabel, suppressedIndexes);
101+
suppressRepeatedStaticDescendants(nodes, position, nodeLabel, suppressedIndexes);
98102
}
99103

100104
function hasEquivalentSemanticDescendant(
101-
descendants: RawSnapshotNode[],
105+
nodes: RawSnapshotNode[],
106+
position: number,
102107
nodeLabel: string,
103108
): boolean {
104-
return descendants.some((descendant) => {
105-
const type = normalizeType(descendant.type ?? '');
106-
return (
107-
(type === 'link' || type === 'searchfield' || isScrollableSnapshotType(descendant.type)) &&
108-
descendant.label?.trim() === nodeLabel
109-
);
110-
});
109+
return Boolean(
110+
findDescendant(nodes, position, (descendant) => {
111+
const type = normalizeType(descendant.type ?? '');
112+
return (
113+
(type === 'link' || type === 'searchfield' || isScrollableSnapshotType(descendant.type)) &&
114+
descendant.label?.trim() === nodeLabel
115+
);
116+
}),
117+
);
111118
}
112119

113120
function suppressRepeatedStaticDescendants(
114-
descendants: RawSnapshotNode[],
121+
nodes: RawSnapshotNode[],
122+
position: number,
115123
label: string,
116124
suppressedIndexes: Set<number>,
117125
): void {
118-
for (const descendant of descendants) {
126+
forEachDescendant(nodes, position, (descendant) => {
119127
if (isRepeatedStaticNode(descendant, label)) {
120128
suppressedIndexes.add(descendant.index);
121129
}
122-
}
130+
});
123131
}
124132

125133
function collectIosActionWrapperSuppression(
126134
nodes: RawSnapshotNode[],
127135
suppressedIndexes: Set<number>,
128136
): void {
129137
forEachOtherNodeWithLabel(nodes, (node, nodeLabel, position) => {
130-
const semanticDescendant = collectDescendants(nodes, position).find(
131-
(descendant) =>
138+
if (!node.rect) {
139+
return;
140+
}
141+
const semanticDescendant = findDescendant(nodes, position, (descendant) => {
142+
return (
132143
isSemanticActionNode(descendant) &&
133144
descendant.label?.trim() === nodeLabel &&
134145
(areRectsApproximatelyEqual(descendant.rect, node.rect) ||
135-
isIosBackdropDismissWrapper(node, descendant)),
136-
);
146+
isIosBackdropDismissWrapper(node, descendant))
147+
);
148+
});
137149
if (semanticDescendant) {
138150
suppressedIndexes.add(node.index);
139151
}
@@ -193,9 +205,9 @@ function collectIosOffscreenKeyboardSuppression(
193205
}
194206
suppressedIndexes.add(node.index);
195207
suppressOffscreenKeyboardAncestors(node, nodes, suppressedIndexes, screenBottom);
196-
for (const descendant of collectDescendants(nodes, position)) {
208+
forEachDescendant(nodes, position, (descendant) => {
197209
suppressedIndexes.add(descendant.index);
198-
}
210+
});
199211
}
200212
}
201213

@@ -255,8 +267,9 @@ function collectIosSearchToolbarSuppression(
255267
if (node.label !== 'Toolbar') {
256268
continue;
257269
}
258-
const descendants = collectDescendants(nodes, position);
259-
const innerSearch = descendants.find(
270+
const innerSearch = findDescendant(
271+
nodes,
272+
position,
260273
(candidate) =>
261274
normalizeType(candidate.type ?? '') === 'searchfield' && candidate.label === 'Search',
262275
);
@@ -276,14 +289,14 @@ function suppressSearchToolbarDescendants(
276289
keptSearchIndex: number | null,
277290
suppressedIndexes: Set<number>,
278291
): void {
279-
for (const descendant of collectDescendants(nodes, position)) {
292+
forEachDescendant(nodes, position, (descendant) => {
280293
if (descendant.index === keptSearchIndex) {
281-
continue;
294+
return;
282295
}
283296
if (shouldSuppressIosSearchToolbarDescendant(descendant)) {
284297
suppressedIndexes.add(descendant.index);
285298
}
286-
}
299+
});
287300
}
288301

289302
function suppressToolbarAncestors(

src/daemon/snapshot-presentation/ios/rows.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,16 @@ function collectIosRowPresentationForNode(
3030
rowLabel: string,
3131
context: SnapshotTreeRuleContext,
3232
): void {
33-
const descendants = collectDescendants(nodes, position);
3433
const rowType = normalizeType(row.type ?? '');
3534
if (rowType === 'button') {
35+
const descendants = collectDescendants(nodes, position);
3636
suppressRepeatedRowDescendants(descendants, rowLabel, context.suppressedIndexes, row);
3737
return;
3838
}
3939
if (rowType !== 'cell') {
4040
return;
4141
}
42+
const descendants = collectDescendants(nodes, position);
4243
if (collectSwitchRowPresentation(descendants, row, rowLabel, context)) {
4344
return;
4445
}

0 commit comments

Comments
 (0)