|
1 | 1 | import { beforeEach, test, vi } from 'vitest'; |
2 | 2 | import assert from 'node:assert/strict'; |
| 3 | +import fs from 'node:fs'; |
| 4 | +import os from 'node:os'; |
| 5 | +import path from 'node:path'; |
3 | 6 | import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts'; |
4 | 7 | import { AppError } from '../../../utils/errors.ts'; |
| 8 | +import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../../utils/diagnostics.ts'; |
5 | 9 | import type { RunnerSession } from '../runner-session-types.ts'; |
6 | 10 |
|
7 | 11 | const { |
@@ -131,6 +135,28 @@ test('mutating commands restart stale sessions when readiness preflight times ou |
131 | 135 | assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[1], freshSession); |
132 | 136 | }); |
133 | 137 |
|
| 138 | +test('mutating commands emit readiness recovery diagnostics after failed preflight restart succeeds', async () => { |
| 139 | + const staleSession = makeRunnerSession({ port: 8100, ready: true }); |
| 140 | + const freshSession = makeRunnerSession({ port: 8101, ready: false }); |
| 141 | + |
| 142 | + mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); |
| 143 | + mockExecuteRunnerCommandWithSession |
| 144 | + .mockRejectedValueOnce( |
| 145 | + new AppError('COMMAND_FAILED', 'fetch failed', { |
| 146 | + runnerReadinessPreflightFailed: true, |
| 147 | + }), |
| 148 | + ) |
| 149 | + .mockResolvedValueOnce({ message: 'tapped' }); |
| 150 | + |
| 151 | + const diagnostics = await captureDiagnostics(async () => { |
| 152 | + const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); |
| 153 | + assert.deepEqual(result, { message: 'tapped' }); |
| 154 | + }); |
| 155 | + |
| 156 | + assert.match(diagnostics, /ios_runner_readiness_preflight_recovered/); |
| 157 | + assert.match(diagnostics, /"recovery":"session_restarted"/); |
| 158 | +}); |
| 159 | + |
134 | 160 | test('mutating commands do not restart or replay after command send failure', async () => { |
135 | 161 | const session = makeRunnerSession({ port: 8100, ready: true }); |
136 | 162 |
|
@@ -175,6 +201,35 @@ test('mutating commands recover cached responses before invalidating after comma |
175 | 201 | assert.equal(statusCommand.statusCommandId, sentCommand.commandId); |
176 | 202 | }); |
177 | 203 |
|
| 204 | +test('mutating commands run status recovery after transport failure when readiness preflight was skipped', async () => { |
| 205 | + const session = makeRunnerSession({ port: 8100, ready: true }); |
| 206 | + |
| 207 | + mockEnsureRunnerSession.mockResolvedValueOnce(session); |
| 208 | + mockExecuteRunnerCommandWithSession |
| 209 | + .mockRejectedValueOnce( |
| 210 | + new AppError('COMMAND_FAILED', 'fetch failed', { |
| 211 | + runnerReadinessPreflightSkipped: true, |
| 212 | + runnerReadinessPreflightSkipReason: 'recent_successful_response', |
| 213 | + }), |
| 214 | + ) |
| 215 | + .mockResolvedValueOnce({ |
| 216 | + lifecycleState: 'completed', |
| 217 | + lifecycleResponseJson: JSON.stringify({ ok: true, data: { message: 'tapped' } }), |
| 218 | + }); |
| 219 | + |
| 220 | + const diagnostics = await captureDiagnostics(async () => { |
| 221 | + const result = await runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); |
| 222 | + assert.deepEqual(result, { message: 'tapped' }); |
| 223 | + }); |
| 224 | + |
| 225 | + assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0); |
| 226 | + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); |
| 227 | + assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[1]?.[2].command, 'status'); |
| 228 | + assert.match(diagnostics, /ios_runner_command_status_recovery/); |
| 229 | + assert.match(diagnostics, /"readinessPreflightSkipped":true/); |
| 230 | + assert.match(diagnostics, /"readinessPreflightSkipReason":"recent_successful_response"/); |
| 231 | +}); |
| 232 | + |
178 | 233 | test('mutating commands keep invalidating when status cannot find the command', async () => { |
179 | 234 | const session = makeRunnerSession({ port: 8100, ready: true }); |
180 | 235 |
|
@@ -329,3 +384,21 @@ function makeRunnerSession(overrides: Partial<RunnerSession> = {}): RunnerSessio |
329 | 384 | ...overrides, |
330 | 385 | } as RunnerSession; |
331 | 386 | } |
| 387 | + |
| 388 | +async function captureDiagnostics(callback: () => Promise<void>): Promise<string> { |
| 389 | + const previousHome = process.env.HOME; |
| 390 | + process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-diag-')); |
| 391 | + try { |
| 392 | + return await withDiagnosticsScope( |
| 393 | + { session: 'runner-client-test', requestId: 'request-1', command: 'tap' }, |
| 394 | + async () => { |
| 395 | + await callback(); |
| 396 | + const diagnosticsPath = flushDiagnosticsToSessionFile({ force: true }); |
| 397 | + assert.ok(diagnosticsPath); |
| 398 | + return fs.readFileSync(diagnosticsPath, 'utf8'); |
| 399 | + }, |
| 400 | + ); |
| 401 | + } finally { |
| 402 | + process.env.HOME = previousHome; |
| 403 | + } |
| 404 | +} |
0 commit comments