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
86 changes: 86 additions & 0 deletions src/platforms/ios/__tests__/runner-command-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,28 @@ test('mutating commands restart stale sessions when readiness preflight times ou
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], freshSession);
});

test('mutating commands emit readiness recovery diagnostics after failed preflight restart succeeds', async () => {
const staleSession = makeRunnerSession({ port: 8100, ready: true });
const freshSession = makeRunnerSession({ port: 8101, ready: false });

mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(
new AppError('COMMAND_FAILED', 'fetch failed', {
runnerReadinessPreflightFailed: true,
}),
)
.mockResolvedValueOnce({ message: 'tapped' });

const diagnostics = await captureDiagnostics(async () => {
const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 });
assert.deepEqual(result, { message: 'tapped' });
});

assert.match(diagnostics, /ios_runner_readiness_preflight_recovered/);
assert.match(diagnostics, /"recovery":"session_restarted"/);
});

test('mutating commands do not restart or replay after command send failure', async () => {
const session = makeRunnerSession({ port: 8100, ready: true });

Expand Down Expand Up @@ -197,6 +219,35 @@ test('mutating commands recover cached responses before invalidating after comma
assert.equal(statusCommand.statusCommandId, sentCommand.commandId);
});

test('mutating commands run status recovery after transport failure when readiness preflight was skipped', async () => {
const session = makeRunnerSession({ port: 8100, ready: true });

mockEnsureRunnerSession.mockResolvedValueOnce(session);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(
new AppError('COMMAND_FAILED', 'fetch failed', {
runnerReadinessPreflightSkipped: true,
runnerReadinessPreflightSkipReason: 'recent_successful_response',
}),
)
.mockResolvedValueOnce({
lifecycleState: 'completed',
lifecycleResponseJson: JSON.stringify({ ok: true, data: { message: 'tapped' } }),
});

const diagnostics = await captureDiagnostics(async () => {
const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 });
assert.deepEqual(result, { message: 'tapped' });
});

assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[2].command, 'status');
assert.match(diagnostics, /ios_runner_command_status_recovery/);
assert.match(diagnostics, /"readinessPreflightSkipped":true/);
assert.match(diagnostics, /"readinessPreflightSkipReason":"recent_successful_response"/);
});

test('mutating commands keep invalidating when status cannot find the command', async () => {
const session = makeRunnerSession({ port: 8100, ready: true });

Expand Down Expand Up @@ -348,6 +399,36 @@ test('mutating commands report recovery guidance when completed status has no re
});
});

test('mutating commands include skipped readiness context in lost-response guidance', async () => {
const session = makeRunnerSession({ port: 8100, ready: true });

mockEnsureRunnerSession.mockResolvedValueOnce(session);
mockExecuteRunnerCommandWithSession
.mockRejectedValueOnce(
new AppError('COMMAND_FAILED', 'fetch failed', {
runnerReadinessPreflightSkipped: true,
runnerReadinessPreflightSkipReason: 'recent_successful_response',
runnerReadinessPreflightSkippedAgeMs: 4,
}),
)
.mockResolvedValueOnce({ lifecycleState: 'completed' });

await assert.rejects(
() => runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.details?.recovery, 'completed_without_retained_response');
assert.equal(error.details?.readinessPreflightSkipped, true);
assert.equal(error.details?.readinessPreflightSkipReason, 'recent_successful_response');
assert.equal(error.details?.readinessPreflightSkippedAgeMs, 4);
assert.match(String(error.details?.hint), /skipped the uptime preflight/);
assert.match(String(error.details?.hint), /status recovery confirmed/);
assert.match(String(error.details?.hint), /snapshot -i/);
return true;
},
);
});

test('mutating commands preserve runner failure details from status recovery', async () => {
const session = makeRunnerSession({ port: 8100, ready: true });

Expand Down Expand Up @@ -502,3 +583,8 @@ function makeRunnerSession(overrides: Partial<RunnerSession> = {}): RunnerSessio
...overrides,
} as RunnerSession;
}

async function captureDiagnostics(callback: () => Promise<void>): Promise<string> {
await callback();
return JSON.stringify(mockEmitDiagnostic.mock.calls.map(([event]) => event));
}
109 changes: 109 additions & 0 deletions src/platforms/ios/__tests__/runner-session.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { beforeEach, test, vi } from 'vitest';
import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts';
import { AppError } from '../../../utils/errors.ts';
import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts';
import type { RunnerSession } from '../runner-session-types.ts';

const {
Expand Down Expand Up @@ -196,6 +200,25 @@ test('runner session probes readiness before mutating commands', async () => {
});
});

test('runner session emits reason diagnostics when readiness preflight is used', async () => {
const session = makeRunnerSession({ ready: false });
mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 }));
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));

const diagnostics = await captureDiagnostics(async () => {
await executeRunnerCommandWithSession(
IOS_SIMULATOR,
session,
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
'/tmp/runner.log',
30_000,
);
});

assert.match(diagnostics, /"reason":"startup"/);
assert.match(diagnostics, /ios_runner_readiness_preflight/);
});

test('runner session skips readiness preflight for tap commands after a recent successful response', async () => {
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
Expand All @@ -213,6 +236,74 @@ test('runner session skips readiness preflight for tap commands after a recent s
assert.equal(mockSendRunnerCommandOnce.mock.calls.length, 1);
});

test('runner session emits explicit diagnostics when readiness preflight is skipped', async () => {
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));

const diagnostics = await captureDiagnostics(async () => {
await executeRunnerCommandWithSession(
IOS_SIMULATOR,
session,
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
'/tmp/runner.log',
30_000,
);
});

assert.match(diagnostics, /ios_runner_readiness_preflight_skipped/);
assert.match(diagnostics, /"reason":"recent_successful_response"/);
assert.doesNotMatch(diagnostics, /ios_runner_readiness_preflight_used/);
});

test('runner session marks transport failures after skipped readiness preflight', async () => {
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
mockSendRunnerCommandOnce.mockRejectedValueOnce(new Error('fetch failed'));

await assert.rejects(
() =>
executeRunnerCommandWithSession(
IOS_SIMULATOR,
session,
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
'/tmp/runner.log',
30_000,
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.details?.runnerReadinessPreflightSkipped, true);
assert.equal(error.details?.runnerReadinessPreflightSkipReason, 'recent_successful_response');
return true;
},
);
});

test('runner session does not mark runner response failures as skipped preflight transport failures', async () => {
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
mockSendRunnerCommandOnce.mockResolvedValueOnce(
runnerError({
code: 'COMMAND_FAILED',
message: 'Runner failed after receiving command',
}),
);

await assert.rejects(
() =>
executeRunnerCommandWithSession(
IOS_SIMULATOR,
session,
{ command: 'tap', x: 120, y: 240, appBundleId: 'com.example.demo' },
'/tmp/runner.log',
30_000,
),
(error: unknown) => {
assert.ok(error instanceof AppError);
assert.equal(error.message, 'Runner failed after receiving command');
assert.equal(error.details?.runnerReadinessPreflightSkipped, undefined);
return true;
},
);
});

test('runner session skips readiness preflight for selector taps after a recent successful response', async () => {
const session = makeRunnerSession({ ready: true, lastSuccessfulRunnerResponseAtMs: Date.now() });
mockSendRunnerCommandOnce.mockResolvedValueOnce(runnerResponse({ tapped: true }));
Expand Down Expand Up @@ -489,6 +580,24 @@ function runnerError(error: { code: string; message: string }): Response {
return new Response(JSON.stringify({ ok: false, error }));
}

async function captureDiagnostics(callback: () => Promise<void>): Promise<string> {
const previousHome = process.env.HOME;
process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-diag-'));
try {
return await withDiagnosticsScope(
{ session: 'runner-session-test', requestId: 'request-1', command: 'tap' },
async () => {
await callback();
const diagnosticsPath = flushDiagnosticsToSessionFile({ force: true });
assert.ok(diagnosticsPath);
return fs.readFileSync(diagnosticsPath, 'utf8');
},
);
} finally {
process.env.HOME = previousHome;
}
}

function assertRunnerCommand(
actual: unknown,
expected: Record<string, unknown>,
Expand Down
Loading
Loading