|
| 1 | +import assert from 'node:assert/strict'; |
| 2 | +import { test } from 'vitest'; |
| 3 | +import { IOS_SIMULATOR } from '../../../__tests__/test-utils/index.ts'; |
| 4 | +import { createAppleInteractor } from '../interactor.ts'; |
| 5 | +import { AppError } from '../../../kernel/errors.ts'; |
| 6 | +import type { GesturePlan } from '../../../contracts/gesture-plan-types.ts'; |
| 7 | +import type { Interactor, RunnerContext } from '../../../core/interactor-types.ts'; |
| 8 | +import type { |
| 9 | + AppleRunnerCommandOptions, |
| 10 | + AppleRunnerProvider, |
| 11 | +} from '../core/runner/runner-provider.ts'; |
| 12 | +import type { RunnerCommand } from '../core/runner/runner-contract.ts'; |
| 13 | + |
| 14 | +type RecordedRunnerCall = { command: RunnerCommand; options: AppleRunnerCommandOptions }; |
| 15 | + |
| 16 | +// Every Interactor method must either ride the injected runner transport or |
| 17 | +// fail fast as a local-tooling method the provider composes itself. The two |
| 18 | +// tables below partition the surface; the partition test keeps them total, so |
| 19 | +// a new Interactor method fails loudly here until it is classified. |
| 20 | +const RUNNER_TRANSPORT_METHODS: Record< |
| 21 | + string, |
| 22 | + { invoke: (interactor: Interactor) => Promise<unknown>; runnerCommand: string } |
| 23 | +> = { |
| 24 | + tap: { invoke: (i) => i.tap(10, 20), runnerCommand: 'tap' }, |
| 25 | + tapElementSelector: { |
| 26 | + invoke: (i) => i.tapElementSelector!({ key: 'label', value: 'Go' }), |
| 27 | + runnerCommand: 'tap', |
| 28 | + }, |
| 29 | + doubleTap: { invoke: (i) => i.doubleTap(10, 20), runnerCommand: 'sequence' }, |
| 30 | + longPress: { invoke: (i) => i.longPress(10, 20, 600), runnerCommand: 'longPress' }, |
| 31 | + focus: { invoke: (i) => i.focus(10, 20), runnerCommand: 'tap' }, |
| 32 | + type: { invoke: (i) => i.type('hi'), runnerCommand: 'type' }, |
| 33 | + fillElementSelector: { |
| 34 | + invoke: (i) => i.fillElementSelector!({ key: 'label', value: 'Go' }, 'hi'), |
| 35 | + runnerCommand: 'type', |
| 36 | + }, |
| 37 | + fill: { invoke: (i) => i.fill(10, 20, 'hi'), runnerCommand: 'type' }, |
| 38 | + scroll: { invoke: (i) => i.scroll('down'), runnerCommand: 'scroll' }, |
| 39 | + performGesture: { |
| 40 | + invoke: (i) => i.performGesture!(singlePointerPanPlan()), |
| 41 | + runnerCommand: 'gesture', |
| 42 | + }, |
| 43 | + gestureViewport: { invoke: (i) => i.gestureViewport!(), runnerCommand: 'gestureViewport' }, |
| 44 | + snapshot: { invoke: (i) => i.snapshot(), runnerCommand: 'snapshot' }, |
| 45 | + back: { invoke: (i) => i.back(), runnerCommand: 'backInApp' }, |
| 46 | + home: { invoke: (i) => i.home(), runnerCommand: 'home' }, |
| 47 | + setOrientation: { invoke: (i) => i.setOrientation('portrait'), runnerCommand: 'rotate' }, |
| 48 | + appSwitcher: { invoke: (i) => i.appSwitcher(), runnerCommand: 'appSwitcher' }, |
| 49 | + tvRemote: { invoke: (i) => i.tvRemote('select'), runnerCommand: 'remotePress' }, |
| 50 | +}; |
| 51 | + |
| 52 | +const LOCAL_TOOL_METHODS: Record<string, (interactor: Interactor) => Promise<unknown>> = { |
| 53 | + open: (i) => i.open('com.example.app'), |
| 54 | + openDevice: (i) => i.openDevice(), |
| 55 | + close: (i) => i.close('com.example.app'), |
| 56 | + screenshot: (i) => i.screenshot('/dev/null'), |
| 57 | + readClipboard: (i) => i.readClipboard(), |
| 58 | + writeClipboard: (i) => i.writeClipboard('hi'), |
| 59 | + setSetting: (i) => i.setSetting('wifi', 'on'), |
| 60 | +}; |
| 61 | + |
| 62 | +test('the runner/local partition covers the full provider-backed interactor surface', () => { |
| 63 | + const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([])); |
| 64 | + const classified = [ |
| 65 | + ...Object.keys(RUNNER_TRANSPORT_METHODS), |
| 66 | + ...Object.keys(LOCAL_TOOL_METHODS), |
| 67 | + ].sort(); |
| 68 | + assert.deepEqual(Object.keys(interactor).sort(), classified); |
| 69 | +}); |
| 70 | + |
| 71 | +test('provider-backed interactor routes runner-command methods through the injected transport', async () => { |
| 72 | + const calls: RecordedRunnerCall[] = []; |
| 73 | + const interactor = createAppleInteractor( |
| 74 | + IOS_SIMULATOR, |
| 75 | + { appBundleId: 'com.example.app' }, |
| 76 | + recordingRunnerProvider(calls), |
| 77 | + ); |
| 78 | + for (const [method, { invoke, runnerCommand }] of Object.entries(RUNNER_TRANSPORT_METHODS)) { |
| 79 | + calls.length = 0; |
| 80 | + await invoke(interactor); |
| 81 | + assert.ok(calls.length >= 1, `${method} never reached the injected runner transport`); |
| 82 | + assert.equal(calls[0]!.command.command, runnerCommand, `${method} sent a different command`); |
| 83 | + } |
| 84 | +}); |
| 85 | + |
| 86 | +test('provider-backed interactor rejects local Apple tooling methods with a clear error', async () => { |
| 87 | + const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([])); |
| 88 | + for (const [method, invoke] of Object.entries(LOCAL_TOOL_METHODS)) { |
| 89 | + await assert.rejects( |
| 90 | + invoke(interactor), |
| 91 | + (error: unknown) => |
| 92 | + error instanceof AppError && |
| 93 | + error.code === 'UNSUPPORTED_OPERATION' && |
| 94 | + error.message.includes('provider session'), |
| 95 | + `${method} should reject as a local-tooling method`, |
| 96 | + ); |
| 97 | + } |
| 98 | +}); |
| 99 | + |
| 100 | +test('injected transport still resolves when the runner context carries a request id', async () => { |
| 101 | + const calls: RecordedRunnerCall[] = []; |
| 102 | + const runnerContext: RunnerContext = { requestId: 'req-42' }; |
| 103 | + const interactor = createAppleInteractor( |
| 104 | + IOS_SIMULATOR, |
| 105 | + runnerContext, |
| 106 | + recordingRunnerProvider(calls), |
| 107 | + ); |
| 108 | + await interactor.tap(10, 20); |
| 109 | + assert.equal(calls.length, 1); |
| 110 | + assert.equal(calls[0]!.options.requestId, 'req-42'); |
| 111 | +}); |
| 112 | + |
| 113 | +test('snapshot over the injected transport keeps the shared xctest result shape', async () => { |
| 114 | + const interactor = createAppleInteractor(IOS_SIMULATOR, {}, recordingRunnerProvider([])); |
| 115 | + const result = await interactor.snapshot(); |
| 116 | + assert.equal(result.backend, 'xctest'); |
| 117 | + assert.equal(result.nodes?.length, 2); |
| 118 | +}); |
| 119 | + |
| 120 | +function recordingRunnerProvider(calls: RecordedRunnerCall[]): AppleRunnerProvider { |
| 121 | + return { |
| 122 | + runCommand: async (_device, command, options) => { |
| 123 | + calls.push({ command, options }); |
| 124 | + return runnerResultFor(command); |
| 125 | + }, |
| 126 | + }; |
| 127 | +} |
| 128 | + |
| 129 | +function runnerResultFor(command: RunnerCommand): Record<string, unknown> { |
| 130 | + switch (command.command) { |
| 131 | + case 'snapshot': |
| 132 | + return { |
| 133 | + nodes: [ |
| 134 | + { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, |
| 135 | + { |
| 136 | + index: 1, |
| 137 | + parentIndex: 0, |
| 138 | + type: 'Button', |
| 139 | + label: 'Go', |
| 140 | + hittable: true, |
| 141 | + rect: { x: 10, y: 10, width: 80, height: 40 }, |
| 142 | + }, |
| 143 | + ], |
| 144 | + }; |
| 145 | + case 'gestureViewport': |
| 146 | + return { x: 0, y: 0, x2: 390, y2: 844 }; |
| 147 | + default: |
| 148 | + return {}; |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +function singlePointerPanPlan(): GesturePlan { |
| 153 | + return { |
| 154 | + topology: 'single', |
| 155 | + intent: 'pan', |
| 156 | + executionProfile: 'timed-pan', |
| 157 | + durationMs: 120, |
| 158 | + viewport: { x: 0, y: 0, width: 390, height: 844 }, |
| 159 | + pointers: [ |
| 160 | + { |
| 161 | + pointerId: 0, |
| 162 | + samples: [ |
| 163 | + { offsetMs: 0, point: { x: 100, y: 400 } }, |
| 164 | + { offsetMs: 120, point: { x: 100, y: 200 } }, |
| 165 | + ], |
| 166 | + }, |
| 167 | + ], |
| 168 | + }; |
| 169 | +} |
0 commit comments