diff --git a/packages/contracts/src/alert-runtime.test.ts b/packages/contracts/src/alert-runtime.test.ts index a24cd6ec9..94d1f7cd5 100644 --- a/packages/contracts/src/alert-runtime.test.ts +++ b/packages/contracts/src/alert-runtime.test.ts @@ -1,6 +1,6 @@ import { expect, test, vi } from 'vitest'; import { alertRuntimeOperationFacts, bindAlertLeg } from './alert-runtime.ts'; -import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import { localInteractorSource } from './interactor-operation-binding.ts'; import type { AlertInteractorOptions, Interactor } from './interactor-types.ts'; const device = { @@ -12,42 +12,12 @@ const device = { booted: true, } as const; -// The composition the interactor catalog performs, spelled out so each assertion below -// still exercises one facet executor reached through one interactor source. -const bindLocalAlertReadInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => bindAlertLeg('readAlert', params.signal, localInteractorSource(params)); -const bindLocalAlertWaitInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => bindAlertLeg('awaitAlert', params.signal, localInteractorSource(params)); -const bindLocalAlertAcceptInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => bindAlertLeg('acceptAlert', params.signal, localInteractorSource(params)); -const bindLocalAlertDismissInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => bindAlertLeg('dismissAlert', params.signal, localInteractorSource(params)); -const bindProviderAlertAcceptInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => - bindAlertLeg( - 'acceptAlert', - params.signal, - providerInteractorSource({ ...params, operation: 'alert accept' }), - ); - test('builds the exact alert operation fact catalog', () => { const read = { available: true } as const; - const wait = { available: false, reason: 'owner-capability-missing' } as const; + const wait = { + available: false, + reason: 'owner-capability-missing', + } as const; const accept = { available: true } as const; const dismiss = { available: true } as const; @@ -70,7 +40,7 @@ test('each local leg forwards the window and the session target to its own owner }; const resolveInteractor = vi.fn(async () => legs as unknown as Interactor); const signal = new AbortController().signal; - const params = { device, signal, resolveInteractor }; + const source = localInteractorSource({ device, resolveInteractor }); const input = { timeoutMs: 37, appBundleId: 'com.example.app', @@ -78,12 +48,16 @@ test('each local leg forwards the window and the session target to its own owner execution: { logPath: '/tmp/daemon.log', requestId: 'alert-1' }, }; - await bindLocalAlertReadInteractor(params).readAlert(input); - await bindLocalAlertWaitInteractor(params).awaitAlert(input); - await bindLocalAlertAcceptInteractor(params).acceptAlert(input); - await bindLocalAlertDismissInteractor(params).dismissAlert(input); + await bindAlertLeg('readAlert', signal, source).readAlert(input); + await bindAlertLeg('awaitAlert', signal, source).awaitAlert(input); + await bindAlertLeg('acceptAlert', signal, source).acceptAlert(input); + await bindAlertLeg('dismissAlert', signal, source).dismissAlert(input); - const expectedOptions = { timeoutMs: 37, appBundleId: 'com.example.app', surface: 'app' }; + const expectedOptions = { + timeoutMs: 37, + appBundleId: 'com.example.app', + surface: 'app', + }; expect(legs.readAlert).toHaveBeenCalledWith(expectedOptions); expect(legs.awaitAlert).toHaveBeenCalledWith(expectedOptions); expect(legs.acceptAlert).toHaveBeenCalledWith(expectedOptions); @@ -99,44 +73,12 @@ test('each local leg forwards the window and the session target to its own owner // A frontmost-app session carries no bundle at all, and the option object must not invent one. test('an absent target field never reaches the owner as an explicit undefined', async () => { const readAlert = vi.fn(async (_options?: AlertInteractorOptions) => ({})); - const operations = bindLocalAlertReadInteractor({ - device, - signal: new AbortController().signal, - resolveInteractor: async () => ({ readAlert }) as unknown as Interactor, - }); + const resolveInteractor = async () => ({ readAlert }) as unknown as Interactor; + const source = localInteractorSource({ device, resolveInteractor }); + const operations = bindAlertLeg('readAlert', new AbortController().signal, source); await operations.readAlert({ surface: 'frontmost-app' }); expect(readAlert).toHaveBeenCalledWith({ surface: 'frontmost-app' }); expect(Object.keys(readAlert.mock.calls[0]?.[0] ?? {})).toEqual(['surface']); }); - -test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindProviderAlertAcceptInteractor({ - device, - signal: new AbortController().signal, - resolveInteractor: () => undefined, - }); - - await expect(operations.acceptAlert({})).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, - }); -}); - -test('an already-cancelled request never resolves an interactor', async () => { - const controller = new AbortController(); - controller.abort(); - const readAlert = vi.fn(async () => ({})); - const resolveInteractor = vi.fn(async () => ({ readAlert }) as unknown as Interactor); - - const operations = bindLocalAlertReadInteractor({ - device, - signal: controller.signal, - resolveInteractor, - }); - - await expect(operations.readAlert({})).rejects.toThrow(); - expect(resolveInteractor).not.toHaveBeenCalled(); - expect(readAlert).not.toHaveBeenCalled(); -}); diff --git a/packages/contracts/src/app-event-runtime.test.ts b/packages/contracts/src/app-event-runtime.test.ts index 1d481549e..b3b476c54 100644 --- a/packages/contracts/src/app-event-runtime.test.ts +++ b/packages/contracts/src/app-event-runtime.test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { appEventRuntimeOperationFacts, bindAppEvent } from './app-event-runtime.ts'; import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import { localInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -11,22 +11,11 @@ const device = { booted: true, } as const; -const bindAppEventLocal = ( - params: Parameters[0] & { signal: AbortSignal }, -) => bindAppEvent(params.signal, localInteractorSource(params)); -const bindAppEventProvider = ( - params: Parameters[0] extends infer P - ? Omit & { signal: AbortSignal } - : never, -) => - bindAppEvent( - params.signal, - providerInteractorSource({ ...params, operation: 'trigger-app-event' }), - ); - test('builds the exact app-event operation fact catalog', () => { const triggerAppEvent = { available: true } as const; - expect(appEventRuntimeOperationFacts({ triggerAppEvent })).toEqual({ triggerAppEvent }); + expect(appEventRuntimeOperationFacts({ triggerAppEvent })).toEqual({ + triggerAppEvent, + }); }); // The URL is resolved daemon-side from the event name, payload, and per-platform template; what @@ -36,7 +25,7 @@ test('a local binding opens the resolved event URL against the session app', asy const resolveInteractor = vi.fn(async () => ({ open }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindAppEventLocal({ device, signal, resolveInteractor }); + const operations = bindAppEvent(signal, localInteractorSource({ device, resolveInteractor })); await operations.triggerAppEvent({ eventUrl: 'myapp://agent-device/event?name=checkout', options: { appBundleId: 'com.example.app' }, @@ -53,33 +42,3 @@ test('a local binding opens the resolved event URL against the session app', asy appBundleId: 'com.example.app', }); }); - -test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindAppEventProvider({ - device, - signal: new AbortController().signal, - resolveInteractor: () => undefined, - }); - - await expect(operations.triggerAppEvent({ eventUrl: 'myapp://x' })).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, - }); -}); - -test('an already-cancelled request never resolves an interactor', async () => { - const controller = new AbortController(); - controller.abort(); - const open = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(async () => ({ open }) as unknown as Interactor); - - const operations = bindAppEventLocal({ - device, - signal: controller.signal, - resolveInteractor, - }); - - await expect(operations.triggerAppEvent({ eventUrl: 'myapp://x' })).rejects.toThrow(); - expect(resolveInteractor).not.toHaveBeenCalled(); - expect(open).not.toHaveBeenCalled(); -}); diff --git a/packages/contracts/src/app-switcher-runtime.test.ts b/packages/contracts/src/app-switcher-runtime.test.ts index d44edae9b..8f71459ba 100644 --- a/packages/contracts/src/app-switcher-runtime.test.ts +++ b/packages/contracts/src/app-switcher-runtime.test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { appSwitcherRuntimeOperationFacts, bindAppSwitcher } from './app-switcher-runtime.ts'; import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import { localInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -11,22 +11,11 @@ const device = { booted: true, } as const; -const bindAppSwitcherLocal = ( - params: Parameters[0] & { signal: AbortSignal }, -) => bindAppSwitcher(params.signal, localInteractorSource(params)); -const bindAppSwitcherProvider = ( - params: Parameters[0] extends infer P - ? Omit & { signal: AbortSignal } - : never, -) => - bindAppSwitcher( - params.signal, - providerInteractorSource({ ...params, operation: 'app-switcher' }), - ); - test('builds the exact app-switcher operation fact catalog', () => { const appSwitcher = { available: true } as const; - expect(appSwitcherRuntimeOperationFacts({ appSwitcher })).toEqual({ appSwitcher }); + expect(appSwitcherRuntimeOperationFacts({ appSwitcher })).toEqual({ + appSwitcher, + }); }); test('a local binding drives the interactor with the request runner context', async () => { @@ -34,7 +23,7 @@ test('a local binding drives the interactor with the request runner context', as const resolveInteractor = vi.fn(async () => ({ appSwitcher }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindAppSwitcherLocal({ device, signal, resolveInteractor }); + const operations = bindAppSwitcher(signal, localInteractorSource({ device, resolveInteractor })); await operations.appSwitcher({ options: { appBundleId: 'com.example.app' }, execution: { logPath: '/tmp/daemon.log', requestId: 'switcher-1' }, @@ -48,33 +37,3 @@ test('a local binding drives the interactor with the request runner context', as }); expect(appSwitcher).toHaveBeenCalledOnce(); }); - -test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindAppSwitcherProvider({ - device, - signal: new AbortController().signal, - resolveInteractor: () => undefined, - }); - - await expect(operations.appSwitcher({})).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, - }); -}); - -test('an already-cancelled request never resolves an interactor', async () => { - const controller = new AbortController(); - controller.abort(); - const appSwitcher = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(async () => ({ appSwitcher }) as unknown as Interactor); - - const operations = bindAppSwitcherLocal({ - device, - signal: controller.signal, - resolveInteractor, - }); - - await expect(operations.appSwitcher({})).rejects.toThrow(); - expect(resolveInteractor).not.toHaveBeenCalled(); - expect(appSwitcher).not.toHaveBeenCalled(); -}); diff --git a/packages/contracts/src/back-runtime.test.ts b/packages/contracts/src/back-runtime.test.ts index 9c83e0639..950a18269 100644 --- a/packages/contracts/src/back-runtime.test.ts +++ b/packages/contracts/src/back-runtime.test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { bindBack, backRuntimeOperationFacts } from './back-runtime.ts'; import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import { localInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -11,15 +11,6 @@ const device = { booted: true, } as const; -const bindBackLocal = ( - params: Parameters[0] & { signal: AbortSignal }, -) => bindBack(params.signal, localInteractorSource(params)); -const bindBackProvider = ( - params: Parameters[0] extends infer P - ? Omit & { signal: AbortSignal } - : never, -) => bindBack(params.signal, providerInteractorSource({ ...params, operation: 'back' })); - test('builds the exact back operation fact catalog', () => { const back = { available: true } as const; expect(backRuntimeOperationFacts({ back })).toEqual({ back }); @@ -30,7 +21,7 @@ test('a local binding drives the interactor with the requested mode', async () = const resolveInteractor = vi.fn(async () => ({ back }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindBackLocal({ device, signal, resolveInteractor }); + const operations = bindBack(signal, localInteractorSource({ device, resolveInteractor })); await operations.back({ mode: 'system', options: { appBundleId: 'com.example.app' }, @@ -45,49 +36,3 @@ test('a local binding drives the interactor with the requested mode', async () = }); expect(back).toHaveBeenCalledWith('system'); }); - -test('a provider binding drives its own resolved interactor', async () => { - const back = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(() => ({ back }) as unknown as Interactor); - const signal = new AbortController().signal; - - const operations = bindBackProvider({ device, signal, resolveInteractor }); - await operations.back({ execution: { requestId: 'back-2' } }); - - expect(resolveInteractor).toHaveBeenCalledWith({ - requestId: 'back-2', - appBundleId: undefined, - signal, - }); - expect(back).toHaveBeenCalledWith(undefined); -}); - -test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindBackProvider({ - device, - signal: new AbortController().signal, - resolveInteractor: () => undefined, - }); - - await expect(operations.back({})).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, - }); -}); - -test('an already-cancelled request never resolves an interactor', async () => { - const controller = new AbortController(); - controller.abort(); - const back = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(async () => ({ back }) as unknown as Interactor); - - const operations = bindBackLocal({ - device, - signal: controller.signal, - resolveInteractor, - }); - - await expect(operations.back({})).rejects.toThrow(); - expect(resolveInteractor).not.toHaveBeenCalled(); - expect(back).not.toHaveBeenCalled(); -}); diff --git a/packages/contracts/src/clipboard-runtime.test.ts b/packages/contracts/src/clipboard-runtime.test.ts index a681602ba..8ee5f845b 100644 --- a/packages/contracts/src/clipboard-runtime.test.ts +++ b/packages/contracts/src/clipboard-runtime.test.ts @@ -5,7 +5,10 @@ import { clipboardRuntimeOperationFacts, } from './clipboard-runtime.ts'; import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import { + localInteractorSource, + type LocalInteractorOperationResolver, +} from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -15,40 +18,15 @@ const device = { booted: true, } as const; -// The composition the interactor catalog performs, spelled out so each assertion below -// still exercises one facet executor reached through one interactor source. -const bindLocalClipboardReadInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => bindClipboardRead(params.signal, localInteractorSource(params)); -const bindLocalClipboardWriteInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => bindClipboardWrite(params.signal, localInteractorSource(params)); -const bindProviderClipboardReadInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => - bindClipboardRead( - params.signal, - providerInteractorSource({ ...params, operation: 'clipboard read' }), - ); -const bindProviderClipboardWriteInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => - bindClipboardWrite( - params.signal, - providerInteractorSource({ ...params, operation: 'clipboard write' }), - ); +const local = (resolveInteractor: LocalInteractorOperationResolver) => + localInteractorSource({ device, resolveInteractor }); test('builds the exact clipboard operation fact catalog', () => { const read = { available: true } as const; - const write = { available: false, reason: 'owner-capability-missing' } as const; + const write = { + available: false, + reason: 'owner-capability-missing', + } as const; expect(clipboardRuntimeOperationFacts({ read, write })).toEqual({ readClipboard: read, writeClipboard: write, @@ -60,7 +38,7 @@ test('a local read binding returns the interactor pasteboard text verbatim', asy const resolveInteractor = vi.fn(async () => ({ readClipboard }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalClipboardReadInteractor({ device, signal, resolveInteractor }); + const operations = bindClipboardRead(signal, local(resolveInteractor)); await expect( operations.readClipboard({ options: { appBundleId: 'com.example.app' }, @@ -80,66 +58,8 @@ test('a local write binding hands the interactor the already-joined text', async const writeClipboard = vi.fn(async () => undefined); const resolveInteractor = vi.fn(async () => ({ writeClipboard }) as unknown as Interactor); - const operations = bindLocalClipboardWriteInteractor({ - device, - signal: new AbortController().signal, - resolveInteractor, - }); + const operations = bindClipboardWrite(new AbortController().signal, local(resolveInteractor)); await operations.writeClipboard({ text: 'hello world' }); expect(writeClipboard).toHaveBeenCalledWith('hello world'); }); - -test('a provider binding drives its own resolved interactor', async () => { - const readClipboard = vi.fn(async () => 'provider text'); - const resolveInteractor = vi.fn(() => ({ readClipboard }) as unknown as Interactor); - const signal = new AbortController().signal; - - const operations = bindProviderClipboardReadInteractor({ device, signal, resolveInteractor }); - await expect(operations.readClipboard({ execution: { requestId: 'clipboard-2' } })).resolves.toBe( - 'provider text', - ); - - expect(resolveInteractor).toHaveBeenCalledWith({ - requestId: 'clipboard-2', - appBundleId: undefined, - signal, - }); -}); - -test.each([ - { half: 'read', bind: bindProviderClipboardReadInteractor }, - { half: 'write', bind: bindProviderClipboardWriteInteractor }, -])('a provider $half binding fails closed with no owner interactor', async ({ bind }) => { - const operations = bind({ - device, - signal: new AbortController().signal, - resolveInteractor: () => undefined, - }); - - const invoke = - 'readClipboard' in operations - ? operations.readClipboard({}) - : operations.writeClipboard({ text: '' }); - await expect(invoke).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, - }); -}); - -test('an already-cancelled request never resolves an interactor', async () => { - const controller = new AbortController(); - controller.abort(); - const readClipboard = vi.fn(async () => ''); - const resolveInteractor = vi.fn(async () => ({ readClipboard }) as unknown as Interactor); - - const operations = bindLocalClipboardReadInteractor({ - device, - signal: controller.signal, - resolveInteractor, - }); - - await expect(operations.readClipboard({})).rejects.toThrow(); - expect(resolveInteractor).not.toHaveBeenCalled(); - expect(readClipboard).not.toHaveBeenCalled(); -}); diff --git a/packages/contracts/src/home-runtime.test.ts b/packages/contracts/src/home-runtime.test.ts index e1860561e..3ea5fbb4c 100644 --- a/packages/contracts/src/home-runtime.test.ts +++ b/packages/contracts/src/home-runtime.test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { bindHome, homeRuntimeOperationFacts } from './home-runtime.ts'; import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import { localInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -11,15 +11,6 @@ const device = { booted: true, } as const; -const bindHomeLocal = ( - params: Parameters[0] & { signal: AbortSignal }, -) => bindHome(params.signal, localInteractorSource(params)); -const bindHomeProvider = ( - params: Parameters[0] extends infer P - ? Omit & { signal: AbortSignal } - : never, -) => bindHome(params.signal, providerInteractorSource({ ...params, operation: 'home' })); - test('builds the exact home operation fact catalog', () => { const home = { available: true } as const; expect(homeRuntimeOperationFacts({ home })).toEqual({ home }); @@ -30,7 +21,7 @@ test('a local binding drives the interactor with no arguments', async () => { const resolveInteractor = vi.fn(async () => ({ home }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindHomeLocal({ device, signal, resolveInteractor }); + const operations = bindHome(signal, localInteractorSource({ device, resolveInteractor })); await operations.home({ options: { appBundleId: 'com.example.app' }, execution: { logPath: '/tmp/daemon.log', requestId: 'home-1' }, @@ -44,49 +35,3 @@ test('a local binding drives the interactor with no arguments', async () => { }); expect(home).toHaveBeenCalledWith(); }); - -test('a provider binding drives its own resolved interactor', async () => { - const home = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(() => ({ home }) as unknown as Interactor); - const signal = new AbortController().signal; - - const operations = bindHomeProvider({ device, signal, resolveInteractor }); - await operations.home({ execution: { requestId: 'home-2' } }); - - expect(resolveInteractor).toHaveBeenCalledWith({ - requestId: 'home-2', - appBundleId: undefined, - signal, - }); - expect(home).toHaveBeenCalledTimes(1); -}); - -test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindHomeProvider({ - device, - signal: new AbortController().signal, - resolveInteractor: () => undefined, - }); - - await expect(operations.home({})).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, - }); -}); - -test('an already-cancelled request never resolves an interactor', async () => { - const controller = new AbortController(); - controller.abort(); - const home = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(async () => ({ home }) as unknown as Interactor); - - const operations = bindHomeLocal({ - device, - signal: controller.signal, - resolveInteractor, - }); - - await expect(operations.home({})).rejects.toThrow(); - expect(resolveInteractor).not.toHaveBeenCalled(); - expect(home).not.toHaveBeenCalled(); -}); diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index b787dd3b9..29c34c424 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -51,7 +51,7 @@ type InteractorOperationDefinition = Readonly<{ * key the caller's facts never define is simply never available — which is how an owner with a * narrower, dedicated facts object (Limrun's keyboard-less navigation facts) opts a subset out. */ -const INTERACTOR_OPERATIONS = [ +export const INTERACTOR_OPERATIONS = [ { operation: 'back', label: 'back', bind: bindBack }, { operation: 'home', label: 'home', bind: bindHome }, { operation: 'setOrientation', label: 'orientation', bind: bindOrientation }, diff --git a/packages/contracts/src/interactor-operation-conformance.test.ts b/packages/contracts/src/interactor-operation-conformance.test.ts new file mode 100644 index 000000000..dfc0026c2 --- /dev/null +++ b/packages/contracts/src/interactor-operation-conformance.test.ts @@ -0,0 +1,144 @@ +import { expect, test, vi } from 'vitest'; +import { + INTERACTOR_OPERATIONS, + type CatalogInteractorOperation, +} from './interactor-operation-catalog.ts'; +import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import type { Interactor } from './interactor-types.ts'; +import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; + +// The three binding rules every interactor-catalog operation must obey, driven by the catalog +// itself: each rule walks INTERACTOR_OPERATIONS and binds through the catalog row's own `bind` +// and `label`, exactly as production does. Completeness is therefore structural, not observed: +// the expectation table below is typed over every catalog operation, so a row cannot be missing, +// duplicated, or name an operation the catalog does not register, and every row is executed by +// the tests in this file. Each facet's own `*-runtime.test.ts` keeps only its dedicated tests. + +type ConformanceExpectation = Readonly<{ + /** The `Interactor` method the operation drives once its interactor is resolved. */ + method: keyof Interactor; + /** One input naming no target app; the tests supply `execution` and expect no `appBundleId`. */ + input: Omit[0], 'execution'>; +}>; + +const EXPECTATIONS: { + readonly [Operation in CatalogInteractorOperation]: ConformanceExpectation; +} = { + back: { method: 'back', input: {} }, + home: { method: 'home', input: {} }, + setOrientation: { method: 'setOrientation', input: { rotation: 'portrait' } }, + tvRemote: { method: 'tvRemote', input: { button: 'select' } }, + keyboardStatus: { method: 'keyboardStatus', input: {} }, + keyboardDismiss: { method: 'keyboardDismiss', input: {} }, + keyboardEnter: { method: 'keyboardEnter', input: {} }, + readClipboard: { method: 'readClipboard', input: {} }, + writeClipboard: { method: 'writeClipboard', input: { text: '' } }, + appSwitcher: { method: 'appSwitcher', input: {} }, + triggerAppEvent: { method: 'open', input: { eventUrl: 'myapp://x' } }, + setSetting: { + method: 'setSetting', + input: { setting: 'appearance', state: 'dark' }, + }, + readAlert: { method: 'readAlert', input: {} }, + awaitAlert: { method: 'awaitAlert', input: {} }, + acceptAlert: { method: 'acceptAlert', input: {} }, + dismissAlert: { method: 'dismissAlert', input: {} }, +}; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; + +type CatalogDefinition = (typeof INTERACTOR_OPERATIONS)[number]; +type BoundOperation = (input: unknown) => Promise; + +function bindCatalogOperation( + definition: CatalogDefinition, + signal: AbortSignal, + source: Parameters[1], +): BoundOperation { + const operations: Partial = definition.bind(signal, source); + const bound = operations[definition.operation] as BoundOperation | undefined; + expect(bound, `${definition.operation} binds its own operation`).toBeTypeOf('function'); + return bound as BoundOperation; +} + +test('the expectation table names exactly the catalog operations', () => { + expect(Object.keys(EXPECTATIONS).sort()).toEqual( + INTERACTOR_OPERATIONS.map(({ operation }) => operation).sort(), + ); +}); + +test('a provider binding drives its own resolved interactor', async () => { + for (const definition of INTERACTOR_OPERATIONS) { + const { method: methodName, input } = EXPECTATIONS[definition.operation]; + const method = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(() => ({ [methodName]: method }) as unknown as Interactor); + const signal = new AbortController().signal; + const requestId = `${definition.operation}-provider`; + const source = providerInteractorSource({ + device, + operation: definition.label, + resolveInteractor, + }); + + await bindCatalogOperation(definition, signal, source)({ ...input, execution: { requestId } }); + + expect(resolveInteractor, definition.operation).toHaveBeenCalledWith({ + requestId, + appBundleId: undefined, + signal, + }); + expect(method, definition.operation).toHaveBeenCalledOnce(); + } +}); + +test('a provider binding fails closed when its exact owner exposes no interactor', async () => { + for (const definition of INTERACTOR_OPERATIONS) { + const source = providerInteractorSource({ + device, + operation: definition.label, + resolveInteractor: () => undefined, + }); + + await expect( + bindCatalogOperation( + definition, + new AbortController().signal, + source, + )(EXPECTATIONS[definition.operation].input), + definition.operation, + ).rejects.toMatchObject({ + code: 'UNSUPPORTED_OPERATION', + message: `Provider-owned ${definition.label} operation has no bound provider interactor.`, + details: { + reason: 'provider-runtime-interactor-missing', + deviceId: device.id, + }, + }); + } +}); + +test('an already-cancelled request never resolves an interactor', async () => { + for (const definition of INTERACTOR_OPERATIONS) { + const { method: methodName, input } = EXPECTATIONS[definition.operation]; + const controller = new AbortController(); + controller.abort(); + const method = vi.fn(async () => undefined); + const resolveInteractor = vi.fn( + async () => ({ [methodName]: method }) as unknown as Interactor, + ); + const source = localInteractorSource({ device, resolveInteractor }); + + await expect( + bindCatalogOperation(definition, controller.signal, source)(input), + definition.operation, + ).rejects.toThrow(); + expect(resolveInteractor, definition.operation).not.toHaveBeenCalled(); + expect(method, definition.operation).not.toHaveBeenCalled(); + } +}); diff --git a/packages/contracts/src/keyboard-runtime.test.ts b/packages/contracts/src/keyboard-runtime.test.ts index f34dc0170..810e67ddf 100644 --- a/packages/contracts/src/keyboard-runtime.test.ts +++ b/packages/contracts/src/keyboard-runtime.test.ts @@ -1,11 +1,10 @@ import { expect, test, vi } from 'vitest'; -import { - KEYBOARD_ACTION_LABELS, - bindKeyboardAction, - keyboardRuntimeOperationFacts, -} from './keyboard-runtime.ts'; +import { bindKeyboardAction, keyboardRuntimeOperationFacts } from './keyboard-runtime.ts'; import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import { + localInteractorSource, + type LocalInteractorOperationResolver, +} from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -15,57 +14,15 @@ const device = { booted: true, } as const; -// The composition the interactor catalog performs, spelled out so each assertion below -// still exercises one facet executor reached through one interactor source. -const bindLocalKeyboardStatusInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => bindKeyboardAction('keyboardStatus', params.signal, localInteractorSource(params)); -const bindLocalKeyboardDismissInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => bindKeyboardAction('keyboardDismiss', params.signal, localInteractorSource(params)); -const bindLocalKeyboardEnterInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => bindKeyboardAction('keyboardEnter', params.signal, localInteractorSource(params)); -const bindProviderKeyboardStatusInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => - bindKeyboardAction( - 'keyboardStatus', - params.signal, - providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS.keyboardStatus }), - ); -const bindProviderKeyboardDismissInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => - bindKeyboardAction( - 'keyboardDismiss', - params.signal, - providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS.keyboardDismiss }), - ); -const bindProviderKeyboardEnterInteractor = (params: { - device: typeof device; - signal: AbortSignal; - resolveInteractor: any; -}) => - bindKeyboardAction( - 'keyboardEnter', - params.signal, - providerInteractorSource({ ...params, operation: KEYBOARD_ACTION_LABELS.keyboardEnter }), - ); +const local = (resolveInteractor: LocalInteractorOperationResolver) => + localInteractorSource({ device, resolveInteractor }); test('builds the exact keyboard operation fact catalog', () => { const status = { available: true } as const; - const dismiss = { available: false, reason: 'unsupported-platform-leaf' } as const; + const dismiss = { + available: false, + reason: 'unsupported-platform-leaf', + } as const; const enter = { available: true } as const; expect(keyboardRuntimeOperationFacts({ status, dismiss, enter })).toEqual({ keyboardStatus: status, @@ -79,7 +36,7 @@ test('a local status binding drives the interactor and returns its report', asyn const resolveInteractor = vi.fn(async () => ({ keyboardStatus }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindLocalKeyboardStatusInteractor({ device, signal, resolveInteractor }); + const operations = bindKeyboardAction('keyboardStatus', signal, local(resolveInteractor)); const result = await operations.keyboardStatus({ options: { appBundleId: 'com.example.app' }, execution: { logPath: '/tmp/daemon.log', requestId: 'keyboard-1' }, @@ -96,13 +53,16 @@ test('a local status binding drives the interactor and returns its report', asyn }); test('a local dismiss binding drives the interactor', async () => { - const keyboardDismiss = vi.fn(async () => ({ dismissed: true, visible: false })); + const keyboardDismiss = vi.fn(async () => ({ + dismissed: true, + visible: false, + })); const resolveInteractor = vi.fn(async () => ({ keyboardDismiss }) as unknown as Interactor); - const operations = bindLocalKeyboardDismissInteractor({ - device, - signal: new AbortController().signal, - resolveInteractor, - }); + const operations = bindKeyboardAction( + 'keyboardDismiss', + new AbortController().signal, + local(resolveInteractor), + ); const result = await operations.keyboardDismiss({}); @@ -113,85 +73,30 @@ test('a local dismiss binding drives the interactor', async () => { test('a local enter binding drives the interactor', async () => { const keyboardEnter = vi.fn(async () => ({})); const resolveInteractor = vi.fn(async () => ({ keyboardEnter }) as unknown as Interactor); - const operations = bindLocalKeyboardEnterInteractor({ - device, - signal: new AbortController().signal, - resolveInteractor, - }); + const operations = bindKeyboardAction( + 'keyboardEnter', + new AbortController().signal, + local(resolveInteractor), + ); await operations.keyboardEnter({}); expect(keyboardEnter).toHaveBeenCalledTimes(1); }); -test('a provider binding drives its own resolved interactor', async () => { - const keyboardStatus = vi.fn(async () => ({ visible: false })); - const resolveInteractor = vi.fn(() => ({ keyboardStatus }) as unknown as Interactor); - const signal = new AbortController().signal; - - const operations = bindProviderKeyboardStatusInteractor({ device, signal, resolveInteractor }); - await operations.keyboardStatus({ execution: { requestId: 'keyboard-2' } }); - - expect(resolveInteractor).toHaveBeenCalledWith({ - requestId: 'keyboard-2', - appBundleId: undefined, - signal, - }); - expect(keyboardStatus).toHaveBeenCalledTimes(1); -}); - -test('a provider binding fails closed when its exact owner exposes no interactor at all', async () => { - const dismissOperations = bindProviderKeyboardDismissInteractor({ - device, - signal: new AbortController().signal, - resolveInteractor: () => undefined, - }); - const enterOperations = bindProviderKeyboardEnterInteractor({ - device, - signal: new AbortController().signal, - resolveInteractor: () => undefined, - }); - - await expect(dismissOperations.keyboardDismiss({})).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, - }); - await expect(enterOperations.keyboardEnter({})).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, - }); -}); - test('binding fails as a runtime-contract error when the resolved interactor has no method — `Interactor.keyboardStatus`/`keyboardDismiss`/`keyboardEnter` are optional, so an owner whose fact admitted the operation but whose interactor omits it is a contract bug, not a normal refusal', async () => { - // The interactor is resolved (unlike the "no interactor at all" case above), but it does not - // implement `keyboardEnter` — parity with `hover`, which platforms with no keyboard concept - // simply omit. + // The interactor is resolved (unlike the "no interactor at all" case the conformance rows + // cover), but it does not implement `keyboardEnter` — parity with `hover`, which platforms + // with no keyboard concept simply omit. const resolveInteractor = vi.fn(async () => ({}) as unknown as Interactor); - const operations = bindLocalKeyboardEnterInteractor({ - device, - signal: new AbortController().signal, - resolveInteractor, - }); + const operations = bindKeyboardAction( + 'keyboardEnter', + new AbortController().signal, + local(resolveInteractor), + ); await expect(operations.keyboardEnter({})).rejects.toMatchObject({ code: 'COMMAND_FAILED', details: { reason: 'interactor-method-missing' }, }); }); - -test('an already-cancelled request never resolves an interactor', async () => { - const controller = new AbortController(); - controller.abort(); - const keyboardStatus = vi.fn(async () => ({ visible: true })); - const resolveInteractor = vi.fn(async () => ({ keyboardStatus }) as unknown as Interactor); - - const operations = bindLocalKeyboardStatusInteractor({ - device, - signal: controller.signal, - resolveInteractor, - }); - - await expect(operations.keyboardStatus({})).rejects.toThrow(); - expect(resolveInteractor).not.toHaveBeenCalled(); - expect(keyboardStatus).not.toHaveBeenCalled(); -}); diff --git a/packages/contracts/src/orientation-runtime.test.ts b/packages/contracts/src/orientation-runtime.test.ts index 893c37718..b060ff31f 100644 --- a/packages/contracts/src/orientation-runtime.test.ts +++ b/packages/contracts/src/orientation-runtime.test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { bindOrientation, orientationRuntimeOperationFacts } from './orientation-runtime.ts'; import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import { localInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'android', @@ -11,16 +11,6 @@ const device = { booted: true, } as const; -const bindOrientationLocal = ( - params: Parameters[0] & { signal: AbortSignal }, -) => bindOrientation(params.signal, localInteractorSource(params)); -const bindOrientationProvider = ( - params: Parameters[0] extends infer P - ? Omit & { signal: AbortSignal } - : never, -) => - bindOrientation(params.signal, providerInteractorSource({ ...params, operation: 'orientation' })); - test('builds the exact orientation operation fact catalog', () => { const orientation = { available: true } as const; expect(orientationRuntimeOperationFacts({ orientation })).toEqual({ @@ -29,11 +19,13 @@ test('builds the exact orientation operation fact catalog', () => { }); test('a local binding drives the interactor with the requested rotation and returns its report', async () => { - const setOrientation = vi.fn(async () => ({ orientation: 'landscape-left' as const })); + const setOrientation = vi.fn(async () => ({ + orientation: 'landscape-left' as const, + })); const resolveInteractor = vi.fn(async () => ({ setOrientation }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindOrientationLocal({ device, signal, resolveInteractor }); + const operations = bindOrientation(signal, localInteractorSource({ device, resolveInteractor })); const result = await operations.setOrientation({ rotation: 'landscape-left', options: { appBundleId: 'com.example.app' }, @@ -49,52 +41,3 @@ test('a local binding drives the interactor with the requested rotation and retu expect(setOrientation).toHaveBeenCalledWith('landscape-left'); expect(result).toEqual({ orientation: 'landscape-left' }); }); - -test('a provider binding drives its own resolved interactor', async () => { - const setOrientation = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(() => ({ setOrientation }) as unknown as Interactor); - const signal = new AbortController().signal; - - const operations = bindOrientationProvider({ device, signal, resolveInteractor }); - await operations.setOrientation({ - rotation: 'portrait', - execution: { requestId: 'orientation-2' }, - }); - - expect(resolveInteractor).toHaveBeenCalledWith({ - requestId: 'orientation-2', - appBundleId: undefined, - signal, - }); - expect(setOrientation).toHaveBeenCalledWith('portrait'); -}); - -test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindOrientationProvider({ - device, - signal: new AbortController().signal, - resolveInteractor: () => undefined, - }); - - await expect(operations.setOrientation({ rotation: 'portrait' })).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, - }); -}); - -test('an already-cancelled request never resolves an interactor', async () => { - const controller = new AbortController(); - controller.abort(); - const setOrientation = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(async () => ({ setOrientation }) as unknown as Interactor); - - const operations = bindOrientationLocal({ - device, - signal: controller.signal, - resolveInteractor, - }); - - await expect(operations.setOrientation({ rotation: 'portrait' })).rejects.toThrow(); - expect(resolveInteractor).not.toHaveBeenCalled(); - expect(setOrientation).not.toHaveBeenCalled(); -}); diff --git a/packages/contracts/src/settings-runtime.test.ts b/packages/contracts/src/settings-runtime.test.ts index 686473f2e..9141d2724 100644 --- a/packages/contracts/src/settings-runtime.test.ts +++ b/packages/contracts/src/settings-runtime.test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { bindSetSetting, settingsRuntimeOperationFacts } from './settings-runtime.ts'; import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import { localInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'apple', @@ -12,15 +12,6 @@ const device = { booted: true, } as const; -const bindSetSettingLocal = ( - params: Parameters[0] & { signal: AbortSignal }, -) => bindSetSetting(params.signal, localInteractorSource(params)); -const bindSetSettingProvider = ( - params: Parameters[0] extends infer P - ? Omit & { signal: AbortSignal } - : never, -) => bindSetSetting(params.signal, providerInteractorSource({ ...params, operation: 'settings' })); - test('builds the exact settings operation fact catalog', () => { const setSetting = { available: true } as const; expect(settingsRuntimeOperationFacts({ setSetting })).toEqual({ setSetting }); @@ -33,7 +24,7 @@ test('a local binding forwards the neutral mutation to its owner', async () => { const resolveInteractor = vi.fn(async () => ({ setSetting }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindSetSettingLocal({ device, signal, resolveInteractor }); + const operations = bindSetSetting(signal, localInteractorSource({ device, resolveInteractor })); const result = await operations.setSetting({ setting: 'location', state: 'set', @@ -54,35 +45,3 @@ test('a local binding forwards the neutral mutation to its owner', async () => { }); expect(result).toEqual({ message: 'Location updated' }); }); - -test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindSetSettingProvider({ - device, - signal: new AbortController().signal, - resolveInteractor: () => undefined, - }); - - await expect( - operations.setSetting({ setting: 'appearance', state: 'dark' }), - ).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, - }); -}); - -test('an already-cancelled request never resolves an interactor', async () => { - const controller = new AbortController(); - controller.abort(); - const setSetting = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(async () => ({ setSetting }) as unknown as Interactor); - - const operations = bindSetSettingLocal({ - device, - signal: controller.signal, - resolveInteractor, - }); - - await expect(operations.setSetting({ setting: 'appearance', state: 'dark' })).rejects.toThrow(); - expect(resolveInteractor).not.toHaveBeenCalled(); - expect(setSetting).not.toHaveBeenCalled(); -}); diff --git a/packages/contracts/src/tv-remote-runtime.test.ts b/packages/contracts/src/tv-remote-runtime.test.ts index 4b7e00504..a29dd1e7b 100644 --- a/packages/contracts/src/tv-remote-runtime.test.ts +++ b/packages/contracts/src/tv-remote-runtime.test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { bindTvRemote, tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts'; import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource, providerInteractorSource } from './interactor-operation-binding.ts'; +import { localInteractorSource } from './interactor-operation-binding.ts'; const device = { platform: 'vega', @@ -11,15 +11,6 @@ const device = { booted: true, } as const; -const bindTvRemoteLocal = ( - params: Parameters[0] & { signal: AbortSignal }, -) => bindTvRemote(params.signal, localInteractorSource(params)); -const bindTvRemoteProvider = ( - params: Parameters[0] extends infer P - ? Omit & { signal: AbortSignal } - : never, -) => bindTvRemote(params.signal, providerInteractorSource({ ...params, operation: 'tv-remote' })); - test('builds the exact tv-remote operation fact catalog', () => { const tvRemote = { available: true } as const; expect(tvRemoteRuntimeOperationFacts({ tvRemote })).toEqual({ tvRemote }); @@ -30,7 +21,7 @@ test('a local binding drives the interactor with the button and duration', async const resolveInteractor = vi.fn(async () => ({ tvRemote }) as unknown as Interactor); const signal = new AbortController().signal; - const operations = bindTvRemoteLocal({ device, signal, resolveInteractor }); + const operations = bindTvRemote(signal, localInteractorSource({ device, resolveInteractor })); await operations.tvRemote({ button: 'down', durationMs: 250, @@ -48,49 +39,3 @@ test('a local binding drives the interactor with the button and duration', async // durationMs is the one transposition a point-shaped assertion would not catch. expect(tvRemote).toHaveBeenCalledWith('down', 250); }); - -test('a provider binding drives its own resolved interactor', async () => { - const tvRemote = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(() => ({ tvRemote }) as unknown as Interactor); - const signal = new AbortController().signal; - - const operations = bindTvRemoteProvider({ device, signal, resolveInteractor }); - await operations.tvRemote({ button: 'select', execution: { requestId: 'tv-remote-2' } }); - - expect(resolveInteractor).toHaveBeenCalledWith({ - requestId: 'tv-remote-2', - appBundleId: undefined, - signal, - }); - expect(tvRemote).toHaveBeenCalledWith('select', undefined); -}); - -test('a provider binding fails closed when its exact owner exposes no interactor', async () => { - const operations = bindTvRemoteProvider({ - device, - signal: new AbortController().signal, - resolveInteractor: () => undefined, - }); - - await expect(operations.tvRemote({ button: 'down' })).rejects.toMatchObject({ - code: 'UNSUPPORTED_OPERATION', - details: { reason: 'provider-runtime-interactor-missing', deviceId: device.id }, - }); -}); - -test('an already-cancelled request never resolves an interactor', async () => { - const controller = new AbortController(); - controller.abort(); - const tvRemote = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(async () => ({ tvRemote }) as unknown as Interactor); - - const operations = bindTvRemoteLocal({ - device, - signal: controller.signal, - resolveInteractor, - }); - - await expect(operations.tvRemote({ button: 'down' })).rejects.toThrow(); - expect(resolveInteractor).not.toHaveBeenCalled(); - expect(tvRemote).not.toHaveBeenCalled(); -});