diff --git a/src/native/ax-bridge-recovery.ts b/src/native/ax-bridge-recovery.ts index b1dd3a59..fd38849b 100644 --- a/src/native/ax-bridge-recovery.ts +++ b/src/native/ax-bridge-recovery.ts @@ -46,6 +46,9 @@ export const NON_RECOVERABLE_ERROR_CODES: ReadonlySet = new Set([ 'AX_PERMISSION_DENIED', ]); +/** Typed final error when a Flutter target never rematerialises native semantics. */ +export const FLUTTER_SEMANTICS_INACTIVE = 'FLUTTER_SEMANTICS_INACTIVE'; + /** Default backoff schedule used between retries (ms). */ export const DEFAULT_BACKOFF_MS: readonly number[] = [200, 500, 1200]; /** Default retry budget. */ @@ -124,6 +127,31 @@ function resolveBackoff( return source[Math.min(gapIndex, source.length - 1)] ?? 0; } +function shouldPromoteFlutterSemanticsInactive( + err: AccessibilityBridgeError, + options: DumpTreeWithRecoveryOptions, + stages: readonly AxBridgeRecoveryStage[], +): boolean { + return Boolean(options.bundleId) && + err.code === 'DEVICE_CONTENT_ROOT_EMPTY' && + stages.some((stage) => + stage.action === 'reactivate' && + stage.outcome === 'error' && + stage.errorCode === 'REACTIVATE_RETURNED_FALSE', + ); +} + +function promoteFlutterSemanticsInactive( + err: AccessibilityBridgeError, + options: DumpTreeWithRecoveryOptions, +): AccessibilityBridgeError { + return new AccessibilityBridgeError( + `Flutter semantics remained inactive for bundle ${options.bundleId} after ax-bridge recovery; ` + + `the native accessibility tree stayed empty after simulator reactivation. Original error: ${err.message}`, + FLUTTER_SEMANTICS_INACTIVE, + ); +} + /** * Dump the accessibility tree with bounded retry + optional reactivation. * @@ -250,13 +278,16 @@ export async function dumpTreeWithRecovery( 'ax-bridge dump failed with no recoverable error recorded', 'UNKNOWN', ); - attachRecoveryReport(finalError, { + const surfacedError = shouldPromoteFlutterSemanticsInactive(finalError, options, stages) + ? promoteFlutterSemanticsInactive(finalError, options) + : finalError; + attachRecoveryReport(surfacedError, { attempts: attempt, recovered: false, stages, - lastErrorCode: finalError.code, + lastErrorCode: surfacedError.code, }); - throw finalError; + throw surfacedError; } /** Extend the thrown error with the diagnostics report without breaking existing `catch` blocks. */ diff --git a/tests/unit/ax-bridge-recovery.test.ts b/tests/unit/ax-bridge-recovery.test.ts index 083f9fe7..d25229ad 100644 --- a/tests/unit/ax-bridge-recovery.test.ts +++ b/tests/unit/ax-bridge-recovery.test.ts @@ -10,6 +10,7 @@ import { DEFAULT_BACKOFF_MS, DEFAULT_MAX_ATTEMPTS, DEFAULT_REACTIVATE_TIMEOUT_MS, + FLUTTER_SEMANTICS_INACTIVE, NON_RECOVERABLE_ERROR_CODES, RECOVERABLE_ERROR_CODES, dumpTreeWithRecovery, @@ -163,6 +164,56 @@ describe('dumpTreeWithRecovery', () => { }); }); + test('persistent empty Flutter tree with failed reactivation surfaces a typed semantics-inactive error', async () => { + const errors = [ + new AccessibilityBridgeError('empty 1', 'DEVICE_CONTENT_ROOT_EMPTY'), + new AccessibilityBridgeError('empty 2', 'DEVICE_CONTENT_ROOT_EMPTY'), + new AccessibilityBridgeError('empty 3', 'DEVICE_CONTENT_ROOT_EMPTY'), + ]; + const { bridge } = makeBridge(errors); + + let caught: (AccessibilityBridgeError & { recovery?: unknown }) | undefined; + try { + await dumpTreeWithRecovery(bridge, { + deviceId: 'SIM-1', + bundleId: 'com.example.flutter', + reactivate: async () => false, + sleep: async () => {}, + }); + } catch (err) { + caught = err as AccessibilityBridgeError & { recovery?: unknown }; + } + + expect(caught).toMatchObject({ + name: 'AccessibilityBridgeError', + code: FLUTTER_SEMANTICS_INACTIVE, + }); + expect(caught?.message).toContain('com.example.flutter'); + expect(caught?.message).toContain('Original error: empty 3'); + expect(caught?.recovery).toMatchObject({ + attempts: 3, + recovered: false, + lastErrorCode: FLUTTER_SEMANTICS_INACTIVE, + }); + }); + + test('persistent empty native tree without bundleId preserves original error code', async () => { + const errors = [ + new AccessibilityBridgeError('empty 1', 'DEVICE_CONTENT_ROOT_EMPTY'), + new AccessibilityBridgeError('empty 2', 'DEVICE_CONTENT_ROOT_EMPTY'), + new AccessibilityBridgeError('empty 3', 'DEVICE_CONTENT_ROOT_EMPTY'), + ]; + const { bridge } = makeBridge(errors); + + await expect( + dumpTreeWithRecovery(bridge, { + deviceId: 'SIM-1', + reactivate: async () => false, + sleep: async () => {}, + }), + ).rejects.toMatchObject({ code: 'DEVICE_CONTENT_ROOT_EMPTY', message: 'empty 3' }); + }); + test('non-recoverable error short-circuits without retry', async () => { const { bridge } = makeBridge([ new AccessibilityBridgeError('denied', 'AX_PERMISSION_DENIED'),