diff --git a/test/integration/android-emulator-e2e/live-harness.ts b/test/integration/android-emulator-e2e/live-harness.ts index ed04b1c84..b84f4643d 100644 --- a/test/integration/android-emulator-e2e/live-harness.ts +++ b/test/integration/android-emulator-e2e/live-harness.ts @@ -1,5 +1,7 @@ +import { execFile } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; +import { promisify } from 'node:util'; import { createLiveDeviceContext, @@ -37,9 +39,68 @@ export function createContext(): LiveContext { }; } +const execFileAsync = promisify(execFile); + +/** + * What the OS says about rotation when a step fails: the two settings `orientation` writes, the + * display's current rotation, and every WindowManager rotation decision 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. + */ +async function readAndroidRotationEvidence(context: LiveContext): Promise { + 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']], + ['logcat rotation decisions', ['logcat', '-d', '-v', 'time']], + ]; + const sections: string[] = []; + for (const [title, args] of probes) { + try { + const { stdout } = await execFileAsync('adb', ['-s', context.serial, ...args], { + maxBuffer: 64 * 1024 * 1024, + timeout: 20_000, + }); + 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, 200)) + .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(-60) + .join('\n'); + } + return output.trim(); +} + const harness = createLiveDeviceHarness({ behaviorsForScenario: liveBehaviorsForScenario, commandsForScenario: liveCommandsForScenario, + deviceEvidence: readAndroidRotationEvidence, commonFlags: (context, args) => [ ...args, '--platform', diff --git a/test/integration/live-device-e2e/runtime.ts b/test/integration/live-device-e2e/runtime.ts index ff1976929..731621cf6 100644 --- a/test/integration/live-device-e2e/runtime.ts +++ b/test/integration/live-device-e2e/runtime.ts @@ -49,6 +49,12 @@ type HarnessOptions = { env: NodeJS.ProcessEnv, options?: { timeoutMs?: number }, ) => Promise; + /** + * Platform-owned device facts for a failed step (rotation state, system logs), read outside + * agent-device so they describe the device even when the CLI path is what failed. Best-effort: + * a throw or undefined records nothing. + */ + deviceEvidence?: (context: Context) => Promise; writeCoverageReport: (context: Context) => void; }; @@ -176,6 +182,7 @@ export function createLiveDeviceHarness< `artifacts: ${context.artifactDir}`, `screenshot: ${evidence.screenshotPath ?? '(capture failed)'}`, `snapshot: ${evidence.snapshotPath ?? '(capture failed)'}`, + `device: ${evidence.devicePath ?? '(not collected)'}`, ].join('\n'); fs.writeFileSync(path.join(context.artifactDir, 'failed-step.txt'), message); assert.fail(message); @@ -191,12 +198,22 @@ export function createLiveDeviceHarness< */ async function captureFailedStepEvidence( context: Context, - ): Promise<{ screenshotPath?: string; snapshotPath?: string }> { + ): Promise<{ screenshotPath?: string; snapshotPath?: string; devicePath?: string }> { const stem = path.join(context.artifactDir, `failed-step-${context.stepHistory.length}`); const screenshotPath = `${stem}.png`; const snapshotPath = `${stem}-snapshot.json`; + const devicePath = `${stem}-device.txt`; const runCli = options.runCli ?? runBuiltCliJson; - const evidence: { screenshotPath?: string; snapshotPath?: string } = {}; + const evidence: { screenshotPath?: string; snapshotPath?: string; devicePath?: string } = {}; + try { + const facts = await options.deviceEvidence?.(context); + if (facts !== undefined) { + fs.writeFileSync(devicePath, facts); + evidence.devicePath = devicePath; + } + } catch { + // evidence only + } try { const screenshot = await runCli( options.commonFlags(context, ['screenshot', screenshotPath]),