From 902afc53a38e57dfea2f3e92b759c6d8beb3f76c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Mon, 17 Aug 2026 19:08:16 +0200 Subject: [PATCH] test: assert the specific error code instead of any failure (#1781 B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts the 20 test assertions across the repo that accepted ANY failure (bare `expect(...).toThrow()`, bare `assert.throws(fn)`, bare `assert.rejects(p)`) into assertions on the specific AppError `code` each test is actually about, or — where the propagated error is genuinely opaque (a mocked upstream failure whose identity, not its shape, is the point) — identity assertions with a comment explaining why. Added a synchronous `assertThrowsAppError(fn, {code, message?})` sibling to the existing `assertRejectsAppError` helper in src/__tests__/test-utils/app-error.ts, exported via the test-utils index, for the two src/ sites that needed it. packages/provider-limrun and packages/provider-webdriver have no test-utils dir and cannot import from src/, so those sites use vitest's `expect(...).toThrow(expect.objectContaining({ code }))` or an inline `assert.rejects(p, matcherFn)` instead. Sites converted: - packages/provider-limrun/src/app-log-runtime.test.ts:153-155 (bare `.toThrow()` x3 -> `UNSUPPORTED_OPERATION`) - src/daemon/__tests__/app-log.test.ts:39 (bare `.toThrow()` -> message match; plain Error, not AppError, from verified-file's identity check) - src/daemon/__tests__/resumable-upload-range.test.ts:13 (bare `assert.throws(fn)` -> `INVALID_ARGS`); also fixed line 19's `assert.throws(fn, value)`, a documented Node.js gotcha where a string second argument is the failure message, not a matcher, so it was equally bare in effect - packages/provider-webdriver/src/webdriver-client.test.ts:229 (bare `assert.rejects(p)` -> asserts the raw AbortSignal.timeout() rejection's `name`, since the transport re-throws it unwrapped) - src/daemon/handlers/__tests__/session-device-claims.test.ts:129, 151, 174 (bare `assert.rejects(p)` x3 -> identity assertions; each test's point is device-claim rollback/retention around an opaque mocked upstream failure) - src/platforms/android/__tests__/settings.test.ts:109 (bare `assert.rejects(p)` -> `UNSUPPORTED_OPERATION`) - src/platforms/android/__tests__/snapshot.test.ts:1071, 1342 (bare `assert.rejects(p)` x2 -> `COMMAND_FAILED` + message) - src/platforms/android/__tests__/touch-helper-session.test.ts:526 (bare `assert.rejects(p)` -> `COMMAND_FAILED`, wrong-protocol message) - src/platforms/apple/core/__tests__/runner-command-retry.test.ts:472, 527, 550, 762, 881, 1016 (bare `assert.rejects(p)` x6 -> `COMMAND_FAILED` with the recovery-path-specific details/message) - src/platforms/apple/core/__tests__/runner-transport.test.ts:61 (bare `assert.rejects(p)` -> identity assertion; fetchWithTimeout does not wrap fetch() failures into an AppError) No repo-wide scanner/lint rule added (explicitly out of scope per #1781); no test loosened. --- .../src/app-log-runtime.test.ts | 12 +- .../src/webdriver-client.test.ts | 9 +- src/__tests__/test-utils/app-error.ts | 20 ++++ src/__tests__/test-utils/index.ts | 2 +- src/daemon/__tests__/app-log.test.ts | 7 +- .../__tests__/resumable-upload-range.test.ts | 14 ++- .../__tests__/session-device-claims.test.ts | 107 ++++++++++-------- .../android/__tests__/settings.test.ts | 5 +- .../android/__tests__/snapshot.test.ts | 20 +++- .../__tests__/touch-helper-session.test.ts | 6 + .../__tests__/runner-command-retry.test.ts | 82 +++++++++++--- .../core/__tests__/runner-transport.test.ts | 11 +- 12 files changed, 220 insertions(+), 75 deletions(-) diff --git a/packages/provider-limrun/src/app-log-runtime.test.ts b/packages/provider-limrun/src/app-log-runtime.test.ts index b0221ff913..1d0ee15e11 100644 --- a/packages/provider-limrun/src/app-log-runtime.test.ts +++ b/packages/provider-limrun/src/app-log-runtime.test.ts @@ -150,9 +150,15 @@ test('keeps exact-owner app-log recovery available without a process-local sessi available: false, reason: 'owner-capability-missing', }); - expect(() => narrowDeviceBinding(binding, appStateUse)).toThrow(); - expect(() => narrowDeviceBinding(binding, bootTargetUse)).toThrow(); - expect(() => narrowDeviceBinding(binding, appsRuntimeUse)).toThrow(); + expect(() => narrowDeviceBinding(binding, appStateUse)).toThrow( + expect.objectContaining({ code: 'UNSUPPORTED_OPERATION' }), + ); + expect(() => narrowDeviceBinding(binding, bootTargetUse)).toThrow( + expect.objectContaining({ code: 'UNSUPPORTED_OPERATION' }), + ); + expect(() => narrowDeviceBinding(binding, appsRuntimeUse)).toThrow( + expect.objectContaining({ code: 'UNSUPPORTED_OPERATION' }), + ); await expect(binding.operations.appLogReattach?.({ envelope })).resolves.toEqual({ status: 'missing', }); diff --git a/packages/provider-webdriver/src/webdriver-client.test.ts b/packages/provider-webdriver/src/webdriver-client.test.ts index 837ded2072..bc5c27fbe9 100644 --- a/packages/provider-webdriver/src/webdriver-client.test.ts +++ b/packages/provider-webdriver/src/webdriver-client.test.ts @@ -226,7 +226,14 @@ test('activeElement bounds its two sequential requests by one shared budget', as }); }); - await assert.rejects(client.activeElement(budgetMs)); + // Not an AppError: this is the raw AbortSignal.timeout() rejection the transport + // re-throws unwrapped once the combined signal is already aborted (see + // shouldRetryWebDriverRequest in webdriver-transport.ts) — assert the timeout's + // own reason instead of any failure. + await assert.rejects( + client.activeElement(budgetMs), + (error: unknown) => error instanceof Error && error.name === 'TimeoutError', + ); assert.ok(rectRequestBudgetMs !== undefined, 'the rect request should have been made'); // It must get what the first call left (~120ms), never a fresh 200ms. diff --git a/src/__tests__/test-utils/app-error.ts b/src/__tests__/test-utils/app-error.ts index f2246fca93..8d7da8d410 100644 --- a/src/__tests__/test-utils/app-error.ts +++ b/src/__tests__/test-utils/app-error.ts @@ -21,3 +21,23 @@ export async function assertRejectsAppError( return true; }); } + +/** + * Synchronous sibling of {@link assertRejectsAppError}: asserts that `fn` + * throws an {@link AppError} carrying `code` and, when given, a message + * matching `message`. + */ +export function assertThrowsAppError( + fn: () => unknown, + expected: { code: string; message?: RegExp }, +): void { + assert.throws(fn, (error: unknown) => { + assert.ok( + error instanceof AppError, + `expected AppError, got ${error?.constructor?.name ?? typeof error}: ${String(error)}`, + ); + assert.equal(error.code, expected.code); + if (expected.message) assert.match(error.message, expected.message); + return true; + }); +} diff --git a/src/__tests__/test-utils/index.ts b/src/__tests__/test-utils/index.ts index 9d6c3dcd9f..838a51774d 100644 --- a/src/__tests__/test-utils/index.ts +++ b/src/__tests__/test-utils/index.ts @@ -37,7 +37,7 @@ export { withFakeAdb, type FakeAdbResponse } from './fake-adb.ts'; export { withFakeAppleTool, type FakeAppleToolResponse } from './fake-apple-tool.ts'; -export { assertRejectsAppError } from './app-error.ts'; +export { assertRejectsAppError, assertThrowsAppError } from './app-error.ts'; export { COMPACT_VIEWPORTS, diff --git a/src/daemon/__tests__/app-log.test.ts b/src/daemon/__tests__/app-log.test.ts index e7bdd4b7de..9d4623cc99 100644 --- a/src/daemon/__tests__/app-log.test.ts +++ b/src/daemon/__tests__/app-log.test.ts @@ -32,11 +32,16 @@ test.each(['metadata', 'mark', 'clear'] as const)( fs.writeFileSync(outsidePath, 'outside'); fs.symlinkSync(outsidePath, outPath); + // `mark` runs through ensureAppLogPath's own symlink guard ("must not be a symbolic + // link"); `metadata` and `clear` go straight to the shared verified-file open path, + // whose identity check reports the path as simply not a regular file. + const expectedMessage = + operation === 'mark' ? /must not be a symbolic link/ : /must be a regular file/; expect(() => { if (operation === 'metadata') getAppLogPathMetadata(outPath); else if (operation === 'mark') appendAppLogMarker(outPath, 'checkpoint'); else clearAppLogFiles(outPath); - }).toThrow(); + }).toThrow(expectedMessage); expect(fs.readFileSync(outsidePath, 'utf8')).toBe('outside'); expect(fs.lstatSync(outPath).isSymbolicLink()).toBe(true); }, diff --git a/src/daemon/__tests__/resumable-upload-range.test.ts b/src/daemon/__tests__/resumable-upload-range.test.ts index 5c7ea4b6a1..927bf294ec 100644 --- a/src/daemon/__tests__/resumable-upload-range.test.ts +++ b/src/daemon/__tests__/resumable-upload-range.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; +import { assertThrowsAppError } from '../../__tests__/test-utils/index.ts'; import { parseUploadContentLength, parseUploadContentRange } from '../resumable-upload-range.ts'; test('content ranges are bounded by the declared upload size', () => { @@ -10,13 +11,22 @@ test('content ranges are bounded by the declared upload size', () => { span: 3, }); for (const value of ['bytes 0-5/5', 'bytes 5-5/5', 'bytes 0-0/0']) { - assert.throws(() => parseUploadContentRange(value, Number(value.split('/')[1]))); + assertThrowsAppError(() => parseUploadContentRange(value, Number(value.split('/')[1])), { + code: 'INVALID_ARGS', + message: /Invalid content-range header/, + }); } }); test('content range and length numbers use decimal safe-integer grammar', () => { for (const value of ['+1', '1e3', '0x10', '1.5', '-1', '9007199254740992']) { - assert.throws(() => parseUploadContentLength(value), value); + // A string second argument to node:assert's throws helper is its failure message, + // never an error matcher (a documented Node.js gotcha) — this loop was silently + // accepting any failure. Assert the real code instead. + assertThrowsAppError(() => parseUploadContentLength(value), { + code: 'INVALID_ARGS', + message: /Invalid content-length header/, + }); } assert.equal(parseUploadContentLength('0'), 0); assert.equal(parseUploadContentLength('123'), 123); diff --git a/src/daemon/handlers/__tests__/session-device-claims.test.ts b/src/daemon/handlers/__tests__/session-device-claims.test.ts index d959d138f7..5a05d7d036 100644 --- a/src/daemon/handlers/__tests__/session-device-claims.test.ts +++ b/src/daemon/handlers/__tests__/session-device-claims.test.ts @@ -124,21 +124,26 @@ test('failed local open before device setup rolls its device claim back', async // now, so this is where a pre-device-effect failure surfaces. const stoppedAvd = { ...android, id: 'pixel-avd', booted: false }; mockResolveTargetDevice.mockResolvedValue(stoppedAvd); - mockDiscoverReadyAndroidEmulators.mockRejectedValue(new Error('device not ready')); - - await assert.rejects(async () => - handleOpenCommand({ - req: { - command: 'open', - token: 'test', - session: 'claim-rollback', - positionals: ['Demo'], - flags: { platform: 'android' }, - }, - sessionName: 'claim-rollback', - logPath: path.join(stateDir, 'daemon.log'), - sessionStore: store, - }), + // The point of this test is claim-rollback behavior around an opaque pre-device-effect + // failure, not any particular error shape, so assert identity of the propagated error. + const rejectionError = new Error('device not ready'); + mockDiscoverReadyAndroidEmulators.mockRejectedValue(rejectionError); + + await assert.rejects( + async () => + handleOpenCommand({ + req: { + command: 'open', + token: 'test', + session: 'claim-rollback', + positionals: ['Demo'], + flags: { platform: 'android' }, + }, + sessionName: 'claim-rollback', + logPath: path.join(stateDir, 'daemon.log'), + sessionStore: store, + }), + (error: unknown) => error === rejectionError, ); assert.deepEqual(inspectDeviceClaims({ serial: stoppedAvd.id }), []); }); @@ -146,21 +151,26 @@ test('failed local open before device setup rolls its device claim back', async test('failed local open after dispatch retains its device claim for recovery', async () => { const { store, stateDir } = setup(); mockResolveTargetDevice.mockResolvedValue(android); - mockDispatch.mockRejectedValue(new Error('open failed')); - - await assert.rejects(async () => - handleOpenCommand({ - req: { - command: 'open', - token: 'test', - session: 'claim-dispatch-failure', - positionals: ['Demo'], - flags: { platform: 'android' }, - }, - sessionName: 'claim-dispatch-failure', - logPath: path.join(stateDir, 'daemon.log'), - sessionStore: store, - }), + // The point of this test is claim-retention behavior around an opaque post-dispatch + // failure, not any particular error shape, so assert identity of the propagated error. + const rejectionError = new Error('open failed'); + mockDispatch.mockRejectedValue(rejectionError); + + await assert.rejects( + async () => + handleOpenCommand({ + req: { + command: 'open', + token: 'test', + session: 'claim-dispatch-failure', + positionals: ['Demo'], + flags: { platform: 'android' }, + }, + sessionName: 'claim-dispatch-failure', + logPath: path.join(stateDir, 'daemon.log'), + sessionStore: store, + }), + (error: unknown) => error === rejectionError, ); assert.equal(inspectDeviceClaims({ serial: android.id })[0]?.classification, 'live'); }); @@ -169,22 +179,27 @@ test('failed local runtime-hint setup retains its device claim before open dispa const { store, stateDir } = setup(); mockResolveTargetDevice.mockResolvedValue(android); mockResolveAndroidPackage.mockResolvedValue('com.example.demo'); - mockApplyRuntimeHints.mockRejectedValue(new Error('runtime hints changed before failure')); - - await assert.rejects(async () => - handleOpenCommand({ - req: { - command: 'open', - token: 'test', - session: 'claim-runtime-hint-failure', - positionals: ['Demo'], - flags: { platform: 'android' }, - runtime: { metroHost: '10.0.0.10', metroPort: 8081 }, - }, - sessionName: 'claim-runtime-hint-failure', - logPath: path.join(stateDir, 'daemon.log'), - sessionStore: store, - }), + // The point of this test is claim-retention behavior around an opaque pre-dispatch + // failure, not any particular error shape, so assert identity of the propagated error. + const rejectionError = new Error('runtime hints changed before failure'); + mockApplyRuntimeHints.mockRejectedValue(rejectionError); + + await assert.rejects( + async () => + handleOpenCommand({ + req: { + command: 'open', + token: 'test', + session: 'claim-runtime-hint-failure', + positionals: ['Demo'], + flags: { platform: 'android' }, + runtime: { metroHost: '10.0.0.10', metroPort: 8081 }, + }, + sessionName: 'claim-runtime-hint-failure', + logPath: path.join(stateDir, 'daemon.log'), + sessionStore: store, + }), + (error: unknown) => error === rejectionError, ); assert.equal(mockApplyRuntimeHints.mock.calls.length, 1); diff --git a/src/platforms/android/__tests__/settings.test.ts b/src/platforms/android/__tests__/settings.test.ts index 4eef0d0287..336579af2a 100644 --- a/src/platforms/android/__tests__/settings.test.ts +++ b/src/platforms/android/__tests__/settings.test.ts @@ -106,7 +106,10 @@ test('setAndroidSetting fingerprint does not use adb emu command on physical dev await withFakeAdb( () => ({ stderr: 'unknown command', exitCode: 1 }), async ({ calls, device }) => { - await assert.rejects(() => setAndroidSetting(device, 'fingerprint', 'match')); + await assertRejectsAppError(() => setAndroidSetting(device, 'fingerprint', 'match'), { + code: 'UNSUPPORTED_OPERATION', + message: /Android fingerprint simulation is not supported/, + }); const emuCalls = calls.filter((args) => args[0] === 'emu'); assert.deepEqual(emuCalls, []); }, diff --git a/src/platforms/android/__tests__/snapshot.test.ts b/src/platforms/android/__tests__/snapshot.test.ts index 6cc0655381..1f34642d6a 100644 --- a/src/platforms/android/__tests__/snapshot.test.ts +++ b/src/platforms/android/__tests__/snapshot.test.ts @@ -1068,7 +1068,15 @@ test('snapshotAndroid emits helper failure diagnostics', async () => { const diagnostics = await captureDiagnostics( { session: 'snapshot-failure', requestId: 'req-2', command: 'snapshot', debug: true }, async () => { - await assert.rejects(() => snapshotAndroid(device, { helperAdb, helperArtifact })); + await assert.rejects( + () => snapshotAndroid(device, { helperAdb, helperArtifact }), + (error: unknown) => { + assert(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.match(error.message, /helper unavailable/); + return true; + }, + ); return flushDiagnosticsToSessionFile({ force: true }); }, ); @@ -1339,7 +1347,15 @@ test('snapshotAndroid re-probes helper install after helper capture failure', as helperArtifact, }; - await assert.rejects(() => snapshotAndroid(device, helperOptions)); + await assert.rejects( + () => snapshotAndroid(device, helperOptions), + (error: unknown) => { + assert(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.match(error.message, /instrumentation failed/); + return true; + }, + ); const helper = await snapshotAndroid(device, helperOptions); assert.equal(helper.androidSnapshot.backend, 'android-helper'); diff --git a/src/platforms/android/__tests__/touch-helper-session.test.ts b/src/platforms/android/__tests__/touch-helper-session.test.ts index 262ab7dc9d..12943a731e 100644 --- a/src/platforms/android/__tests__/touch-helper-session.test.ts +++ b/src/platforms/android/__tests__/touch-helper-session.test.ts @@ -534,6 +534,12 @@ test('a malformed session gesture response stops the session and does not fall b { serial: device.id }, async () => await executeAndroidTouchHelperPlan(device, lowerAndroidTouchPlan(flingPlan())), ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.match(error.message, /wrong protocol/); + return true; + }, ); assert.equal(instrumentCalled, false); diff --git a/src/platforms/apple/core/__tests__/runner-command-retry.test.ts b/src/platforms/apple/core/__tests__/runner-command-retry.test.ts index c03a61dc28..79eee07937 100644 --- a/src/platforms/apple/core/__tests__/runner-command-retry.test.ts +++ b/src/platforms/apple/core/__tests__/runner-command-retry.test.ts @@ -469,8 +469,17 @@ test('mutating commands do not restart or replay after command send failure', as .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) .mockResolvedValueOnce({ lifecycleState: 'notAccepted' }); - await assert.rejects(() => - runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), + await assert.rejects( + () => runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), + (error: unknown) => { + // An unknown lifecycle state after a lost transport response synthesizes a new + // COMMAND_FAILED error, keeping the original transport error as its details/cause. + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details?.recovery, 'lifecycle_state_not_recoverable'); + assert.equal(error.details?.transportError, 'fetch failed'); + return true; + }, ); assert.equal(mockEnsureRunnerSession.mock.calls.length, 1); @@ -524,8 +533,17 @@ test('mutating commands keep invalidating when status cannot find the command', lifecycleState: 'notAccepted', }); - await assert.rejects(() => - runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), + await assert.rejects( + () => runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), + (error: unknown) => { + // An unknown lifecycle state after a lost transport response synthesizes a new + // COMMAND_FAILED error, keeping the original transport error as its details/cause. + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details?.recovery, 'lifecycle_state_not_recoverable'); + assert.equal(error.details?.transportError, 'fetch failed'); + return true; + }, ); assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [ @@ -547,8 +565,15 @@ test('mutating commands keep invalidating when status recovery probe fails', asy .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'status probe failed')); - await assert.rejects(() => - runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), + await assert.rejects( + () => runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), + (error: unknown) => { + // A failed status probe re-throws the original transport error, not the probe's own. + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.message, 'fetch failed'); + return true; + }, ); assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [ @@ -759,8 +784,17 @@ test('mutating commands keep conservative invalidation for skipped-preflight fai ) .mockResolvedValueOnce({ lifecycleState: 'paused' }); - await assert.rejects(() => - runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), + await assert.rejects( + () => runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), + (error: unknown) => { + // An unknown lifecycle state after a lost transport response synthesizes a new + // COMMAND_FAILED error, keeping the original transport error as its details/cause. + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details?.recovery, 'lifecycle_state_not_recoverable'); + assert.equal(error.details?.transportError, 'fetch failed'); + return true; + }, ); assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [ @@ -878,8 +912,18 @@ test('mutating commands invalidate the retry session without replaying again', a .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) .mockResolvedValueOnce({ lifecycleState: 'notAccepted' }); - await assert.rejects(() => - runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), + await assert.rejects( + () => runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), + (error: unknown) => { + // An unknown lifecycle state after a lost transport response synthesizes a new + // COMMAND_FAILED error; its details carry the retry's transport error (from the + // fresh session), not the original connect failure that triggered the retry. + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details?.recovery, 'lifecycle_state_not_recoverable'); + assert.equal(error.details?.transportError, 'fetch failed'); + return true; + }, ); assert.equal(mockEnsureRunnerSession.mock.calls.length, 2); @@ -1013,11 +1057,19 @@ test('sequence invalidates the session when the status probe fails', async () => .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'status probe failed')); - await assert.rejects(() => - runAppleRunnerCommand(IOS_SIMULATOR, { - command: 'sequence', - steps: [{ kind: 'tap', x: 1, y: 2 }], - }), + await assert.rejects( + () => + runAppleRunnerCommand(IOS_SIMULATOR, { + command: 'sequence', + steps: [{ kind: 'tap', x: 1, y: 2 }], + }), + (error: unknown) => { + // A failed status probe re-throws the original transport error, not the probe's own. + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.message, 'fetch failed'); + return true; + }, ); assert.deepEqual(mockInvalidateRunnerSession.mock.calls, [ diff --git a/src/platforms/apple/core/__tests__/runner-transport.test.ts b/src/platforms/apple/core/__tests__/runner-transport.test.ts index 4a26286424..ac585d582b 100644 --- a/src/platforms/apple/core/__tests__/runner-transport.test.ts +++ b/src/platforms/apple/core/__tests__/runner-transport.test.ts @@ -51,15 +51,20 @@ afterEach(() => { }); test('sendRunnerCommandOnce does not retry or simulator fallback after request failure', async () => { + // fetchWithTimeout does not wrap fetch() failures into an AppError, so this test's + // real subject is the no-retry/no-fallback behavior around an opaque transport + // failure — assert identity of the propagated error, not any particular code. + const fetchError = new Error('request timed out after reaching runner'); vi.stubGlobal( 'fetch', vi.fn(async () => { - throw new Error('request timed out after reaching runner'); + throw fetchError; }), ); - await assert.rejects(() => - sendRunnerCommandOnce(iosSimulator, 8100, { command: 'tap', x: 120, y: 240 }, 5_000), + await assert.rejects( + () => sendRunnerCommandOnce(iosSimulator, 8100, { command: 'tap', x: 120, y: 240 }, 5_000), + (error: unknown) => error === fetchError, ); assert.equal(vi.mocked(fetch).mock.calls.length, 1);