mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
9d7d60c5e0
* test(coverage): declare every public command's six platform coverage judgments once One row per public command in test/integration/command-coverage/declarations.ts carries the android-emulator, ios-simulator, macOS, tvOS, web and Linux classifications with the same fields the six per-platform manifests use today. Each platform's Record<PublicCommand, ...> is projected from that table at load time by a small per-platform view module, so no projected record is committed. No judgment is derived from another platform's: all six stay authored per command. * test(coverage): read the projected per-platform coverage view The six coverage smoke tests, live harnesses and coverage reports now import the platform view module that projects the declaration table. Both the depgraph blast-radius query and the device-lane test follow the iOS and macOS paths. * test(coverage): delete the six per-platform coverage manifests Their rows now live once, per command, in the declaration table; each platform's record is projected from it at load time. * fix(check-affected): extend macos-coverage and integration-node ownership to command-coverage/ test/integration/command-coverage/declarations.ts now carries the per-command coverage judgments that used to live directly under test/integration/macos-e2e/. Its nested path wasn't matched by macosCoverageOwnership (top-level or macos-e2e/ only) or isNodeIntegrationPath (no nested segments), so it fell through to vitest-related, which can't actually run its node --test consumers. Extend both rules to also match test/integration/command-coverage/.
173 lines
5.9 KiB
TypeScript
173 lines
5.9 KiB
TypeScript
import { execFile } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
|
|
import {
|
|
createLiveDeviceContext,
|
|
createLiveDeviceHarness,
|
|
requiredEnv,
|
|
type LiveDeviceContext,
|
|
} from '../live-device-e2e/runtime.ts';
|
|
import type { AndroidEmulatorBehaviorId } from './behavior-coverage.ts';
|
|
import { liveCommandsForScenario } from './coverage.ts';
|
|
import { liveBehaviorsForScenario, writeCoverageReport } from './live-coverage-report.ts';
|
|
|
|
export { assertCoverageComplete, writeCoverageReport } from './live-coverage-report.ts';
|
|
|
|
export type Tier = 'smoke' | 'full';
|
|
|
|
export type LiveContext = LiveDeviceContext<AndroidEmulatorBehaviorId> & {
|
|
appId: string;
|
|
appPath: string;
|
|
serial: string;
|
|
tier: Tier;
|
|
};
|
|
|
|
export function createContext(): LiveContext {
|
|
const tier = requiredEnv('AGENT_DEVICE_ANDROID_E2E_TIER', 'AGENT_DEVICE_ANDROID_E2E');
|
|
assertTier(tier);
|
|
return {
|
|
...createLiveDeviceContext<AndroidEmulatorBehaviorId>({
|
|
artifactRoot: 'test/artifacts/android-emulator',
|
|
session: `android-e2e-${process.pid.toString(36)}`,
|
|
}),
|
|
appId: requiredEnv('AGENT_DEVICE_FIXTURE_APP_ID', 'AGENT_DEVICE_ANDROID_E2E'),
|
|
appPath: requiredEnv('AGENT_DEVICE_FIXTURE_APP_PATH', 'AGENT_DEVICE_ANDROID_E2E'),
|
|
serial: requiredEnv('AGENT_DEVICE_ANDROID_SERIAL', 'AGENT_DEVICE_ANDROID_E2E'),
|
|
tier,
|
|
};
|
|
}
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const ROTATION_PROBE_TIMEOUT_MS = 5_000;
|
|
/** Lines read back from logcat; the rotation decisions of the last few minutes fit comfortably. */
|
|
const ROTATION_LOG_TAIL = 4_000;
|
|
const ROTATION_LOG_LINES = 60;
|
|
const ROTATION_LOG_LINE_LENGTH = 240;
|
|
|
|
/**
|
|
* What the OS says about rotation when a step fails: the two settings `orientation` writes, the
|
|
* display's current rotation, and the WindowManager rotation decisions logcat still holds (with
|
|
* the reason it gives). Read through adb, not agent-device, so it stands even when the CLI path
|
|
* is what failed; the shared collector bounds the whole read so it never delays the screenshot.
|
|
*/
|
|
async function readAndroidRotationEvidence(context: LiveContext): Promise<string> {
|
|
const probes: readonly [string, string[]][] = [
|
|
['accelerometer_rotation', ['shell', 'settings', 'get', 'system', 'accelerometer_rotation']],
|
|
['user_rotation', ['shell', 'settings', 'get', 'system', 'user_rotation']],
|
|
['display rotation', ['shell', 'dumpsys', 'display']],
|
|
// The tail, not the whole buffer: the emulator keeps 2MB and a loaded one takes seconds to
|
|
// dump it, which is what the group bound is for, not this read.
|
|
['logcat rotation decisions', ['logcat', '-d', '-v', 'time', '-t', String(ROTATION_LOG_TAIL)]],
|
|
];
|
|
const sections: string[] = [];
|
|
for (const [title, args] of probes) {
|
|
try {
|
|
const { stdout } = await execFileAsync('adb', ['-s', context.serial, ...args], {
|
|
maxBuffer: 64 * 1024 * 1024,
|
|
timeout: ROTATION_PROBE_TIMEOUT_MS,
|
|
});
|
|
sections.push(`## ${title}\n${selectRotationLines(title, stdout)}`);
|
|
} catch (error) {
|
|
sections.push(
|
|
`## ${title}\n(failed: ${error instanceof Error ? error.message : String(error)})`,
|
|
);
|
|
}
|
|
}
|
|
return `${sections.join('\n\n')}\n`;
|
|
}
|
|
|
|
function selectRotationLines(title: string, output: string): string {
|
|
if (title === 'display rotation') {
|
|
return output
|
|
.split('\n')
|
|
.filter((line) =>
|
|
/mCurrentOrientation|mRotation=|installOrientation|\brotation \d/.test(line),
|
|
)
|
|
.map((line) => line.trim().slice(0, ROTATION_LOG_LINE_LENGTH))
|
|
.slice(0, 8)
|
|
.join('\n');
|
|
}
|
|
if (title === 'logcat rotation decisions') {
|
|
return output
|
|
.split('\n')
|
|
.filter(
|
|
(line) =>
|
|
/(WindowManager|DisplayRotation|WindowOrientationListener|RotationResolver|DisplayContent|SensorService)/.test(
|
|
line,
|
|
) && /rotat|orient/i.test(line),
|
|
)
|
|
.slice(-ROTATION_LOG_LINES)
|
|
.map((line) => line.slice(0, ROTATION_LOG_LINE_LENGTH))
|
|
.join('\n');
|
|
}
|
|
return output.trim();
|
|
}
|
|
|
|
const harness = createLiveDeviceHarness<LiveContext, AndroidEmulatorBehaviorId>({
|
|
behaviorsForScenario: liveBehaviorsForScenario,
|
|
commandsForScenario: liveCommandsForScenario,
|
|
deviceEvidence: readAndroidRotationEvidence,
|
|
commonFlags: (context, args) => [
|
|
...args,
|
|
'--platform',
|
|
'android',
|
|
'--serial',
|
|
context.serial,
|
|
'--session',
|
|
context.session,
|
|
'--daemon-server-mode',
|
|
'dual',
|
|
...(args.includes('--json') ? [] : ['--json']),
|
|
],
|
|
writeCoverageReport,
|
|
});
|
|
|
|
export const {
|
|
runScenario,
|
|
runStep,
|
|
sessionExists,
|
|
verifyBehavior,
|
|
verifyCommand,
|
|
verifyNestedCommand: verifyNestedReplayCommand,
|
|
} = harness;
|
|
|
|
export async function cleanupSession(context: LiveContext): Promise<void> {
|
|
const failures: unknown[] = [];
|
|
if (context.tier === 'full') {
|
|
for (const [step, args] of [
|
|
['bind fixture before Android permission reset', ['open', context.appId]],
|
|
['reset Android microphone permission', ['settings', 'permission', 'reset', 'microphone']],
|
|
] as const) {
|
|
try {
|
|
await runStep(context, `cleanup: ${step}`, [...args]);
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
}
|
|
}
|
|
if (context.sessionOpen) {
|
|
for (const [step, args] of [
|
|
['restore portrait orientation', ['orientation', 'portrait']],
|
|
['close fixture session', ['close']],
|
|
] as const) {
|
|
try {
|
|
await runStep(context, `cleanup: ${step}`, [...args]);
|
|
} catch (error) {
|
|
failures.push(error);
|
|
}
|
|
}
|
|
}
|
|
if (failures.length === 0) return;
|
|
const errorPath = path.join(context.artifactDir, 'cleanup-error.txt');
|
|
fs.writeFileSync(errorPath, failures.map(String).join('\n\n'));
|
|
throw new AggregateError(failures, `Android E2E cleanup failed; details: ${errorPath}`);
|
|
}
|
|
|
|
function assertTier(value: string): asserts value is Tier {
|
|
if (value === 'smoke' || value === 'full') return;
|
|
throw new Error(`unsupported Android E2E tier: ${value}`);
|
|
}
|