@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.
- package/README.md +66 -14
- package/build/cjs/_virtual/_rollupPluginBabelHelpers.js +15 -0
- package/build/cjs/admin/factories.js +18 -0
- package/build/cjs/api-version.js +2 -2
- package/build/cjs/checkout/factories.js +0 -5
- package/build/cjs/index.js +209 -86
- package/build/cjs/point-of-sale/factories.js +6 -1
- package/build/esm/_virtual/_rollupPluginBabelHelpers.mjs +10 -0
- package/build/esm/admin/factories.mjs +18 -0
- package/build/esm/api-version.mjs +2 -2
- package/build/esm/checkout/factories.mjs +0 -5
- package/build/esm/index.mjs +208 -87
- package/build/esm/point-of-sale/factories.mjs +6 -1
- package/build/esnext/_virtual/_rollupPluginBabelHelpers.esnext +10 -0
- package/build/esnext/admin/factories.esnext +18 -0
- package/build/esnext/api-version.esnext +2 -2
- package/build/esnext/checkout/factories.esnext +0 -5
- package/build/esnext/index.esnext +208 -87
- package/build/esnext/point-of-sale/factories.esnext +6 -1
- package/build/ts/admin/factories.d.ts.map +1 -1
- package/build/ts/checkout/factories.d.ts.map +1 -1
- package/build/ts/disposable.d.ts +20 -0
- package/build/ts/index.d.ts +62 -14
- package/build/ts/index.d.ts.map +1 -1
- package/build/ts/point-of-sale/factories.d.ts.map +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/admin/README.md +6 -0
- package/src/admin/factories.ts +22 -0
- package/src/checkout/factories.ts +0 -1
- package/src/disposable.d.ts +20 -0
- package/src/index.ts +214 -119
- package/src/point-of-sale/factories.ts +8 -1
- package/src/tests/admin-factories.test.ts +25 -1
- package/src/tests/getExtension.test.ts +93 -1
- 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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
):
|
|
116
|
-
const
|
|
270
|
+
): ExtensionHarness<T> {
|
|
271
|
+
const resolvedConfigSearchDir =
|
|
117
272
|
options?.configSearchDir ?? path.dirname(getCallerFile());
|
|
118
|
-
const tomlPath = findToml(
|
|
119
|
-
const
|
|
120
|
-
const
|
|
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
|
-
|
|
168
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
//
|
|
305
|
-
|
|
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:
|
|
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
|
+
});
|