@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
package/README.md CHANGED
@@ -14,6 +14,7 @@ This testing library provides strongly typed mocks of the extension API--like th
14
14
  ## 📋 Recommendations
15
15
 
16
16
  - **TypeScript** — we recommend TypeScript to enforce API compliance against mock objects
17
+ - **Node.js ≥ 22.0.0** and **TypeScript ≥ 5.2** — to use ([Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management))
17
18
  - **@testing-library/preact** — if your extension uses [Preact](https://preactjs.com/), we recommend installing [`@testing-library/preact`](https://preactjs.com/guide/v10/preact-testing-library/) for its `fireEvent` and `waitFor` helpers
18
19
 
19
20
  ## 📦 Installation
@@ -72,7 +73,7 @@ Your extension's own `package.json` (inside `extensions/my-extension/`) already
72
73
  "devDependencies": {
73
74
  "@shopify/ui-extensions-tester": "latest",
74
75
  "@testing-library/preact": "^3.2.0",
75
- "typescript": "^5.0.0",
76
+ "typescript": "^5.2.0",
76
77
  "vitest": "^3.0.0"
77
78
  }
78
79
  }
@@ -144,7 +145,34 @@ The path must match the target you pass to `getExtension()`.
144
145
 
145
146
  ## 🏊‍♀️ Getting started
146
147
 
147
- Every test file follows the same pattern: create an extension harness, call `extension.setUp()` before each test, call `extension.tearDown()` after.
148
+ Every test file follows the same pattern:
149
+ create an extension harness, set it up before
150
+ each test, and tear it down after.
151
+
152
+ ### Quick start with `using` (Node ≥ 22.0.0)
153
+
154
+ If your runtime supports
155
+ [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management),
156
+ use `setUpExtension` for zero-boilerplate
157
+ setup and automatic teardown:
158
+
159
+ ```ts
160
+ import {setUpExtension} from '@shopify/ui-extensions-tester';
161
+
162
+ test('rendering the extension', async () => {
163
+ using extension = setUpExtension(
164
+ 'purchase.checkout.block.render',
165
+ );
166
+ await extension.render();
167
+ // tearDown() is called automatically at the end of the block
168
+ });
169
+ ```
170
+
171
+ ### Classic setup
172
+
173
+ Alternatively, create the harness once and
174
+ manage the lifecycle with `beforeEach` /
175
+ `afterEach`:
148
176
 
149
177
  ```ts
150
178
  import {getExtension} from '@shopify/ui-extensions-tester';
@@ -339,25 +367,31 @@ Each surface exports some helpers:
339
367
 
340
368
  Exports from `@shopify/ui-extensions-tester`:
341
369
 
342
- ### `getExtension(target, options?)`
370
+ ### `setUpExtension(target, options?)`
371
+
372
+ Sets up an extension for testing and returns a
373
+ disposable object that supports automatic
374
+ teardown with the `using` keyword:
375
+
376
+ ```ts
377
+ test('example', async () => {
378
+ using extension = setUpExtension(
379
+ 'purchase.checkout.block.render',
380
+ );
381
+ await extension.render();
382
+ // tearDown() called automatically
383
+ });
384
+ ```
343
385
 
344
- Returns an extension test harness for the given target. It reads `shopify.extension.toml`, finds the module for the given target, and provides helpers to mock the environment and render the extension. It locates `shopify.extension.toml` by walking up from the calling test file's directory, and falls back to searching `extensions/` under the current working directory.
386
+ It reads `shopify.extension.toml`, finds the module for the given target, and provides helpers to mock the environment and render the extension. It locates `shopify.extension.toml` by walking up from the calling test file's directory, and falls back to searching `extensions/` under the current working directory. Results are cached: calling `getExtension` twice with the same target and the same resolved TOML returns the same instance.
345
387
 
346
388
  | Option | Type | Default | Description |
347
389
  | ----------------- | -------- | ----------------------------- | ---------------------------------------------------------- |
348
390
  | `configSearchDir` | `string` | calling test file's directory | Directory to start searching for `shopify.extension.toml`. |
349
391
 
350
- By default `getExtension` walks up from the test file's directory to find `shopify.extension.toml`.
392
+ By default, it walks up from the test file's directory to find `shopify.extension.toml`.
351
393
 
352
- **Returns** an `Extension<T>` object with the following members:
353
-
354
- #### `extension.setUp()`
355
-
356
- Sets up an extension environment for testing. Creates a mock `shopify` global with some defaults.
357
-
358
- #### `extension.tearDown()`
359
-
360
- Tears down the extension environment. Resets the `shopify` global and clears `document.body`.
394
+ **Returns** an `Extension` object with the following members:
361
395
 
362
396
  #### `extension.render()`
363
397
 
@@ -367,6 +401,8 @@ Imports and executes the extension module's default export, rendering the extens
367
401
 
368
402
  A mock `shopify` global, typed correctly for the target under test. You can mutate any property.
369
403
 
404
+ When testing `admin.app.intent.render`, the mock `shopify.intents` object also includes `response.ok()`, `response.error()`, and `response.closed()`.
405
+
370
406
  #### `extension.fetch`
371
407
 
372
408
  A mock `fetch()` function installed as `globalThis.fetch` during `setUp()` and removed during `tearDown()`.
@@ -402,6 +438,22 @@ extension.navigation.currentEntry =
402
438
 
403
439
  Assigning to `extension.navigation` also updates `globalThis.navigation`, so extension code that calls `navigation.navigate()` directly will use the mock.
404
440
 
441
+ ### `getExtension(target, options?)`
442
+
443
+ > ⚠️ Prefer [`setUpExtension`](#setupextensiontarget-options) on Node ≥ 22.0.0. Use `getExtension` only if your runtime does not support [Explicit Resource Management](https://github.com/tc39/proposal-explicit-resource-management).
444
+
445
+ Accepts the same arguments as `setUpExtension`. You must call `extension.setUp()` and `extension.tearDown()` explicitly.
446
+
447
+ **Returns** an `Extension` object with the following additional members:
448
+
449
+ #### `extension.setUp()`
450
+
451
+ Sets up an extension environment for testing. Creates a mock `shopify` global with some defaults.
452
+
453
+ #### `extension.tearDown()`
454
+
455
+ Tears down the extension environment. Resets the `shopify` global and clears `document.body`.
456
+
405
457
  ### `createNavigationHistoryEntry(options?)`
406
458
 
407
459
  Creates a [`NavigationHistoryEntry`](https://developer.mozilla.org/en-US/docs/Web/API/NavigationHistoryEntry) for mocking `navigation.currentEntry` or other navigation values.
@@ -0,0 +1,15 @@
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ function _classPrivateFieldLooseBase(e, t) {
6
+ if (!{}.hasOwnProperty.call(e, t)) throw new TypeError("attempted to use private field on non-instance");
7
+ return e;
8
+ }
9
+ var id = 0;
10
+ function _classPrivateFieldLooseKey(e) {
11
+ return "__private_" + id++ + "_" + e;
12
+ }
13
+
14
+ exports.classPrivateFieldLooseBase = _classPrivateFieldLooseBase;
15
+ exports.classPrivateFieldLooseKey = _classPrivateFieldLooseKey;
@@ -38,6 +38,13 @@ function createConfigApp() {
38
38
  applicationUrl: 'https://mock-app.test'
39
39
  };
40
40
  }
41
+ function createIntentResponseApi() {
42
+ return {
43
+ ok: async () => {},
44
+ error: async () => {},
45
+ closed: async () => {}
46
+ };
47
+ }
41
48
  function createMockStandardApi(target) {
42
49
  return {
43
50
  extension: {
@@ -70,6 +77,15 @@ function createMockStandardRenderingApi(target) {
70
77
  })
71
78
  };
72
79
  }
80
+ function createAppIntentRenderMock(target) {
81
+ return {
82
+ ...createMockStandardRenderingApi(target),
83
+ intents: {
84
+ ...createMockStandardApi(target).intents,
85
+ response: createIntentResponseApi()
86
+ }
87
+ };
88
+ }
73
89
  function createMockBlockApi(target) {
74
90
  return {
75
91
  ...createMockStandardRenderingApi(target),
@@ -214,6 +230,8 @@ const adminMockFactories = {
214
230
  // Runnable targets
215
231
  'admin.customers.segmentation-templates.data': createCustomerSegmentTemplateMock,
216
232
  'admin.app.tools.data': createMockStandardApi,
233
+ // App render targets
234
+ 'admin.app.intent.render': createAppIntentRenderMock,
217
235
  // Block targets
218
236
  'admin.product-details.block.render': createMockBlockApi,
219
237
  'admin.order-details.block.render': createMockBlockApi,
@@ -5,14 +5,14 @@ Object.defineProperty(exports, '__esModule', { value: true });
5
5
  /**
6
6
  * The API version supported by this version of the library.
7
7
  *
8
- * At build time, `"2026.4.0-rc.2"` is replaced by rollup with the
8
+ * At build time, `"2026.4.0"` is replaced by rollup with the
9
9
  * raw NPM version string from package.json (e.g. `"2026.4.0-rc.1"`).
10
10
  *
11
11
  * When running from source (e.g. in tests), the placeholder is still
12
12
  * present, so we fall back to reading package.json via require.
13
13
  */
14
14
 
15
- const npmVersion = "2026.4.0-rc.2";
15
+ const npmVersion = "2026.4.0";
16
16
  function npmVersionToApiVersion(version) {
17
17
  const [year, minor] = version.split('.');
18
18
  return `${year}-${minor.padStart(2, '0')}`;
@@ -115,11 +115,6 @@ function createMockStandardApi(target) {
115
115
  }),
116
116
  selectedPaymentOptions: signals.createSubscribableSignalLike([]),
117
117
  settings: signals.createSubscribableSignalLike({}),
118
- ui: {
119
- overlay: {
120
- close: () => {}
121
- }
122
- },
123
118
  version: '0.0.0',
124
119
  customerPrivacy: signals.createSubscribableSignalLike({
125
120
  allowedProcessing: {
@@ -2,6 +2,7 @@
2
2
 
3
3
  Object.defineProperty(exports, '__esModule', { value: true });
4
4
 
5
+ var _rollupPluginBabelHelpers = require('./_virtual/_rollupPluginBabelHelpers.js');
5
6
  var fs = require('fs');
6
7
  var path = require('path');
7
8
  var targets = require('./targets.js');
@@ -31,6 +32,8 @@ function _interopNamespace(e) {
31
32
  var fs__namespace = /*#__PURE__*/_interopNamespace(fs);
32
33
  var path__namespace = /*#__PURE__*/_interopNamespace(path);
33
34
 
35
+ var _dispose;
36
+
34
37
  /**
35
38
  * Makes all properties in the API deeply mutable so tests can
36
39
  * override any value through the `extension.shopify` proxy:
@@ -39,6 +42,168 @@ var path__namespace = /*#__PURE__*/_interopNamespace(path);
39
42
  * extension.shopify.i18n.translate = (key) => myTranslations[key];
40
43
  */
41
44
 
45
+ /**
46
+ * `Symbol.dispose` for runtimes that support it, with a polyfill
47
+ * fallback so the library works on older Node versions too.
48
+ */
49
+ const SymbolDispose = (_dispose = Symbol.dispose) !== null && _dispose !== void 0 ? _dispose : Symbol.for('Symbol.dispose');
50
+
51
+ /**
52
+ * Members shared by both {@link ExtensionHarness} (returned by
53
+ * `getExtension`) and {@link DisposableExtensionHarness} (returned
54
+ * by `setUpExtension`).
55
+ */
56
+
57
+ /**
58
+ * Returned by `getExtension`. The caller is responsible for calling
59
+ * `setUp()` before each test and `tearDown()` after.
60
+ */
61
+
62
+ /**
63
+ * Returned by `setUpExtension`. Already set up — tears down
64
+ * automatically via `Symbol.dispose` (the `using` keyword):
65
+ *
66
+ * ```ts
67
+ * using extension = setUpExtension('purchase.checkout.block.render');
68
+ * ```
69
+ */
70
+ var _target = /*#__PURE__*/_rollupPluginBabelHelpers.classPrivateFieldLooseKey("target");
71
+ var _resolvedModule = /*#__PURE__*/_rollupPluginBabelHelpers.classPrivateFieldLooseKey("resolvedModule");
72
+ var _modulePath = /*#__PURE__*/_rollupPluginBabelHelpers.classPrivateFieldLooseKey("modulePath");
73
+ var _checkout = /*#__PURE__*/_rollupPluginBabelHelpers.classPrivateFieldLooseKey("checkout");
74
+ var _networkAccess = /*#__PURE__*/_rollupPluginBabelHelpers.classPrivateFieldLooseKey("networkAccess");
75
+ var _apiAccess = /*#__PURE__*/_rollupPluginBabelHelpers.classPrivateFieldLooseKey("apiAccess");
76
+ var _fetchImpl = /*#__PURE__*/_rollupPluginBabelHelpers.classPrivateFieldLooseKey("fetchImpl");
77
+ var _previousFetch = /*#__PURE__*/_rollupPluginBabelHelpers.classPrivateFieldLooseKey("previousFetch");
78
+ var _navigationImpl = /*#__PURE__*/_rollupPluginBabelHelpers.classPrivateFieldLooseKey("navigationImpl");
79
+ var _previousNavigation = /*#__PURE__*/_rollupPluginBabelHelpers.classPrivateFieldLooseKey("previousNavigation");
80
+ class Extension {
81
+ constructor(target, options) {
82
+ var _options$configSearch;
83
+ Object.defineProperty(this, _target, {
84
+ writable: true,
85
+ value: void 0
86
+ });
87
+ Object.defineProperty(this, _resolvedModule, {
88
+ writable: true,
89
+ value: void 0
90
+ });
91
+ Object.defineProperty(this, _modulePath, {
92
+ writable: true,
93
+ value: void 0
94
+ });
95
+ Object.defineProperty(this, _checkout, {
96
+ writable: true,
97
+ value: void 0
98
+ });
99
+ Object.defineProperty(this, _networkAccess, {
100
+ writable: true,
101
+ value: void 0
102
+ });
103
+ Object.defineProperty(this, _apiAccess, {
104
+ writable: true,
105
+ value: void 0
106
+ });
107
+ Object.defineProperty(this, _fetchImpl, {
108
+ writable: true,
109
+ value: void 0
110
+ });
111
+ Object.defineProperty(this, _previousFetch, {
112
+ writable: true,
113
+ value: void 0
114
+ });
115
+ Object.defineProperty(this, _navigationImpl, {
116
+ writable: true,
117
+ value: navigation.createMockNavigation()
118
+ });
119
+ Object.defineProperty(this, _previousNavigation, {
120
+ writable: true,
121
+ value: void 0
122
+ });
123
+ const configSearchDir = (_options$configSearch = options === null || options === void 0 ? void 0 : options.configSearchDir) !== null && _options$configSearch !== void 0 ? _options$configSearch : path__namespace.dirname(getCallerFile());
124
+ const tomlPath = findToml(configSearchDir);
125
+ const tomlDir = path__namespace.dirname(tomlPath);
126
+ const tomlContent = fs__namespace.readFileSync(tomlPath, 'utf-8');
127
+ validateApiVersion(tomlContent);
128
+ const modulePath = parseTargetModule(tomlContent, target);
129
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _target)[_target] = target;
130
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _modulePath)[_modulePath] = modulePath;
131
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _resolvedModule)[_resolvedModule] = path__namespace.resolve(tomlDir, modulePath);
132
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _checkout)[_checkout] = targets.isCheckoutTarget(target);
133
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _networkAccess)[_networkAccess] = _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _checkout)[_checkout] && parseNetworkAccess(tomlContent);
134
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _apiAccess)[_apiAccess] = _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _checkout)[_checkout] && parseApiAccess(tomlContent);
135
+ }
136
+ setUp() {
137
+ fetchPolyfills.installFetchPolyfills();
138
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _fetchImpl)[_fetchImpl] = _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _checkout)[_checkout] && !_rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _networkAccess)[_networkAccess] && !_rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _apiAccess)[_apiAccess] ? async () => {
139
+ // Checkout is the only surface that currently enforces
140
+ // fetch capabilities.
141
+ throw new Error('fetch() is not available. Add network_access = true or ' + 'api_access = true to [extensions.capabilities] in shopify.extension.toml.');
142
+ } : async () => new Response();
143
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _previousFetch)[_previousFetch] = globalThis.fetch;
144
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _previousNavigation)[_previousNavigation] = globalThis.navigation;
145
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _navigationImpl)[_navigationImpl] = navigation.createMockNavigation();
146
+ globalThis.shopify = deepWritableProxy(targetApis.createMockTargetApi(_rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _target)[_target]));
147
+ globalThis.fetch = _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _fetchImpl)[_fetchImpl];
148
+ globalThis.navigation = _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _navigationImpl)[_navigationImpl];
149
+ }
150
+ get shopify() {
151
+ if (!globalThis.shopify) {
152
+ throw new Error('You must call extension.setUp() before accessing extension.shopify.');
153
+ }
154
+ return globalThis.shopify;
155
+ }
156
+ get fetch() {
157
+ return _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _fetchImpl)[_fetchImpl];
158
+ }
159
+ set fetch(fn) {
160
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _fetchImpl)[_fetchImpl] = fn;
161
+ globalThis.fetch = fn;
162
+ }
163
+ get navigation() {
164
+ return _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _navigationImpl)[_navigationImpl];
165
+ }
166
+ set navigation(obj) {
167
+ _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _navigationImpl)[_navigationImpl] = obj;
168
+ globalThis.navigation = obj;
169
+ }
170
+ async render() {
171
+ const mod = await (function (t) { return Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require(t)); }); })(_rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _resolvedModule)[_resolvedModule]);
172
+ const renderFn = mod.default;
173
+ if (typeof renderFn !== 'function') {
174
+ throw new Error(`Expected default export of ${_rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _modulePath)[_modulePath]} to be a function, got ${typeof renderFn}`);
175
+ }
176
+ await renderFn();
177
+ }
178
+ tearDown() {
179
+ // Dynamically import preact to unmount cleanly without requiring
180
+ // the test file to depend on preact directly.
181
+ try {
182
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
183
+ const {
184
+ render
185
+ } = require('preact');
186
+ render(null, document.body);
187
+ } catch {
188
+ // Fallback if preact isn't available
189
+ document.body.innerHTML = '';
190
+ }
191
+ delete globalThis.shopify;
192
+ if (_rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _previousFetch)[_previousFetch] === undefined) {
193
+ delete globalThis.fetch;
194
+ } else {
195
+ globalThis.fetch = _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _previousFetch)[_previousFetch];
196
+ }
197
+ if (_rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _previousNavigation)[_previousNavigation] === undefined) {
198
+ delete globalThis.navigation;
199
+ } else {
200
+ globalThis.navigation = _rollupPluginBabelHelpers.classPrivateFieldLooseBase(this, _previousNavigation)[_previousNavigation];
201
+ }
202
+ fetchPolyfills.uninstallFetchPolyfills();
203
+ }
204
+ }
205
+ const extensionCache = new Map();
206
+
42
207
  /**
43
208
  * Returns an extension test harness for the given target.
44
209
  *
@@ -55,92 +220,46 @@ var path__namespace = /*#__PURE__*/_interopNamespace(path);
55
220
  * `shopify.extension.toml`. Defaults to the calling test file's directory.
56
221
  */
57
222
  function getExtension(target, options) {
58
- var _options$configSearch;
59
- const configSearchDir = (_options$configSearch = options === null || options === void 0 ? void 0 : options.configSearchDir) !== null && _options$configSearch !== void 0 ? _options$configSearch : path__namespace.dirname(getCallerFile());
60
- const tomlPath = findToml(configSearchDir);
61
- const tomlDir = path__namespace.dirname(tomlPath);
62
- const tomlContent = fs__namespace.readFileSync(tomlPath, 'utf-8');
63
- validateApiVersion(tomlContent);
64
- const modulePath = parseTargetModule(tomlContent, target);
65
- const resolvedModule = path__namespace.resolve(tomlDir, modulePath);
66
- const checkout = targets.isCheckoutTarget(target);
67
- const networkAccess = checkout && parseNetworkAccess(tomlContent);
68
- const apiAccess = checkout && parseApiAccess(tomlContent);
69
- let fetchImpl;
70
- let previousFetch;
71
- let navigationImpl = navigation.createMockNavigation();
72
- let previousNavigation;
73
- const ext = {
74
- setUp() {
75
- fetchPolyfills.installFetchPolyfills();
76
- fetchImpl = checkout && !networkAccess && !apiAccess ? async () => {
77
- // Checkout is the only surface that currently enforces
78
- // fetch capabilities.
79
- throw new Error('fetch() is not available. Add network_access = true or ' + 'api_access = true to [extensions.capabilities] in shopify.extension.toml.');
80
- } : async () => new Response();
81
- previousFetch = globalThis.fetch;
82
- previousNavigation = globalThis.navigation;
83
- globalThis.shopify = deepWritableProxy(targetApis.createMockTargetApi(target));
84
- globalThis.fetch = fetchImpl;
85
- globalThis.navigation = navigationImpl;
86
- },
87
- get shopify() {
88
- if (!globalThis.shopify) {
89
- throw new Error('You must call extension.setUp() before accessing extension.shopify.');
90
- }
91
- return globalThis.shopify;
92
- },
93
- get fetch() {
94
- return fetchImpl;
95
- },
96
- set fetch(fn) {
97
- fetchImpl = fn;
98
- globalThis.fetch = fn;
99
- },
100
- get navigation() {
101
- return navigationImpl;
102
- },
103
- set navigation(obj) {
104
- navigationImpl = obj;
105
- globalThis.navigation = obj;
106
- },
107
- async render() {
108
- const mod = await (function (t) { return Promise.resolve().then(function () { return /*#__PURE__*/_interopNamespace(require(t)); }); })(resolvedModule);
109
- const renderFn = mod.default;
110
- if (typeof renderFn !== 'function') {
111
- throw new Error(`Expected default export of ${modulePath} to be a function, got ${typeof renderFn}`);
112
- }
113
- await renderFn();
114
- },
115
- tearDown() {
116
- // Dynamically import preact to unmount cleanly without requiring
117
- // the test file to depend on preact directly.
118
- try {
119
- // eslint-disable-next-line @typescript-eslint/no-var-requires
120
- const {
121
- render
122
- } = require('preact');
123
- render(null, document.body);
124
- } catch {
125
- // Fallback if preact isn't available
126
- document.body.innerHTML = '';
127
- }
128
- delete globalThis.shopify;
129
- if (previousFetch === undefined) {
130
- delete globalThis.fetch;
131
- } else {
132
- globalThis.fetch = previousFetch;
133
- }
134
- if (previousNavigation === undefined) {
135
- delete globalThis.navigation;
136
- } else {
137
- globalThis.navigation = previousNavigation;
138
- }
139
- fetchPolyfills.uninstallFetchPolyfills();
140
- }
141
- };
223
+ var _options$configSearch2;
224
+ const resolvedConfigSearchDir = (_options$configSearch2 = options === null || options === void 0 ? void 0 : options.configSearchDir) !== null && _options$configSearch2 !== void 0 ? _options$configSearch2 : path__namespace.dirname(getCallerFile());
225
+ const tomlPath = findToml(resolvedConfigSearchDir);
226
+ const tomlMtimeMs = fs__namespace.statSync(tomlPath).mtimeMs;
227
+ const cacheKey = JSON.stringify([target, tomlPath, tomlMtimeMs]);
228
+ const cached = extensionCache.get(cacheKey);
229
+ if (cached) {
230
+ return cached;
231
+ }
232
+ const ext = new Extension(target, {
233
+ configSearchDir: resolvedConfigSearchDir
234
+ });
235
+ extensionCache.set(cacheKey, ext);
142
236
  return ext;
143
237
  }
238
+
239
+ /**
240
+ * Sets up an extension for testing and returns a disposable object
241
+ * that supports automatic teardown with the `using` keyword:
242
+ *
243
+ * ```ts
244
+ * test('rendering the extension', async () => {
245
+ * using extension = setUpExtension(
246
+ * 'purchase.checkout.block.render',
247
+ * );
248
+ * await extension.render();
249
+ * // tearDown() is called automatically at the end of the block
250
+ * });
251
+ * ```
252
+ *
253
+ * @param target - The extension target to mock.
254
+ * @param options - Optional configuration (same as {@link getExtension}).
255
+ */
256
+ function setUpExtension(target, options) {
257
+ const extension = getExtension(target, options);
258
+ extension.setUp();
259
+ return Object.assign(extension, {
260
+ [SymbolDispose]: () => extension.tearDown()
261
+ });
262
+ }
144
263
  function validateApiVersion(toml) {
145
264
  const match = toml.match(/^\s*api_version\s*=\s*"([^"]+)"/m);
146
265
  const tomlVersion = match === null || match === void 0 ? void 0 : match[1];
@@ -208,8 +327,10 @@ function getCallerFile() {
208
327
  const err = new Error();
209
328
  let callerFile = '';
210
329
  Error.prepareStackTrace = (_err, stack) => {
211
- // stack[0] is getCallerFile, stack[1] is getExtension, stack[2] is the caller
212
- for (let i = 2; i < stack.length; i++) {
330
+ // Walk the stack, skipping all frames that originate from this
331
+ // package file. This works regardless of whether the caller is
332
+ // getExtension() or setUpExtension() → getExtension().
333
+ for (let i = 1; i < stack.length; i++) {
213
334
  const fileName = stack[i].getFileName();
214
335
  if (fileName && fileName !== thisPackageFilePath) {
215
336
  callerFile = fileName;
@@ -341,4 +462,6 @@ function deepWritableProxy(obj) {
341
462
  }
342
463
 
343
464
  exports.createNavigationHistoryEntry = navigation.createNavigationHistoryEntry;
465
+ exports.SymbolDispose = SymbolDispose;
344
466
  exports.getExtension = getExtension;
467
+ exports.setUpExtension = setUpExtension;
@@ -57,9 +57,14 @@ function createTransaction() {
57
57
  lineItems: []
58
58
  };
59
59
  }
60
+ function createConnectivityApiContent() {
61
+ return {
62
+ current: signals.createReadonlySignalLike(createConnectivityState())
63
+ };
64
+ }
60
65
  function createMockBaseEventData() {
61
66
  return {
62
- connectivity: createConnectivityState(),
67
+ connectivity: createConnectivityApiContent(),
63
68
  device: {
64
69
  name: 'Mock POS Device',
65
70
  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: {