Files
callstack__agent-device/test/integration/live-device-e2e/failed-step-evidence.ts
Michał Pierzchała ff59309415 test(android-e2e): record rotation state and logcat rotation decisions on a failed step (#2350)
* test(android-e2e): record rotation state and logcat rotation decisions on a failed step

The Android smoke has failed on the post-alert canary since 2026-09-03,
and the failed-step screenshot from run 34021894996 shows why the reads
miss: the device is in landscape at that point, with the canary below
the fold, although `orientation portrait` had taken effect (the fixture
confirmed it and every tap before the alert landed at x=540). Nothing we
keep says what rotated it. A failed step now also writes
failed-step-N-device.txt with the two rotation settings, the display's
rotation lines, and WindowManager's rotation decisions from logcat, read
through adb so they stand even when the CLI path failed.

* test(android-e2e): keep the rotation evidence to WindowManager decisions and display rotation fields

* test(e2e): own failed-step evidence in one collector, bound the device probes, test it

Review follow-up on the rotation evidence. The collectors move out of
the harness closure into failed-step-evidence.ts (fallow complexity),
where the platform hook runs alongside the screenshot and snapshot and
is bounded as a group (15s) so it can never delay them; a hook that
throws, answers nothing, or never answers records nothing for the device
file and leaves the CLI evidence in place. The Android probes get a 5s
per-command bound, and logcat lines are capped in count and length.
Deterministic tests cover the file contents, the hook failure and
timeout cases, and the harness naming every evidence file, device file
included, in failed-step.txt.
2026-09-06 12:46:27 +02:00

103 lines
3.3 KiB
TypeScript

import fs from 'node:fs';
import type { CliJsonResult } from '../cli-json.ts';
export type FailedStepEvidence = {
screenshotPath?: string;
snapshotPath?: string;
devicePath?: string;
};
export type FailedStepEvidenceInput = {
/** Artifact path prefix, e.g. `<artifactDir>/failed-step-7`. */
stem: string;
/** The CLI bound to the failed step's device and session. */
runCli: (args: string[]) => Promise<CliJsonResult>;
/** Platform-owned device facts, read outside the CLI. */
deviceEvidence?: () => Promise<string | undefined>;
/** Upper bound for the device facts as a group; the CLI evidence never waits on them. */
deviceEvidenceTimeoutMs?: number;
};
const DEVICE_EVIDENCE_TIMEOUT_MS = 15_000;
/**
* What the device showed when a step failed: the pixels and the accessibility tree the next
* capture would have read, plus whatever the platform can say about the device outside
* agent-device. Every collector is best-effort and independent: a throw, a non-zero exit, or a
* timed-out hook records nothing for that item and nothing else.
*/
export async function collectFailedStepEvidence(
input: FailedStepEvidenceInput,
): Promise<FailedStepEvidence> {
const [cli, devicePath] = await Promise.all([
collectCliEvidence(input),
collectDeviceEvidence(input),
]);
return { ...cli, ...(devicePath ? { devicePath } : {}) };
}
async function collectCliEvidence(
input: FailedStepEvidenceInput,
): Promise<Pick<FailedStepEvidence, 'screenshotPath' | 'snapshotPath'>> {
const screenshotPath = await captureScreenshot(input);
const snapshotPath = await captureSnapshot(input);
return {
...(screenshotPath ? { screenshotPath } : {}),
...(snapshotPath ? { snapshotPath } : {}),
};
}
async function captureScreenshot(input: FailedStepEvidenceInput): Promise<string | undefined> {
const screenshotPath = `${input.stem}.png`;
try {
const result = await input.runCli(['screenshot', screenshotPath]);
return result.status === 0 ? screenshotPath : undefined;
} catch {
return undefined;
}
}
async function captureSnapshot(input: FailedStepEvidenceInput): Promise<string | undefined> {
const snapshotPath = `${input.stem}-snapshot.json`;
try {
const result = await input.runCli(['snapshot']);
if (result.status !== 0 || result.json === undefined) return undefined;
fs.writeFileSync(snapshotPath, JSON.stringify(result.json, null, 2));
return snapshotPath;
} catch {
return undefined;
}
}
async function collectDeviceEvidence(input: FailedStepEvidenceInput): Promise<string | undefined> {
if (!input.deviceEvidence) return undefined;
const devicePath = `${input.stem}-device.txt`;
try {
const facts = await withinTimeout(
input.deviceEvidence(),
input.deviceEvidenceTimeoutMs ?? DEVICE_EVIDENCE_TIMEOUT_MS,
);
if (facts === undefined) return undefined;
fs.writeFileSync(devicePath, facts);
return devicePath;
} catch {
return undefined;
}
}
async function withinTimeout<T>(pending: Promise<T>, timeoutMs: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const expired = new Promise<never>((_, reject) => {
timer = setTimeout(
() => reject(new Error(`device evidence exceeded ${timeoutMs}ms`)),
timeoutMs,
);
});
try {
return await Promise.race([pending, expired]);
} finally {
clearTimeout(timer);
}
}