mirror of
https://github.com/callstack/agent-device.git
synced 2026-09-14 20:06:34 +08:00
611858103e
* fix: type into focused iOS inputs without AX * fix: fill AX-hostile iOS text inputs * fix: keep scrolling containers from stealing taps * fix: stop agents after explicit task success * chore: format benchmark guidance * fix(ios): preserve fill semantics across fast paths * test: retire direct selector fill expectations * test: assert runtime selector fill evidence * fix(ios): preserve verified and Maestro fill paths * refactor(ios): isolate synthesized text entry * fix(client): preserve open diagnostic paths * fix(ios): expose structured text entry route * fix(packaging): strip text entry policy tests
143 lines
5.2 KiB
TypeScript
143 lines
5.2 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { resolveDaemonPaths } from '../../../src/daemon/config.ts';
|
|
import type { CliJsonResult } from '../cli-json.ts';
|
|
import {
|
|
createLiveDeviceContext,
|
|
createLiveDeviceHarness,
|
|
requiredEnv,
|
|
type LiveDeviceContext,
|
|
} from '../live-device-e2e/runtime.ts';
|
|
import type { IosSimulatorBehaviorId } from './behavior-coverage.ts';
|
|
import { liveCommandsForScenario } from './coverage-manifest.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<IosSimulatorBehaviorId> & {
|
|
appId: string;
|
|
appPath: string;
|
|
stateDir: string;
|
|
runnerLogPath?: string;
|
|
tier: Tier;
|
|
udid: string;
|
|
};
|
|
|
|
export function createContext(): LiveContext {
|
|
const tier = requiredEnv('AGENT_DEVICE_IOS_E2E_TIER', 'AGENT_DEVICE_IOS_E2E');
|
|
assert.ok(tier === 'smoke' || tier === 'full', `unsupported iOS E2E tier: ${tier}`);
|
|
if (tier === 'full') {
|
|
requiredEnv('AGENT_DEVICE_IOS_APP_EVENT_URL_TEMPLATE', 'AGENT_DEVICE_IOS_E2E');
|
|
}
|
|
return {
|
|
...createLiveDeviceContext<IosSimulatorBehaviorId>({
|
|
artifactRoot: `test/artifacts/ios-simulator/${tier}`,
|
|
session: `ios-e2e-${tier}-${process.pid.toString(36)}`,
|
|
}),
|
|
appId: requiredEnv('AGENT_DEVICE_FIXTURE_APP_ID', 'AGENT_DEVICE_IOS_E2E'),
|
|
appPath: requiredEnv('AGENT_DEVICE_FIXTURE_APP_PATH', 'AGENT_DEVICE_IOS_E2E'),
|
|
stateDir: resolveDaemonPaths(process.env.AGENT_DEVICE_STATE_DIR, {
|
|
env: process.env,
|
|
}).baseDir,
|
|
tier,
|
|
udid: requiredEnv('AGENT_DEVICE_IOS_UDID', 'AGENT_DEVICE_IOS_E2E'),
|
|
};
|
|
}
|
|
|
|
const harness = createLiveDeviceHarness<LiveContext, IosSimulatorBehaviorId>({
|
|
behaviorsForScenario: liveBehaviorsForScenario,
|
|
commandsForScenario: liveCommandsForScenario,
|
|
commonFlags: (context, args) => [
|
|
...args,
|
|
'--platform',
|
|
'ios',
|
|
'--udid',
|
|
context.udid,
|
|
'--session',
|
|
context.session,
|
|
'--state-dir',
|
|
context.stateDir,
|
|
...(args.includes('--json') ? [] : ['--json']),
|
|
],
|
|
writeCoverageReport,
|
|
});
|
|
|
|
export const { runScenario, runStep, sessionExists, verifyBehavior, verifyCommand } = harness;
|
|
|
|
export function verifyNestedReplayCommand(
|
|
context: LiveContext,
|
|
command: 'gesture' | 'swipe',
|
|
executedVia: 'replay' | 'test',
|
|
evidence: string,
|
|
): void {
|
|
harness.verifyNestedCommand(context, command, executedVia, evidence);
|
|
}
|
|
|
|
// Shared between the step list and the guard below so the two can't drift apart.
|
|
const MICROPHONE_PERMISSION_RESET_STEP = 'reset microphone permission';
|
|
|
|
export async function cleanupSession(context: LiveContext): Promise<void> {
|
|
const failures: unknown[] = [];
|
|
const cleanupSteps: Array<[string, string[]]> = [];
|
|
if (context.tier === 'full') {
|
|
cleanupSteps.push(
|
|
[MICROPHONE_PERMISSION_RESET_STEP, ['settings', 'permission', 'reset', 'microphone']],
|
|
['restore light appearance', ['settings', 'appearance', 'light']],
|
|
['restore portrait orientation', ['orientation', 'portrait']],
|
|
);
|
|
}
|
|
cleanupSteps.push(['close fixture session', ['close']]);
|
|
for (const [step, args] of cleanupSteps) {
|
|
const failure = await retryCleanupStep(step, (attempt) =>
|
|
runStep(context, `cleanup: ${step} (attempt ${attempt})`, args, {
|
|
allowFailure: attempt < 3,
|
|
}),
|
|
);
|
|
if (failure !== undefined) failures.push(failure);
|
|
}
|
|
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, `iOS E2E cleanup failed; details: ${errorPath}`);
|
|
}
|
|
|
|
/**
|
|
* Runs one cleanup step's 3-attempt retry policy: success or an already-clean session
|
|
* stops immediately, anything else waits and retries. `runAttempt` mirrors the real
|
|
* `runStep(..., { allowFailure: attempt < 3 })` contract, including that the final
|
|
* attempt throws instead of returning a failed result. Exported so the retry/guard
|
|
* behavior is unit-testable without spawning the CLI (see
|
|
* `test/integration/ios-simulator-e2e-cleanup.test.ts`).
|
|
*/
|
|
export async function retryCleanupStep(
|
|
step: string,
|
|
runAttempt: (attempt: number) => Promise<CliJsonResult>,
|
|
): Promise<unknown> {
|
|
for (let attempt = 1; attempt <= 3; attempt += 1) {
|
|
try {
|
|
const result = await runAttempt(attempt);
|
|
if (result.status === 0 || sessionAlreadyClean(step, result)) return undefined;
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
} catch (error) {
|
|
return error;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// SESSION_NOT_FOUND: nothing left to reset, any step. INVALID_ARGS: only the
|
|
// mic-permission reset needs an app bundle and app-settings.ts has no reason code for
|
|
// it, so we match its exact message — scoped to this step so it can't hide another failure.
|
|
function sessionAlreadyClean(step: string, result: CliJsonResult): boolean {
|
|
if (result.json?.error?.code === 'SESSION_NOT_FOUND') return true;
|
|
return (
|
|
step === MICROPHONE_PERMISSION_RESET_STEP &&
|
|
result.json?.error?.code === 'INVALID_ARGS' &&
|
|
result.json?.error?.message === 'permission setting requires an active app in session'
|
|
);
|
|
}
|