|
1 | 1 | import assert from 'node:assert/strict'; |
2 | | -import { test } from 'vitest'; |
3 | | -import type { AgentDeviceBackend, BackendSnapshotResult } from '@agent-device/contracts/backend'; |
| 2 | +import { test, vi } from 'vitest'; |
| 3 | +import type { |
| 4 | + AgentDeviceBackend, |
| 5 | + BackendCommandContext, |
| 6 | + BackendSnapshotResult, |
| 7 | +} from '@agent-device/contracts/backend'; |
4 | 8 | import { createLocalArtifactAdapter } from '../../../io.ts'; |
5 | 9 | import { createAgentDevice } from '../../../runtime.ts'; |
6 | 10 | import { localCommandPolicy } from '@agent-device/contracts/command-policy'; |
7 | 11 | import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; |
8 | 12 | import { createFakeClock } from './__tests__/test-utils/index.ts'; |
9 | 13 | import { AppError } from '@agent-device/kernel/errors'; |
| 14 | +import { WAIT_REASONS } from '@agent-device/contracts/wait'; |
10 | 15 | import { createMemorySessionStore } from '../../../command-runtime/runtime-factory.ts'; |
11 | 16 |
|
12 | 17 | const SELECTOR = 'label="Removed"'; |
@@ -52,6 +57,36 @@ function absentDevice( |
52 | 57 | }); |
53 | 58 | } |
54 | 59 |
|
| 60 | +function cancelledAbsentDevice(params: { |
| 61 | + firstSnapshot?: BackendSnapshotResult; |
| 62 | + cancellation: AppError; |
| 63 | +}) { |
| 64 | + let captures = 0; |
| 65 | + const captureSnapshot = vi.fn(async (context: BackendCommandContext) => { |
| 66 | + captures += 1; |
| 67 | + if (captures === 1 && params.firstSnapshot) return params.firstSnapshot; |
| 68 | + const signal = context.signal; |
| 69 | + if (!signal) throw new Error('the wait deadline never reached the capture'); |
| 70 | + return await new Promise<never>((_resolve, reject) => { |
| 71 | + const cancel = () => reject(params.cancellation); |
| 72 | + if (signal.aborted) cancel(); |
| 73 | + else signal.addEventListener('abort', cancel, { once: true }); |
| 74 | + }); |
| 75 | + }); |
| 76 | + const clock = { now: () => 0, sleep: async (_durationMs: number) => {} }; |
| 77 | + const device = createAgentDevice({ |
| 78 | + backend: { |
| 79 | + platform: 'ios', |
| 80 | + captureSnapshot, |
| 81 | + } satisfies AgentDeviceBackend, |
| 82 | + artifacts: createLocalArtifactAdapter(), |
| 83 | + sessions: createMemorySessionStore([{ name: 'default' }]), |
| 84 | + policy: localCommandPolicy(), |
| 85 | + clock, |
| 86 | + }); |
| 87 | + return { device, captureSnapshot }; |
| 88 | +} |
| 89 | + |
55 | 90 | async function waitAbsent(device: ReturnType<typeof createAgentDevice>, timeoutMs = 1000) { |
56 | 91 | return await device.selectors.wait({ |
57 | 92 | session: 'default', |
@@ -222,3 +257,129 @@ test('wait absent preserves a final Android unreadable diagnostic when no valid |
222 | 257 | return true; |
223 | 258 | }); |
224 | 259 | }); |
| 260 | + |
| 261 | +test('strict wait absent reports capture-stalled when every capture stalls', async () => { |
| 262 | + vi.useFakeTimers(); |
| 263 | + try { |
| 264 | + const { device, captureSnapshot } = cancelledAbsentDevice({ |
| 265 | + cancellation: new AppError('COMMAND_FAILED', 'capture cancelled'), |
| 266 | + }); |
| 267 | + const pending = waitAbsent(device, 20); |
| 268 | + const failure = assert.rejects(pending, (error: unknown) => { |
| 269 | + assert.ok(error instanceof AppError); |
| 270 | + assert.equal(error.details?.reason, WAIT_REASONS.captureStalled); |
| 271 | + assert.equal(error.details?.captureStalled, true); |
| 272 | + assert.equal(error.details?.readableCaptures, 0); |
| 273 | + return true; |
| 274 | + }); |
| 275 | + |
| 276 | + await vi.advanceTimersByTimeAsync(20); |
| 277 | + await failure; |
| 278 | + assert.equal(captureSnapshot.mock.calls.length, 1); |
| 279 | + } finally { |
| 280 | + vi.useRealTimers(); |
| 281 | + } |
| 282 | +}); |
| 283 | + |
| 284 | +test('strict wait absent keeps target-present evidence when a steady-state capture is cancelled', async () => { |
| 285 | + vi.useFakeTimers(); |
| 286 | + try { |
| 287 | + const { device, captureSnapshot } = cancelledAbsentDevice({ |
| 288 | + firstSnapshot: snapshot('Removed'), |
| 289 | + cancellation: new AppError('COMMAND_FAILED', 'capture cancelled'), |
| 290 | + }); |
| 291 | + const pending = waitAbsent(device, 20); |
| 292 | + const failure = assert.rejects(pending, (error: unknown) => { |
| 293 | + assert.ok(error instanceof AppError); |
| 294 | + assert.equal(error.details?.reason, WAIT_REASONS.targetPresent); |
| 295 | + assert.equal(error.details?.readableCaptures, 1); |
| 296 | + assert.equal(error.details?.readinessPhase, undefined); |
| 297 | + return true; |
| 298 | + }); |
| 299 | + |
| 300 | + await vi.advanceTimersByTimeAsync(20); |
| 301 | + await failure; |
| 302 | + assert.equal(captureSnapshot.mock.calls.length, 2); |
| 303 | + } finally { |
| 304 | + vi.useRealTimers(); |
| 305 | + } |
| 306 | +}); |
| 307 | + |
| 308 | +test('strict wait absent preserves runner-restart exhaustion over an earlier present capture', async () => { |
| 309 | + vi.useFakeTimers(); |
| 310 | + try { |
| 311 | + const { device, captureSnapshot } = cancelledAbsentDevice({ |
| 312 | + firstSnapshot: snapshot('Removed'), |
| 313 | + cancellation: new AppError('COMMAND_FAILED', 'request cancelled', { |
| 314 | + runnerRestarted: true, |
| 315 | + runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send', |
| 316 | + }), |
| 317 | + }); |
| 318 | + const pending = waitAbsent(device, 20); |
| 319 | + const failure = assert.rejects(pending, (error: unknown) => { |
| 320 | + assert.ok(error instanceof AppError); |
| 321 | + assert.equal(error.details?.reason, WAIT_REASONS.runnerRestartExhausted); |
| 322 | + assert.equal(error.details?.runnerRestarted, true); |
| 323 | + assert.equal(error.details?.readableCaptures, 1); |
| 324 | + assert.equal(error.details?.captureStalled, undefined); |
| 325 | + return true; |
| 326 | + }); |
| 327 | + |
| 328 | + await vi.advanceTimersByTimeAsync(20); |
| 329 | + await failure; |
| 330 | + assert.equal(captureSnapshot.mock.calls.length, 2); |
| 331 | + } finally { |
| 332 | + vi.useRealTimers(); |
| 333 | + } |
| 334 | +}); |
| 335 | + |
| 336 | +test('strict wait absent keeps runner-restart exhaustion as the deadline reason', async () => { |
| 337 | + vi.useFakeTimers(); |
| 338 | + try { |
| 339 | + const { device, captureSnapshot } = cancelledAbsentDevice({ |
| 340 | + cancellation: new AppError('COMMAND_FAILED', 'request cancelled', { |
| 341 | + runnerRestarted: true, |
| 342 | + runnerRestartReason: 'runner_readiness_preflight_failed_before_command_send', |
| 343 | + }), |
| 344 | + }); |
| 345 | + const pending = waitAbsent(device, 20); |
| 346 | + const failure = assert.rejects(pending, (error: unknown) => { |
| 347 | + assert.ok(error instanceof AppError); |
| 348 | + assert.equal(error.details?.reason, WAIT_REASONS.runnerRestartExhausted); |
| 349 | + assert.equal(error.details?.readableCaptures, 0); |
| 350 | + return true; |
| 351 | + }); |
| 352 | + |
| 353 | + await vi.advanceTimersByTimeAsync(20); |
| 354 | + await failure; |
| 355 | + assert.equal(captureSnapshot.mock.calls.length, 1); |
| 356 | + } finally { |
| 357 | + vi.useRealTimers(); |
| 358 | + } |
| 359 | +}); |
| 360 | + |
| 361 | +test('strict wait absent preserves readiness exhaustion over an earlier present capture', async () => { |
| 362 | + vi.useFakeTimers(); |
| 363 | + try { |
| 364 | + const { device, captureSnapshot } = cancelledAbsentDevice({ |
| 365 | + firstSnapshot: snapshot('Removed'), |
| 366 | + cancellation: new AppError('COMMAND_FAILED', 'request cancelled', { |
| 367 | + readinessPhase: 'target-discovery', |
| 368 | + }), |
| 369 | + }); |
| 370 | + const pending = waitAbsent(device, 20); |
| 371 | + const failure = assert.rejects(pending, (error: unknown) => { |
| 372 | + assert.ok(error instanceof AppError); |
| 373 | + assert.equal(error.details?.reason, WAIT_REASONS.readinessExhausted); |
| 374 | + assert.equal(error.details?.readinessPhase, 'target-discovery'); |
| 375 | + assert.equal(error.details?.readableCaptures, 1); |
| 376 | + return true; |
| 377 | + }); |
| 378 | + |
| 379 | + await vi.advanceTimersByTimeAsync(20); |
| 380 | + await failure; |
| 381 | + assert.equal(captureSnapshot.mock.calls.length, 2); |
| 382 | + } finally { |
| 383 | + vi.useRealTimers(); |
| 384 | + } |
| 385 | +}); |
0 commit comments