@essential-apps/shopify-test-runner 1.0.11 → 1.0.13

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 (97) hide show
  1. package/dist/lib/guestVnc.d.ts +31 -0
  2. package/dist/lib/guestVnc.d.ts.map +1 -0
  3. package/dist/lib/guestVnc.js +111 -0
  4. package/dist/lib/guestVnc.js.map +1 -0
  5. package/dist/probes/runProbe.js +0 -0
  6. package/dist/scripts/addStore.js +0 -0
  7. package/dist/scripts/buildImage.d.ts +3 -0
  8. package/dist/scripts/buildImage.d.ts.map +1 -0
  9. package/dist/scripts/{buildDockerImage.js → buildImage.js} +12 -10
  10. package/dist/scripts/buildImage.js.map +1 -0
  11. package/dist/scripts/captureAuth.js +0 -0
  12. package/dist/scripts/captureContracts.js +0 -0
  13. package/dist/scripts/captureRestContracts.js +0 -0
  14. package/dist/scripts/captureSharedContracts.d.ts +3 -0
  15. package/dist/scripts/captureSharedContracts.d.ts.map +1 -0
  16. package/dist/scripts/captureSharedContracts.js +209 -0
  17. package/dist/scripts/captureSharedContracts.js.map +1 -0
  18. package/dist/scripts/checkOperationCoverage.js +0 -0
  19. package/dist/scripts/cleanupStores.js +0 -0
  20. package/dist/scripts/createStores.js +0 -0
  21. package/dist/scripts/deployAppVersion.js +0 -0
  22. package/dist/scripts/devOnlineBackend.js +0 -0
  23. package/dist/scripts/installApp.js +0 -0
  24. package/dist/scripts/listStores.js +0 -0
  25. package/dist/scripts/runOffline.js +83 -1
  26. package/dist/scripts/runOffline.js.map +1 -1
  27. package/dist/scripts/runOfflineFullTests.js +82 -6
  28. package/dist/scripts/runOfflineFullTests.js.map +1 -1
  29. package/dist/scripts/runTests.js +0 -0
  30. package/dist/scripts/runVm.js +0 -0
  31. package/dist/scripts/runVmAuth.js +0 -0
  32. package/dist/scripts/setupTestDb.js +0 -0
  33. package/dist/scripts/verifyContracts.js +20 -29
  34. package/dist/scripts/verifyContracts.js.map +1 -1
  35. package/dist/scripts/verifyRestContracts.js +17 -30
  36. package/dist/scripts/verifyRestContracts.js.map +1 -1
  37. package/docker/Dockerfile.vm +1 -0
  38. package/package.json +11 -9
  39. package/src/lib/guestVnc.ts +147 -0
  40. package/src/scripts/{buildDockerImage.ts → buildImage.ts} +11 -9
  41. package/src/scripts/captureSharedContracts.ts +228 -0
  42. package/src/scripts/runOffline.ts +87 -1
  43. package/src/scripts/runOfflineFullTests.ts +94 -6
  44. package/src/scripts/verifyContracts.ts +22 -38
  45. package/src/scripts/verifyRestContracts.ts +23 -42
  46. package/dist/edge/nodeShim.d.ts +0 -2
  47. package/dist/edge/nodeShim.d.ts.map +0 -1
  48. package/dist/edge/nodeShim.js +0 -217
  49. package/dist/edge/nodeShim.js.map +0 -1
  50. package/dist/scripts/_probeSourceUrl.d.ts +0 -3
  51. package/dist/scripts/_probeSourceUrl.d.ts.map +0 -1
  52. package/dist/scripts/_probeSourceUrl.js +0 -119
  53. package/dist/scripts/_probeSourceUrl.js.map +0 -1
  54. package/dist/scripts/buildDockerImage.d.ts +0 -3
  55. package/dist/scripts/buildDockerImage.d.ts.map +0 -1
  56. package/dist/scripts/buildDockerImage.js.map +0 -1
  57. package/dist/scripts/devE2eBackend.d.ts +0 -3
  58. package/dist/scripts/devE2eBackend.d.ts.map +0 -1
  59. package/dist/scripts/devE2eBackend.js +0 -117
  60. package/dist/scripts/devE2eBackend.js.map +0 -1
  61. package/dist/scripts/runDocker.d.ts +0 -3
  62. package/dist/scripts/runDocker.d.ts.map +0 -1
  63. package/dist/scripts/runDocker.js +0 -88
  64. package/dist/scripts/runDocker.js.map +0 -1
  65. package/dist/scripts/runDockerAuth.d.ts +0 -3
  66. package/dist/scripts/runDockerAuth.d.ts.map +0 -1
  67. package/dist/scripts/runDockerAuth.js +0 -108
  68. package/dist/scripts/runDockerAuth.js.map +0 -1
  69. package/dist/scripts/runDockerOffline.d.ts +0 -3
  70. package/dist/scripts/runDockerOffline.d.ts.map +0 -1
  71. package/dist/scripts/runDockerOffline.js +0 -129
  72. package/dist/scripts/runDockerOffline.js.map +0 -1
  73. package/dist/scripts/runDockerOfflineExplore.d.ts +0 -3
  74. package/dist/scripts/runDockerOfflineExplore.d.ts.map +0 -1
  75. package/dist/scripts/runDockerOfflineExplore.js +0 -116
  76. package/dist/scripts/runDockerOfflineExplore.js.map +0 -1
  77. package/dist/scripts/runIsolatedDockerOffline.d.ts +0 -3
  78. package/dist/scripts/runIsolatedDockerOffline.d.ts.map +0 -1
  79. package/dist/scripts/runIsolatedDockerOffline.js +0 -351
  80. package/dist/scripts/runIsolatedDockerOffline.js.map +0 -1
  81. package/dist/scripts/runOfflineE2e.d.ts +0 -3
  82. package/dist/scripts/runOfflineE2e.d.ts.map +0 -1
  83. package/dist/scripts/runOfflineE2e.js +0 -408
  84. package/dist/scripts/runOfflineE2e.js.map +0 -1
  85. package/dist/scripts/runSupermachine.d.ts +0 -3
  86. package/dist/scripts/runSupermachine.d.ts.map +0 -1
  87. package/dist/scripts/runSupermachine.js +0 -474
  88. package/dist/scripts/runSupermachine.js.map +0 -1
  89. package/dist/scripts/runSupermachineAuth.d.ts +0 -3
  90. package/dist/scripts/runSupermachineAuth.d.ts.map +0 -1
  91. package/dist/scripts/runSupermachineAuth.js +0 -454
  92. package/dist/scripts/runSupermachineAuth.js.map +0 -1
  93. package/dist/vite/offlineConfig.d.ts +0 -34
  94. package/dist/vite/offlineConfig.d.ts.map +0 -1
  95. package/dist/vite/offlineConfig.js +0 -61
  96. package/dist/vite/offlineConfig.js.map +0 -1
  97. package/src/scripts/runDockerAuth.ts +0 -120
@@ -1229,13 +1229,15 @@ async function main(): Promise<void> {
1229
1229
 
1230
1230
  // ── Explore mode (no Playwright; manual click-around) ──
1231
1231
  // When `TEST_OFFLINE_EXPLORE=true` is set, skip Playwright
1232
- // entirely. Instead, launch a single non-headless Chrome on the
1233
- // container's Xvfb display, pointed at the admin app's entry
1234
- // URL. The container is started with `--publish 5900:5900` +
1235
- // `TEST_ONLINE_VNC=1`, so the developer can connect from macOS via
1232
+ // entirely. Instead, launch a non-headless Chrome on the guest's
1233
+ // Xvfb :99 display with TWO tabs — the admin app and the storefront
1234
+ // both pointed at the in-VM mock stack. The host-side runner
1235
+ // (`runOffline.ts`) surfaces :99 over VNC via supermachine's
1236
+ // `vm.exposeTcp` (NOT docker --publish — we don't use docker for
1237
+ // anything), so the developer connects from macOS via
1236
1238
  // open vnc://localhost:5900 (password: `test`)
1237
- // and click through the admin and storefront against the online
1238
- // mock stack. The process holds open until SIGINT/SIGTERM.
1239
+ // and clicks through admin + storefront against the mock stack.
1240
+ // The process holds open until the browser is closed / SIGINT.
1239
1241
  //
1240
1242
  // This is the "interactive Shopify dev store" mode — useful
1241
1243
  // for visually inspecting funnel rendering, debugging admin
@@ -1342,6 +1344,26 @@ async function main(): Promise<void> {
1342
1344
  exploreCtx.pages()[0] ?? (await exploreCtx.newPage());
1343
1345
  await explorePage.goto(adminUrl, { waitUntil: 'domcontentloaded' });
1344
1346
 
1347
+ // Second tab: the storefront (app embed + widget render against
1348
+ // the seeded catalog). Opening both up-front means the developer
1349
+ // lands on a ready admin + storefront without typing URLs. Errors
1350
+ // are swallowed — a slow/failed storefront load shouldn't abort
1351
+ // the whole explore session (the admin tab is still useful).
1352
+ const storefrontUrl =
1353
+ process.env['TEST_OFFLINE_EXPLORE_STOREFRONT_URL'] ??
1354
+ `https://${DEFAULT_SHOP_DOMAIN}/`;
1355
+ try {
1356
+ const sfPage = await exploreCtx.newPage();
1357
+ await sfPage.goto(storefrontUrl, { waitUntil: 'domcontentloaded' });
1358
+ } catch (err) {
1359
+ console.error(
1360
+ `[runOfflineFull] explore: storefront tab failed to load (${(err as Error).message}) — continuing`,
1361
+ );
1362
+ }
1363
+ // Leave the admin tab focused as the active one.
1364
+ await explorePage.bringToFront().catch(() => {});
1365
+ console.log(` Storefront tab opened: ${storefrontUrl}`);
1366
+
1345
1367
  // Wait indefinitely. Exit triggers:
1346
1368
  // - Developer closes the browser window → context emits `close`.
1347
1369
  // - Container receives SIGINT/SIGTERM → we close the context
@@ -1361,8 +1383,74 @@ async function main(): Promise<void> {
1361
1383
 
1362
1384
  // ── Playwright ─────────────────────────────────────────
1363
1385
  console.log('[runOfflineFull] running Playwright…');
1386
+ // Headed-under-Xvfb is the DEFAULT for offline runs (production-
1387
+ // faithful, ~4% slower than headless). Opt OUT with
1388
+ // TEST_OFFLINE_HEADLESS=true. Full-screen recording (TEST_OFFLINE_VIDEO)
1389
+ // and TEST_VISIBLE always force headed. Xvfb ships in the Playwright
1390
+ // base image; ffmpeg (for recording) is baked in too. We start Xvfb
1391
+ // :99 whenever headed, and only require ffmpeg when recording.
1392
+ let videoOn = process.env['TEST_OFFLINE_VIDEO'] === 'true';
1393
+ const headlessOn = process.env['TEST_OFFLINE_HEADLESS'] === 'true';
1394
+ let headedOn = videoOn || process.env['TEST_VISIBLE'] === 'true' || !headlessOn;
1395
+ let headedReady = false;
1396
+ if (videoOn) {
1397
+ // Recording needs ffmpeg (with x11grab). An image built before
1398
+ // video support won't have it — degrade to headed-no-record with
1399
+ // a clear rebuild hint rather than failing every test's recording.
1400
+ let hasFfmpeg = false;
1401
+ try {
1402
+ const { execSync: execSyncCheck } = await import('node:child_process');
1403
+ execSyncCheck('command -v ffmpeg', { stdio: 'ignore' });
1404
+ hasFfmpeg = true;
1405
+ } catch {
1406
+ hasFfmpeg = false;
1407
+ }
1408
+ if (!hasFfmpeg) {
1409
+ console.error(
1410
+ '[runOfflineFull] TEST_OFFLINE_VIDEO set but `ffmpeg` is missing from the ' +
1411
+ 'VM image — rebuild it (it predates video support): ' +
1412
+ '`container image rm essential-apps/shopify-test-vm:latest` then re-run. ' +
1413
+ 'Continuing headed WITHOUT recording.',
1414
+ );
1415
+ videoOn = false;
1416
+ process.env['TEST_OFFLINE_VIDEO'] = 'false';
1417
+ }
1418
+ }
1419
+ if (headedOn) {
1420
+ const { existsSync } = await import('node:fs');
1421
+ if (!existsSync('/tmp/.X11-unix/X99')) {
1422
+ const { spawn: spawnXvfb } = await import('node:child_process');
1423
+ const xv = spawnXvfb(
1424
+ 'Xvfb',
1425
+ [':99', '-screen', '0', '1400x900x24', '-nolisten', 'tcp', '-ac'],
1426
+ { detached: true, stdio: 'ignore' },
1427
+ );
1428
+ xv.unref();
1429
+ const t0 = Date.now();
1430
+ while (!existsSync('/tmp/.X11-unix/X99') && Date.now() - t0 < 5000) {
1431
+ await new Promise((r) => setTimeout(r, 100));
1432
+ }
1433
+ }
1434
+ headedReady = existsSync('/tmp/.X11-unix/X99');
1435
+ if (!headedReady) {
1436
+ console.error(
1437
+ '[runOfflineFull] WARNING: Xvfb :99 did not start — falling back to headless',
1438
+ );
1439
+ videoOn = false;
1440
+ headedOn = false;
1441
+ process.env['TEST_OFFLINE_VIDEO'] = 'false';
1442
+ process.env['TEST_OFFLINE_HEADED'] = 'false';
1443
+ } else {
1444
+ console.error(
1445
+ `[runOfflineFull] headed under Xvfb :99 (${videoOn ? 'recording' : 'no recording'})`,
1446
+ );
1447
+ }
1448
+ }
1449
+
1364
1450
  const playwrightEnv: NodeJS.ProcessEnv = {
1365
1451
  ...process.env,
1452
+ // Point headed Chromium + the screen-video fixture at the Xvfb display.
1453
+ ...(headedReady ? { DISPLAY: ':99' } : {}),
1366
1454
  // Expose the per-run test DB to specs so they can seed/inspect the
1367
1455
  // app's OWN Postgres directly (e.g. seed Article rows for
1368
1456
  // list/detail flows). The mock ShopState covers Shopify resources,
@@ -29,8 +29,8 @@
29
29
  *
30
30
  * Run: `npm run test:online:verify-contracts`.
31
31
  */
32
- import { readdirSync, readFileSync, statSync } from 'node:fs';
33
- import { resolve, relative } from 'node:path';
32
+ import { resolve } from 'node:path';
33
+ import { resolveContracts } from '@essential-apps/shopify-test-contracts/operation-contract';
34
34
  import {
35
35
  createAdminApi,
36
36
  createStorefrontApi,
@@ -112,16 +112,6 @@ function buildOfflineExecutor(api: ApiType, state: ShopState): Executor {
112
112
  };
113
113
  }
114
114
 
115
- interface Contract {
116
- operationName: string;
117
- source: string;
118
- variables: Record<string, unknown>;
119
- response: unknown;
120
- capturedFrom: 'offline' | 'live';
121
- capturedAt: string;
122
- warning?: string;
123
- }
124
-
125
115
  /**
126
116
  * Same deterministic ShopState seed `captureContracts` uses. The
127
117
  * contracts were captured against this state; verification must
@@ -214,21 +204,24 @@ function diff(
214
204
  async function main(): Promise<void> {
215
205
  const args = parseArgs();
216
206
 
217
- let entries: string[];
218
- try {
219
- entries = readdirSync(args.contractsDir);
220
- } catch {
207
+ // Merged view: shared package goldens (the centralized generic ops)
208
+ // overlaid by the app's local contracts dir, which overrides by
209
+ // operation name. An app vendors a local file only when its shape
210
+ // genuinely diverges from the shared golden.
211
+ const resolved = resolveContracts({ api: args.api, appDir: args.contractsDir });
212
+ if (resolved.length === 0) {
221
213
  console.error(
222
- `[verify-contracts] no contracts directory at ${args.contractsDir}. ` +
223
- `Run \`npm run test:online:capture-contracts\` first.`,
214
+ `[verify-contracts] no contracts for api=${args.api} ` +
215
+ `(shared package + ${args.contractsDir}).`,
224
216
  );
225
217
  process.exit(2);
226
218
  }
227
- const contractFiles = entries.filter((f) => f.endsWith('.json'));
228
- if (contractFiles.length === 0) {
229
- console.error(`[verify-contracts] no contracts found in ${args.contractsDir}`);
230
- process.exit(2);
231
- }
219
+ const sharedCount = resolved.filter((r) => r.origin === 'shared').length;
220
+ const appCount = resolved.length - sharedCount;
221
+ console.log(
222
+ `[verify-contracts] api=${args.api}: ${resolved.length} operations ` +
223
+ `(${sharedCount} shared, ${appCount} app-local override)`,
224
+ );
232
225
 
233
226
  const state = buildSeededState();
234
227
  const executor: Executor =
@@ -239,16 +232,9 @@ async function main(): Promise<void> {
239
232
  let skipped = 0;
240
233
  const failures: { contract: string; diff: string }[] = [];
241
234
 
242
- for (const f of contractFiles) {
243
- const path = resolve(args.contractsDir, f);
244
- let st;
245
- try {
246
- st = statSync(path);
247
- } catch {
248
- continue;
249
- }
250
- if (!st.isFile()) continue;
251
- const contract = JSON.parse(readFileSync(path, 'utf8')) as Contract;
235
+ for (const entry of resolved) {
236
+ const contract = entry.contract;
237
+ const label = `${entry.operationName} [${entry.origin}]`;
252
238
  // Contracts captured with a warning never executed cleanly;
253
239
  // skip them in verify (they need fixtures.json before they can
254
240
  // be verified).
@@ -262,7 +248,7 @@ async function main(): Promise<void> {
262
248
  } catch (err) {
263
249
  drift++;
264
250
  failures.push({
265
- contract: f,
251
+ contract: label,
266
252
  diff: `executor threw: ${(err as Error).message}`,
267
253
  });
268
254
  continue;
@@ -280,7 +266,7 @@ async function main(): Promise<void> {
280
266
  pass++;
281
267
  } else {
282
268
  drift++;
283
- failures.push({ contract: f, diff: d });
269
+ failures.push({ contract: label, diff: d });
284
270
  }
285
271
  }
286
272
 
@@ -291,9 +277,7 @@ async function main(): Promise<void> {
291
277
  console.log('');
292
278
  console.log('[verify-contracts] drift:');
293
279
  for (const f of failures) {
294
- console.log(
295
- ` ${relative(args.cwd, resolve(args.contractsDir, f.contract))}`,
296
- );
280
+ console.log(` ${f.contract}`);
297
281
  console.log(` ${f.diff}`);
298
282
  }
299
283
  console.log('');
@@ -20,8 +20,11 @@
20
20
  * GraphQL verifier uses. Offline-offline matches byte-for-byte
21
21
  * anyway; the normaliser only matters for live-vs-contract.
22
22
  */
23
- import { readdirSync, readFileSync, statSync } from 'node:fs';
24
- import { resolve, relative } from 'node:path';
23
+ import { resolve } from 'node:path';
24
+ import {
25
+ resolveRestContracts,
26
+ type RestContract,
27
+ } from '@essential-apps/shopify-test-contracts/operation-contract';
25
28
  import {
26
29
  createAdminApi,
27
30
  } from '@essential-apps/shopify-test-shopify-api';
@@ -33,21 +36,7 @@ interface Args {
33
36
  cwd: string;
34
37
  }
35
38
 
36
- interface Contract {
37
- operationName: string;
38
- protocol: 'rest';
39
- method: 'GET' | 'POST' | 'PUT' | 'DELETE';
40
- path: string;
41
- pathParams?: Record<string, string>;
42
- query?: Record<string, string>;
43
- body?: unknown;
44
- response: {
45
- status: number;
46
- body: unknown;
47
- };
48
- capturedFrom: 'offline';
49
- warning?: string;
50
- }
39
+ type Contract = RestContract;
51
40
 
52
41
  const ADMIN_API_VERSION = '2025-07';
53
42
 
@@ -173,21 +162,20 @@ function diff(
173
162
 
174
163
  async function main(): Promise<void> {
175
164
  const args = parseArgs();
176
- let entries: string[];
177
- try {
178
- entries = readdirSync(args.contractsDir);
179
- } catch {
165
+ // Merged view: shared package REST goldens overlaid by the app's
166
+ // local admin-rest dir (app wins by operation name).
167
+ const resolved = resolveRestContracts({ appDir: args.contractsDir });
168
+ if (resolved.length === 0) {
180
169
  console.error(
181
- `[verify-rest] no contracts directory at ${args.contractsDir}. ` +
182
- `Run \`npm run test:online:capture-rest-contracts\` first.`,
170
+ `[verify-rest] no REST contracts (shared package + ${args.contractsDir}).`,
183
171
  );
184
172
  process.exit(2);
185
173
  }
186
- const contractFiles = entries.filter((f) => f.endsWith('.json'));
187
- if (contractFiles.length === 0) {
188
- console.error(`[verify-rest] no contracts found in ${args.contractsDir}`);
189
- process.exit(2);
190
- }
174
+ const sharedCount = resolved.filter((r) => r.origin === 'shared').length;
175
+ console.log(
176
+ `[verify-rest] ${resolved.length} operations ` +
177
+ `(${sharedCount} shared, ${resolved.length - sharedCount} app-local override)`,
178
+ );
191
179
 
192
180
  const state = buildSeededState();
193
181
  const executor: RestExecutor =
@@ -198,16 +186,9 @@ async function main(): Promise<void> {
198
186
  let skipped = 0;
199
187
  const failures: { contract: string; diff: string }[] = [];
200
188
 
201
- for (const f of contractFiles) {
202
- const path = resolve(args.contractsDir, f);
203
- let st;
204
- try {
205
- st = statSync(path);
206
- } catch {
207
- continue;
208
- }
209
- if (!st.isFile()) continue;
210
- const contract = JSON.parse(readFileSync(path, 'utf8')) as Contract;
189
+ for (const entry of resolved) {
190
+ const contract = entry.contract as Contract;
191
+ const label = `${entry.operationName} [${entry.origin}]`;
211
192
  if (contract.warning) {
212
193
  skipped++;
213
194
  continue;
@@ -217,14 +198,14 @@ async function main(): Promise<void> {
217
198
  actual = await executor(contract);
218
199
  } catch (err) {
219
200
  drift++;
220
- failures.push({ contract: f, diff: `executor threw: ${(err as Error).message}` });
201
+ failures.push({ contract: label, diff: `executor threw: ${(err as Error).message}` });
221
202
  continue;
222
203
  }
223
204
  // Status-code mismatch is a hard diff — surface it directly.
224
205
  if (actual.status !== contract.response.status) {
225
206
  drift++;
226
207
  failures.push({
227
- contract: f,
208
+ contract: label,
228
209
  diff: `$.status: expected ${contract.response.status}, got ${actual.status}`,
229
210
  });
230
211
  continue;
@@ -238,7 +219,7 @@ async function main(): Promise<void> {
238
219
  pass++;
239
220
  } else {
240
221
  drift++;
241
- failures.push({ contract: f, diff: d });
222
+ failures.push({ contract: label, diff: d });
242
223
  }
243
224
  }
244
225
 
@@ -249,7 +230,7 @@ async function main(): Promise<void> {
249
230
  console.log('');
250
231
  console.log('[verify-rest] drift:');
251
232
  for (const f of failures) {
252
- console.log(` ${relative(args.cwd, resolve(args.contractsDir, f.contract))}`);
233
+ console.log(` ${f.contract}`);
253
234
  console.log(` ${f.diff}`);
254
235
  }
255
236
  console.log('');
@@ -1,2 +0,0 @@
1
- export {};
2
- //# sourceMappingURL=nodeShim.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"nodeShim.d.ts","sourceRoot":"","sources":["../../src/edge/nodeShim.ts"],"names":[],"mappings":""}
@@ -1,217 +0,0 @@
1
- /**
2
- * Node-side socket-layer shim that routes Shopify hostnames to the
3
- * edge proxy. Mirrors what `--host-resolver-rules` does for Chromium,
4
- * but for the Node runtime that Playwright tests execute in.
5
- *
6
- * Why this exists:
7
- * Chromium's `--host-resolver-rules` only affects the browser.
8
- * Node's HTTP stack — used by Playwright's `page.request`, by
9
- * `globalThis.fetch` (undici-based), and by anything calling
10
- * `node:http` / `node:https` directly — uses Node's own DNS. A
11
- * test that does `page.request.get('https://test-shop.myshopify.com/x')`
12
- * would resolve that hostname via Node's DNS and hit real Shopify
13
- * on the public internet.
14
- *
15
- * What this does:
16
- * - Patches `dns.lookup` so `*.shopify.com` / `*.myshopify.com`
17
- * resolve to `127.0.0.1`.
18
- * - Patches `net.createConnection` (the path every HTTPS client
19
- * eventually takes, including undici) so when the resolved
20
- * address is `127.0.0.1` AND the original hostname was a
21
- * Shopify host, the connect targets the edge port instead of
22
- * the original 80/443.
23
- *
24
- * Net effect: any Node-side HTTP/HTTPS request to a Shopify
25
- * hostname lands on our edge proxy. From the calling code's
26
- * perspective, it's hitting real Shopify; only the kernel-adjacent
27
- * shim knows the difference. TLS SNI carries the original hostname
28
- * to the edge, which presents a cert with broad SANs for it.
29
- *
30
- * Loading: import this module BEFORE Playwright loads its HTTP
31
- * machinery — `NODE_OPTIONS=--import <path-to-built-shim.js>` is
32
- * the canonical hook (Node 20.6+). The orchestrator sets this when
33
- * it spawns Playwright.
34
- *
35
- * Env: reads `E2E_MOCK_EDGE_PORT` at module load. If unset, the
36
- * shim no-ops (so loading it in non-offline contexts is harmless).
37
- */
38
- import dns from 'node:dns';
39
- import net from 'node:net';
40
- const SHOPIFY_HOSTNAME = /(?:^|\.)(my)?shopify\.com$/i;
41
- const EDGE_PORT = process.env['E2E_MOCK_EDGE_PORT']
42
- ? Number(process.env['E2E_MOCK_EDGE_PORT'])
43
- : undefined;
44
- if (EDGE_PORT && Number.isFinite(EDGE_PORT)) {
45
- installDnsPatch();
46
- installConnectPatch(EDGE_PORT);
47
- // Accept self-signed certs at the Node TLS layer. The edge proxy
48
- // serves a self-signed cert for *.myshopify.com / *.shopify.com
49
- // (see packages/runner/src/edge/cert.ts) — Chromium already
50
- // accepts it via the `--ignore-certificate-errors` launch flag,
51
- // and Playwright's APIRequestContext inherits the BrowserContext's
52
- // `ignoreHTTPSErrors: true`. But raw `globalThis.fetch` (undici)
53
- // and direct `https.request` calls in the test process have no
54
- // per-call override — they need this process-level flag.
55
- //
56
- // SCOPE: this affects ALL TLS in the test process, not just
57
- // Shopify hostnames. That's acceptable here because:
58
- // 1. The test process should never make TLS connections that
59
- // depend on cert verification — production Shopify code
60
- // runs in the Remix backend (a separate process), not here.
61
- // 2. The shim already aggressively redirects to localhost via
62
- // DNS+connect patches; cert verification on those would
63
- // always fail anyway (hostname mismatch).
64
- // If a future test genuinely needs strict TLS verification against
65
- // a real public host, it should reset this flag locally for that
66
- // request.
67
- process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0';
68
- if (process.env['E2E_OFFLINE_SHIM_DEBUG'] === 'true') {
69
- console.error(`[node-shim] Shopify hostname routing → 127.0.0.1:${EDGE_PORT}; ` +
70
- `NODE_TLS_REJECT_UNAUTHORIZED=0 (test-process scope)`);
71
- }
72
- }
73
- else if (process.env['E2E_OFFLINE_SHIM_DEBUG'] === 'true') {
74
- console.error('[node-shim] E2E_MOCK_EDGE_PORT not set — shim is a no-op');
75
- }
76
- /**
77
- * Patch `dns.lookup` so Shopify hostnames resolve to 127.0.0.1.
78
- * Every HTTP client in Node ultimately calls this (or `dns.lookup`
79
- * with the `family`/`hints` options form). We handle both shapes.
80
- *
81
- * Don't patch `dns.resolve*` — those do direct DNS queries (used
82
- * for explicit DNS-record inspection, not for connection setup);
83
- * patching them would surprise tests that genuinely want to query
84
- * DNS records.
85
- */
86
- function installDnsPatch() {
87
- const origLookup = dns.lookup;
88
- /**
89
- * Signature variants to support:
90
- * dns.lookup(hostname, callback)
91
- * dns.lookup(hostname, family, callback)
92
- * dns.lookup(hostname, options, callback)
93
- */
94
- const patched = function patched(hostname, optionsOrCallback, maybeCallback) {
95
- if (!SHOPIFY_HOSTNAME.test(hostname)) {
96
- // Pass through to the original — same arity & types.
97
- return origLookup.call(dns, hostname, optionsOrCallback, maybeCallback);
98
- }
99
- // Synthesize the response Node's resolver would have produced.
100
- const cb = typeof optionsOrCallback === 'function' ? optionsOrCallback : maybeCallback;
101
- const opts = typeof optionsOrCallback === 'object' && optionsOrCallback !== null
102
- ? optionsOrCallback
103
- : { family: typeof optionsOrCallback === 'number' ? optionsOrCallback : 0 };
104
- if (typeof cb !== 'function') {
105
- // No callback → Promise form. Node 18+ supports this via
106
- // `dns/promises`, but `dns.lookup` itself is callback-only.
107
- // If someone calls it without a callback, that's their bug —
108
- // throw the same TypeError Node would.
109
- throw new TypeError('dns.lookup: callback required');
110
- }
111
- const address = { address: '127.0.0.1', family: 4 };
112
- if (opts.all) {
113
- cb(null, [address]);
114
- }
115
- else {
116
- cb(null, address.address, address.family);
117
- }
118
- return undefined;
119
- };
120
- // `dns.lookup` is a property of the `dns` module object; reassigning
121
- // it affects every consumer that does `dns.lookup(...)` (which is
122
- // every Node HTTP client). `Object.defineProperty` rather than `=`
123
- // to avoid surprise from frozen properties in future Node versions.
124
- Object.defineProperty(dns, 'lookup', { value: patched, writable: true, configurable: true });
125
- }
126
- /**
127
- * Patch `net.createConnection` (aliased as `net.connect`) so that
128
- * outbound TCP connections to Shopify hosts get their port rewritten
129
- * to the edge port. The hostname is preserved on the socket, so TLS
130
- * SNI carries it through and the edge sees the real Host header.
131
- *
132
- * Detection: we look at the `host` option (most callers) or the
133
- * `hostname` option (some), AND/OR the path option (Unix sockets,
134
- * irrelevant here). If neither matches, we pass through.
135
- */
136
- function installConnectPatch(edgePort) {
137
- /**
138
- * Decide whether a connect() call should be rewritten to the edge.
139
- *
140
- * Two triggers:
141
- * 1. `host` is a Shopify hostname (e.g. callers that pass
142
- * hostname+port to net.connect and let the kernel resolve
143
- * DNS during connect).
144
- * 2. `host` is loopback (127.0.0.1 / ::1) AND port is 80/443.
145
- * This catches clients that resolve DNS themselves (undici
146
- * does this — calls dns.lookup, then connects with the IP).
147
- * Our dns.lookup patch maps Shopify hostnames to 127.0.0.1;
148
- * the only way loopback:80/443 happens in the test process
149
- * is via that redirected resolution.
150
- */
151
- function parseAndMaybeRewrite(args) {
152
- const first = args[0];
153
- let opts = null;
154
- if (typeof first === 'object' && first !== null && !Array.isArray(first)) {
155
- opts = first;
156
- }
157
- else if (typeof first === 'number') {
158
- const second = args[1];
159
- const host = typeof second === 'string' ? second : undefined;
160
- opts = host !== undefined ? { port: first, host } : { port: first };
161
- }
162
- if (!opts)
163
- return null;
164
- const host = opts.host ?? opts.hostname;
165
- const isShopifyHost = !!host && SHOPIFY_HOSTNAME.test(host);
166
- const isLoopback = host === '127.0.0.1' || host === '::1' || host === '::ffff:127.0.0.1';
167
- const isWellKnownPort = opts.port === 80 || opts.port === 443;
168
- const shouldRedirect = isShopifyHost || (isLoopback && isWellKnownPort);
169
- if (!shouldRedirect)
170
- return null;
171
- if (process.env['E2E_OFFLINE_SHIM_DEBUG'] === 'true') {
172
- console.error(`[node-shim] connect ${host}:${opts.port ?? '?'} → 127.0.0.1:${edgePort}`);
173
- }
174
- return { ...opts, port: edgePort };
175
- }
176
- // ── 1. net.createConnection / net.connect ───────────────────
177
- // Path used by `http.request` / `https.request` (Node's built-in
178
- // HTTP clients) and many libraries built on them.
179
- const origCreate = net.createConnection;
180
- const patchedCreate = function patchedCreate(...args) {
181
- const rewritten = parseAndMaybeRewrite(args);
182
- if (rewritten) {
183
- const listener = args.find((a) => typeof a === 'function');
184
- return listener
185
- ? origCreate.call(net, rewritten, listener)
186
- : origCreate.call(net, rewritten);
187
- }
188
- return origCreate.apply(net, args);
189
- };
190
- Object.defineProperty(net, 'createConnection', {
191
- value: patchedCreate,
192
- writable: true,
193
- configurable: true,
194
- });
195
- Object.defineProperty(net, 'connect', {
196
- value: patchedCreate,
197
- writable: true,
198
- configurable: true,
199
- });
200
- // ── 2. net.Socket.prototype.connect ─────────────────────────
201
- // Path used by undici (Node's built-in `fetch`). undici creates
202
- // a Socket directly and calls .connect() on it — bypassing
203
- // net.createConnection entirely. Patch the prototype method so
204
- // it gets the same rewrite treatment.
205
- const origSocketConnect = net.Socket.prototype.connect;
206
- net.Socket.prototype.connect = function patchedSocketConnect(...args) {
207
- const rewritten = parseAndMaybeRewrite(args);
208
- if (rewritten) {
209
- const listener = args.find((a) => typeof a === 'function');
210
- return listener
211
- ? origSocketConnect.call(this, rewritten, listener)
212
- : origSocketConnect.call(this, rewritten);
213
- }
214
- return origSocketConnect.apply(this, args);
215
- };
216
- }
217
- //# sourceMappingURL=nodeShim.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"nodeShim.js","sourceRoot":"","sources":["../../src/edge/nodeShim.ts"],"names":[],"mappings":"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"}
@@ -1,3 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
3
- //# sourceMappingURL=_probeSourceUrl.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"_probeSourceUrl.d.ts","sourceRoot":"","sources":["../../src/scripts/_probeSourceUrl.ts"],"names":[],"mappings":""}