Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 4 additions & 52 deletions scripts/vitest-slow-test-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,7 @@ import type { Reporter, TestCase, TestModule } from 'vitest/node';
* Unit tests must not wait real time: measured 2026-07-04, the unit suite's
* wall clock was bounded by files whose tests slept through production
* timeouts (a 10.8s test proving "times out" by waiting the full 10s budget).
* This reporter fails the run when a unit test exceeds the budget UNLESS it
* is pinned below. The pin is a ratchet: it may only shrink, or grow in the
* same PR that adds the entry here with a justification — the same
* only-changes-in-reviewed-diffs rule as the guarantee-matrix gap list.
* This reporter fails the run when a unit test exceeds the enforced budget.
*
* Budgets are per suite family: integration scenarios drive a real daemon
* request path and get more room; unit tests get 2.5s, which is already
Expand All @@ -23,49 +20,6 @@ const INTEGRATION_BUDGET_MS = 15_000;
// gate reports without failing.
const ENFORCE_FACTOR = 2;

// Ratchet pin: known offenders at gate introduction (tracking issue #1098).
// Every entry is a test that waits real time (production timeout constants,
// 1Hz poll loops, real retry backoff) and shrinks as those are converted to
// budget-wiring assertions or budget-derived poll intervals.
const PINNED_SLOW_UNIT_TESTS = new Set([
"src/__tests__/daemon-entrypoint.test.ts :: daemon runtime starts HTTP transport in-process and shuts down cleanly",
"src/daemon/__tests__/artifact-materialization.test.ts :: materializeArtifact extracts iOS app bundle tar archives and returns the installable .app path",
"src/daemon/__tests__/runtime-hints.test.ts :: applyRuntimeHintsToApp preserves write failures after a successful run-as probe",
"src/daemon/__tests__/runtime-hints.test.ts :: applyRuntimeHintsToApp writes React Native Android dev prefs",
"src/daemon/__tests__/runtime-hints.test.ts :: applyRuntimeHintsToApp writes iOS simulator React Native defaults",
"src/daemon/__tests__/runtime-hints.test.ts :: clearRuntimeHintsFromApp removes managed Android runtime prefs but preserves unrelated entries",
"src/daemon/handlers/__tests__/session-replay-vars.test.ts :: runReplayScriptFile skips Maestro runFlow.when.visible commands when absent",
"src/platforms/__tests__/install-source.test.ts :: prepareIosInstallArtifact cleans URL materialization when IPA payload resolution fails",
"src/platforms/__tests__/install-source.test.ts :: prepareIosInstallArtifact extracts trusted GitHub artifact ZIP containing nested app tar",
"src/platforms/__tests__/install-source.test.ts :: prepareIosInstallArtifact extracts trusted GitHub artifact ZIP containing one IPA",
"src/platforms/android/__tests__/devices.test.ts :: ensureAndroidEmulatorBooted falls back to ANDROID_SDK_ROOT when PATH is incomplete",
"src/platforms/android/__tests__/devices.test.ts :: ensureAndroidEmulatorBooted launches emulator in headless mode when requested",
"src/platforms/android/__tests__/devices.test.ts :: ensureAndroidEmulatorBooted launches emulator with GUI by default",
"src/platforms/android/__tests__/devices.test.ts :: ensureAndroidEmulatorBooted reuses running emulator for headless requests",
"src/platforms/android/__tests__/devices.test.ts :: listAndroidDevices falls back to model when emulator avd name is unavailable",
"src/platforms/android/__tests__/input-actions.test.ts :: fillAndroid uses chunk-safe shell input and retries when verification still fails",
"src/platforms/android/__tests__/app-lifecycle-install.test.ts :: installAndroidApp .aab reports missing bundletool tooling",
"src/platforms/android/__tests__/app-lifecycle-install.test.ts :: installAndroidApp installs .aab via bundletool build-apks + install-apks",
"src/platforms/android/__tests__/app-lifecycle-install.test.ts :: installAndroidApp installs .apk via adb install -r",
"src/platforms/android/__tests__/app-lifecycle-install.test.ts :: installAndroidApp resolves packageName and launchTarget from nested archive artifacts",
"src/platforms/android/__tests__/perf.test.ts :: stopAndroidSimpleperfProfile fails before pull when remote artifact never stabilizes",
"src/platforms/android/__tests__/snapshot-helper-session.test.ts :: allows a persistent session snapshot to use the helper command budget",
"src/platforms/apple/core/__tests__/index.test.ts :: openIosSimulatorApp times out instead of hanging indefinitely",
"src/platforms/apple/core/__tests__/index.test.ts :: prepareSimulatorStatusBarForScreenshot restores prior visible overrides",
"src/platforms/apple/core/__tests__/index.test.ts :: prepareSimulatorStatusBarForScreenshot skips known redundant status bar commands",
"src/platforms/apple/core/__tests__/index.test.ts :: prepareSimulatorStatusBarForScreenshot still normalizes when snapshotting current overrides fails",
"src/platforms/apple/core/__tests__/index.test.ts :: screenshotIos retries simulator capture timeouts and eventually succeeds",
"src/platforms/apple/core/__tests__/runner-adoption.test.ts :: adoption is skipped on artifact fingerprint mismatch",
"src/platforms/apple/core/__tests__/runner-adoption.test.ts :: adoption is skipped when the probe fails",
"src/platforms/apple/core/__tests__/runner-client.test.ts :: ensureXctestrun falls back to scan when cache manifest is stale",
"src/platforms/apple/core/__tests__/runner-client.test.ts :: ensureXctestrun rebuilds after cached macOS runner repair failure",
"src/platforms/apple/core/__tests__/runner-client.test.ts :: ensureXctestrun rebuilds cached runner when Swift build flags mismatch",
"src/platforms/apple/core/__tests__/runner-client.test.ts :: ensureXctestrun rebuilds foreign artifacts when metadata does not match",
"src/platforms/apple/core/__tests__/runner-xctestrun.test.ts :: setup metadata script matches expected iOS simulator cache metadata",
"src/recording/__tests__/recording-scripts.test.ts :: recording overlay Swift script typechecks",
"src/utils/__tests__/daemon-client.test.ts :: cleanupFailedDaemonStartupMetadata retains live startup daemon on timeout",
]);

type Offender = { key: string; durationMs: number; budgetMs: number; enforce: boolean };

function budgetForPath(relativePath: string): number {
Expand All @@ -90,8 +44,6 @@ export function classifySlowTest(params: {
const budgetMs = budgetForPath(relative);
if (params.durationMs <= budgetMs) return null;
const fullKey = `${relative} :: ${params.fullName.split(' > ').join(' ')}`;
if (PINNED_SLOW_UNIT_TESTS.has(`${relative} :: ${params.name}`)) return null;
if (PINNED_SLOW_UNIT_TESTS.has(fullKey)) return null;
return {
key: fullKey,
durationMs: params.durationMs,
Expand Down Expand Up @@ -119,10 +71,10 @@ export function reportSlowTests(
}
if (failing.length === 0) return false;
write(
`\nSlow-test gate: ${failing.length} test(s) exceeded ${ENFORCE_FACTOR}x the wall-clock budget.\n` +
`\nSlow-test gate: ${failing.length} test(s) exceeded ${ENFORCE_FACTOR}x the wall-clock budget.\n` +
`Tests must not wait real time — inject the timeout/poll budget or assert the budget is\n` +
`wired instead of waiting it out (docs/agents/testing.md). If the wait is genuinely\n` +
`irreducible, pin it in scripts/vitest-slow-test-reporter.ts in this PR with a reason.\n` +
`wired instead of waiting it out (docs/agents/testing.md). If the runtime cost is genuinely\n` +
`irreducible, document the reason in the owning test or move it out of the unit lane.\n` +
failing.map(line).join('\n'),
);
return true;
Expand Down
108 changes: 64 additions & 44 deletions scripts/write-xcuitest-cache-metadata.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,16 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { pathToFileURL } from 'node:url';

const args = process.argv.slice(2);
const [platform, derivedPath, destination] = args;
let platform = '';
let derivedPath = '';
let destination = '';
let projectRoot = '';
let metadataPath = '';

if (!platform || !derivedPath || !destination) {
console.error(
'Usage: write-xcuitest-cache-metadata.mjs <ios|macos|tvos|visionos> <derived> <destination>',
);
process.exit(1);
}

const projectRoot = process.cwd();
const metadataPath = path.join(derivedPath, '.agent-device-runner-cache.json');
const USAGE =
'Usage: write-xcuitest-cache-metadata.mjs <ios|macos|tvos|visionos> <derived> <destination>';

const DEFAULT_IOS_RUNNER_APP_BUNDLE_ID = 'com.callstack.agentdevice.runner';

Expand Down Expand Up @@ -215,39 +212,62 @@ function resolveSandboxBuildArgs() {
];
}

const appBundleId = resolveRunnerAppBundleId();
const testBundleId = resolveRunnerTestBundleId();
const metadata = {
schemaVersion: 2,
packageVersion: readPackageVersion(),
runnerSourceFingerprint: computeRunnerSourceFingerprint(),
...resolveRunnerToolchainFingerprint(),
platformName: resolvePlatformName(),
deviceKind: resolveDeviceKind(),
target: resolveTarget(),
buildDestinationFamily: resolveBuildDestinationFamily(),
runnerBundleBuildSettings: [
`AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=${appBundleId}`,
`AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=${testBundleId}`,
],
runnerSigningBuildSettings: resolveSigningBuildSettings(),
runnerPerformanceBuildSettings: [
'COMPILER_INDEX_STORE_ENABLE=NO',
'ENABLE_CODE_COVERAGE=NO',
'ONLY_ACTIVE_ARCH=YES',
'ENABLE_PREVIEWS=NO',
'ENABLE_DEBUG_DYLIB=NO',
],
runnerSandboxBuildArgs: resolveSandboxBuildArgs(),
};

const artifacts = resolveRunnerCacheArtifacts();
if (artifacts) {
metadata.artifacts = artifacts;
}

fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
export function writeXcuitestCacheMetadata(args = process.argv.slice(2), cwd = process.cwd()) {
const [nextPlatform, nextDerivedPath, nextDestination] = args;
if (!nextPlatform || !nextDerivedPath || !nextDestination) {
throw new Error(USAGE);
}

platform = nextPlatform;
derivedPath = nextDerivedPath;
destination = nextDestination;
projectRoot = cwd;
metadataPath = path.join(derivedPath, '.agent-device-runner-cache.json');

const appBundleId = resolveRunnerAppBundleId();
const testBundleId = resolveRunnerTestBundleId();
const metadata = {
schemaVersion: 2,
packageVersion: readPackageVersion(),
runnerSourceFingerprint: computeRunnerSourceFingerprint(),
...resolveRunnerToolchainFingerprint(),
platformName: resolvePlatformName(),
deviceKind: resolveDeviceKind(),
target: resolveTarget(),
buildDestinationFamily: resolveBuildDestinationFamily(),
runnerBundleBuildSettings: [
`AGENT_DEVICE_IOS_RUNNER_APP_BUNDLE_ID=${appBundleId}`,
`AGENT_DEVICE_IOS_RUNNER_TEST_BUNDLE_ID=${testBundleId}`,
],
runnerSigningBuildSettings: resolveSigningBuildSettings(),
runnerPerformanceBuildSettings: [
'COMPILER_INDEX_STORE_ENABLE=NO',
'ENABLE_CODE_COVERAGE=NO',
'ONLY_ACTIVE_ARCH=YES',
'ENABLE_PREVIEWS=NO',
'ENABLE_DEBUG_DYLIB=NO',
],
runnerSandboxBuildArgs: resolveSandboxBuildArgs(),
};

const artifacts = resolveRunnerCacheArtifacts();
if (artifacts) {
metadata.artifacts = artifacts;
}

fs.mkdirSync(path.dirname(metadataPath), { recursive: true });
fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`);
return metadata;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
writeXcuitestCacheMetadata();
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
}

function resolveRunnerCacheArtifacts() {
const xctestrunPath = findXctestrun(derivedPath);
Expand Down
5 changes: 3 additions & 2 deletions src/__tests__/vitest-slow-test-reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,16 @@ test('integration paths get the larger budget', () => {
assert.equal(offender, null);
});

test('pinned tests never become offenders', () => {
test('known slow tests are reported when they exceed the budget', () => {
const offender = classifySlowTest({
root: '/repo',
moduleId: '/repo/src/platforms/android/__tests__/app-lifecycle-install.test.ts',
name: 'installAndroidApp installs .apk via adb install -r',
fullName: 'installAndroidApp installs .apk via adb install -r',
durationMs: 9_000,
});
assert.equal(offender, null);
assert.ok(offender);
assert.equal(offender.enforce, true);
});

test('reportSlowTests fails only on enforced offenders and prints both bands', () => {
Expand Down
86 changes: 82 additions & 4 deletions src/platforms/android/__tests__/devices.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { beforeEach, test, vi } from 'vitest';
import assert from 'node:assert/strict';
import { appendFileSync, promises as fs, writeFileSync } from 'node:fs';
import { appendFileSync, existsSync, promises as fs, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
vi.mock('../../../utils/exec.ts', async (importOriginal) => {
Expand All @@ -12,7 +12,8 @@ const execActual =
await vi.importActual<typeof import('../../../utils/exec.ts')>('../../../utils/exec.ts');

import { AppError } from '../../../kernel/errors.ts';
import { runCmdDetached } from '../../../utils/exec.ts';
import { runCmdDetached, withCommandExecutorOverride } from '../../../utils/exec.ts';
import type { CommandExecutorOverride, ExecResult } from '../../../utils/exec.ts';
import {
ensureAndroidEmulatorBooted,
listAndroidDevices,
Expand Down Expand Up @@ -199,7 +200,14 @@ async function withMockedAndroidTools(
ANDROID_SDK_ROOT: undefined,
ANDROID_HOME: undefined,
},
async () => await run({ emulatorLogPath, emulatorBootedPath }),
async () =>
await withCommandExecutorOverride(
createMockedAndroidToolsCommandOverride({
emulatorBootedPath,
avdNameMode: options.avdNameMode ?? 'success',
}),
async () => await run({ emulatorLogPath, emulatorBootedPath }),
),
);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
Expand Down Expand Up @@ -238,13 +246,83 @@ async function withMockedAndroidSdkRoot(
ANDROID_SDK_ROOT: sdkRoot,
ANDROID_HOME: undefined,
},
async () => await run({ emulatorLogPath, emulatorBootedPath, sdkRoot }),
async () =>
await withCommandExecutorOverride(
createMockedAndroidToolsCommandOverride({ emulatorBootedPath }),
async () => await run({ emulatorLogPath, emulatorBootedPath, sdkRoot }),
),
);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
}

function createMockedAndroidToolsCommandOverride(params: {
emulatorBootedPath: string;
avdNameMode?: 'success' | 'missing';
}): CommandExecutorOverride {
return async (cmd, args) => {
if (cmd === 'emulator') return handleMockedEmulatorCommand(args);
if (cmd === 'adb') return handleMockedAdbCommand(args, params);
throw new Error(`Unexpected command: ${cmd} ${args.join(' ')}`);
};
}

function handleMockedEmulatorCommand(args: string[]): ExecResult {
const emulatorKey = args.join('\0');
if (emulatorKey === '-list-avds') return execResult('Pixel_9_Pro_XL\n');
throw new Error(`Unexpected emulator args: ${args.join(' ')}`);
}

function handleMockedAdbCommand(
args: string[],
params: { emulatorBootedPath: string; avdNameMode?: 'success' | 'missing' },
): ExecResult {
const adbArgs = stripAdbSerial(args);
const adbKey = adbArgs.join('\0');
if (adbKey === 'devices\0-l') return mockedAdbDevicesResult(params.emulatorBootedPath);
if (isAdbAvdNameProbe(adbKey)) {
return execResult(params.avdNameMode === 'missing' ? '' : 'Pixel_9_Pro_XL\n');
}
if (adbKey === 'shell\0getprop\0sys.boot_completed') {
return execResult(existsSync(params.emulatorBootedPath) ? '1\n' : '0\n');
}
if (adbKey === 'shell\0getprop\0ro.build.characteristics') return execResult('phone\n');
if (isAdbPackageProbe(adbArgs)) return execResult('false\n');
if (adbKey === 'shell\0pm\0list\0features') return execResult('\n');
throw new Error(`Unexpected adb args: ${args.join(' ')}`);
}

function mockedAdbDevicesResult(emulatorBootedPath: string): ExecResult {
const bootedDevice = existsSync(emulatorBootedPath)
? [
'emulator-5554 device product:sdk_gphone64 model:Pixel_9_Pro_XL device:emu64a transport_id:2',
]
: [];
return execResult(['List of devices attached', ...bootedDevice, ''].join('\n'));
}

function isAdbAvdNameProbe(adbKey: string): boolean {
return (
adbKey === 'emu\0avd\0name' ||
adbKey === 'shell\0getprop\0ro.boot.qemu.avd_name' ||
adbKey === 'shell\0getprop\0persist.sys.avd_name'
);
}

function isAdbPackageProbe(adbArgs: string[]): boolean {
return adbArgs[0] === 'shell' && adbArgs[1] === 'cmd' && adbArgs[2] === 'package';
}

function stripAdbSerial(args: string[]): string[] {
if (args[0] === '-s') return args.slice(2);
return args;
}

function execResult(stdout = '', stderr = '', exitCode = 0): ExecResult {
return { stdout, stderr, exitCode };
}

test('resolveAndroidEmulatorAvdName ignores probe timeouts and keeps probing', async () => {
const calls: string[][] = [];
const results = [
Expand Down
12 changes: 10 additions & 2 deletions src/platforms/android/__tests__/snapshot-helper-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, beforeEach, test } from 'vitest';
import {
captureAndroidSnapshotWithHelperSession,
resetAndroidSnapshotHelperSessions,
resolveAndroidSnapshotHelperSessionRequestTimeoutMs,
} from '../snapshot-helper.ts';
import type { AndroidAdbExecutor, AndroidAdbProcess, AndroidAdbProvider } from '../adb-executor.ts';

Expand Down Expand Up @@ -115,8 +116,15 @@ test('starts and reuses a persistent Android snapshot helper session', async ()

test('allows a persistent session snapshot to use the helper command budget', async () => {
const calls: string[][] = [];
// The delay must stay above the previous 3s session cap to guard the regression.
const provider = createSessionProvider({ calls, responseDelayMs: 3_200 });
const provider = createSessionProvider({ calls, responseDelayMs: 25 });

assert.equal(
resolveAndroidSnapshotHelperSessionRequestTimeoutMs({
timeoutMs: 10,
commandTimeoutMs: 4_000,
}),
4_000,
);

const output = await captureAndroidSnapshotWithHelperSession({
adb: provider.exec,
Expand Down
8 changes: 5 additions & 3 deletions src/platforms/android/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -506,7 +506,9 @@ export async function ensureAndroidEmulatorBooted(params: {
export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise<void> {
const timeoutBudget = timeoutMs;
const deadline = Deadline.fromTimeoutMs(timeoutBudget);
const maxAttempts = Math.max(1, Math.ceil(timeoutBudget / ANDROID_BOOT_POLL_MS));
// Aim for at least 20 polls without busy-looping, capped at the production cadence.
const pollMs = Math.min(ANDROID_BOOT_POLL_MS, Math.max(50, Math.floor(timeoutBudget / 20)));
const maxAttempts = Math.max(1, Math.ceil(timeoutBudget / pollMs));
let lastBootResult: ExecResult | undefined;
let timedOut = false;
try {
Expand Down Expand Up @@ -539,8 +541,8 @@ export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Pro
},
{
maxAttempts,
baseDelayMs: ANDROID_BOOT_POLL_MS,
maxDelayMs: ANDROID_BOOT_POLL_MS,
baseDelayMs: pollMs,
maxDelayMs: pollMs,
jitter: 0,
shouldRetry: (error) => {
const reason = classifyBootFailure({
Expand Down
Loading
Loading