@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/src/index.ts CHANGED
@@ -32,21 +32,19 @@ type Mutable<T> = T extends (...args: any[]) => any
32
32
  ? {-readonly [K in keyof T]: Mutable<T[K]>}
33
33
  : T;
34
34
 
35
- interface Extension<T extends AnyExtensionTarget> {
36
- /**
37
- * Sets up an extension environment for testing.
38
- *
39
- * For example, it creates a mock `shopify` global with some defaults.
40
- */
41
- setUp(): void;
42
-
43
- /**
44
- * Tears down the extension environment.
45
- *
46
- * For example, it resets the `shopify` global and clears `document.body`.
47
- */
48
- tearDown(): void;
35
+ /**
36
+ * `Symbol.dispose` for runtimes that support it, with a polyfill
37
+ * fallback so the library works on older Node versions too.
38
+ */
39
+ export const SymbolDispose: typeof Symbol.dispose = ((Symbol as any).dispose ??
40
+ Symbol.for('Symbol.dispose')) as typeof Symbol.dispose;
49
41
 
42
+ /**
43
+ * Members shared by both {@link ExtensionHarness} (returned by
44
+ * `getExtension`) and {@link DisposableExtensionHarness} (returned
45
+ * by `setUpExtension`).
46
+ */
47
+ interface BaseExtensionHarness<T extends AnyExtensionTarget> {
50
48
  /**
51
49
  * Imports and executes the extension module's default export,
52
50
  * rendering the extension into `document.body`.
@@ -94,6 +92,163 @@ interface Extension<T extends AnyExtensionTarget> {
94
92
  navigation: Navigation;
95
93
  }
96
94
 
95
+ /**
96
+ * Returned by `getExtension`. The caller is responsible for calling
97
+ * `setUp()` before each test and `tearDown()` after.
98
+ */
99
+ interface ExtensionHarness<T extends AnyExtensionTarget>
100
+ extends BaseExtensionHarness<T> {
101
+ /**
102
+ * Sets up an extension environment for testing.
103
+ *
104
+ * For example, it creates a mock `shopify` global with some defaults.
105
+ */
106
+ setUp(): void;
107
+
108
+ /**
109
+ * Tears down the extension environment.
110
+ *
111
+ * For example, it resets the `shopify` global and clears `document.body`.
112
+ */
113
+ tearDown(): void;
114
+ }
115
+
116
+ /**
117
+ * Returned by `setUpExtension`. Already set up — tears down
118
+ * automatically via `Symbol.dispose` (the `using` keyword):
119
+ *
120
+ * ```ts
121
+ * using extension = setUpExtension('purchase.checkout.block.render');
122
+ * ```
123
+ */
124
+ interface DisposableExtensionHarness<T extends AnyExtensionTarget>
125
+ extends BaseExtensionHarness<T> {
126
+ [SymbolDispose](): void;
127
+ }
128
+
129
+ class Extension<T extends AnyExtensionTarget> implements ExtensionHarness<T> {
130
+ #target: T;
131
+ #resolvedModule: string;
132
+ #modulePath: string;
133
+ #checkout: boolean;
134
+ #networkAccess: boolean;
135
+ #apiAccess: boolean;
136
+
137
+ #fetchImpl!: typeof globalThis.fetch;
138
+ #previousFetch: typeof globalThis.fetch | undefined;
139
+ #navigationImpl: Navigation = createMockNavigation();
140
+ #previousNavigation: any;
141
+
142
+ constructor(target: T, options?: {configSearchDir?: string}) {
143
+ const configSearchDir =
144
+ options?.configSearchDir ?? path.dirname(getCallerFile());
145
+ const tomlPath = findToml(configSearchDir);
146
+ const tomlDir = path.dirname(tomlPath);
147
+ const tomlContent = fs.readFileSync(tomlPath, 'utf-8');
148
+ validateApiVersion(tomlContent);
149
+ const modulePath = parseTargetModule(tomlContent, target);
150
+
151
+ this.#target = target;
152
+ this.#modulePath = modulePath;
153
+ this.#resolvedModule = path.resolve(tomlDir, modulePath);
154
+ this.#checkout = isCheckoutTarget(target);
155
+ this.#networkAccess = this.#checkout && parseNetworkAccess(tomlContent);
156
+ this.#apiAccess = this.#checkout && parseApiAccess(tomlContent);
157
+ }
158
+
159
+ setUp(): void {
160
+ installFetchPolyfills();
161
+
162
+ this.#fetchImpl =
163
+ this.#checkout && !this.#networkAccess && !this.#apiAccess
164
+ ? async () => {
165
+ // Checkout is the only surface that currently enforces
166
+ // fetch capabilities.
167
+ throw new Error(
168
+ 'fetch() is not available. Add network_access = true or ' +
169
+ 'api_access = true to [extensions.capabilities] in shopify.extension.toml.',
170
+ );
171
+ }
172
+ : async () => new Response();
173
+
174
+ this.#previousFetch = (globalThis as any).fetch;
175
+ this.#previousNavigation = (globalThis as any).navigation;
176
+ this.#navigationImpl = createMockNavigation();
177
+ (globalThis as any).shopify = deepWritableProxy(
178
+ createMockTargetApi(this.#target),
179
+ );
180
+ (globalThis as any).fetch = this.#fetchImpl;
181
+ (globalThis as any).navigation = this.#navigationImpl;
182
+ }
183
+
184
+ get shopify(): Mutable<ApiForTarget<T>> {
185
+ if (!(globalThis as any).shopify) {
186
+ throw new Error(
187
+ 'You must call extension.setUp() before accessing extension.shopify.',
188
+ );
189
+ }
190
+ return (globalThis as any).shopify;
191
+ }
192
+
193
+ get fetch(): typeof globalThis.fetch {
194
+ return this.#fetchImpl;
195
+ }
196
+
197
+ set fetch(fn: typeof globalThis.fetch) {
198
+ this.#fetchImpl = fn;
199
+ (globalThis as any).fetch = fn;
200
+ }
201
+
202
+ get navigation() {
203
+ return this.#navigationImpl;
204
+ }
205
+
206
+ set navigation(obj: any) {
207
+ this.#navigationImpl = obj;
208
+ (globalThis as any).navigation = obj;
209
+ }
210
+
211
+ async render(): Promise<void> {
212
+ const mod = await import(this.#resolvedModule);
213
+ const renderFn = mod.default;
214
+ if (typeof renderFn !== 'function') {
215
+ throw new Error(
216
+ `Expected default export of ${
217
+ this.#modulePath
218
+ } to be a function, got ${typeof renderFn}`,
219
+ );
220
+ }
221
+ await renderFn();
222
+ }
223
+
224
+ tearDown(): void {
225
+ // Dynamically import preact to unmount cleanly without requiring
226
+ // the test file to depend on preact directly.
227
+ try {
228
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
229
+ const {render} = require('preact');
230
+ render(null, document.body);
231
+ } catch {
232
+ // Fallback if preact isn't available
233
+ document.body.innerHTML = '';
234
+ }
235
+ delete (globalThis as any).shopify;
236
+ if (this.#previousFetch === undefined) {
237
+ delete (globalThis as any).fetch;
238
+ } else {
239
+ (globalThis as any).fetch = this.#previousFetch;
240
+ }
241
+ if (this.#previousNavigation === undefined) {
242
+ delete (globalThis as any).navigation;
243
+ } else {
244
+ (globalThis as any).navigation = this.#previousNavigation;
245
+ }
246
+ uninstallFetchPolyfills();
247
+ }
248
+ }
249
+
250
+ const extensionCache = new Map<string, Extension<any>>();
251
+
97
252
  /**
98
253
  * Returns an extension test harness for the given target.
99
254
  *
@@ -112,114 +267,52 @@ interface Extension<T extends AnyExtensionTarget> {
112
267
  export function getExtension<T extends AnyExtensionTarget>(
113
268
  target: T,
114
269
  options?: {configSearchDir?: string},
115
- ): Extension<T> {
116
- const configSearchDir =
270
+ ): ExtensionHarness<T> {
271
+ const resolvedConfigSearchDir =
117
272
  options?.configSearchDir ?? path.dirname(getCallerFile());
118
- const tomlPath = findToml(configSearchDir);
119
- const tomlDir = path.dirname(tomlPath);
120
- const tomlContent = fs.readFileSync(tomlPath, 'utf-8');
121
- validateApiVersion(tomlContent);
122
- const modulePath = parseTargetModule(tomlContent, target);
123
- const resolvedModule = path.resolve(tomlDir, modulePath);
124
- const checkout = isCheckoutTarget(target);
125
- const networkAccess = checkout && parseNetworkAccess(tomlContent);
126
- const apiAccess = checkout && parseApiAccess(tomlContent);
127
-
128
- let fetchImpl: typeof globalThis.fetch;
129
- let previousFetch: typeof globalThis.fetch | undefined;
130
- let navigationImpl = createMockNavigation();
131
- let previousNavigation: any;
132
-
133
- const ext = {
134
- setUp(): void {
135
- installFetchPolyfills();
136
-
137
- fetchImpl =
138
- checkout && !networkAccess && !apiAccess
139
- ? async () => {
140
- // Checkout is the only surface that currently enforces
141
- // fetch capabilities.
142
- throw new Error(
143
- 'fetch() is not available. Add network_access = true or ' +
144
- 'api_access = true to [extensions.capabilities] in shopify.extension.toml.',
145
- );
146
- }
147
- : async () => new Response();
148
-
149
- previousFetch = (globalThis as any).fetch;
150
- previousNavigation = (globalThis as any).navigation;
151
- (globalThis as any).shopify = deepWritableProxy(
152
- createMockTargetApi(target),
153
- );
154
- (globalThis as any).fetch = fetchImpl;
155
- (globalThis as any).navigation = navigationImpl;
156
- },
157
-
158
- get shopify(): any {
159
- if (!(globalThis as any).shopify) {
160
- throw new Error(
161
- 'You must call extension.setUp() before accessing extension.shopify.',
162
- );
163
- }
164
- return (globalThis as any).shopify;
165
- },
273
+ const tomlPath = findToml(resolvedConfigSearchDir);
274
+ const tomlMtimeMs = fs.statSync(tomlPath).mtimeMs;
275
+ const cacheKey = JSON.stringify([target, tomlPath, tomlMtimeMs]);
166
276
 
167
- get fetch(): typeof globalThis.fetch {
168
- return fetchImpl;
169
- },
170
-
171
- set fetch(fn: typeof globalThis.fetch) {
172
- fetchImpl = fn;
173
- (globalThis as any).fetch = fn;
174
- },
175
-
176
- get navigation() {
177
- return navigationImpl;
178
- },
179
-
180
- set navigation(obj: any) {
181
- navigationImpl = obj;
182
- (globalThis as any).navigation = obj;
183
- },
277
+ const cached = extensionCache.get(cacheKey);
278
+ if (cached) {
279
+ return cached as Extension<T>;
280
+ }
184
281
 
185
- async render(): Promise<void> {
186
- const mod = await import(resolvedModule);
187
- const renderFn = mod.default;
188
- if (typeof renderFn !== 'function') {
189
- throw new Error(
190
- `Expected default export of ${modulePath} to be a function, got ${typeof renderFn}`,
191
- );
192
- }
193
- await renderFn();
194
- },
282
+ const ext = new Extension<T>(target, {
283
+ configSearchDir: resolvedConfigSearchDir,
284
+ });
285
+ extensionCache.set(cacheKey, ext);
286
+ return ext;
287
+ }
195
288
 
196
- tearDown(): void {
197
- // Dynamically import preact to unmount cleanly without requiring
198
- // the test file to depend on preact directly.
199
- try {
200
- // eslint-disable-next-line @typescript-eslint/no-var-requires
201
- const {render} = require('preact');
202
- render(null, document.body);
203
- } catch {
204
- // Fallback if preact isn't available
205
- document.body.innerHTML = '';
206
- }
207
- delete (globalThis as any).shopify;
208
- if (previousFetch === undefined) {
209
- delete (globalThis as any).fetch;
210
- } else {
211
- (globalThis as any).fetch = previousFetch;
212
- }
213
- if (previousNavigation === undefined) {
214
- delete (globalThis as any).navigation;
215
- } else {
216
- (globalThis as any).navigation = previousNavigation;
217
- }
218
- uninstallFetchPolyfills();
219
- },
220
- };
289
+ /**
290
+ * Sets up an extension for testing and returns a disposable object
291
+ * that supports automatic teardown with the `using` keyword:
292
+ *
293
+ * ```ts
294
+ * test('rendering the extension', async () => {
295
+ * using extension = setUpExtension(
296
+ * 'purchase.checkout.block.render',
297
+ * );
298
+ * await extension.render();
299
+ * // tearDown() is called automatically at the end of the block
300
+ * });
301
+ * ```
302
+ *
303
+ * @param target - The extension target to mock.
304
+ * @param options - Optional configuration (same as {@link getExtension}).
305
+ */
306
+ export function setUpExtension<T extends AnyExtensionTarget>(
307
+ target: T,
308
+ options?: {configSearchDir?: string},
309
+ ): DisposableExtensionHarness<T> {
310
+ const extension = getExtension(target, options);
311
+ extension.setUp();
221
312
 
222
- return ext as Extension<T>;
313
+ return Object.assign(extension, {
314
+ [SymbolDispose]: () => extension.tearDown(),
315
+ }) as DisposableExtensionHarness<T>;
223
316
  }
224
317
 
225
318
  function validateApiVersion(toml: string): void {
@@ -301,8 +394,10 @@ function getCallerFile(): string {
301
394
  let callerFile = '';
302
395
 
303
396
  Error.prepareStackTrace = (_err, stack) => {
304
- // stack[0] is getCallerFile, stack[1] is getExtension, stack[2] is the caller
305
- for (let i = 2; i < stack.length; i++) {
397
+ // Walk the stack, skipping all frames that originate from this
398
+ // package file. This works regardless of whether the caller is
399
+ // getExtension() or setUpExtension() → getExtension().
400
+ for (let i = 1; i < stack.length; i++) {
306
401
  const fileName = stack[i]!.getFileName();
307
402
  if (fileName && fileName !== thisPackageFilePath) {
308
403
  callerFile = fileName;
@@ -14,6 +14,7 @@ import type {
14
14
  CashDrawerApi,
15
15
  ScannerSource,
16
16
  StorageApi,
17
+ ConnectivityApiContent,
17
18
  ConnectivityState,
18
19
  Cart,
19
20
  Session,
@@ -87,9 +88,15 @@ function createTransaction(): Transaction {
87
88
  } as Transaction;
88
89
  }
89
90
 
91
+ function createConnectivityApiContent(): ConnectivityApiContent {
92
+ return {
93
+ current: createReadonlySignalLike(createConnectivityState()),
94
+ };
95
+ }
96
+
90
97
  function createMockBaseEventData() {
91
98
  return {
92
- connectivity: createConnectivityState(),
99
+ connectivity: createConnectivityApiContent(),
93
100
  device: {name: 'Mock POS Device', deviceId: 1, isTablet: false},
94
101
  locale: 'en-US',
95
102
  session: createSessionCurrentSession(),
@@ -1,4 +1,4 @@
1
- import {gidFromTarget} from '../admin/factories';
1
+ import {createMockAdminTargetApi, gidFromTarget} from '../admin/factories';
2
2
 
3
3
  describe('gidFromTarget', () => {
4
4
  it.each([
@@ -29,3 +29,27 @@ describe('gidFromTarget', () => {
29
29
  expect(gidFromTarget(target)).toBe(expectedGid);
30
30
  });
31
31
  });
32
+
33
+ describe('createMockAdminTargetApi', () => {
34
+ it('keeps app tools targets on the standard api', () => {
35
+ const api = createMockAdminTargetApi('admin.app.tools.data');
36
+
37
+ expect(api.extension.target).toBe('admin.app.tools.data');
38
+ expect(typeof api.query).toBe('function');
39
+ expect(api).not.toHaveProperty('tools');
40
+ expect(api).not.toHaveProperty('resourcePicker');
41
+ });
42
+
43
+ it('creates a standard rendering api for app intent targets', () => {
44
+ const api = createMockAdminTargetApi('admin.app.intent.render');
45
+
46
+ expect(api.extension.target).toBe('admin.app.intent.render');
47
+ expect(typeof api.resourcePicker).toBe('function');
48
+ expect(typeof api.picker).toBe('function');
49
+ expect(api).not.toHaveProperty('tools');
50
+ expect(typeof api.intents.response?.ok).toBe('function');
51
+ expect(typeof api.intents.response?.error).toBe('function');
52
+ expect(typeof api.intents.response?.closed).toBe('function');
53
+ expect(api).not.toHaveProperty('close');
54
+ });
55
+ });
@@ -1,7 +1,10 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+
1
4
  import {getExtension} from '../index';
2
5
 
3
6
  import {API_VERSION} from '../api-version';
4
- import {createTestSandbox, type TestSandbox} from './helpers';
7
+ import {createTestSandbox, makeToml, type TestSandbox} from './helpers';
5
8
 
6
9
  describe('getExtension', () => {
7
10
  let sandbox: TestSandbox;
@@ -164,4 +167,93 @@ describe('getExtension', () => {
164
167
  }).toThrow(/bogus.*does not exist/);
165
168
  });
166
169
  });
170
+
171
+ describe('caching', () => {
172
+ it('returns the same instance for the same target and configSearchDir', () => {
173
+ sandbox.placeToml();
174
+ const ext1 = getExtension('purchase.checkout.block.render', {
175
+ configSearchDir: sandbox.tempDir,
176
+ });
177
+ const ext2 = getExtension('purchase.checkout.block.render', {
178
+ configSearchDir: sandbox.tempDir,
179
+ });
180
+ expect(ext1).toBe(ext2);
181
+ });
182
+
183
+ it('returns different instances for the same target but different configSearchDir', () => {
184
+ sandbox.placeToml({inDir: 'root'});
185
+ sandbox.placeToml({inDir: 'subDir'});
186
+ const ext1 = getExtension('purchase.checkout.block.render', {
187
+ configSearchDir: sandbox.tempDir,
188
+ });
189
+ const ext2 = getExtension('purchase.checkout.block.render', {
190
+ configSearchDir: sandbox.tomlDirs.subDir,
191
+ });
192
+ expect(ext1).not.toBe(ext2);
193
+ });
194
+
195
+ it('returns the same instance when configSearchDir resolves to the same path', () => {
196
+ sandbox.placeToml();
197
+ const ext1 = getExtension('purchase.checkout.block.render', {
198
+ configSearchDir: sandbox.tempDir,
199
+ });
200
+ // Use a path with a redundant segment that resolves to the same directory
201
+ const ext2 = getExtension('purchase.checkout.block.render', {
202
+ configSearchDir: `${sandbox.tempDir}/sub/..`,
203
+ });
204
+ expect(ext1).toBe(ext2);
205
+ });
206
+
207
+ it('returns the same instance when configSearchDir is omitted and then explicitly set to the resolved default', () => {
208
+ sandbox.placeToml();
209
+ // When configSearchDir is omitted, it defaults to path.dirname(getCallerFile()).
210
+ // If we then call with that same directory explicitly, we should get the same instance.
211
+ const callerDir = path.dirname(__filename);
212
+
213
+ // Place a toml in the caller's directory tree so both calls find it
214
+ const tomlPath = path.join(callerDir, 'shopify.extension.toml');
215
+ fs.writeFileSync(tomlPath, makeToml());
216
+
217
+ try {
218
+ const ext1 = getExtension('purchase.checkout.block.render');
219
+ const ext2 = getExtension('purchase.checkout.block.render', {
220
+ configSearchDir: callerDir,
221
+ });
222
+ expect(ext1).toBe(ext2);
223
+ } finally {
224
+ fs.unlinkSync(tomlPath);
225
+ }
226
+ });
227
+
228
+ it('returns different instances for different targets', () => {
229
+ sandbox.placeToml({
230
+ target: 'purchase.checkout.block.render',
231
+ });
232
+ const ext1 = getExtension('purchase.checkout.block.render', {
233
+ configSearchDir: sandbox.tempDir,
234
+ });
235
+
236
+ sandbox.placeToml({target: 'pos.home.tile.render'});
237
+ const ext2 = getExtension('pos.home.tile.render', {
238
+ configSearchDir: sandbox.tempDir,
239
+ });
240
+
241
+ expect(ext1).not.toBe(ext2);
242
+ });
243
+
244
+ it('returns a new instance when the config file changes on disk', () => {
245
+ sandbox.placeToml();
246
+ const ext1 = getExtension('purchase.checkout.block.render', {
247
+ configSearchDir: sandbox.tempDir,
248
+ });
249
+
250
+ // Rewrite the toml — the resolved config file has changed
251
+ sandbox.placeToml();
252
+ const ext2 = getExtension('purchase.checkout.block.render', {
253
+ configSearchDir: sandbox.tempDir,
254
+ });
255
+
256
+ expect(ext1).not.toBe(ext2);
257
+ });
258
+ });
167
259
  });
@@ -0,0 +1,52 @@
1
+ import {setUpExtension, SymbolDispose} from '../index';
2
+
3
+ import {createTestSandbox, type TestSandbox} from './helpers';
4
+
5
+ describe('setUpExtension', () => {
6
+ let sandbox: TestSandbox;
7
+
8
+ beforeEach(() => {
9
+ sandbox = createTestSandbox();
10
+ sandbox.placeToml();
11
+ });
12
+
13
+ afterEach(() => {
14
+ sandbox.destroy();
15
+ });
16
+
17
+ it('calls setUp and returns a ready extension', () => {
18
+ const extension = setUpExtension('purchase.checkout.block.render', {
19
+ configSearchDir: sandbox.tempDir,
20
+ });
21
+ // shopify global should be available without calling setUp manually
22
+ expect(extension.shopify).toBeDefined();
23
+ expect(extension.shopify.extension.apiVersion).toBeDefined();
24
+ });
25
+
26
+ it('tears down when Symbol.dispose is called', () => {
27
+ const extension = setUpExtension('purchase.checkout.block.render', {
28
+ configSearchDir: sandbox.tempDir,
29
+ });
30
+ expect((globalThis as any).shopify).toBeDefined();
31
+ extension[SymbolDispose]();
32
+ expect((globalThis as any).shopify).toBeUndefined();
33
+ });
34
+
35
+ it('supports repeated setUp/tearDown cycles via setUpExtension', () => {
36
+ // First cycle
37
+ const ext1 = setUpExtension('purchase.checkout.block.render', {
38
+ configSearchDir: sandbox.tempDir,
39
+ });
40
+ expect((globalThis as any).shopify).toBeDefined();
41
+ ext1[SymbolDispose]();
42
+ expect((globalThis as any).shopify).toBeUndefined();
43
+
44
+ // Second cycle (reuses cached Extension)
45
+ const ext2 = setUpExtension('purchase.checkout.block.render', {
46
+ configSearchDir: sandbox.tempDir,
47
+ });
48
+ expect((globalThis as any).shopify).toBeDefined();
49
+ ext2[SymbolDispose]();
50
+ expect((globalThis as any).shopify).toBeUndefined();
51
+ });
52
+ });