From d0f0d694c3522f341120f8712c579627828c4108 Mon Sep 17 00:00:00 2001 From: Victor Chu Date: Thu, 20 Aug 2026 12:22:44 -0700 Subject: [PATCH] feat(ui-extensions-tester): add shopify.intercept mock and POS event data factories Assisted-By: devx/c380b28c-ab53-4c64-a5d7-b1e4b72559e9 --- .changeset/pos-intercept-tester-mock.md | 5 + .../admin-testing-example/package-lock.json | 6 +- packages/ui-extensions-tester/src/index.ts | 80 ++++++++ .../src/point-of-sale/README.md | 49 +++++ .../src/point-of-sale/factories.ts | 28 +-- .../src/point-of-sale/index.ts | 45 +++++ packages/ui-extensions-tester/src/targets.ts | 11 ++ .../src/tests/pos-intercepts.test.ts | 177 ++++++++++++++++++ 8 files changed, 380 insertions(+), 21 deletions(-) create mode 100644 .changeset/pos-intercept-tester-mock.md create mode 100644 packages/ui-extensions-tester/src/tests/pos-intercepts.test.ts diff --git a/.changeset/pos-intercept-tester-mock.md b/.changeset/pos-intercept-tester-mock.md new file mode 100644 index 0000000000..bf3aa0f57a --- /dev/null +++ b/.changeset/pos-intercept-tester-mock.md @@ -0,0 +1,5 @@ +--- +'@shopify/ui-extensions-tester': minor +--- + +Add `shopify.intercept` mocking for POS background extensions: `extension.fireIntercept()` runs the registered interceptor with host-contract semantics (one interceptor per event, synchronous only), and new `createPosCart`, `createCartValidationsEventData`, and `createPaymentValidationsEventData` factories build intercept payloads and `shopify.resolution.event` values. diff --git a/examples/testing/admin-testing-example/package-lock.json b/examples/testing/admin-testing-example/package-lock.json index 87894e1080..7d3c04489b 100644 --- a/examples/testing/admin-testing-example/package-lock.json +++ b/examples/testing/admin-testing-example/package-lock.json @@ -20,7 +20,7 @@ }, "../../../packages/ui-extensions": { "name": "@shopify/ui-extensions", - "version": "2026.10.0-rc.0", + "version": "2026.10.0-rc.6", "license": "MIT", "dependencies": { "ts-morph": "^25.0.1" @@ -49,11 +49,11 @@ }, "../../../packages/ui-extensions-tester": { "name": "@shopify/ui-extensions-tester", - "version": "2026.10.0-rc.0", + "version": "2026.10.0-rc.6", "dev": true, "license": "MIT", "dependencies": { - "@shopify/ui-extensions": "2026.10.0-rc.0" + "@shopify/ui-extensions": "2026.10.0-rc.6" }, "devDependencies": { "typescript": "^4.9.0" diff --git a/packages/ui-extensions-tester/src/index.ts b/packages/ui-extensions-tester/src/index.ts index 5b999d8e4a..ebf6d034d3 100644 --- a/packages/ui-extensions-tester/src/index.ts +++ b/packages/ui-extensions-tester/src/index.ts @@ -5,7 +5,10 @@ import type { AnyExtensionTarget, ApiForTarget, EventMapForTarget, + InterceptMapForTarget, } from './targets'; +import type {InterceptResult} from '@shopify/ui-extensions/point-of-sale'; +import {POS_INTERCEPT_NAMES} from '@shopify/ui-extensions/point-of-sale'; import {isCheckoutTarget} from './targets'; import {createMockTargetApi} from './mocks/target-apis'; import {createMockNavigation, type Navigation} from './navigation'; @@ -120,6 +123,30 @@ interface BaseExtensionHarness { type: K, event: EventMapForTarget[K], ): void; + + /** + * Runs the interceptor registered via `shopify.intercept(name, interceptor)` + * and returns its `InterceptResult`, or `undefined` when no interceptor is + * registered for the event. + * + * Matches the host contract: the event delivered to the interceptor is + * `{type, ...data}`, and an interceptor that returns a Promise throws โ€” + * interceptors must be synchronous. + * + * ```ts + * shopify.intercept('cartvalidations', (event) => { ... }); + * + * const result = extension.fireIntercept( + * 'cartvalidations', + * createCartValidationsEventData(), + * ); + * expect(result?.operations).toEqual([]); + * ``` + */ + fireIntercept>( + type: K, + data: Omit[K], 'type'>, + ): InterceptResult | undefined; } /** @@ -169,6 +196,7 @@ class Extension implements ExtensionHarness { #navigationImpl: Navigation = createMockNavigation(); #previousNavigation: any; #eventListeners = new Map void>>(); + #interceptors = new Map any>(); constructor(target: T, options?: {configSearchDir?: string}) { const configSearchDir = @@ -206,10 +234,12 @@ class Extension implements ExtensionHarness { this.#previousNavigation = (globalThis as any).navigation; this.#navigationImpl = createMockNavigation(); this.#eventListeners.clear(); + this.#interceptors.clear(); (globalThis as any).shopify = deepWritableProxy( Object.assign(createMockTargetApi(this.#target), { addEventListener: this.#addEventListener, removeEventListener: this.#removeEventListener, + intercept: this.#intercept, }), ); (globalThis as any).fetch = this.#fetchImpl; @@ -232,6 +262,55 @@ class Extension implements ExtensionHarness { this.#eventListeners.get(type)?.delete(listener); }; + #intercept = ( + type: string, + interceptor: (event: any) => any, + ): (() => void) => { + if (typeof interceptor !== 'function') { + throw new TypeError('Interceptor must be a function'); + } + const supported: ReadonlyArray = Object.values(POS_INTERCEPT_NAMES); + if (!supported.includes(type)) { + throw new Error( + `'${type}' is not a supported intercept event. Supported events: ${supported.join( + ', ', + )}.`, + ); + } + if (this.#interceptors.has(type)) { + throw new Error( + `An interceptor for '${type}' is already registered; only one interceptor per event type is allowed.`, + ); + } + this.#interceptors.set(type, interceptor); + return () => { + if (this.#interceptors.get(type) === interceptor) { + this.#interceptors.delete(type); + } + }; + }; + + fireIntercept>( + type: K, + data: Omit[K], 'type'>, + ): InterceptResult | undefined { + const interceptor = this.#interceptors.get(type as string); + if (!interceptor) return undefined; + const result = interceptor({type, ...data}); + if ( + result != null && + (typeof result === 'object' || typeof result === 'function') && + typeof (result as {then?: unknown}).then === 'function' + ) { + throw new Error( + `An interceptor for '${String( + type, + )}' must be synchronous but it returned a Promise.`, + ); + } + return result; + } + dispatch>( type: K, event: EventMapForTarget[K], @@ -303,6 +382,7 @@ class Extension implements ExtensionHarness { } delete (globalThis as any).shopify; this.#eventListeners.clear(); + this.#interceptors.clear(); if (this.#previousFetch === undefined) { delete (globalThis as any).fetch; } else { diff --git a/packages/ui-extensions-tester/src/point-of-sale/README.md b/packages/ui-extensions-tester/src/point-of-sale/README.md index a30a20cc41..1ee2dd8aab 100644 --- a/packages/ui-extensions-tester/src/point-of-sale/README.md +++ b/packages/ui-extensions-tester/src/point-of-sale/README.md @@ -75,6 +75,43 @@ extension.shopify.cart.bulkCartUpdate = vi ); ``` +## ๐Ÿšฆ Testing interceptors + +Register an interceptor via `shopify.intercept()` in your extension, then use `extension.fireIntercept()` to run it and assert on the returned operations. Build event payloads with the event data factories: + +```ts +import {createCartValidationsEventData} from '@shopify/ui-extensions-tester/point-of-sale'; + +await extension.render(); + +const result = extension.fireIntercept( + 'cartvalidations', + createCartValidationsEventData(), +); + +expect( + result?.operations[0]?.validationAdd?.level, +).toBe('ERROR'); +``` + +Interceptors follow the host contract: one interceptor per event, synchronous only (returning a Promise throws), and `fireIntercept()` returns `undefined` when nothing is registered. + +## ๐Ÿงพ Mocking resolution event data + +The same factories provide the `shopify.resolution.event` value on resolution targets: + +```ts +import {createPaymentValidationsEventData} from '@shopify/ui-extensions-tester/point-of-sale'; + +extension.shopify.resolution.event.value = + createPaymentValidationsEventData({ + amount: { + amount: '150.00', + currencyCode: 'CAD', + }, + }); +``` + ## ๐Ÿ“‚ Example See the [point of sale example](../../../../examples/testing/point-of-sale-testing-example) for a fully working extension with a test suite. @@ -89,6 +126,18 @@ Creates a mock POS `LineItem` with sensible defaults. Pass a partial override to Creates a mock `Storage` instance. Optionally accepts a `Record` of initial entries. +### `createPosCart(overrides?)` + +Creates a mock POS `Cart` with empty, zero-total defaults. Pass a partial override to customize fields. + +### `createCartValidationsEventData(overrides?)` + +Creates mock `cartvalidations` event data (`{cart}`): the payload for `extension.fireIntercept()` and the `shopify.resolution.event` value on the cart resolution target. + +### `createPaymentValidationsEventData(overrides?)` + +Creates mock `paymentvalidations` event data (`{paymentMethod, amount}`) with cash defaults: the payload for `extension.fireIntercept()` and the `shopify.resolution.event` value on the payment resolution target. + ### `createResult(mutation, result?)` Creates a typed mock result for a POS mutation API. The `mutation` argument is strongly typed to only accept known mutation names. diff --git a/packages/ui-extensions-tester/src/point-of-sale/factories.ts b/packages/ui-extensions-tester/src/point-of-sale/factories.ts index 067055b13e..9216be215d 100644 --- a/packages/ui-extensions-tester/src/point-of-sale/factories.ts +++ b/packages/ui-extensions-tester/src/point-of-sale/factories.ts @@ -20,7 +20,6 @@ import type { StorageApi, ConnectivityApiContent, ConnectivityState, - Cart, Session, StaffMember, TransactionCompleteWithReprintData, @@ -31,7 +30,14 @@ import type { import {createReadonlySignalLike} from '../mocks/signals'; import {createMockI18n} from '../mocks/i18n'; -import {createCartLineItem, createStorage, createResult} from './index'; +import { + createCartLineItem, + createStorage, + createResult, + createPosCart, + createCartValidationsEventData, + createPaymentValidationsEventData, +} from './index'; /** * Extracts the API type for a given POS extension target directly from the @@ -71,17 +77,6 @@ function createStaffMember(): StaffMember { }; } -function createPosCart(): Cart { - return { - subtotal: '0.00', - taxTotal: '0.00', - grandTotal: '0.00', - cartDiscounts: [], - lineItems: [], - properties: {}, - }; -} - function createTransaction(): Transaction { const money: Money = {amount: 0, currency: 'USD'}; return { @@ -471,7 +466,7 @@ function createCartValidationsResolutionMock( ...createMockScannerApi(), ...createMockCartApi(), resolution: { - event: createReadonlySignalLike({cart: createPosCart()}), + event: createReadonlySignalLike(createCartValidationsEventData()), }, }; } @@ -490,10 +485,7 @@ function createPaymentValidationsResolutionMock< ...createMockScannerApi(), cart: {current: createReadonlySignalLike(createPosCart())}, resolution: { - event: createReadonlySignalLike({ - paymentMethod: {type: 'cash' as const}, - amount: {amount: '10.00', currencyCode: 'USD'}, - }), + event: createReadonlySignalLike(createPaymentValidationsEventData()), }, }; } diff --git a/packages/ui-extensions-tester/src/point-of-sale/index.ts b/packages/ui-extensions-tester/src/point-of-sale/index.ts index a1acbee263..4249365457 100644 --- a/packages/ui-extensions-tester/src/point-of-sale/index.ts +++ b/packages/ui-extensions-tester/src/point-of-sale/index.ts @@ -1,5 +1,8 @@ import type { + Cart, + CartValidationsEventData, LineItem, + PaymentValidationsEventData, Storage, SubscribableStorage, CartApiContent, @@ -28,6 +31,48 @@ export function createCartLineItem(overrides?: Partial): LineItem { }; } +/** + * Creates a mock POS `Cart` with empty, zero-total defaults. + * Pass a partial override to customize fields. + */ +export function createPosCart(overrides?: Partial): Cart { + return { + subtotal: '0.00', + taxTotal: '0.00', + grandTotal: '0.00', + cartDiscounts: [], + lineItems: [], + properties: {}, + ...overrides, + }; +} + +/** + * Creates mock `cartvalidations` event data: the payload an interceptor + * receives from `extension.fireIntercept()` and the value exposed by + * `shopify.resolution.event` on the cart resolution target. + */ +export function createCartValidationsEventData( + overrides?: Partial, +): CartValidationsEventData { + return {cart: createPosCart(), ...overrides}; +} + +/** + * Creates mock `paymentvalidations` event data: the payload an interceptor + * receives from `extension.fireIntercept()` and the value exposed by + * `shopify.resolution.event` on the payment resolution target. + */ +export function createPaymentValidationsEventData( + overrides?: Partial, +): PaymentValidationsEventData { + return { + paymentMethod: {type: 'cash'}, + amount: {amount: '10.00', currencyCode: 'USD'}, + ...overrides, + }; +} + /** * Creates a mock `Storage` instance. * diff --git a/packages/ui-extensions-tester/src/targets.ts b/packages/ui-extensions-tester/src/targets.ts index 5ec9986ca9..263d4b20f5 100644 --- a/packages/ui-extensions-tester/src/targets.ts +++ b/packages/ui-extensions-tester/src/targets.ts @@ -10,6 +10,7 @@ import type { ExtensionTarget as PosExtensionTarget, ExtensionTargets as PointOfSaleExtensionTargets, ShopifyEventMap as PosEventMap, + ShopifyInterceptMap as PosInterceptMap, } from '@shopify/ui-extensions/point-of-sale'; import type { CustomerAccountExtensionTarget, @@ -53,6 +54,16 @@ export type ApiForTarget = export type EventMapForTarget = T extends PosExtensionTarget ? PosEventMap : Record; +/** + * Maps an extension target to the intercept map available via + * `shopify.intercept` on that surface. + * + * - POS targets: the POS `ShopifyInterceptMap`. + * - Other surfaces: no interceptable workflows, so the map is empty. + */ +export type InterceptMapForTarget = + T extends PosExtensionTarget ? PosInterceptMap : Record; + export function isCheckoutTarget( target: string, ): target is CheckoutExtensionTarget { diff --git a/packages/ui-extensions-tester/src/tests/pos-intercepts.test.ts b/packages/ui-extensions-tester/src/tests/pos-intercepts.test.ts new file mode 100644 index 0000000000..0ffda0af7d --- /dev/null +++ b/packages/ui-extensions-tester/src/tests/pos-intercepts.test.ts @@ -0,0 +1,177 @@ +import {getExtension} from '../index'; +import { + createCartValidationsEventData, + createPaymentValidationsEventData, +} from '../point-of-sale'; + +import {createTestSandbox, type TestSandbox} from './helpers'; + +describe('shopify.intercept / extension.fireIntercept', () => { + let sandbox: TestSandbox; + + beforeEach(() => { + sandbox = createTestSandbox(); + sandbox.placeToml({target: 'pos.app.ready.data'}); + }); + + afterEach(() => { + sandbox.destroy(); + }); + + function setUpExt() { + const extension = getExtension('pos.app.ready.data', { + configSearchDir: sandbox.tempDir, + }); + extension.setUp(); + return extension; + } + + it('exposes intercept on the shopify global', () => { + const extension = setUpExt(); + const shopify = extension.shopify as any; + expect(typeof shopify.intercept).toBe('function'); + }); + + it('invokes the registered interceptor with the event name and data', () => { + const extension = setUpExt(); + const shopify = extension.shopify as any; + const interceptor = jest.fn().mockReturnValue({operations: []}); + shopify.intercept('cartvalidations', interceptor); + + const data = createCartValidationsEventData(); + const result = extension.fireIntercept('cartvalidations', data); + + expect(interceptor).toHaveBeenCalledWith({ + type: 'cartvalidations', + cart: data.cart, + }); + expect(result).toStrictEqual({operations: []}); + }); + + it('returns the interceptor result, including blocking operations', () => { + const extension = setUpExt(); + const shopify = extension.shopify as any; + shopify.intercept('paymentvalidations', () => ({ + operations: [ + { + validationAdd: { + level: 'ERROR', + handle: 'id-verification', + target: '$.payment', + }, + }, + ], + })); + + const result = extension.fireIntercept( + 'paymentvalidations', + createPaymentValidationsEventData({ + amount: {amount: '150.00', currencyCode: 'CAD'}, + }), + ); + + expect(result?.operations[0]?.validationAdd?.handle).toBe( + 'id-verification', + ); + }); + + it('returns undefined when no interceptor is registered', () => { + const extension = setUpExt(); + + const result = extension.fireIntercept( + 'cartvalidations', + createCartValidationsEventData(), + ); + + expect(result).toBeUndefined(); + }); + + it('rejects a non-function interceptor', () => { + const extension = setUpExt(); + const shopify = extension.shopify as any; + + expect(() => shopify.intercept('cartvalidations', 'nope')).toThrow( + TypeError, + ); + }); + + it('rejects an unsupported event name', () => { + const extension = setUpExt(); + const shopify = extension.shopify as any; + + expect(() => shopify.intercept('checkout', jest.fn())).toThrow( + /not a supported intercept event/, + ); + }); + + it('allows only one interceptor per event', () => { + const extension = setUpExt(); + const shopify = extension.shopify as any; + shopify.intercept('cartvalidations', jest.fn()); + + expect(() => shopify.intercept('cartvalidations', jest.fn())).toThrow( + /already registered/, + ); + }); + + it('unregisters via the returned function, allowing re-registration', () => { + const extension = setUpExt(); + const shopify = extension.shopify as any; + const first = jest.fn().mockReturnValue({operations: []}); + const unregister = shopify.intercept('cartvalidations', first); + + unregister(); + const second = jest.fn().mockReturnValue({operations: []}); + shopify.intercept('cartvalidations', second); + extension.fireIntercept( + 'cartvalidations', + createCartValidationsEventData(), + ); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalled(); + }); + + it('throws when the interceptor returns a Promise', () => { + const extension = setUpExt(); + const shopify = extension.shopify as any; + shopify.intercept('cartvalidations', async () => ({operations: []})); + + expect(() => + extension.fireIntercept( + 'cartvalidations', + createCartValidationsEventData(), + ), + ).toThrow(/must be synchronous/); + }); + + it('clears interceptors between setUp calls', () => { + const extension = setUpExt(); + const shopify = extension.shopify as any; + shopify.intercept('cartvalidations', jest.fn()); + + extension.tearDown(); + extension.setUp(); + + const result = extension.fireIntercept( + 'cartvalidations', + createCartValidationsEventData(), + ); + expect(result).toBeUndefined(); + }); +}); + +describe('event data factories', () => { + it('creates cartvalidations data with an empty cart by default', () => { + const data = createCartValidationsEventData(); + expect(data.cart.lineItems).toStrictEqual([]); + }); + + it('creates paymentvalidations data with cash defaults and accepts overrides', () => { + const data = createPaymentValidationsEventData({ + amount: {amount: '99.00', currencyCode: 'USD'}, + }); + expect(data.paymentMethod.type).toBe('cash'); + expect(data.amount.amount).toBe('99.00'); + }); +});