|
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 |
|
@@ -256,6 +311,38 @@ test('mutating commands report recovery guidance when completed status has no re |
256 | 311 | assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2); |
257 | 312 | }); |
258 | 313 |
|
| 314 | +test('mutating commands include skipped readiness context in lost-response guidance', async () => { |
| 315 | + const session = makeRunnerSession({ port: 8100, ready: true }); |
| 316 | + |
| 317 | + mockEnsureRunnerSession.mockResolvedValueOnce(session); |
| 318 | + mockExecuteRunnerCommandWithSession |
| 319 | + .mockRejectedValueOnce( |
| 320 | + new AppError('COMMAND_FAILED', 'fetch failed', { |
| 321 | + runnerReadinessPreflightSkipped: true, |
| 322 | + runnerReadinessPreflightSkipFreshnessMs: 10_000, |
| 323 | + runnerReadinessPreflightSkipReason: 'recent_successful_response', |
| 324 | + runnerReadinessPreflightSkippedAgeMs: 4, |
| 325 | + }), |
| 326 | + ) |
| 327 | + .mockResolvedValueOnce({ lifecycleState: 'completed' }); |
| 328 | + |
| 329 | + await assert.rejects( |
| 330 | + () => runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), |
| 331 | + (error: unknown) => { |
| 332 | + assert.ok(error instanceof AppError); |
| 333 | + assert.equal(error.details?.recovery, 'completed_without_retained_response'); |
| 334 | + assert.equal(error.details?.readinessPreflightSkipped, true); |
| 335 | + assert.equal(error.details?.readinessPreflightSkipReason, 'recent_successful_response'); |
| 336 | + assert.equal(error.details?.readinessPreflightSkippedAgeMs, 4); |
| 337 | + assert.equal(error.details?.readinessPreflightSkipFreshnessMs, 10_000); |
| 338 | + assert.match(String(error.details?.hint), /skipped the uptime preflight/); |
| 339 | + assert.match(String(error.details?.hint), /status recovery confirmed/); |
| 340 | + assert.match(String(error.details?.hint), /snapshot -i/); |
| 341 | + return true; |
| 342 | + }, |
| 343 | + ); |
| 344 | +}); |
| 345 | + |
259 | 346 | test('mutating commands preserve runner failure details from status recovery', async () => { |
260 | 347 | const session = makeRunnerSession({ port: 8100, ready: true }); |
261 | 348 |
|
@@ -347,3 +434,21 @@ function makeRunnerSession(overrides: Partial<RunnerSession> = {}): RunnerSessio |
347 | 434 | ...overrides, |
348 | 435 | } as RunnerSession; |
349 | 436 | } |
| 437 | + |
| 438 | +async function captureDiagnostics(callback: () => Promise<void>): Promise<string> { |
| 439 | + const previousHome = process.env.HOME; |
| 440 | + process.env.HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-runner-diag-')); |
| 441 | + try { |
| 442 | + return await withDiagnosticsScope( |
| 443 | + { session: 'runner-client-test', requestId: 'request-1', command: 'tap' }, |
| 444 | + async () => { |
| 445 | + await callback(); |
| 446 | + const diagnosticsPath = flushDiagnosticsToSessionFile({ force: true }); |
| 447 | + assert.ok(diagnosticsPath); |
| 448 | + return fs.readFileSync(diagnosticsPath, 'utf8'); |
| 449 | + }, |
| 450 | + ); |
| 451 | + } finally { |
| 452 | + process.env.HOME = previousHome; |
| 453 | + } |
| 454 | +} |
0 commit comments