mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
5260ac8edd
* fix: harden node runtime cleanup * fix: propagate command cancellation * fix: address runtime cleanup review * fix: close partial artifact writes before cleanup
241 lines
8.3 KiB
TypeScript
241 lines
8.3 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
import {
|
|
analyzeOverlayCrop,
|
|
createIntegrationTestContext,
|
|
runCliJson,
|
|
runRecordingInspect,
|
|
type RecordingInspectionManifest,
|
|
} from './test-helpers.ts';
|
|
|
|
const recordingE2EEnabled = isTruthy(process.env.AGENT_DEVICE_RECORDING_E2E);
|
|
|
|
test('recording tap overlay on iOS simulator', { skip: shouldSkipIosRecordingE2E() }, () => {
|
|
runRecordingOverlayCase({
|
|
platform: 'ios',
|
|
testName: 'recording tap overlay',
|
|
outFile: 'ios-tap.mp4',
|
|
sessionName: 'recording-ios-tap',
|
|
openArgs: ['open', 'com.apple.Preferences', '--platform', 'ios', '--relaunch', '--json'],
|
|
steps: [['tap general', ['click', 'role=cell', 'label=General', '--json']]],
|
|
inspectPrefix: 'ios-tap',
|
|
overlayKind: 'tap',
|
|
overlayOptions: { minPixelCount: 180, maxCenterDistance: 80 },
|
|
});
|
|
});
|
|
|
|
test('recording scroll overlay on iOS simulator', { skip: shouldSkipIosRecordingE2E() }, () => {
|
|
runRecordingOverlayCase({
|
|
platform: 'ios',
|
|
testName: 'recording scroll overlay',
|
|
outFile: 'ios-scroll.mp4',
|
|
sessionName: 'recording-ios-scroll',
|
|
openArgs: ['open', 'com.apple.Preferences', '--platform', 'ios', '--relaunch', '--json'],
|
|
steps: [['scroll down', ['scroll', 'down', '0.45', '--json']]],
|
|
inspectPrefix: 'ios-scroll',
|
|
overlayKind: 'scroll',
|
|
overlayOptions: { minPixelCount: 5 },
|
|
});
|
|
});
|
|
|
|
test('recording back-swipe overlay on iOS simulator', { skip: shouldSkipIosRecordingE2E() }, () => {
|
|
runRecordingOverlayCase({
|
|
platform: 'ios',
|
|
testName: 'recording back swipe overlay',
|
|
outFile: 'ios-back-swipe.mp4',
|
|
sessionName: 'recording-ios-back-swipe',
|
|
openArgs: ['open', 'com.apple.Preferences', '--platform', 'ios', '--relaunch', '--json'],
|
|
steps: [
|
|
['open general', ['press', '201', '319', '--json']],
|
|
['edge swipe', ['swipe', '10', '400', '250', '400', '250', '--json']],
|
|
],
|
|
inspectPrefix: 'ios-back-swipe',
|
|
overlayKind: 'back-swipe',
|
|
overlayOptions: { minPixelCount: 80 },
|
|
});
|
|
});
|
|
|
|
test('recording tap overlay on Android emulator', { skip: shouldSkipAndroidRecordingE2E() }, () => {
|
|
runRecordingOverlayCase({
|
|
platform: 'android',
|
|
testName: 'recording tap overlay',
|
|
outFile: 'android-tap.mp4',
|
|
sessionName: 'recording-android-tap',
|
|
openArgs: ['open', 'settings', '--platform', 'android', '--relaunch', '--json'],
|
|
steps: [
|
|
['tap apps', ['press', '672', '1362', '--json']],
|
|
['scroll down', ['scroll', 'down', '0.2', '--json']],
|
|
['settle', ['wait', '1200', '--json']],
|
|
],
|
|
inspectPrefix: 'android-tap',
|
|
overlayKind: 'tap',
|
|
overlayOptions: { minPixelCount: 180, maxCenterDistance: 80 },
|
|
});
|
|
});
|
|
|
|
test(
|
|
'recording scroll overlay on Android emulator',
|
|
{ skip: shouldSkipAndroidRecordingE2E() },
|
|
() => {
|
|
runRecordingOverlayCase({
|
|
platform: 'android',
|
|
testName: 'recording scroll overlay',
|
|
outFile: 'android-scroll.mp4',
|
|
sessionName: 'recording-android-scroll',
|
|
openArgs: ['open', 'settings', '--platform', 'android', '--relaunch', '--json'],
|
|
steps: [
|
|
['scroll down', ['scroll', 'down', '0.45', '--json']],
|
|
['settle', ['wait', '1200', '--json']],
|
|
],
|
|
inspectPrefix: 'android-scroll',
|
|
overlayKind: 'scroll',
|
|
overlayOptions: { minPixelCount: 5 },
|
|
});
|
|
},
|
|
);
|
|
|
|
type RecordingOverlayCase = {
|
|
platform: 'ios' | 'android';
|
|
testName: string;
|
|
outFile: string;
|
|
sessionName: string;
|
|
openArgs: string[];
|
|
steps: Array<[string, string[]]>;
|
|
inspectPrefix: string;
|
|
overlayKind: string;
|
|
overlayOptions: { minPixelCount: number; maxCenterDistance?: number };
|
|
};
|
|
|
|
function runRecordingOverlayCase(options: RecordingOverlayCase): void {
|
|
const integration = createRecordingIntegrationContext(options.platform, options.testName);
|
|
const outPath = path.join(integration.artifactDir(), options.outFile);
|
|
const session = ['--session', options.sessionName];
|
|
let recordingStarted = false;
|
|
let recordingStopped = false;
|
|
|
|
try {
|
|
integration.runStep('open settings', [...options.openArgs, ...session]);
|
|
integration.runStep('record start', ['record', 'start', outPath, '--json', ...session]);
|
|
recordingStarted = true;
|
|
for (const [label, args] of options.steps) {
|
|
integration.runStep(label, [...args, ...session]);
|
|
}
|
|
const stop = integration.runStep('record stop', ['record', 'stop', '--json', ...session]);
|
|
recordingStopped = true;
|
|
assertRecordingArtifacts(stop, outPath);
|
|
const manifest = inspectRecording(
|
|
outPath,
|
|
stop.json?.data?.telemetryPath,
|
|
integration.artifactDir(),
|
|
options.inspectPrefix,
|
|
);
|
|
assertOverlayForKind(manifest, options.overlayKind, options.overlayOptions);
|
|
} finally {
|
|
cleanupRecordingSession(integration, session, recordingStarted, recordingStopped);
|
|
}
|
|
}
|
|
|
|
function createRecordingIntegrationContext(platform: 'ios' | 'android', testName: string) {
|
|
const runId = new Date().toISOString().replaceAll(':', '-');
|
|
const stateDir = path.resolve('test/artifacts', platform, sanitize(testName), runId, 'state');
|
|
return createIntegrationTestContext({
|
|
platform,
|
|
testName,
|
|
extraEnv: { ...process.env, AGENT_DEVICE_STATE_DIR: stateDir },
|
|
});
|
|
}
|
|
|
|
function cleanupRecordingSession(
|
|
integration: ReturnType<typeof createRecordingIntegrationContext>,
|
|
session: string[],
|
|
recordingStarted: boolean,
|
|
recordingStopped: boolean,
|
|
): void {
|
|
if (recordingStarted && !recordingStopped) {
|
|
integration.runCleanupStep('cleanup record stop', ['record', 'stop', '--json', ...session]);
|
|
}
|
|
integration.runCleanupStep('cleanup close', ['close', '--json', ...session]);
|
|
}
|
|
|
|
function assertRecordingArtifacts(result: ReturnType<typeof runCliJson>, outPath: string): void {
|
|
assert.equal(result.status, 0, JSON.stringify(result.json ?? result.stderr));
|
|
assert.equal(result.json?.success, true);
|
|
assert.equal(typeof result.json?.data?.telemetryPath, 'string');
|
|
assert.ok(existsSync(outPath), `expected recording at ${outPath}`);
|
|
assert.ok(
|
|
existsSync(String(result.json?.data?.telemetryPath)),
|
|
`expected telemetry sidecar at ${String(result.json?.data?.telemetryPath)}`,
|
|
);
|
|
assert.equal(
|
|
result.json?.data?.artifacts?.some(
|
|
(artifact: { field?: string }) => artifact.field === 'telemetryPath',
|
|
),
|
|
true,
|
|
'expected telemetryPath artifact in record stop response',
|
|
);
|
|
}
|
|
|
|
function inspectRecording(
|
|
outPath: string,
|
|
telemetryPath: string,
|
|
artifactDir: string,
|
|
prefix: string,
|
|
): RecordingInspectionManifest {
|
|
return runRecordingInspect({
|
|
videoPath: outPath,
|
|
telemetryPath,
|
|
outputDir: path.join(artifactDir, `${prefix}-inspect`),
|
|
});
|
|
}
|
|
|
|
function assertOverlayForKind(
|
|
manifest: RecordingInspectionManifest,
|
|
kind: string,
|
|
options: { minPixelCount: number; maxCenterDistance?: number },
|
|
): void {
|
|
const item = manifest.items.find((candidate) => candidate.kind === kind);
|
|
assert.ok(item, `expected manifest item for ${kind}`);
|
|
const analysis = analyzeOverlayCrop(item.cropPath);
|
|
assert.ok(
|
|
analysis.matchingPixelCount >= options.minPixelCount,
|
|
`expected at least ${options.minPixelCount} overlay-colored pixels in ${item.cropPath}, saw ${analysis.matchingPixelCount}`,
|
|
);
|
|
if (options.maxCenterDistance === undefined) {
|
|
return;
|
|
}
|
|
const centerX = analysis.width / 2;
|
|
const centerY = analysis.height / 2;
|
|
const distance = Math.hypot(analysis.centroidX - centerX, analysis.centroidY - centerY);
|
|
assert.ok(
|
|
distance <= options.maxCenterDistance,
|
|
`expected overlay centroid near crop center for ${item.cropPath}, distance=${distance.toFixed(2)}`,
|
|
);
|
|
}
|
|
|
|
function shouldSkipIosRecordingE2E(): string | false {
|
|
if (!recordingE2EEnabled)
|
|
return 'set AGENT_DEVICE_RECORDING_E2E=1 to run live recording overlay tests';
|
|
if (process.platform !== 'darwin') return 'iOS recording overlay E2E runs only on macOS';
|
|
return false;
|
|
}
|
|
|
|
function shouldSkipAndroidRecordingE2E(): string | false {
|
|
if (!recordingE2EEnabled)
|
|
return 'set AGENT_DEVICE_RECORDING_E2E=1 to run live recording overlay tests';
|
|
return false;
|
|
}
|
|
|
|
function isTruthy(value: string | undefined): boolean {
|
|
return ['1', 'true', 'yes', 'on'].includes((value ?? '').toLowerCase());
|
|
}
|
|
|
|
function sanitize(input: string): string {
|
|
return input
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9._-]+/g, '-')
|
|
.replace(/-+/g, '-');
|
|
}
|