Files
Michał Pierzchała 9d7d60c5e0 test(coverage): declare every public command's coverage judgments once (#2418)
* 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/.
2026-09-09 15:55:00 +02:00

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}`);
}