Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pos-intercept-tester-mock.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions examples/testing/admin-testing-example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

80 changes: 80 additions & 0 deletions packages/ui-extensions-tester/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -120,6 +123,30 @@ interface BaseExtensionHarness<T extends AnyExtensionTarget> {
type: K,
event: EventMapForTarget<T>[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<K extends keyof InterceptMapForTarget<T>>(
type: K,
data: Omit<InterceptMapForTarget<T>[K], 'type'>,
): InterceptResult | undefined;
}

/**
Expand Down Expand Up @@ -169,6 +196,7 @@ class Extension<T extends AnyExtensionTarget> implements ExtensionHarness<T> {
#navigationImpl: Navigation = createMockNavigation();
#previousNavigation: any;
#eventListeners = new Map<string, Set<(event: any) => void>>();
#interceptors = new Map<string, (event: any) => any>();

constructor(target: T, options?: {configSearchDir?: string}) {
const configSearchDir =
Expand Down Expand Up @@ -206,10 +234,12 @@ class Extension<T extends AnyExtensionTarget> implements ExtensionHarness<T> {
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;
Expand All @@ -232,6 +262,55 @@ class Extension<T extends AnyExtensionTarget> implements ExtensionHarness<T> {
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<string> = 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<K extends keyof InterceptMapForTarget<T>>(
type: K,
data: Omit<InterceptMapForTarget<T>[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<K extends keyof EventMapForTarget<T>>(
type: K,
event: EventMapForTarget<T>[K],
Expand Down Expand Up @@ -303,6 +382,7 @@ class Extension<T extends AnyExtensionTarget> implements ExtensionHarness<T> {
}
delete (globalThis as any).shopify;
this.#eventListeners.clear();
this.#interceptors.clear();
if (this.#previousFetch === undefined) {
delete (globalThis as any).fetch;
} else {
Expand Down
49 changes: 49 additions & 0 deletions packages/ui-extensions-tester/src/point-of-sale/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<string, unknown>` 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.
Expand Down
28 changes: 10 additions & 18 deletions packages/ui-extensions-tester/src/point-of-sale/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import type {
StorageApi,
ConnectivityApiContent,
ConnectivityState,
Cart,
Session,
StaffMember,
TransactionCompleteWithReprintData,
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -471,7 +466,7 @@ function createCartValidationsResolutionMock<T extends RenderExtensionTarget>(
...createMockScannerApi(),
...createMockCartApi(),
resolution: {
event: createReadonlySignalLike({cart: createPosCart()}),
event: createReadonlySignalLike(createCartValidationsEventData()),
},
};
}
Expand All @@ -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()),
},
};
}
Expand Down
45 changes: 45 additions & 0 deletions packages/ui-extensions-tester/src/point-of-sale/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type {
Cart,
CartValidationsEventData,
LineItem,
PaymentValidationsEventData,
Storage,
SubscribableStorage,
CartApiContent,
Expand Down Expand Up @@ -28,6 +31,48 @@ export function createCartLineItem(overrides?: Partial<LineItem>): LineItem {
};
}

/**
* Creates a mock POS `Cart` with empty, zero-total defaults.
* Pass a partial override to customize fields.
*/
export function createPosCart(overrides?: Partial<Cart>): 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>,
): 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>,
): PaymentValidationsEventData {
return {
paymentMethod: {type: 'cash'},
amount: {amount: '10.00', currencyCode: 'USD'},
...overrides,
};
}

/**
* Creates a mock `Storage` instance.
*
Expand Down
11 changes: 11 additions & 0 deletions packages/ui-extensions-tester/src/targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,6 +54,16 @@ export type ApiForTarget<T extends AnyExtensionTarget> =
export type EventMapForTarget<T extends AnyExtensionTarget> =
T extends PosExtensionTarget ? PosEventMap : Record<string, never>;

/**
* 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 AnyExtensionTarget> =
T extends PosExtensionTarget ? PosInterceptMap : Record<string, never>;

export function isCheckoutTarget(
target: string,
): target is CheckoutExtensionTarget {
Expand Down
Loading
Loading