@shopify/ui-extensions-tester 2026.4.0-rc.2 → 2026.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +66 -14
  2. package/build/cjs/_virtual/_rollupPluginBabelHelpers.js +15 -0
  3. package/build/cjs/admin/factories.js +18 -0
  4. package/build/cjs/api-version.js +2 -2
  5. package/build/cjs/checkout/factories.js +0 -5
  6. package/build/cjs/index.js +209 -86
  7. package/build/cjs/point-of-sale/factories.js +6 -1
  8. package/build/esm/_virtual/_rollupPluginBabelHelpers.mjs +10 -0
  9. package/build/esm/admin/factories.mjs +18 -0
  10. package/build/esm/api-version.mjs +2 -2
  11. package/build/esm/checkout/factories.mjs +0 -5
  12. package/build/esm/index.mjs +208 -87
  13. package/build/esm/point-of-sale/factories.mjs +6 -1
  14. package/build/esnext/_virtual/_rollupPluginBabelHelpers.esnext +10 -0
  15. package/build/esnext/admin/factories.esnext +18 -0
  16. package/build/esnext/api-version.esnext +2 -2
  17. package/build/esnext/checkout/factories.esnext +0 -5
  18. package/build/esnext/index.esnext +208 -87
  19. package/build/esnext/point-of-sale/factories.esnext +6 -1
  20. package/build/ts/admin/factories.d.ts.map +1 -1
  21. package/build/ts/checkout/factories.d.ts.map +1 -1
  22. package/build/ts/disposable.d.ts +20 -0
  23. package/build/ts/index.d.ts +62 -14
  24. package/build/ts/index.d.ts.map +1 -1
  25. package/build/ts/point-of-sale/factories.d.ts.map +1 -1
  26. package/build/tsconfig.tsbuildinfo +1 -1
  27. package/package.json +2 -2
  28. package/src/admin/README.md +6 -0
  29. package/src/admin/factories.ts +22 -0
  30. package/src/checkout/factories.ts +0 -1
  31. package/src/disposable.d.ts +20 -0
  32. package/src/index.ts +214 -119
  33. package/src/point-of-sale/factories.ts +8 -1
  34. package/src/tests/admin-factories.test.ts +25 -1
  35. package/src/tests/getExtension.test.ts +93 -1
  36. package/src/tests/setUpExtension.test.ts +52 -0
@@ -1,3 +1,4 @@
1
+ import { classPrivateFieldLooseBase as _classPrivateFieldLooseBase, classPrivateFieldLooseKey as _classPrivateFieldLooseKey } from './_virtual/_rollupPluginBabelHelpers.mjs';
1
2
  import * as fs from 'fs';
2
3
  import * as path from 'path';
3
4
  import { isCheckoutTarget } from './targets.mjs';
@@ -7,6 +8,8 @@ export { createNavigationHistoryEntry } from './navigation.mjs';
7
8
  import { API_VERSION } from './api-version.mjs';
8
9
  import { installFetchPolyfills, uninstallFetchPolyfills } from './fetch-polyfills.mjs';
9
10
 
11
+ var _dispose;
12
+
10
13
  /**
11
14
  * Makes all properties in the API deeply mutable so tests can
12
15
  * override any value through the `extension.shopify` proxy:
@@ -15,6 +18,168 @@ import { installFetchPolyfills, uninstallFetchPolyfills } from './fetch-polyfill
15
18
  * extension.shopify.i18n.translate = (key) => myTranslations[key];
16
19
  */
17
20
 
21
+ /**
22
+ * `Symbol.dispose` for runtimes that support it, with a polyfill
23
+ * fallback so the library works on older Node versions too.
24
+ */
25
+ const SymbolDispose = (_dispose = Symbol.dispose) !== null && _dispose !== void 0 ? _dispose : Symbol.for('Symbol.dispose');
26
+
27
+ /**
28
+ * Members shared by both {@link ExtensionHarness} (returned by
29
+ * `getExtension`) and {@link DisposableExtensionHarness} (returned
30
+ * by `setUpExtension`).
31
+ */
32
+
33
+ /**
34
+ * Returned by `getExtension`. The caller is responsible for calling
35
+ * `setUp()` before each test and `tearDown()` after.
36
+ */
37
+
38
+ /**
39
+ * Returned by `setUpExtension`. Already set up — tears down
40
+ * automatically via `Symbol.dispose` (the `using` keyword):
41
+ *
42
+ * ```ts
43
+ * using extension = setUpExtension('purchase.checkout.block.render');
44
+ * ```
45
+ */
46
+ var _target = /*#__PURE__*/_classPrivateFieldLooseKey("target");
47
+ var _resolvedModule = /*#__PURE__*/_classPrivateFieldLooseKey("resolvedModule");
48
+ var _modulePath = /*#__PURE__*/_classPrivateFieldLooseKey("modulePath");
49
+ var _checkout = /*#__PURE__*/_classPrivateFieldLooseKey("checkout");
50
+ var _networkAccess = /*#__PURE__*/_classPrivateFieldLooseKey("networkAccess");
51
+ var _apiAccess = /*#__PURE__*/_classPrivateFieldLooseKey("apiAccess");
52
+ var _fetchImpl = /*#__PURE__*/_classPrivateFieldLooseKey("fetchImpl");
53
+ var _previousFetch = /*#__PURE__*/_classPrivateFieldLooseKey("previousFetch");
54
+ var _navigationImpl = /*#__PURE__*/_classPrivateFieldLooseKey("navigationImpl");
55
+ var _previousNavigation = /*#__PURE__*/_classPrivateFieldLooseKey("previousNavigation");
56
+ class Extension {
57
+ constructor(target, options) {
58
+ var _options$configSearch;
59
+ Object.defineProperty(this, _target, {
60
+ writable: true,
61
+ value: void 0
62
+ });
63
+ Object.defineProperty(this, _resolvedModule, {
64
+ writable: true,
65
+ value: void 0
66
+ });
67
+ Object.defineProperty(this, _modulePath, {
68
+ writable: true,
69
+ value: void 0
70
+ });
71
+ Object.defineProperty(this, _checkout, {
72
+ writable: true,
73
+ value: void 0
74
+ });
75
+ Object.defineProperty(this, _networkAccess, {
76
+ writable: true,
77
+ value: void 0
78
+ });
79
+ Object.defineProperty(this, _apiAccess, {
80
+ writable: true,
81
+ value: void 0
82
+ });
83
+ Object.defineProperty(this, _fetchImpl, {
84
+ writable: true,
85
+ value: void 0
86
+ });
87
+ Object.defineProperty(this, _previousFetch, {
88
+ writable: true,
89
+ value: void 0
90
+ });
91
+ Object.defineProperty(this, _navigationImpl, {
92
+ writable: true,
93
+ value: createMockNavigation()
94
+ });
95
+ Object.defineProperty(this, _previousNavigation, {
96
+ writable: true,
97
+ value: void 0
98
+ });
99
+ const configSearchDir = (_options$configSearch = options === null || options === void 0 ? void 0 : options.configSearchDir) !== null && _options$configSearch !== void 0 ? _options$configSearch : path.dirname(getCallerFile());
100
+ const tomlPath = findToml(configSearchDir);
101
+ const tomlDir = path.dirname(tomlPath);
102
+ const tomlContent = fs.readFileSync(tomlPath, 'utf-8');
103
+ validateApiVersion(tomlContent);
104
+ const modulePath = parseTargetModule(tomlContent, target);
105
+ _classPrivateFieldLooseBase(this, _target)[_target] = target;
106
+ _classPrivateFieldLooseBase(this, _modulePath)[_modulePath] = modulePath;
107
+ _classPrivateFieldLooseBase(this, _resolvedModule)[_resolvedModule] = path.resolve(tomlDir, modulePath);
108
+ _classPrivateFieldLooseBase(this, _checkout)[_checkout] = isCheckoutTarget(target);
109
+ _classPrivateFieldLooseBase(this, _networkAccess)[_networkAccess] = _classPrivateFieldLooseBase(this, _checkout)[_checkout] && parseNetworkAccess(tomlContent);
110
+ _classPrivateFieldLooseBase(this, _apiAccess)[_apiAccess] = _classPrivateFieldLooseBase(this, _checkout)[_checkout] && parseApiAccess(tomlContent);
111
+ }
112
+ setUp() {
113
+ installFetchPolyfills();
114
+ _classPrivateFieldLooseBase(this, _fetchImpl)[_fetchImpl] = _classPrivateFieldLooseBase(this, _checkout)[_checkout] && !_classPrivateFieldLooseBase(this, _networkAccess)[_networkAccess] && !_classPrivateFieldLooseBase(this, _apiAccess)[_apiAccess] ? async () => {
115
+ // Checkout is the only surface that currently enforces
116
+ // fetch capabilities.
117
+ throw new Error('fetch() is not available. Add network_access = true or ' + 'api_access = true to [extensions.capabilities] in shopify.extension.toml.');
118
+ } : async () => new Response();
119
+ _classPrivateFieldLooseBase(this, _previousFetch)[_previousFetch] = globalThis.fetch;
120
+ _classPrivateFieldLooseBase(this, _previousNavigation)[_previousNavigation] = globalThis.navigation;
121
+ _classPrivateFieldLooseBase(this, _navigationImpl)[_navigationImpl] = createMockNavigation();
122
+ globalThis.shopify = deepWritableProxy(createMockTargetApi(_classPrivateFieldLooseBase(this, _target)[_target]));
123
+ globalThis.fetch = _classPrivateFieldLooseBase(this, _fetchImpl)[_fetchImpl];
124
+ globalThis.navigation = _classPrivateFieldLooseBase(this, _navigationImpl)[_navigationImpl];
125
+ }
126
+ get shopify() {
127
+ if (!globalThis.shopify) {
128
+ throw new Error('You must call extension.setUp() before accessing extension.shopify.');
129
+ }
130
+ return globalThis.shopify;
131
+ }
132
+ get fetch() {
133
+ return _classPrivateFieldLooseBase(this, _fetchImpl)[_fetchImpl];
134
+ }
135
+ set fetch(fn) {
136
+ _classPrivateFieldLooseBase(this, _fetchImpl)[_fetchImpl] = fn;
137
+ globalThis.fetch = fn;
138
+ }
139
+ get navigation() {
140
+ return _classPrivateFieldLooseBase(this, _navigationImpl)[_navigationImpl];
141
+ }
142
+ set navigation(obj) {
143
+ _classPrivateFieldLooseBase(this, _navigationImpl)[_navigationImpl] = obj;
144
+ globalThis.navigation = obj;
145
+ }
146
+ async render() {
147
+ const mod = await import(_classPrivateFieldLooseBase(this, _resolvedModule)[_resolvedModule]);
148
+ const renderFn = mod.default;
149
+ if (typeof renderFn !== 'function') {
150
+ throw new Error(`Expected default export of ${_classPrivateFieldLooseBase(this, _modulePath)[_modulePath]} to be a function, got ${typeof renderFn}`);
151
+ }
152
+ await renderFn();
153
+ }
154
+ tearDown() {
155
+ // Dynamically import preact to unmount cleanly without requiring
156
+ // the test file to depend on preact directly.
157
+ try {
158
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
159
+ const {
160
+ render
161
+ } = require('preact');
162
+ render(null, document.body);
163
+ } catch {
164
+ // Fallback if preact isn't available
165
+ document.body.innerHTML = '';
166
+ }
167
+ delete globalThis.shopify;
168
+ if (_classPrivateFieldLooseBase(this, _previousFetch)[_previousFetch] === undefined) {
169
+ delete globalThis.fetch;
170
+ } else {
171
+ globalThis.fetch = _classPrivateFieldLooseBase(this, _previousFetch)[_previousFetch];
172
+ }
173
+ if (_classPrivateFieldLooseBase(this, _previousNavigation)[_previousNavigation] === undefined) {
174
+ delete globalThis.navigation;
175
+ } else {
176
+ globalThis.navigation = _classPrivateFieldLooseBase(this, _previousNavigation)[_previousNavigation];
177
+ }
178
+ uninstallFetchPolyfills();
179
+ }
180
+ }
181
+ const extensionCache = new Map();
182
+
18
183
  /**
19
184
  * Returns an extension test harness for the given target.
20
185
  *
@@ -31,92 +196,46 @@ import { installFetchPolyfills, uninstallFetchPolyfills } from './fetch-polyfill
31
196
  * `shopify.extension.toml`. Defaults to the calling test file's directory.
32
197
  */
33
198
  function getExtension(target, options) {
34
- var _options$configSearch;
35
- const configSearchDir = (_options$configSearch = options === null || options === void 0 ? void 0 : options.configSearchDir) !== null && _options$configSearch !== void 0 ? _options$configSearch : path.dirname(getCallerFile());
36
- const tomlPath = findToml(configSearchDir);
37
- const tomlDir = path.dirname(tomlPath);
38
- const tomlContent = fs.readFileSync(tomlPath, 'utf-8');
39
- validateApiVersion(tomlContent);
40
- const modulePath = parseTargetModule(tomlContent, target);
41
- const resolvedModule = path.resolve(tomlDir, modulePath);
42
- const checkout = isCheckoutTarget(target);
43
- const networkAccess = checkout && parseNetworkAccess(tomlContent);
44
- const apiAccess = checkout && parseApiAccess(tomlContent);
45
- let fetchImpl;
46
- let previousFetch;
47
- let navigationImpl = createMockNavigation();
48
- let previousNavigation;
49
- const ext = {
50
- setUp() {
51
- installFetchPolyfills();
52
- fetchImpl = checkout && !networkAccess && !apiAccess ? async () => {
53
- // Checkout is the only surface that currently enforces
54
- // fetch capabilities.
55
- throw new Error('fetch() is not available. Add network_access = true or ' + 'api_access = true to [extensions.capabilities] in shopify.extension.toml.');
56
- } : async () => new Response();
57
- previousFetch = globalThis.fetch;
58
- previousNavigation = globalThis.navigation;
59
- globalThis.shopify = deepWritableProxy(createMockTargetApi(target));
60
- globalThis.fetch = fetchImpl;
61
- globalThis.navigation = navigationImpl;
62
- },
63
- get shopify() {
64
- if (!globalThis.shopify) {
65
- throw new Error('You must call extension.setUp() before accessing extension.shopify.');
66
- }
67
- return globalThis.shopify;
68
- },
69
- get fetch() {
70
- return fetchImpl;
71
- },
72
- set fetch(fn) {
73
- fetchImpl = fn;
74
- globalThis.fetch = fn;
75
- },
76
- get navigation() {
77
- return navigationImpl;
78
- },
79
- set navigation(obj) {
80
- navigationImpl = obj;
81
- globalThis.navigation = obj;
82
- },
83
- async render() {
84
- const mod = await import(resolvedModule);
85
- const renderFn = mod.default;
86
- if (typeof renderFn !== 'function') {
87
- throw new Error(`Expected default export of ${modulePath} to be a function, got ${typeof renderFn}`);
88
- }
89
- await renderFn();
90
- },
91
- tearDown() {
92
- // Dynamically import preact to unmount cleanly without requiring
93
- // the test file to depend on preact directly.
94
- try {
95
- // eslint-disable-next-line @typescript-eslint/no-var-requires
96
- const {
97
- render
98
- } = require('preact');
99
- render(null, document.body);
100
- } catch {
101
- // Fallback if preact isn't available
102
- document.body.innerHTML = '';
103
- }
104
- delete globalThis.shopify;
105
- if (previousFetch === undefined) {
106
- delete globalThis.fetch;
107
- } else {
108
- globalThis.fetch = previousFetch;
109
- }
110
- if (previousNavigation === undefined) {
111
- delete globalThis.navigation;
112
- } else {
113
- globalThis.navigation = previousNavigation;
114
- }
115
- uninstallFetchPolyfills();
116
- }
117
- };
199
+ var _options$configSearch2;
200
+ const resolvedConfigSearchDir = (_options$configSearch2 = options === null || options === void 0 ? void 0 : options.configSearchDir) !== null && _options$configSearch2 !== void 0 ? _options$configSearch2 : path.dirname(getCallerFile());
201
+ const tomlPath = findToml(resolvedConfigSearchDir);
202
+ const tomlMtimeMs = fs.statSync(tomlPath).mtimeMs;
203
+ const cacheKey = JSON.stringify([target, tomlPath, tomlMtimeMs]);
204
+ const cached = extensionCache.get(cacheKey);
205
+ if (cached) {
206
+ return cached;
207
+ }
208
+ const ext = new Extension(target, {
209
+ configSearchDir: resolvedConfigSearchDir
210
+ });
211
+ extensionCache.set(cacheKey, ext);
118
212
  return ext;
119
213
  }
214
+
215
+ /**
216
+ * Sets up an extension for testing and returns a disposable object
217
+ * that supports automatic teardown with the `using` keyword:
218
+ *
219
+ * ```ts
220
+ * test('rendering the extension', async () => {
221
+ * using extension = setUpExtension(
222
+ * 'purchase.checkout.block.render',
223
+ * );
224
+ * await extension.render();
225
+ * // tearDown() is called automatically at the end of the block
226
+ * });
227
+ * ```
228
+ *
229
+ * @param target - The extension target to mock.
230
+ * @param options - Optional configuration (same as {@link getExtension}).
231
+ */
232
+ function setUpExtension(target, options) {
233
+ const extension = getExtension(target, options);
234
+ extension.setUp();
235
+ return Object.assign(extension, {
236
+ [SymbolDispose]: () => extension.tearDown()
237
+ });
238
+ }
120
239
  function validateApiVersion(toml) {
121
240
  const match = toml.match(/^\s*api_version\s*=\s*"([^"]+)"/m);
122
241
  const tomlVersion = match === null || match === void 0 ? void 0 : match[1];
@@ -184,8 +303,10 @@ function getCallerFile() {
184
303
  const err = new Error();
185
304
  let callerFile = '';
186
305
  Error.prepareStackTrace = (_err, stack) => {
187
- // stack[0] is getCallerFile, stack[1] is getExtension, stack[2] is the caller
188
- for (let i = 2; i < stack.length; i++) {
306
+ // Walk the stack, skipping all frames that originate from this
307
+ // package file. This works regardless of whether the caller is
308
+ // getExtension() or setUpExtension() → getExtension().
309
+ for (let i = 1; i < stack.length; i++) {
189
310
  const fileName = stack[i].getFileName();
190
311
  if (fileName && fileName !== thisPackageFilePath) {
191
312
  callerFile = fileName;
@@ -316,4 +437,4 @@ function deepWritableProxy(obj) {
316
437
  });
317
438
  }
318
439
 
319
- export { getExtension };
440
+ export { SymbolDispose, getExtension, setUpExtension };
@@ -53,9 +53,14 @@ function createTransaction() {
53
53
  lineItems: []
54
54
  };
55
55
  }
56
+ function createConnectivityApiContent() {
57
+ return {
58
+ current: createReadonlySignalLike(createConnectivityState())
59
+ };
60
+ }
56
61
  function createMockBaseEventData() {
57
62
  return {
58
- connectivity: createConnectivityState(),
63
+ connectivity: createConnectivityApiContent(),
59
64
  device: {
60
65
  name: 'Mock POS Device',
61
66
  deviceId: 1,
@@ -0,0 +1,10 @@
1
+ function _classPrivateFieldLooseBase(e, t) {
2
+ if (!{}.hasOwnProperty.call(e, t)) throw new TypeError("attempted to use private field on non-instance");
3
+ return e;
4
+ }
5
+ var id = 0;
6
+ function _classPrivateFieldLooseKey(e) {
7
+ return "__private_" + id++ + "_" + e;
8
+ }
9
+
10
+ export { _classPrivateFieldLooseBase as classPrivateFieldLooseBase, _classPrivateFieldLooseKey as classPrivateFieldLooseKey };
@@ -34,6 +34,13 @@ function createConfigApp() {
34
34
  applicationUrl: 'https://mock-app.test'
35
35
  };
36
36
  }
37
+ function createIntentResponseApi() {
38
+ return {
39
+ ok: async () => {},
40
+ error: async () => {},
41
+ closed: async () => {}
42
+ };
43
+ }
37
44
  function createMockStandardApi(target) {
38
45
  return {
39
46
  extension: {
@@ -66,6 +73,15 @@ function createMockStandardRenderingApi(target) {
66
73
  })
67
74
  };
68
75
  }
76
+ function createAppIntentRenderMock(target) {
77
+ return {
78
+ ...createMockStandardRenderingApi(target),
79
+ intents: {
80
+ ...createMockStandardApi(target).intents,
81
+ response: createIntentResponseApi()
82
+ }
83
+ };
84
+ }
69
85
  function createMockBlockApi(target) {
70
86
  return {
71
87
  ...createMockStandardRenderingApi(target),
@@ -210,6 +226,8 @@ const adminMockFactories = {
210
226
  // Runnable targets
211
227
  'admin.customers.segmentation-templates.data': createCustomerSegmentTemplateMock,
212
228
  'admin.app.tools.data': createMockStandardApi,
229
+ // App render targets
230
+ 'admin.app.intent.render': createAppIntentRenderMock,
213
231
  // Block targets
214
232
  'admin.product-details.block.render': createMockBlockApi,
215
233
  'admin.order-details.block.render': createMockBlockApi,
@@ -1,14 +1,14 @@
1
1
  /**
2
2
  * The API version supported by this version of the library.
3
3
  *
4
- * At build time, `"2026.4.0-rc.2"` is replaced by rollup with the
4
+ * At build time, `"2026.4.0"` is replaced by rollup with the
5
5
  * raw NPM version string from package.json (e.g. `"2026.4.0-rc.1"`).
6
6
  *
7
7
  * When running from source (e.g. in tests), the placeholder is still
8
8
  * present, so we fall back to reading package.json via require.
9
9
  */
10
10
 
11
- const npmVersion = "2026.4.0-rc.2";
11
+ const npmVersion = "2026.4.0";
12
12
  function npmVersionToApiVersion(version) {
13
13
  const [year, minor] = version.split('.');
14
14
  return `${year}-${minor.padStart(2, '0')}`;
@@ -111,11 +111,6 @@ function createMockStandardApi(target) {
111
111
  }),
112
112
  selectedPaymentOptions: createSubscribableSignalLike([]),
113
113
  settings: createSubscribableSignalLike({}),
114
- ui: {
115
- overlay: {
116
- close: () => {}
117
- }
118
- },
119
114
  version: '0.0.0',
120
115
  customerPrivacy: createSubscribableSignalLike({
121
116
  allowedProcessing: {