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
95 changes: 95 additions & 0 deletions src/platforms/apple/core/__tests__/fake-runner-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import http from 'node:http';
import type { AddressInfo } from 'node:net';

/**
* A deterministic fake iOS runner: a local HTTP server standing in for the
* XCTest runner process, scripted per request. Recovery/retry suites drive the
* REAL send stack (executeRunnerCommandWithSession → transport fetch) against
* it instead of vi-mocking internal functions — the #1631 testing seam. Each
* incoming request consumes the next scripted response; running out of script
* fails loudly rather than improvising.
*/

export type FakeRunnerResponse =
| { kind: 'ok'; data: Record<string, unknown> }
| { kind: 'runnerError'; code: string; message: string }
| { kind: 'hangUp' };

export type FakeRunnerRequest = {
command: string;
body: Record<string, unknown>;
};

export type FakeRunnerServer = {
port: number;
requests: FakeRunnerRequest[];
close: () => Promise<void>;
};

/**
* Per-command scripts. Production sends a readiness `uptime` probe before a
* mutating command, and recovery sends `status` afterwards, so a rigid
* one-queue script couples every test to that ordering; keying by command
* lets a test say only what it cares about. Each command's list is consumed
* in order, and a command with no script left answers `ok` with no data.
*/
export type FakeRunnerCommandScript = Record<string, FakeRunnerResponse[]>;

export async function startFakeRunnerServer(
script: FakeRunnerResponse[] | FakeRunnerCommandScript,
): Promise<FakeRunnerServer> {
const sequential = Array.isArray(script) ? [...script] : undefined;
const byCommand = Array.isArray(script)
? undefined
: Object.fromEntries(Object.entries(script).map(([key, list]) => [key, [...list]]));
const remaining = sequential ?? [];
const requests: FakeRunnerRequest[] = [];
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', (chunk) => {
raw += chunk;
});
req.on('end', () => {
const body = parseBody(raw);
requests.push({ command: String(body.command ?? ''), body });
const next = byCommand
? (byCommand[String(body.command ?? '')]?.shift() ?? { kind: 'ok' as const, data: {} })
: remaining.shift();
if (!next) {
res.statusCode = 500;
res.end(JSON.stringify({ ok: false, error: { message: 'fake runner script exhausted' } }));
return;
}
if (next.kind === 'hangUp') {
res.destroy();
return;
}
if (next.kind === 'runnerError') {
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ ok: false, error: { code: next.code, message: next.message } }));
return;
}
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify({ ok: true, data: next.data }));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as AddressInfo).port;
return {
port,
requests,
close: () =>
new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
),
};
}

function parseBody(raw: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
151 changes: 151 additions & 0 deletions src/platforms/apple/core/__tests__/runner-command-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import { afterEach, test, vi } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import { IOS_SIMULATOR } from '../../../../__tests__/test-utils/device-fixtures.ts';
import type { ExecResult } from '../../../../utils/exec.ts';
import { handleRunnerTransportErrorAfterCommandSend } from '../runner/runner-command-recovery.ts';
import type { RunnerCommand } from '../runner/runner-contract.ts';
import type { RunnerSession } from '../runner/runner-session.ts';
import {
startFakeRunnerServer,
type FakeRunnerResponse,
type FakeRunnerServer,
} from './fake-runner-server.ts';

// Recovery driven through the REAL stack: real executeRunnerCommandWithSession,
// real transport fetch, a scripted fake runner on localhost, and the
// invalidateSession parameter the module already exposes as its seam. The only
// faked thing is the runner process itself (#1631).

let server: FakeRunnerServer | undefined;

afterEach(async () => {
await server?.close();
server = undefined;
});

function makeRunnerSession(port: number): RunnerSession {
return {
sessionId: `fake:${port}`,
device: IOS_SIMULATOR,
deviceId: IOS_SIMULATOR.id,
port,
xctestrunPath: '/tmp/fake.xctestrun',
jsonPath: '/tmp/fake.json',
testPromise: new Promise<ExecResult>(() => {}),
child: { pid: process.pid, exitCode: null },
ready: true,
};
}

function tapCommand(commandId = 'cmd-1'): RunnerCommand {
return { command: 'tap', x: 10, y: 10, commandId } as RunnerCommand;
}

async function runRecovery(params: {
script: FakeRunnerResponse[];
command?: RunnerCommand;
transportError?: AppError;
}): Promise<{
result: Promise<Record<string, unknown>>;
session: RunnerSession;
invalidate: ReturnType<typeof vi.fn>;
transportError: AppError;
}> {
server = await startFakeRunnerServer(params.script);
const session = makeRunnerSession(server.port);
const invalidate = vi.fn(async () => {});
const transportError = params.transportError ?? new AppError('COMMAND_FAILED', 'socket hang up');
const result = handleRunnerTransportErrorAfterCommandSend({
device: IOS_SIMULATOR,
session,
command: params.command ?? tapCommand(),
transportError,
options: {},
signal: undefined,
invalidationReason: 'transport_error_after_command_send',
invalidateSession: invalidate,
});
return { result, session, invalidate, transportError };
}

test('a completed command with a retained response recovers without invalidation', async () => {
const { result, invalidate } = await runRecovery({
script: [
{
kind: 'ok',
data: {
lifecycleState: 'completed',
lifecycleResponseJson: JSON.stringify({ ok: true, data: { tapped: true } }),
},
},
],
});

assert.deepEqual(await result, { tapped: true });
assert.equal(invalidate.mock.calls.length, 0);
assert.equal(server?.requests[0]?.command, 'status');
assert.equal(server?.requests[0]?.body.statusCommandId, 'cmd-1');
});

test('a runner-reported failure surfaces without invalidating the session', async () => {
const { result, invalidate } = await runRecovery({
script: [
{ kind: 'ok', data: { lifecycleState: 'failed', lifecycleErrorMessage: 'tap failed' } },
],
});

await assert.rejects(result, (error: unknown) => error instanceof AppError);
assert.equal(invalidate.mock.calls.length, 0);
});

test('a command still in flight surfaces without invalidating the session', async () => {
const { result, invalidate } = await runRecovery({
script: [{ kind: 'ok', data: { lifecycleState: 'started' } }],
});

await assert.rejects(result, (error: unknown) => error instanceof AppError);
assert.equal(invalidate.mock.calls.length, 0);
});

test('an unknown lifecycle state invalidates the session and says so', async () => {
const { result, invalidate } = await runRecovery({
script: [{ kind: 'ok', data: { lifecycleState: 'zombie' } }],
});

await assert.rejects(result, (error: unknown) => {
return error instanceof AppError && error.message.includes('invalidated the runner session');
});
assert.equal(invalidate.mock.calls.length, 1);
assert.equal(invalidate.mock.calls[0]?.[1], 'transport_error_after_command_send');
});

test('a failing status probe retains the invalidation and rethrows the transport error', async () => {
const { result, invalidate, transportError } = await runRecovery({
script: [{ kind: 'runnerError', code: 'COMMAND_FAILED', message: 'status probe exploded' }],
});

await assert.rejects(result, (error: unknown) => error === transportError);
assert.equal(invalidate.mock.calls.length, 1);
});

test('a command without an id cannot be probed: invalidate and rethrow', async () => {
const { result, invalidate, transportError } = await runRecovery({
script: [],
command: { command: 'tap', x: 10, y: 10 } as RunnerCommand,
});

await assert.rejects(result, (error: unknown) => error === transportError);
assert.equal(invalidate.mock.calls.length, 1);
assert.equal(server?.requests.length, 0);
});

test('a completed read-only command without a retained response rethrows without invalidation', async () => {
const { result, invalidate, transportError } = await runRecovery({
script: [{ kind: 'ok', data: { lifecycleState: 'completed' } }],
command: { command: 'snapshot', commandId: 'cmd-ro' } as RunnerCommand,
});

await assert.rejects(result, (error: unknown) => error === transportError);
assert.equal(invalidate.mock.calls.length, 0);
});
112 changes: 112 additions & 0 deletions src/platforms/apple/core/__tests__/runner-error-classification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import { test } from 'vitest';
import { AppError } from '@agent-device/kernel/errors';
import {
RUNNER_ERROR_RULES,
isRetryableRunnerError,
resolveRunnerFatalErrorReason,
shouldRestartRunnerBeforeCommandSend,
shouldRetryRunnerConnectError,
} from '../runner/runner-contract.ts';

function commandFailed(message: string, details?: Record<string, unknown>): AppError {
return new AppError('COMMAND_FAILED', message, details);
}

test('every rule carries a unique reason', () => {
const reasons = RUNNER_ERROR_RULES.map((rule) => rule.reason);
assert.equal(new Set(reasons).size, reasons.length);
});

// --- retryable axis (isRetryableRunnerError) ---

test('transport-shaped failures are retryable', () => {
for (const message of [
'Runner did not accept connection on port 8100',
'fetch failed',
'connect ECONNREFUSED 127.0.0.1:8100',
'socket hang up',
]) {
assert.equal(isRetryableRunnerError(commandFailed(message)), true, message);
}
});

test('boot-shaped failures are not retryable', () => {
assert.equal(isRetryableRunnerError(commandFailed('xcodebuild exited early (code 65)')), false);
assert.equal(
isRetryableRunnerError(commandFailed('Device is busy (Connecting to Simulator)')),
false,
);
});

test('an explicitly retriable flag wins over any message denial', () => {
const flagged = commandFailed('xcodebuild exited early', { retriable: true });
assert.equal(isRetryableRunnerError(flagged), true);
});

test('retryable requires an AppError with COMMAND_FAILED', () => {
assert.equal(isRetryableRunnerError(new Error('fetch failed')), false);
assert.equal(isRetryableRunnerError(new AppError('DEVICE_NOT_FOUND', 'fetch failed')), false);
});

// --- connect-retry axis (shouldRetryRunnerConnectError) ---

test('connect loop keeps waiting by default, including for unknown errors', () => {
assert.equal(
shouldRetryRunnerConnectError(commandFailed('Runner did not accept connection')),
true,
);
assert.equal(shouldRetryRunnerConnectError(new Error('anything')), true);
assert.equal(shouldRetryRunnerConnectError(new AppError('INVALID_ARGS', 'nope')), true);
});

test('connect loop stops for terminal verdicts', () => {
assert.equal(shouldRetryRunnerConnectError(commandFailed('xcodebuild exited early')), false);
const unattached = new AppError('DEVICE_NOT_FOUND', 'device not attached', {
usbmuxDeviceAttached: false,
});
assert.equal(shouldRetryRunnerConnectError(unattached), false);
// The same code without the usbmux evidence keeps waiting.
assert.equal(shouldRetryRunnerConnectError(new AppError('DEVICE_NOT_FOUND', 'gone')), true);
});

// --- session-fatal axis (resolveRunnerFatalErrorReason) ---

test('session-fatal codes map to their invalidation reasons', () => {
assert.equal(
resolveRunnerFatalErrorReason(new AppError('IOS_AX_SNAPSHOT_FAILED', 'ax root failed')),
'ax_snapshot_failure',
);
assert.equal(
resolveRunnerFatalErrorReason(new AppError('XCTEST_RECORDED_FAILURE', 'recorded failure')),
'xctest_recorded_failure',
);
assert.equal(
resolveRunnerFatalErrorReason(new AppError('RUNNER_WEDGED', 'main thread stuck')),
'runner_main_thread_wedged',
);
});

test('ordinary errors are never session-fatal', () => {
assert.equal(resolveRunnerFatalErrorReason(commandFailed('socket hang up')), undefined);
assert.equal(resolveRunnerFatalErrorReason(new Error('boom')), undefined);
});

// --- restart-before-send axis (shouldRestartRunnerBeforeCommandSend) ---

test('a refused connection before send restarts the session, case-insensitively', () => {
assert.equal(
shouldRestartRunnerBeforeCommandSend(commandFailed('Runner did not accept connection')),
true,
);
assert.equal(
shouldRestartRunnerBeforeCommandSend(commandFailed('runner did not accept connection')),
true,
);
});

test('a terminal connect verdict refuses the restart even when the message matches', () => {
const both = commandFailed('xcodebuild exited early: runner did not accept connection');
assert.equal(shouldRestartRunnerBeforeCommandSend(both), false);
assert.equal(shouldRestartRunnerBeforeCommandSend(commandFailed('socket hang up')), false);
});
Loading
Loading