|
| 1 | +import { describe, expect, it } from 'vitest'; |
| 2 | +import { mockProcess } from '../../../../test-utils/mock-executors.ts'; |
| 3 | +import type { CommandExecutor } from '../../../../utils/execution/index.ts'; |
| 4 | +import { createRuntimeSnapshotRecord } from '../shared/runtime-snapshot.ts'; |
| 5 | +import { |
| 6 | + createSemanticTapBatchSteps, |
| 7 | + createSemanticTapCommand, |
| 8 | + executeSemanticTapWithAmbiguityFallback, |
| 9 | + isRecoverableAxeSelectorError, |
| 10 | +} from '../shared/semantic-tap.ts'; |
| 11 | +import { |
| 12 | + createMockAxeHelpers, |
| 13 | + createNode, |
| 14 | + createSequencedExecutor, |
| 15 | + simulatorId, |
| 16 | +} from './ui-action-test-helpers.ts'; |
| 17 | + |
| 18 | +function createElements(nodes = [createNode()]) { |
| 19 | + return createRuntimeSnapshotRecord({ simulatorId, uiHierarchy: nodes, nowMs: 1_000 }).elements; |
| 20 | +} |
| 21 | + |
| 22 | +describe('semantic tap helpers', () => { |
| 23 | + it('recognizes recoverable AXe selector failures', () => { |
| 24 | + expect( |
| 25 | + isRecoverableAxeSelectorError( |
| 26 | + new Error('Multiple (2) accessibility elements matched selector'), |
| 27 | + ), |
| 28 | + ).toBe(true); |
| 29 | + expect( |
| 30 | + isRecoverableAxeSelectorError({ |
| 31 | + axeOutput: 'No accessibility element matched --label Continue', |
| 32 | + }), |
| 33 | + ).toBe(true); |
| 34 | + expect(isRecoverableAxeSelectorError(new Error('Simulator is not booted'))).toBe(false); |
| 35 | + }); |
| 36 | + |
| 37 | + it('uses a unique semantic selector before coordinates', () => { |
| 38 | + const [element] = createElements([ |
| 39 | + createNode({ AXUniqueId: 'continue.button', AXLabel: 'Continue' }), |
| 40 | + ]); |
| 41 | + |
| 42 | + const command = createSemanticTapCommand(element!, 'e1', ['--duration', '0.1'], [element!]); |
| 43 | + |
| 44 | + expect(command.selectorArgs).toEqual([ |
| 45 | + 'tap', |
| 46 | + '--id', |
| 47 | + 'continue.button', |
| 48 | + '--element-type', |
| 49 | + 'Button', |
| 50 | + '--duration', |
| 51 | + '0.1', |
| 52 | + ]); |
| 53 | + expect(command.primaryArgs).toBe(command.selectorArgs); |
| 54 | + expect(command.usedSelector).toBe(true); |
| 55 | + }); |
| 56 | + |
| 57 | + it('falls back to coordinates when semantic selectors are duplicated', () => { |
| 58 | + const elements = createElements([ |
| 59 | + createNode({ AXUniqueId: 'duplicate.button', AXLabel: 'Duplicate' }), |
| 60 | + createNode({ |
| 61 | + AXUniqueId: 'duplicate.button', |
| 62 | + AXLabel: 'Duplicate', |
| 63 | + frame: { x: 20, y: 80, width: 100, height: 40 }, |
| 64 | + }), |
| 65 | + ]); |
| 66 | + |
| 67 | + const command = createSemanticTapCommand(elements[0]!, 'e1', [], elements); |
| 68 | + |
| 69 | + expect(command.selectorArgs).toBeNull(); |
| 70 | + expect(command.primaryArgs).toEqual(['tap', '-x', '60', '-y', '40']); |
| 71 | + expect(command.usedSelector).toBe(false); |
| 72 | + }); |
| 73 | + |
| 74 | + it('represents switch taps as down/up touch batch steps', () => { |
| 75 | + const [element] = createElements([ |
| 76 | + createNode({ |
| 77 | + type: 'Switch', |
| 78 | + role: 'AXSwitch', |
| 79 | + AXLabel: 'Alerts', |
| 80 | + frame: { x: 10, y: 20, width: 200, height: 40 }, |
| 81 | + }), |
| 82 | + ]); |
| 83 | + |
| 84 | + const command = createSemanticTapCommand(element!, 'e1'); |
| 85 | + |
| 86 | + expect(command.selectorArgs).toBeNull(); |
| 87 | + expect(command.coordinateArgs).toEqual(['touch', '-x', '158', '-y', '40', '--down', '--up']); |
| 88 | + expect(createSemanticTapBatchSteps(command)).toEqual([ |
| 89 | + 'touch -x 158 -y 40 --down', |
| 90 | + 'touch -x 158 -y 40 --up', |
| 91 | + ]); |
| 92 | + }); |
| 93 | + |
| 94 | + it('retries recoverable selector failures with coordinates', async () => { |
| 95 | + const [element] = createElements([ |
| 96 | + createNode({ AXUniqueId: 'continue.button', AXLabel: 'Continue' }), |
| 97 | + ]); |
| 98 | + const command = createSemanticTapCommand(element!, 'e1', [], [element!]); |
| 99 | + const { calls, executor } = createSequencedExecutor([ |
| 100 | + { success: false, error: 'Multiple (2) accessibility elements matched selector' }, |
| 101 | + { success: true, output: 'ok' }, |
| 102 | + ]); |
| 103 | + |
| 104 | + await executeSemanticTapWithAmbiguityFallback({ |
| 105 | + command, |
| 106 | + simulatorId, |
| 107 | + executor, |
| 108 | + axeHelpers: createMockAxeHelpers(), |
| 109 | + }); |
| 110 | + |
| 111 | + expect(calls.map((call) => call.command.slice(1, -2))).toEqual([ |
| 112 | + ['tap', '--id', 'continue.button', '--element-type', 'Button'], |
| 113 | + ['tap', '-x', '60', '-y', '40'], |
| 114 | + ]); |
| 115 | + }); |
| 116 | + |
| 117 | + it('does not retry unrecoverable selector failures', async () => { |
| 118 | + const [element] = createElements([ |
| 119 | + createNode({ AXUniqueId: 'continue.button', AXLabel: 'Continue' }), |
| 120 | + ]); |
| 121 | + const command = createSemanticTapCommand(element!, 'e1', [], [element!]); |
| 122 | + const calls: string[][] = []; |
| 123 | + const executor: CommandExecutor = async (commandArgs) => { |
| 124 | + calls.push(commandArgs); |
| 125 | + return { success: false, output: '', error: 'Simulator is not booted', process: mockProcess }; |
| 126 | + }; |
| 127 | + |
| 128 | + await expect( |
| 129 | + executeSemanticTapWithAmbiguityFallback({ |
| 130 | + command, |
| 131 | + simulatorId, |
| 132 | + executor, |
| 133 | + axeHelpers: createMockAxeHelpers(), |
| 134 | + }), |
| 135 | + ).rejects.toThrow("axe command 'tap' failed."); |
| 136 | + expect(calls).toHaveLength(1); |
| 137 | + }); |
| 138 | +}); |
0 commit comments