mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
ff59309415
* 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.
313 lines
9.8 KiB
TypeScript
313 lines
9.8 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { type CliJsonResult, formatResultDebug, runBuiltCliJson } from '../cli-json.ts';
|
|
import { collectFailedStepEvidence, type FailedStepEvidence } from './failed-step-evidence.ts';
|
|
|
|
export type StepRecord = {
|
|
accepted: boolean;
|
|
command: string;
|
|
commandName?: string;
|
|
durationMs: number;
|
|
errorCode?: string;
|
|
errorMessage?: string;
|
|
scenario: string;
|
|
status: number;
|
|
step: string;
|
|
};
|
|
|
|
export type ScenarioTiming = {
|
|
durationMs: number;
|
|
id: string;
|
|
};
|
|
|
|
export type LiveDeviceContext<BehaviorId extends string> = {
|
|
artifactDir: string;
|
|
behaviorEvidence: Partial<Record<BehaviorId, string[]>>;
|
|
commandEvidence: Record<string, string[]>;
|
|
completedScenarios: string[];
|
|
currentScenario: string;
|
|
env: NodeJS.ProcessEnv;
|
|
session: string;
|
|
sessionOpen: boolean;
|
|
startedAtMs: number;
|
|
stepHistory: StepRecord[];
|
|
timings: ScenarioTiming[];
|
|
};
|
|
|
|
export type LiveScenario<Context> = {
|
|
id: string;
|
|
run: (context: Context) => Promise<void>;
|
|
};
|
|
|
|
type HarnessOptions<Context, BehaviorId extends string> = {
|
|
behaviorsForScenario: (scenarioId: string) => readonly BehaviorId[];
|
|
commandsForScenario: (scenarioId: string) => readonly string[];
|
|
commonFlags: (context: Context, args: readonly string[]) => string[];
|
|
runCli?: (
|
|
args: string[],
|
|
env: NodeJS.ProcessEnv,
|
|
options?: { timeoutMs?: number },
|
|
) => Promise<CliJsonResult>;
|
|
/**
|
|
* 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<string | undefined>;
|
|
writeCoverageReport: (context: Context) => void;
|
|
};
|
|
|
|
type RunStepOptions = {
|
|
allowFailure?: boolean;
|
|
commonFlags?: boolean;
|
|
expectFailure?: boolean;
|
|
timeoutMs?: number;
|
|
};
|
|
|
|
export function createLiveDeviceContext<BehaviorId extends string>(options: {
|
|
artifactRoot: string;
|
|
session: string;
|
|
}): LiveDeviceContext<BehaviorId> {
|
|
const runId = `${Date.now()}-${process.pid}`;
|
|
const artifactDir = path.resolve(options.artifactRoot, runId);
|
|
fs.mkdirSync(artifactDir, { recursive: true });
|
|
return {
|
|
artifactDir,
|
|
behaviorEvidence: {},
|
|
commandEvidence: {},
|
|
completedScenarios: [],
|
|
currentScenario: 'bootstrap',
|
|
env: process.env,
|
|
session: options.session,
|
|
sessionOpen: false,
|
|
startedAtMs: Date.now(),
|
|
stepHistory: [],
|
|
timings: [],
|
|
};
|
|
}
|
|
|
|
export function createLiveDeviceHarness<
|
|
Context extends LiveDeviceContext<BehaviorId>,
|
|
BehaviorId extends string,
|
|
>(options: HarnessOptions<Context, BehaviorId>) {
|
|
async function runScenario(context: Context, scenario: LiveScenario<Context>): Promise<void> {
|
|
context.currentScenario = scenario.id;
|
|
const commandCounts = evidenceCounts(
|
|
options.commandsForScenario(scenario.id),
|
|
context.commandEvidence,
|
|
);
|
|
const behaviorCounts = evidenceCounts(
|
|
options.behaviorsForScenario(scenario.id),
|
|
context.behaviorEvidence,
|
|
);
|
|
const startedAt = Date.now();
|
|
try {
|
|
await scenario.run(context);
|
|
assertNewEvidence(
|
|
scenario.id,
|
|
options.commandsForScenario(scenario.id),
|
|
context.commandEvidence,
|
|
commandCounts,
|
|
);
|
|
assertNewEvidence(
|
|
scenario.id,
|
|
options.behaviorsForScenario(scenario.id),
|
|
context.behaviorEvidence,
|
|
behaviorCounts,
|
|
);
|
|
context.completedScenarios.push(scenario.id);
|
|
} finally {
|
|
context.timings.push({ durationMs: Date.now() - startedAt, id: scenario.id });
|
|
options.writeCoverageReport(context);
|
|
}
|
|
}
|
|
|
|
async function runStep(
|
|
context: Context,
|
|
step: string,
|
|
args: string[],
|
|
stepOptions: RunStepOptions = {},
|
|
): Promise<CliJsonResult> {
|
|
const fullArgs = buildStepArgs(context, args, stepOptions);
|
|
const startedAt = Date.now();
|
|
const result = await (options.runCli ?? runBuiltCliJson)(fullArgs, context.env, {
|
|
timeoutMs: stepOptions.timeoutMs,
|
|
});
|
|
const failedAsExpected = stepOptions.expectFailure === true && result.status !== 0;
|
|
recordStep(context, {
|
|
accepted: result.status === 0 || failedAsExpected,
|
|
command: `agent-device ${fullArgs.join(' ')}`,
|
|
commandName: args[0],
|
|
durationMs: Date.now() - startedAt,
|
|
errorCode: stringValue(result.json?.error?.code),
|
|
errorMessage: stringValue(result.json?.error?.message),
|
|
scenario: context.currentScenario,
|
|
status: result.status,
|
|
step,
|
|
});
|
|
await assertStepOutcome(context, step, fullArgs, result, failedAsExpected, stepOptions);
|
|
updateSessionState(context, args[0], result.status);
|
|
return result;
|
|
}
|
|
|
|
function buildStepArgs(
|
|
context: Context,
|
|
args: readonly string[],
|
|
stepOptions: RunStepOptions,
|
|
): string[] {
|
|
return stepOptions.commonFlags === false ? withJson(args) : options.commonFlags(context, args);
|
|
}
|
|
|
|
function recordStep(context: Context, record: StepRecord): void {
|
|
context.stepHistory.push(record);
|
|
writeStepHistory(context);
|
|
}
|
|
|
|
async function assertStepOutcome(
|
|
context: Context,
|
|
step: string,
|
|
fullArgs: string[],
|
|
result: CliJsonResult,
|
|
failedAsExpected: boolean,
|
|
stepOptions: RunStepOptions,
|
|
): Promise<void> {
|
|
const unexpectedFailure =
|
|
result.status !== 0 && !failedAsExpected && stepOptions.allowFailure !== true;
|
|
if (unexpectedFailure) {
|
|
const evidence = await captureFailedStepEvidence(context);
|
|
const message = [
|
|
formatResultDebug(step, fullArgs, result),
|
|
`scenario: ${context.currentScenario}`,
|
|
`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);
|
|
}
|
|
if (stepOptions.expectFailure === true && result.status === 0) {
|
|
assert.fail(`${step} unexpectedly succeeded\ncommand: agent-device ${fullArgs.join(' ')}`);
|
|
}
|
|
}
|
|
|
|
function captureFailedStepEvidence(context: Context): Promise<FailedStepEvidence> {
|
|
const runCli = options.runCli ?? runBuiltCliJson;
|
|
const deviceEvidence = options.deviceEvidence;
|
|
return collectFailedStepEvidence({
|
|
stem: path.join(context.artifactDir, `failed-step-${context.stepHistory.length}`),
|
|
runCli: (args) => runCli(options.commonFlags(context, args), context.env),
|
|
...(deviceEvidence ? { deviceEvidence: () => deviceEvidence(context) } : {}),
|
|
});
|
|
}
|
|
|
|
function updateSessionState(context: Context, command: string | undefined, status: number): void {
|
|
if (status !== 0) return;
|
|
if (command === 'open') context.sessionOpen = true;
|
|
if (command === 'close') context.sessionOpen = false;
|
|
}
|
|
|
|
function verifyCommand(context: Context, command: string, evidence: string): void {
|
|
recordCommandEvidence(context, command, command, evidence);
|
|
}
|
|
|
|
function verifyNestedCommand(
|
|
context: Context,
|
|
command: string,
|
|
executedCommand: string,
|
|
evidence: string,
|
|
): void {
|
|
recordCommandEvidence(context, command, executedCommand, evidence);
|
|
}
|
|
|
|
function recordCommandEvidence(
|
|
context: Context,
|
|
command: string,
|
|
executedCommand: string,
|
|
evidence: string,
|
|
): void {
|
|
assert.ok(
|
|
context.stepHistory.some(
|
|
(record) =>
|
|
record.scenario === context.currentScenario &&
|
|
record.commandName === executedCommand &&
|
|
record.accepted,
|
|
),
|
|
`${context.currentScenario} credited ${command} without a successful ${executedCommand} execution`,
|
|
);
|
|
context.commandEvidence[command] = [...(context.commandEvidence[command] ?? []), evidence];
|
|
}
|
|
|
|
function verifyBehavior(context: Context, behavior: BehaviorId, evidence: string): void {
|
|
context.behaviorEvidence[behavior] = [...(context.behaviorEvidence[behavior] ?? []), evidence];
|
|
}
|
|
|
|
async function sessionExists(context: Context): Promise<boolean> {
|
|
const inventory = await runStep(
|
|
context,
|
|
'inspect final session ownership',
|
|
['session', 'list'],
|
|
{
|
|
commonFlags: false,
|
|
},
|
|
);
|
|
const sessions = Array.isArray(inventory.json?.data?.sessions)
|
|
? inventory.json.data.sessions
|
|
: [];
|
|
return sessions.some((session: { name?: unknown }) => session.name === context.session);
|
|
}
|
|
|
|
return {
|
|
runScenario,
|
|
runStep,
|
|
sessionExists,
|
|
verifyBehavior,
|
|
verifyCommand,
|
|
verifyNestedCommand,
|
|
};
|
|
}
|
|
|
|
export function requiredEnv(name: string, enabledFlag: string): string {
|
|
const value = process.env[name]?.trim();
|
|
assert.ok(value, `${name} is required when ${enabledFlag}=1`);
|
|
return value;
|
|
}
|
|
|
|
function evidenceCounts<Key extends string>(
|
|
keys: readonly Key[],
|
|
evidence: Partial<Record<Key, string[]>>,
|
|
): ReadonlyMap<Key, number> {
|
|
return new Map(keys.map((key) => [key, evidence[key]?.length ?? 0]));
|
|
}
|
|
|
|
function assertNewEvidence<Key extends string>(
|
|
scenarioId: string,
|
|
keys: readonly Key[],
|
|
evidence: Partial<Record<Key, string[]>>,
|
|
counts: ReadonlyMap<Key, number>,
|
|
): void {
|
|
for (const key of keys) {
|
|
assert.ok(
|
|
(evidence[key]?.length ?? 0) > (counts.get(key) ?? 0),
|
|
`${scenarioId} produced no specific evidence for ${key}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
function withJson(args: readonly string[]): string[] {
|
|
return args.includes('--json') ? [...args] : [...args, '--json'];
|
|
}
|
|
|
|
function writeStepHistory<BehaviorId extends string>(context: LiveDeviceContext<BehaviorId>): void {
|
|
fs.writeFileSync(
|
|
path.join(context.artifactDir, 'step-history.json'),
|
|
JSON.stringify(context.stepHistory, null, 2),
|
|
);
|
|
}
|
|
|
|
function stringValue(value: unknown): string | undefined {
|
|
return typeof value === 'string' ? value : undefined;
|
|
}
|