@essential-apps/shopify-test-runner 1.0.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 (265) hide show
  1. package/dist/contracts/normalize.d.ts +61 -0
  2. package/dist/contracts/normalize.d.ts.map +1 -0
  3. package/dist/contracts/normalize.js +99 -0
  4. package/dist/contracts/normalize.js.map +1 -0
  5. package/dist/contracts/normalizeHtml.d.ts +37 -0
  6. package/dist/contracts/normalizeHtml.d.ts.map +1 -0
  7. package/dist/contracts/normalizeHtml.js +89 -0
  8. package/dist/contracts/normalizeHtml.js.map +1 -0
  9. package/dist/edge/cert.d.ts +44 -0
  10. package/dist/edge/cert.d.ts.map +1 -0
  11. package/dist/edge/cert.js +117 -0
  12. package/dist/edge/cert.js.map +1 -0
  13. package/dist/edge/edgeProxy.d.ts +43 -0
  14. package/dist/edge/edgeProxy.d.ts.map +1 -0
  15. package/dist/edge/edgeProxy.js +297 -0
  16. package/dist/edge/edgeProxy.js.map +1 -0
  17. package/dist/edge/nodeShim.d.ts +2 -0
  18. package/dist/edge/nodeShim.d.ts.map +1 -0
  19. package/dist/edge/nodeShim.js +217 -0
  20. package/dist/edge/nodeShim.js.map +1 -0
  21. package/dist/index.d.ts +39 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +36 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/lib/buildSourceBundle.d.ts +56 -0
  26. package/dist/lib/buildSourceBundle.d.ts.map +1 -0
  27. package/dist/lib/buildSourceBundle.js +153 -0
  28. package/dist/lib/buildSourceBundle.js.map +1 -0
  29. package/dist/lib/freePort.d.ts +5 -0
  30. package/dist/lib/freePort.d.ts.map +1 -0
  31. package/dist/lib/freePort.js +33 -0
  32. package/dist/lib/freePort.js.map +1 -0
  33. package/dist/lib/functionBuild.d.ts +99 -0
  34. package/dist/lib/functionBuild.d.ts.map +1 -0
  35. package/dist/lib/functionBuild.js +413 -0
  36. package/dist/lib/functionBuild.js.map +1 -0
  37. package/dist/lib/neonWsProxy.d.ts +41 -0
  38. package/dist/lib/neonWsProxy.d.ts.map +1 -0
  39. package/dist/lib/neonWsProxy.js +101 -0
  40. package/dist/lib/neonWsProxy.js.map +1 -0
  41. package/dist/lib/sourceZipUpload.d.ts +45 -0
  42. package/dist/lib/sourceZipUpload.d.ts.map +1 -0
  43. package/dist/lib/sourceZipUpload.js +129 -0
  44. package/dist/lib/sourceZipUpload.js.map +1 -0
  45. package/dist/lib/stealthLaunch.d.ts +35 -0
  46. package/dist/lib/stealthLaunch.d.ts.map +1 -0
  47. package/dist/lib/stealthLaunch.js +46 -0
  48. package/dist/lib/stealthLaunch.js.map +1 -0
  49. package/dist/lib/storeAutomation.d.ts +22 -0
  50. package/dist/lib/storeAutomation.d.ts.map +1 -0
  51. package/dist/lib/storeAutomation.js +85 -0
  52. package/dist/lib/storeAutomation.js.map +1 -0
  53. package/dist/playwright/baseConfig.d.ts +62 -0
  54. package/dist/playwright/baseConfig.d.ts.map +1 -0
  55. package/dist/playwright/baseConfig.js +68 -0
  56. package/dist/playwright/baseConfig.js.map +1 -0
  57. package/dist/playwright/globalSetup.d.ts +2 -0
  58. package/dist/playwright/globalSetup.d.ts.map +1 -0
  59. package/dist/playwright/globalSetup.js +139 -0
  60. package/dist/playwright/globalSetup.js.map +1 -0
  61. package/dist/playwright/index.d.ts +9 -0
  62. package/dist/playwright/index.d.ts.map +1 -0
  63. package/dist/playwright/index.js +9 -0
  64. package/dist/playwright/index.js.map +1 -0
  65. package/dist/probes/fonts.d.ts +4 -0
  66. package/dist/probes/fonts.d.ts.map +1 -0
  67. package/dist/probes/fonts.js +255 -0
  68. package/dist/probes/fonts.js.map +1 -0
  69. package/dist/probes/mirror.d.ts +4 -0
  70. package/dist/probes/mirror.d.ts.map +1 -0
  71. package/dist/probes/mirror.js +260 -0
  72. package/dist/probes/mirror.js.map +1 -0
  73. package/dist/probes/runProbe.d.ts +3 -0
  74. package/dist/probes/runProbe.d.ts.map +1 -0
  75. package/dist/probes/runProbe.js +219 -0
  76. package/dist/probes/runProbe.js.map +1 -0
  77. package/dist/probes/types.d.ts +72 -0
  78. package/dist/probes/types.d.ts.map +1 -0
  79. package/dist/probes/types.js +2 -0
  80. package/dist/probes/types.js.map +1 -0
  81. package/dist/scripts/_probeSourceUrl.d.ts +3 -0
  82. package/dist/scripts/_probeSourceUrl.d.ts.map +1 -0
  83. package/dist/scripts/_probeSourceUrl.js +119 -0
  84. package/dist/scripts/_probeSourceUrl.js.map +1 -0
  85. package/dist/scripts/addStore.d.ts +3 -0
  86. package/dist/scripts/addStore.d.ts.map +1 -0
  87. package/dist/scripts/addStore.js +46 -0
  88. package/dist/scripts/addStore.js.map +1 -0
  89. package/dist/scripts/buildDockerImage.d.ts +3 -0
  90. package/dist/scripts/buildDockerImage.d.ts.map +1 -0
  91. package/dist/scripts/buildDockerImage.js +60 -0
  92. package/dist/scripts/buildDockerImage.js.map +1 -0
  93. package/dist/scripts/captureAuth.d.ts +3 -0
  94. package/dist/scripts/captureAuth.d.ts.map +1 -0
  95. package/dist/scripts/captureAuth.js +124 -0
  96. package/dist/scripts/captureAuth.js.map +1 -0
  97. package/dist/scripts/captureContracts.d.ts +3 -0
  98. package/dist/scripts/captureContracts.d.ts.map +1 -0
  99. package/dist/scripts/captureContracts.js +517 -0
  100. package/dist/scripts/captureContracts.js.map +1 -0
  101. package/dist/scripts/captureRestContracts.d.ts +3 -0
  102. package/dist/scripts/captureRestContracts.d.ts.map +1 -0
  103. package/dist/scripts/captureRestContracts.js +245 -0
  104. package/dist/scripts/captureRestContracts.js.map +1 -0
  105. package/dist/scripts/checkOperationCoverage.d.ts +3 -0
  106. package/dist/scripts/checkOperationCoverage.d.ts.map +1 -0
  107. package/dist/scripts/checkOperationCoverage.js +302 -0
  108. package/dist/scripts/checkOperationCoverage.js.map +1 -0
  109. package/dist/scripts/cleanupStores.d.ts +3 -0
  110. package/dist/scripts/cleanupStores.d.ts.map +1 -0
  111. package/dist/scripts/cleanupStores.js +77 -0
  112. package/dist/scripts/cleanupStores.js.map +1 -0
  113. package/dist/scripts/createStores.d.ts +3 -0
  114. package/dist/scripts/createStores.d.ts.map +1 -0
  115. package/dist/scripts/createStores.js +66 -0
  116. package/dist/scripts/createStores.js.map +1 -0
  117. package/dist/scripts/deployAppVersion.d.ts +3 -0
  118. package/dist/scripts/deployAppVersion.d.ts.map +1 -0
  119. package/dist/scripts/deployAppVersion.js +591 -0
  120. package/dist/scripts/deployAppVersion.js.map +1 -0
  121. package/dist/scripts/devE2eBackend.d.ts +3 -0
  122. package/dist/scripts/devE2eBackend.d.ts.map +1 -0
  123. package/dist/scripts/devE2eBackend.js +117 -0
  124. package/dist/scripts/devE2eBackend.js.map +1 -0
  125. package/dist/scripts/devOnlineBackend.d.ts +3 -0
  126. package/dist/scripts/devOnlineBackend.d.ts.map +1 -0
  127. package/dist/scripts/devOnlineBackend.js +117 -0
  128. package/dist/scripts/devOnlineBackend.js.map +1 -0
  129. package/dist/scripts/installApp.d.ts +3 -0
  130. package/dist/scripts/installApp.d.ts.map +1 -0
  131. package/dist/scripts/installApp.js +163 -0
  132. package/dist/scripts/installApp.js.map +1 -0
  133. package/dist/scripts/listStores.d.ts +3 -0
  134. package/dist/scripts/listStores.d.ts.map +1 -0
  135. package/dist/scripts/listStores.js +18 -0
  136. package/dist/scripts/listStores.js.map +1 -0
  137. package/dist/scripts/runDocker.d.ts +3 -0
  138. package/dist/scripts/runDocker.d.ts.map +1 -0
  139. package/dist/scripts/runDocker.js +88 -0
  140. package/dist/scripts/runDocker.js.map +1 -0
  141. package/dist/scripts/runDockerAuth.d.ts +3 -0
  142. package/dist/scripts/runDockerAuth.d.ts.map +1 -0
  143. package/dist/scripts/runDockerAuth.js +108 -0
  144. package/dist/scripts/runDockerAuth.js.map +1 -0
  145. package/dist/scripts/runDockerOffline.d.ts +3 -0
  146. package/dist/scripts/runDockerOffline.d.ts.map +1 -0
  147. package/dist/scripts/runDockerOffline.js +129 -0
  148. package/dist/scripts/runDockerOffline.js.map +1 -0
  149. package/dist/scripts/runDockerOfflineExplore.d.ts +3 -0
  150. package/dist/scripts/runDockerOfflineExplore.d.ts.map +1 -0
  151. package/dist/scripts/runDockerOfflineExplore.js +116 -0
  152. package/dist/scripts/runDockerOfflineExplore.js.map +1 -0
  153. package/dist/scripts/runIsolatedDockerOffline.d.ts +3 -0
  154. package/dist/scripts/runIsolatedDockerOffline.d.ts.map +1 -0
  155. package/dist/scripts/runIsolatedDockerOffline.js +351 -0
  156. package/dist/scripts/runIsolatedDockerOffline.js.map +1 -0
  157. package/dist/scripts/runOffline.d.ts +3 -0
  158. package/dist/scripts/runOffline.d.ts.map +1 -0
  159. package/dist/scripts/runOffline.js +521 -0
  160. package/dist/scripts/runOffline.js.map +1 -0
  161. package/dist/scripts/runOfflineE2e.d.ts +3 -0
  162. package/dist/scripts/runOfflineE2e.d.ts.map +1 -0
  163. package/dist/scripts/runOfflineE2e.js +408 -0
  164. package/dist/scripts/runOfflineE2e.js.map +1 -0
  165. package/dist/scripts/runOfflineFullTests.d.ts +3 -0
  166. package/dist/scripts/runOfflineFullTests.d.ts.map +1 -0
  167. package/dist/scripts/runOfflineFullTests.js +1456 -0
  168. package/dist/scripts/runOfflineFullTests.js.map +1 -0
  169. package/dist/scripts/runSupermachine.d.ts +3 -0
  170. package/dist/scripts/runSupermachine.d.ts.map +1 -0
  171. package/dist/scripts/runSupermachine.js +474 -0
  172. package/dist/scripts/runSupermachine.js.map +1 -0
  173. package/dist/scripts/runSupermachineAuth.d.ts +3 -0
  174. package/dist/scripts/runSupermachineAuth.d.ts.map +1 -0
  175. package/dist/scripts/runSupermachineAuth.js +454 -0
  176. package/dist/scripts/runSupermachineAuth.js.map +1 -0
  177. package/dist/scripts/runTests.d.ts +3 -0
  178. package/dist/scripts/runTests.d.ts.map +1 -0
  179. package/dist/scripts/runTests.js +278 -0
  180. package/dist/scripts/runTests.js.map +1 -0
  181. package/dist/scripts/runVm.d.ts +3 -0
  182. package/dist/scripts/runVm.d.ts.map +1 -0
  183. package/dist/scripts/runVm.js +524 -0
  184. package/dist/scripts/runVm.js.map +1 -0
  185. package/dist/scripts/runVmAuth.d.ts +3 -0
  186. package/dist/scripts/runVmAuth.d.ts.map +1 -0
  187. package/dist/scripts/runVmAuth.js +475 -0
  188. package/dist/scripts/runVmAuth.js.map +1 -0
  189. package/dist/scripts/runVmScript.d.ts +3 -0
  190. package/dist/scripts/runVmScript.d.ts.map +1 -0
  191. package/dist/scripts/runVmScript.js +242 -0
  192. package/dist/scripts/runVmScript.js.map +1 -0
  193. package/dist/scripts/setupTestDb.d.ts +3 -0
  194. package/dist/scripts/setupTestDb.d.ts.map +1 -0
  195. package/dist/scripts/setupTestDb.js +61 -0
  196. package/dist/scripts/setupTestDb.js.map +1 -0
  197. package/dist/scripts/verifyContracts.d.ts +3 -0
  198. package/dist/scripts/verifyContracts.d.ts.map +1 -0
  199. package/dist/scripts/verifyContracts.js +258 -0
  200. package/dist/scripts/verifyContracts.js.map +1 -0
  201. package/dist/scripts/verifyRestContracts.d.ts +3 -0
  202. package/dist/scripts/verifyRestContracts.d.ts.map +1 -0
  203. package/dist/scripts/verifyRestContracts.js +237 -0
  204. package/dist/scripts/verifyRestContracts.js.map +1 -0
  205. package/dist/vite/offlineConfig.d.ts +34 -0
  206. package/dist/vite/offlineConfig.d.ts.map +1 -0
  207. package/dist/vite/offlineConfig.js +61 -0
  208. package/dist/vite/offlineConfig.js.map +1 -0
  209. package/dist/vite/onlineConfig.d.ts +42 -0
  210. package/dist/vite/onlineConfig.d.ts.map +1 -0
  211. package/dist/vite/onlineConfig.js +56 -0
  212. package/dist/vite/onlineConfig.js.map +1 -0
  213. package/docker/Dockerfile +67 -0
  214. package/docker/Dockerfile.vm +137 -0
  215. package/docker/README.md +50 -0
  216. package/docker/entrypoint.sh +198 -0
  217. package/package.json +85 -0
  218. package/src/contracts/normalize.ts +96 -0
  219. package/src/contracts/normalizeHtml.ts +98 -0
  220. package/src/edge/ca.cnf +14 -0
  221. package/src/edge/ca.crt +22 -0
  222. package/src/edge/ca.key +28 -0
  223. package/src/edge/cert.ts +117 -0
  224. package/src/edge/edgeProxy.ts +390 -0
  225. package/src/edge/server.cnf +28 -0
  226. package/src/edge/server.crt +26 -0
  227. package/src/edge/server.key +28 -0
  228. package/src/index.ts +67 -0
  229. package/src/lib/buildSourceBundle.ts +197 -0
  230. package/src/lib/freePort.ts +33 -0
  231. package/src/lib/functionBuild.ts +490 -0
  232. package/src/lib/neonWsProxy.ts +124 -0
  233. package/src/lib/sourceZipUpload.ts +168 -0
  234. package/src/lib/stealthLaunch.ts +57 -0
  235. package/src/lib/storeAutomation.ts +110 -0
  236. package/src/playwright/baseConfig.ts +120 -0
  237. package/src/playwright/globalSetup.ts +179 -0
  238. package/src/playwright/index.ts +11 -0
  239. package/src/probes/fonts.ts +279 -0
  240. package/src/probes/mirror.ts +283 -0
  241. package/src/probes/runProbe.ts +257 -0
  242. package/src/probes/types.ts +73 -0
  243. package/src/scripts/addStore.ts +59 -0
  244. package/src/scripts/buildDockerImage.ts +66 -0
  245. package/src/scripts/captureAuth.ts +145 -0
  246. package/src/scripts/captureContracts.ts +675 -0
  247. package/src/scripts/captureRestContracts.ts +319 -0
  248. package/src/scripts/checkOperationCoverage.ts +365 -0
  249. package/src/scripts/cleanupStores.ts +91 -0
  250. package/src/scripts/createStores.ts +77 -0
  251. package/src/scripts/deployAppVersion.ts +692 -0
  252. package/src/scripts/devOnlineBackend.ts +141 -0
  253. package/src/scripts/installApp.ts +188 -0
  254. package/src/scripts/listStores.ts +19 -0
  255. package/src/scripts/runDockerAuth.ts +120 -0
  256. package/src/scripts/runOffline.ts +577 -0
  257. package/src/scripts/runOfflineFullTests.ts +1634 -0
  258. package/src/scripts/runTests.ts +306 -0
  259. package/src/scripts/runVm.ts +562 -0
  260. package/src/scripts/runVmAuth.ts +541 -0
  261. package/src/scripts/runVmScript.ts +282 -0
  262. package/src/scripts/setupTestDb.ts +71 -0
  263. package/src/scripts/verifyContracts.ts +310 -0
  264. package/src/scripts/verifyRestContracts.ts +275 -0
  265. package/src/vite/onlineConfig.ts +60 -0
@@ -0,0 +1,1456 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * runOfflineFullTests — full-stack offline orchestrator.
4
+ *
5
+ * Boots the entire offline stack in ONE Node process:
6
+ *
7
+ * - Postgres (per-run UUID DB, migrated)
8
+ * - 4 mock servers in-process: admin shell, Admin GraphQL,
9
+ * Storefront GraphQL, mock storefront (Liquid) — all reading
10
+ * ONE shared ShopState (in-memory, owned by this process)
11
+ * - Remix backend (Vite) as a child process, with NODE_OPTIONS
12
+ * preloading MSW so its outbound `fetch()` to *.myshopify.com
13
+ * reaches our Admin GraphQL mock
14
+ * - Playwright as a child process, with mock URLs in env
15
+ *
16
+ * The Playwright fixture (`offlineFullTest`, orchestrated mode)
17
+ * reads the mock URLs from env and routes browser requests via
18
+ * `page.route()`. Tests mutate ShopState via control-plane HTTP
19
+ * endpoints exposed by the storefront mock (mounted under
20
+ * `/__test__/state/*`).
21
+ *
22
+ * This is the offline counterpart to runTests.ts (online mode).
23
+ * Online tests still go via runTests.ts; offline-full tests via this.
24
+ *
25
+ * Env it sets in the spawned Remix backend:
26
+ * SHOPIFY_API_SECRET = mock JWT secret (so JWTs validate)
27
+ * NODE_OPTIONS = --import @essential-apps/shopify-test-shopify-api/msw-loader
28
+ * TEST_OFFLINE_MOCK_ADMIN_API_URL = mock GraphQL baseUrl (used by MSW loader)
29
+ * TEST_OFFLINE = true (app-side opt-in flag for any code that
30
+ * wants to no-op real Shopify side-effects)
31
+ *
32
+ * Env it sets in the Playwright run:
33
+ * TEST_OFFLINE_ORCHESTRATED = true
34
+ * TEST_OFFLINE_MOCK_ADMIN_SHELL_URL = http://127.0.0.1:N
35
+ * TEST_OFFLINE_MOCK_ADMIN_API_URL = http://127.0.0.1:N
36
+ * TEST_OFFLINE_MOCK_STOREFRONT_API_URL = http://127.0.0.1:N
37
+ * TEST_OFFLINE_MOCK_STOREFRONT_URL = http://127.0.0.1:N
38
+ * TEST_OFFLINE_MOCK_BACKEND_URL = the Remix backend's URL
39
+ * TEST_OFFLINE_MOCK_SHOP_DOMAIN = test-shop.myshopify.com
40
+ */
41
+ import { spawn, execSync } from 'node:child_process';
42
+ import { assertInVm, computeBuildHash } from '@essential-apps/shopify-test-core';
43
+ import { startEdgeProxy } from '../edge/edgeProxy.js';
44
+ import { startNeonWsProxy } from '../lib/neonWsProxy.js';
45
+ import { TEST_CA_CERT_PEM } from '../edge/cert.js';
46
+ import { randomUUID } from 'node:crypto';
47
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, unlinkSync } from 'node:fs';
48
+ import { resolve } from 'node:path';
49
+ import { tmpdir } from 'node:os';
50
+ import { setTimeout as sleep } from 'node:timers/promises';
51
+ import { Agent } from 'undici';
52
+ import { ShopState, createStorefrontApp, startStorefront, } from '@essential-apps/shopify-test-storefront';
53
+ import { dawn } from '@essential-apps/shopify-test-themes';
54
+ import { createAdminApi, startAdminApi, createStorefrontApi, startStorefrontApi, createPartnerApi, startPartnerApi, } from '@essential-apps/shopify-test-shopify-api';
55
+ import { createMockAdminApp, startMockAdmin, DEFAULT_MOCK_JWT_SECRET, mintIdToken, } from '@essential-apps/shopify-test-mock-admin';
56
+ const insecureFetchDispatcher = new Agent({ connect: { rejectUnauthorized: false } });
57
+ const PG_HOST = 'localhost';
58
+ const PG_PORT = '5432';
59
+ const READY_TIMEOUT_MS = 120_000;
60
+ const DEFAULT_SHOP_DOMAIN = 'test-shop.myshopify.com';
61
+ const DEFAULT_APP_HANDLE = process.env['SHOPIFY_APP_HANDLE'] ?? 'essential-app';
62
+ // Mock client id — must match what the Remix backend reads as
63
+ // SHOPIFY_API_KEY for App Bridge validation paths to align. We use a
64
+ // fixed value so seeded sessions and JWT audience claims line up.
65
+ // Matches the default clientId in the offline mock-admin / shopState
66
+ // fixtures. If those defaults change, change this too.
67
+ const MOCK_CLIENT_ID = 'mock-api-key';
68
+ function readAppName() {
69
+ const pkgPath = resolve(process.cwd(), 'package.json');
70
+ if (!existsSync(pkgPath)) {
71
+ throw new Error(`No package.json at ${pkgPath}.`);
72
+ }
73
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
74
+ if (!pkg.name)
75
+ throw new Error(`package.json has no name field.`);
76
+ return pkg.name.replace(/^@[^/]+\//, '');
77
+ }
78
+ function dbUrl(name) {
79
+ const user = process.env['PGUSER'] || process.env['USER'] || 'root';
80
+ return `postgresql://${user}@${PG_HOST}:${PG_PORT}/${name}`;
81
+ }
82
+ /**
83
+ * Drop any DBs left over from previous crashed orchestrator runs.
84
+ * We match on the per-app naming pattern `${app}_offline_*`. A
85
+ * graceful exit drops the DB in the `finally` block; a SIGKILL
86
+ * (or test runner timeout) leaks it. Cleanup at the START of the
87
+ * next run keeps the dev's Postgres from accumulating dozens of
88
+ * dead databases over months.
89
+ *
90
+ * Safe: only deletes DBs matching our prefix; never touches anything
91
+ * the user might have manually named.
92
+ */
93
+ function dropOrphanDatabases(prefix) {
94
+ try {
95
+ const output = execSync(`psql -h ${PG_HOST} -p ${PG_PORT} -d postgres -At -c "SELECT datname FROM pg_database WHERE datname LIKE '${prefix}_%'"`, { encoding: 'utf8' });
96
+ const orphans = output
97
+ .split('\n')
98
+ .map((s) => s.trim())
99
+ .filter((s) => s.length > 0);
100
+ if (orphans.length === 0)
101
+ return;
102
+ console.log(`[runOfflineFull] cleanup: dropping ${orphans.length} orphan DB(s)…`);
103
+ for (const name of orphans) {
104
+ try {
105
+ // `--force` (psql 13+) terminates any leftover connections
106
+ // before drop. Falls back to plain dropdb if --force isn't
107
+ // supported in the local psql.
108
+ execSync(`dropdb --force -h ${PG_HOST} -p ${PG_PORT} ${name} 2>/dev/null || dropdb -h ${PG_HOST} -p ${PG_PORT} ${name}`, {
109
+ stdio: 'ignore',
110
+ });
111
+ }
112
+ catch {
113
+ // Couldn't drop (active connection from another orchestrator
114
+ // run, perhaps). Skip silently — we'll get it next time.
115
+ }
116
+ }
117
+ }
118
+ catch (err) {
119
+ // If psql isn't available at all we just skip cleanup; the
120
+ // primary createdb below will fail loudly anyway.
121
+ console.warn(`[runOfflineFull] orphan-DB cleanup failed: ${err.message}`);
122
+ }
123
+ }
124
+ /**
125
+ * Build a `resetDb` hook the storefront control plane can call
126
+ * between tests. Runs:
127
+ * 1. TRUNCATE every user-defined table in `public` (CASCADE,
128
+ * RESTART IDENTITY) — discovered dynamically from pg_tables
129
+ * so adding a model doesn't break the hook.
130
+ * 2. Re-seed the consuming app's Session row.
131
+ *
132
+ * Why TRUNCATE-then-reseed vs. literal per-test database:
133
+ * - Per-test DB would require restarting the backend (a single
134
+ * long-running process with a fixed DATABASE_URL) on every
135
+ * test, ~5s overhead per test → multiple minutes for the
136
+ * current suite. TRUNCATE runs in ~5ms and is observationally
137
+ * identical for the consuming code.
138
+ * - This hook is ORCHESTRATOR-owned, so the consuming app no
139
+ * longer needs a `/test-internal/reset-db` route. The backend
140
+ * stays test-unaware.
141
+ */
142
+ function makeResetDbHook(dbName, shopDomain, scopes) {
143
+ return async () => {
144
+ // Build the SQL as a single transaction: one round-trip, atomic.
145
+ // The DO block iterates pg_tables for `public` schema, skipping
146
+ // Prisma's migration metadata. RESTART IDENTITY resets serial
147
+ // sequences so per-test IDs start from 1 (predictable in
148
+ // snapshots / assertions).
149
+ const seedSql =
150
+ // Insert the seeded session row. We pass scopes as a literal
151
+ // here (escaped) because the `psql -c` interface doesn't have
152
+ // proper parameterized queries.
153
+ `INSERT INTO "Session" (id, shop, state, "isOnline", "accessToken", scope) ` +
154
+ `VALUES ('offline_${shopDomain}', '${shopDomain}', 'offline-mock', false, 'mock-access-token', '${scopes.replace(/'/g, "''")}') ` +
155
+ `ON CONFLICT (id) DO UPDATE SET ` +
156
+ `shop = EXCLUDED.shop, state = EXCLUDED.state, "accessToken" = EXCLUDED."accessToken", scope = EXCLUDED.scope;`;
157
+ const sql = `
158
+ BEGIN;
159
+ DO $$
160
+ DECLARE
161
+ t text;
162
+ BEGIN
163
+ FOR t IN
164
+ SELECT tablename FROM pg_tables
165
+ WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'
166
+ LOOP
167
+ EXECUTE 'TRUNCATE TABLE "' || t || '" RESTART IDENTITY CASCADE';
168
+ END LOOP;
169
+ END $$;
170
+ ${seedSql}
171
+ COMMIT;
172
+ `;
173
+ // Use a tempfile + `psql -f` rather than `-c "..."`. The
174
+ // inline form fights shell quoting on multi-line SQL that
175
+ // contains dollar-quoted DO blocks ($$ ... $$). A file is
176
+ // also easier to inspect when debugging.
177
+ const tmpFile = resolve(tmpdir(), `offline-reset-db-${randomUUID()}.sql`);
178
+ writeFileSync(tmpFile, sql, 'utf8');
179
+ try {
180
+ execSync(`psql -h ${PG_HOST} -p ${PG_PORT} -d ${dbName} -v ON_ERROR_STOP=1 -f ${tmpFile}`, { stdio: ['ignore', 'pipe', 'pipe'] });
181
+ }
182
+ catch (err) {
183
+ const stderr = err.stderr?.toString() ?? '';
184
+ const stdout = err.stdout?.toString() ?? '';
185
+ throw new Error(`[resetDb] psql failed: ${err.message}\n` +
186
+ `stdout: ${stdout}\nstderr: ${stderr}\nSQL file (preserved for inspection): ${tmpFile}`);
187
+ }
188
+ try {
189
+ unlinkSync(tmpFile);
190
+ }
191
+ catch {
192
+ /* best-effort */
193
+ }
194
+ };
195
+ }
196
+ /** Probe a URL until it responds (any status). Used for backend readiness. */
197
+ async function waitForReady(url, timeoutMs) {
198
+ const deadline = Date.now() + timeoutMs;
199
+ let lastErr = '';
200
+ while (Date.now() < deadline) {
201
+ try {
202
+ const res = await fetch(url, {
203
+ redirect: 'manual',
204
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
205
+ dispatcher: insecureFetchDispatcher,
206
+ });
207
+ if (res.status > 0)
208
+ return;
209
+ }
210
+ catch (e) {
211
+ lastErr = e.message;
212
+ }
213
+ await sleep(1_000);
214
+ }
215
+ throw new Error(`Backend never became ready at ${url}. Last error: ${lastErr}`);
216
+ }
217
+ /**
218
+ * Configure the container for offline mode: write Shopify hostname
219
+ * entries into /etc/hosts and install the edge proxy's self-signed
220
+ * cert as a system CA. The container entrypoint
221
+ * (tests/test-offline/docker/entrypoint.sh) does the same thing when
222
+ * node_modules is warm at container start; on a cold cache, the
223
+ * entrypoint can't (cert file doesn't exist yet) and this is the
224
+ * post-install backstop.
225
+ *
226
+ * Idempotent — safe to call multiple times. The `grep -q` guards
227
+ * make /etc/hosts edits skip if entries are already present;
228
+ * `update-ca-certificates` is a no-op if the cert is already
229
+ * trusted.
230
+ */
231
+ function ensureOfflineSetup() {
232
+ // Source of truth for the CA cert: imported from edge/cert.js
233
+ // (statically imported at the top of this file). `certutil -A -i`
234
+ // requires an on-disk path so we write the CA to a temp file
235
+ // before importing into NSS; the system CA store install (which
236
+ // uses /usr/local/share/ca-certificates/) writes the PEM string
237
+ // directly.
238
+ // /etc/hosts entries — IPv4 ONLY. The edge proxy binds on 0.0.0.0
239
+ // (IPv4); listen() on `::` (IPv6 dual-stack) hangs forever on the
240
+ // VM kernel, see commit 603594e. If we also add `::1`
241
+ // entries, Chrome resolves AAAA → ::1, connects to nothing, and
242
+ // returns "Failed to fetch" inside page.evaluate(). IPv4-only
243
+ // mapping forces Chrome down the IPv4 path that actually reaches
244
+ // the edge proxy.
245
+ const hostsPath = '/etc/hosts';
246
+ let hostsContent = '';
247
+ try {
248
+ hostsContent = readFileSync(hostsPath, 'utf8');
249
+ }
250
+ catch {
251
+ console.warn(`[runOfflineFull] could not read ${hostsPath}; skipping /etc/hosts setup`);
252
+ return;
253
+ }
254
+ const hostsToAdd = [];
255
+ for (const host of [
256
+ 'test-shop.myshopify.com',
257
+ 'admin.shopify.com',
258
+ 'cdn.shopify.com',
259
+ // Real Shopify font CDN — routed by the edge proxy to the
260
+ // storefront mock's /__shopify-fonts/* handler so the live
261
+ // `font_face` filter output (which contains real
262
+ // fonts.shopifycdn.com URLs) just works in-browser.
263
+ 'fonts.shopifycdn.com',
264
+ ]) {
265
+ if (!new RegExp(`^127\\.0\\.0\\.1\\s+${host.replace(/\./g, '\\.')}\\s*$`, 'm').test(hostsContent)) {
266
+ hostsToAdd.push(`127.0.0.1 ${host}`);
267
+ }
268
+ // NOTE: deliberately NOT adding `::1 <host>` entries — see the
269
+ // comment block above for why.
270
+ }
271
+ if (hostsToAdd.length > 0) {
272
+ try {
273
+ writeFileSync(hostsPath, hostsContent + '\n' + hostsToAdd.join('\n') + '\n');
274
+ console.log(`[runOfflineFull] wrote ${hostsToAdd.length} /etc/hosts entries`);
275
+ }
276
+ catch (err) {
277
+ console.warn(`[runOfflineFull] could not write ${hostsPath}: ${err.message}. ` +
278
+ `Browser may resolve Shopify hostnames to real Shopify on the internet.`);
279
+ }
280
+ }
281
+ // System CA trust (Node). `/usr/local/share/ca-certificates/*.crt`
282
+ // is the canonical apt-style location; update-ca-certificates
283
+ // appends to /etc/ssl/certs/ca-certificates.crt which Node +
284
+ // glibc OpenSSL read. NODE_EXTRA_CA_CERTS belt-and-suspenders.
285
+ const systemCertDest = '/usr/local/share/ca-certificates/edge-ca.crt';
286
+ try {
287
+ let existing = '';
288
+ try {
289
+ existing = readFileSync(systemCertDest, 'utf8');
290
+ }
291
+ catch {
292
+ /* not installed yet */
293
+ }
294
+ if (existing !== TEST_CA_CERT_PEM) {
295
+ writeFileSync(systemCertDest, TEST_CA_CERT_PEM);
296
+ execSync('update-ca-certificates >/dev/null 2>&1 || true', { stdio: 'ignore' });
297
+ console.log('[runOfflineFull] installed edge CA into system trust store');
298
+ }
299
+ process.env['NODE_EXTRA_CA_CERTS'] = systemCertDest;
300
+ }
301
+ catch (err) {
302
+ console.warn(`[runOfflineFull] could not install edge CA: ${err.message}. ` +
303
+ `TLS connections to the edge may fail.`);
304
+ }
305
+ // Chrome / Chromium on Linux verifies certs against NSS DB
306
+ // (`~/.pki/nssdb`), NOT the system CA store. We import the CA
307
+ // cert (NOT the server cert) — Chrome's chain validation walks
308
+ // server cert → CA in NSS → trust anchor. `certutil` (from
309
+ // libnss3-tools) is the canonical tool. NSS DB is per-user; we
310
+ // run as root so it lives at /root/.pki/nssdb and is shared
311
+ // across every Chromium instance (test runs + explore mode +
312
+ // any future headed-Chrome workflow).
313
+ //
314
+ // Trust attribute "C,,":
315
+ // First slot C = trusted CA for SSL/TLS server cert validation
316
+ // Second slot empty = not trusted for email
317
+ // Third slot empty = not trusted for code/object signing
318
+ // Minimum-privilege scope for what we need.
319
+ try {
320
+ const nssDir = `${process.env['HOME'] ?? '/root'}/.pki/nssdb`;
321
+ if (!existsSync(nssDir)) {
322
+ mkdirSync(nssDir, { recursive: true });
323
+ execSync(`certutil -N --empty-password -d sql:${nssDir}`, {
324
+ stdio: 'ignore',
325
+ });
326
+ }
327
+ // Write CA cert to a temp file so certutil -i can read it.
328
+ const tmpCaPath = '/tmp/edge-ca.crt';
329
+ writeFileSync(tmpCaPath, TEST_CA_CERT_PEM);
330
+ // Delete-then-add for idempotency (avoids "already exists").
331
+ execSync(`certutil -d sql:${nssDir} -D -n edge-ca >/dev/null 2>&1 || true`, { stdio: 'ignore' });
332
+ execSync(`certutil -d sql:${nssDir} -A -t "C,," -n edge-ca -i ${tmpCaPath}`, { stdio: 'ignore' });
333
+ unlinkSync(tmpCaPath);
334
+ console.log('[runOfflineFull] installed edge CA into NSS DB (Chrome)');
335
+ }
336
+ catch (err) {
337
+ console.warn(`[runOfflineFull] could not install edge CA into NSS DB: ${err.message}. ` +
338
+ `Chrome will reject the edge's cert. Verify libnss3-tools is installed in the image.`);
339
+ }
340
+ }
341
+ /**
342
+ * Build the absolute path to the MSW loader inside the consuming
343
+ * app's node_modules. NODE_OPTIONS=--import requires either an absolute
344
+ * path or a Node-resolvable specifier; an absolute path is the
345
+ * safest bet across Node versions.
346
+ */
347
+ function resolveMswLoaderPath() {
348
+ const candidate = resolve(process.cwd(), 'node_modules/@essential-apps/shopify-test-shopify-api/dist/admin/mswLoader.js');
349
+ if (!existsSync(candidate)) {
350
+ throw new Error(`MSW loader not found at ${candidate}. Did you install ` +
351
+ `@essential-apps/shopify-test-shopify-api as a dependency of the ` +
352
+ `consuming app?`);
353
+ }
354
+ return candidate;
355
+ }
356
+ /**
357
+ * Absolute path to the Neon-local preload shim (ships in
358
+ * shopify-test-shopify-api, beside the MSW loader). Preloaded into the
359
+ * backend so an app whose prisma client uses @neondatabase/serverless
360
+ * reaches THIS run's local Postgres via the WS proxy — no app change.
361
+ */
362
+ function resolveNeonShimPath() {
363
+ const candidate = resolve(process.cwd(), 'node_modules/@essential-apps/shopify-test-shopify-api/dist/admin/neonLocalShim.js');
364
+ if (!existsSync(candidate)) {
365
+ throw new Error(`Neon-local shim not found at ${candidate}. Did you install ` +
366
+ `@essential-apps/shopify-test-shopify-api as a dependency of the ` +
367
+ `consuming app?`);
368
+ }
369
+ return candidate;
370
+ }
371
+ /**
372
+ * Seed a Session row directly via prisma so authenticate.admin()
373
+ * finds the merchant. We invoke a tiny inline TS script via the app's
374
+ * own node_modules so we get the same Prisma client + schema.
375
+ */
376
+ function seedSession(env, shopDomain) {
377
+ // Write the seed to a temp file rather than --eval to avoid having
378
+ // to escape `$` in the inline script (which gets eaten by various
379
+ // levels of shell/template-literal interpretation).
380
+ const tmpFile = resolve(process.cwd(), `.offline-seed-${randomUUID()}.mjs`);
381
+ const script = [
382
+ `import { PrismaClient } from '@prisma/client';`,
383
+ `const prisma = new PrismaClient();`,
384
+ `await prisma.session.upsert({`,
385
+ ` where: { id: 'offline_${shopDomain}' },`,
386
+ ` update: {},`,
387
+ ` create: {`,
388
+ ` id: 'offline_${shopDomain}',`,
389
+ ` shop: '${shopDomain}',`,
390
+ ` state: 'offline-mock',`,
391
+ ` isOnline: false,`,
392
+ ` accessToken: 'mock-access-token',`,
393
+ ` scope: process.env.SCOPES || '',`,
394
+ ` },`,
395
+ `});`,
396
+ `await prisma.$disconnect();`,
397
+ `console.log('[seedSession] OK');`,
398
+ ].join('\n');
399
+ try {
400
+ writeFileSync(tmpFile, script);
401
+ execSync(`node ${tmpFile}`, { stdio: 'inherit', env });
402
+ }
403
+ finally {
404
+ try {
405
+ unlinkSync(tmpFile);
406
+ }
407
+ catch {
408
+ /* ignore */
409
+ }
410
+ }
411
+ }
412
+ /**
413
+ * Multi-worker via the shard-supervisor pattern.
414
+ *
415
+ * Setting `TEST_OFFLINE_WORKERS=N` runs N parallel processes, each
416
+ * isolated end-to-end:
417
+ * - own Postgres DB (random UUID, no collision)
418
+ * - own Remix backend on `BASE_PORT + shardIndex`
419
+ * - own 5 mock servers on OS-assigned ports
420
+ * - own Playwright invocation with `--shard k/N` to partition tests
421
+ *
422
+ * Architecture:
423
+ * - Supervisor (no `TEST_OFFLINE_SHARD_INDEX` set, N>1):
424
+ * 1. Build cache check + build once (so shards don't race)
425
+ * 2. Orphan-DB cleanup once
426
+ * 3. Spawn N child processes (`node ... runOfflineFullTests.ts`)
427
+ * each with `TEST_OFFLINE_SHARD_INDEX=k`, `TEST_OFFLINE_SKIP_BUILD=true`
428
+ * 4. Stream child stdout/stderr prefixed with [shard k]
429
+ * 5. Wait for all, exit with worst exit code
430
+ * - Child (`TEST_OFFLINE_SHARD_INDEX` set):
431
+ * 1. Skip build (supervisor did it)
432
+ * 2. Skip orphan cleanup (supervisor did it)
433
+ * 3. Use BASE_PORT + (shardIndex-1) for backend
434
+ * 4. Per-shard Playwright HTML report dir
435
+ * 5. Append `--shard k/N` to playwright args
436
+ *
437
+ * Why a process-per-shard vs. multi-slot-in-one-process:
438
+ * - Avoids ~500-line refactor of main() to manage N parallel
439
+ * boots / closes / signal handlers
440
+ * - Keeps Playwright's "one worker per process" model intact
441
+ * - Each shard is a clean unit; crashes don't cross-contaminate
442
+ * - Cost: ~5s extra per shard for backend boot (parallelized)
443
+ */
444
+ async function main() {
445
+ // Hard gate: this orchestrator only runs inside the offline container.
446
+ // The whole offline architecture (edge proxy on :443, /etc/hosts
447
+ // for *.myshopify.com, system-CA-trusted self-signed cert, port
448
+ // 443 bind) depends on running in a Linux microVM where:
449
+ // - /etc/hosts modifications stick for the test session
450
+ // - port 443 is bindable (we're root in the container)
451
+ // - the system CA store can be updated without sudo theater
452
+ // - a fresh DB + Postgres come with the image
453
+ //
454
+ // Running host-native would either need a different cert/port/DNS
455
+ // strategy or invasive Chromium/Node bypasses. We tried that path
456
+ // (page.route() + host-resolver-rules + a Node socket-layer shim);
457
+ // it worked but maintained ~400 lines of bypass code in parallel.
458
+ // Use `npm run test:offline-full` from the consuming app — it
459
+ // routes through `runDockerOffline.ts` which spawns the
460
+ // `essential-upsell-test:latest` image where TEST_IN_CONTAINER=true
461
+ // is set by docker/entrypoint.sh.
462
+ if (process.env['TEST_IN_CONTAINER'] !== 'true') {
463
+ console.error('[runOfflineFull] FATAL: must run inside the offline container.\n' +
464
+ ' Run: `npm run test:offline-full` (which invokes runDockerOffline.ts)\n' +
465
+ ' If you are seeing this from inside what should be a container, the\n' +
466
+ " entrypoint script (tests/test-offline/docker/entrypoint.sh) didn't run or\n" +
467
+ " didn't export TEST_IN_CONTAINER=true.");
468
+ process.exit(2);
469
+ }
470
+ // ── Offline-mode setup (idempotent, survives cold cache) ──
471
+ // The container entrypoint (tests/test-offline/docker/entrypoint.sh) does
472
+ // this when node_modules is already populated at container start.
473
+ // But on a FRESH Linux node_modules cache, the entrypoint runs
474
+ // BEFORE `npm install`, so the cert isn't on disk yet and the
475
+ // entrypoint's offline-setup block is skipped. Re-apply it here
476
+ // now that we know node_modules exists.
477
+ ensureOfflineSetup();
478
+ // ── Supervisor branch ─────────────────────────────────────
479
+ // Default to 1 (serial). The supervisor model spawns N child
480
+ // processes inside ONE libkrun container; each child boots its
481
+ // OWN edge proxy on :443 for the *.myshopify.com → 127.0.0.1
482
+ // hosts-file redirect. Inside a single network namespace only one
483
+ // bind to :443 wins — N>1 here fails with EADDRINUSE.
484
+ //
485
+ // For real parallelism, use `runIsolatedDockerOffline.ts` (one
486
+ // libkrun microVM per test) — each VM has its own network ns so
487
+ // :443 is per-VM. That's what `npm run test:offline-full:isolated`
488
+ // invokes; set `TEST_PARALLEL_VMS=N` to scale.
489
+ //
490
+ // Set `TEST_OFFLINE_WORKERS=N` (≥2) here only when the supervisor's
491
+ // edge-proxy-per-shard collision is fixed; today it's a footgun.
492
+ const requestedWorkers = Math.max(1, Number(process.env['TEST_OFFLINE_WORKERS'] ?? '1'));
493
+ const shardIndex = process.env['TEST_OFFLINE_SHARD_INDEX']
494
+ ? Number(process.env['TEST_OFFLINE_SHARD_INDEX'])
495
+ : null;
496
+ if (requestedWorkers > 1 && shardIndex === null) {
497
+ process.exit(await runSupervisor(requestedWorkers));
498
+ }
499
+ // From here on we're either a single-worker run (shardIndex
500
+ // null, requestedWorkers === 1) OR a child shard. Same code
501
+ // path; child uses shardIndex to compute its port.
502
+ // Minimal env probe: we don't need the full online-mode `.env.test`
503
+ // schema, but we DO want SCOPES to match what the app expects.
504
+ const scopes = process.env['SCOPES'] ?? 'write_products,read_products,write_discounts,read_discounts';
505
+ const playwrightConfig = process.env['TEST_PLAYWRIGHT_CONFIG'] ??
506
+ 'tests/test-offline/playwright.offline-full.config.ts';
507
+ const isChildShard = shardIndex !== null;
508
+ // Backend port: shard k uses BASE + (k-1); single-worker uses BASE.
509
+ const baseBackendPort = Number(process.env['TEST_OFFLINE_BACKEND_PORT'] ?? '8181');
510
+ const backendPort = String(isChildShard ? baseBackendPort + (shardIndex - 1) : baseBackendPort);
511
+ // Production Remix serves via plain HTTP (remix-serve has no TLS).
512
+ // Vite dev had self-signed HTTPS; offline-full uses HTTP. The mock
513
+ // admin shell serves the iframe pointing here; mixed-content
514
+ // warnings are silenced via Chrome flags in the offlineFullStack
515
+ // fixture.
516
+ const backendUrl = `http://localhost:${backendPort}`;
517
+ // Skip the build if TEST_OFFLINE_SKIP_BUILD=true and a recent build
518
+ // exists — saves ~30s on iterative runs. Default: always build for
519
+ // a fresh, deterministic result.
520
+ const skipBuild = process.env['TEST_OFFLINE_SKIP_BUILD'] === 'true';
521
+ // ── Postgres ──────────────────────────────────────────────
522
+ const appName = readAppName();
523
+ const dbPrefix = `${appName}_offline`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
524
+ const dbName = `${dbPrefix}_${randomUUID().replace(/-/g, '_')}`;
525
+ const dbConn = dbUrl(dbName);
526
+ const preserveDb = process.env['TEST_PRESERVE_DB'] === 'true';
527
+ console.log(`[runOfflineFull] Test DB: ${dbConn}`);
528
+ console.log(`[runOfflineFull] Backend: ${backendUrl}`);
529
+ // Drop orphan DBs from previous crashed runs. The supervisor
530
+ // already did this once — children skip.
531
+ if (!isChildShard) {
532
+ dropOrphanDatabases(dbPrefix);
533
+ }
534
+ console.log('[runOfflineFull] createdb…');
535
+ execSync(`createdb -h ${PG_HOST} -p ${PG_PORT} ${dbName}`, { stdio: 'inherit' });
536
+ // ── Mock servers (in-process, sharing one ShopState) ─────
537
+ const state = new ShopState({
538
+ shop: { domain: DEFAULT_SHOP_DOMAIN, permanent_domain: DEFAULT_SHOP_DOMAIN },
539
+ });
540
+ const shopOrigin = `https://${DEFAULT_SHOP_DOMAIN}`;
541
+ // Populate `state.settings` from the active theme's
542
+ // `config/settings_data.json` so Dawn's CSS-variable scaffolding
543
+ // (`{{ settings.color_schemes }}`, `{{ settings.type_body_font }}`,
544
+ // etc.) renders proper CSS. Without this every storefront page
545
+ // ships with empty CSS variables and looks unstyled.
546
+ state.loadThemeSettingsFrom(dawn);
547
+ // Explore mode shows the storefront to a human, so populate it
548
+ // with a small set of products + a `frontpage` collection so
549
+ // Dawn's featured-collection section renders something other
550
+ // than its onboarding placeholders. Tests don't get this seed —
551
+ // they seed exactly what they need to assert on, which is the
552
+ // entire point of fresh state per test.
553
+ if (process.env['TEST_OFFLINE_EXPLORE'] === 'true') {
554
+ seedExploreProducts(state);
555
+ }
556
+ console.log('[runOfflineFull] booting mock servers…');
557
+ // 127.0.0.1 binding is the safe default — orchestrator and child
558
+ // both reach the mocks on loopback. Used to be subtly broken
559
+ // because we ran Playwright via execSync (sync, blocks the event
560
+ // loop), so the mocks couldn't accept connections during the
561
+ // test run. Now we use spawn() which keeps the loop spinning.
562
+ const HOSTNAME = '127.0.0.1';
563
+ const startOpts = { port: 0, hostname: HOSTNAME };
564
+ // Optional theme-app-extensions to inject into the storefront. Tests
565
+ // that exercise the storefront-rendering side of the app
566
+ // (e.g. funnel published in admin → visible on PDP) set this so
567
+ // the extension's blocks/app-embed.liquid actually runs.
568
+ //
569
+ // The env var `TEST_OFFLINE_EXTENSIONS_JSON` is a JSON array of
570
+ // `{ name, rootDir, enabled }` entries. Paths may be absolute or
571
+ // relative to the consuming app's cwd. Empty/unset → no extensions
572
+ // (legacy behavior: smoke tests don't need this).
573
+ const extensions = loadExtensions();
574
+ // Optional path to the built post-purchase extension JS. When
575
+ // set, the storefront mock serves a synthetic post-purchase
576
+ // host at `/checkouts/:token/post_purchase` that loads the JS
577
+ // and drives its ShouldRender callback. Env-driven so the
578
+ // shopify-test framework stays app-agnostic.
579
+ const postPurchaseExtensionPath = process.env['TEST_OFFLINE_POST_PURCHASE_EXT']
580
+ ? resolve(process.cwd(), process.env['TEST_OFFLINE_POST_PURCHASE_EXT'])
581
+ : undefined;
582
+ if (postPurchaseExtensionPath && !existsSync(postPurchaseExtensionPath)) {
583
+ throw new Error(`[runOfflineFull] TEST_OFFLINE_POST_PURCHASE_EXT points at ${postPurchaseExtensionPath} but the file doesn't exist. ` +
584
+ `Build the extension first — the bundled .js must exist on disk before the test run.`);
585
+ }
586
+ // The orchestrator owns the DB lifecycle: we build the
587
+ // reset-DB hook here (it captures the per-run dbName) and pass
588
+ // it into the storefront mock. The control plane exposes it at
589
+ // `POST /__test__/reset-db`. This keeps the consuming app
590
+ // test-agnostic — no special routes shipped in the backend.
591
+ // `scopes` is computed at the top of main(); reuse it here.
592
+ const resetDbHook = makeResetDbHook(dbName, DEFAULT_SHOP_DOMAIN, scopes);
593
+ const storefrontMock = await startStorefront(createStorefrontApp({
594
+ state,
595
+ theme: dawn,
596
+ originForRender: shopOrigin,
597
+ enableControlPlane: true,
598
+ extensions,
599
+ hooks: { resetDb: resetDbHook },
600
+ ...(postPurchaseExtensionPath
601
+ ? { postPurchaseExtensionPath }
602
+ : {}),
603
+ }), startOpts);
604
+ const adminApi = await startAdminApi(createAdminApi({ state }), startOpts);
605
+ const storefrontApi = await startStorefrontApi(createStorefrontApi({ state }), startOpts);
606
+ const partnerApi = await startPartnerApi(createPartnerApi({ state }), startOpts);
607
+ const adminShell = await startMockAdmin(createMockAdminApp({
608
+ state,
609
+ appUrl: backendUrl,
610
+ clientId: MOCK_CLIENT_ID,
611
+ jwtSecret: DEFAULT_MOCK_JWT_SECRET,
612
+ }), startOpts);
613
+ console.log(`[runOfflineFull] mocks ready:\n` +
614
+ ` shell: ${adminShell.baseUrl}\n` +
615
+ ` admin GQL: ${adminApi.baseUrl}\n` +
616
+ ` storefront GQL: ${storefrontApi.baseUrl}\n` +
617
+ ` storefront: ${storefrontMock.baseUrl}\n` +
618
+ ` partner GQL: ${partnerApi.baseUrl}`);
619
+ // Self-probe: confirm each mock is reachable from this process.
620
+ const probes = [
621
+ { name: 'shell', url: `${adminShell.baseUrl}/store/test/apps/x`, init: { method: 'GET' } },
622
+ { name: 'admin GQL', url: `${adminApi.baseUrl}/admin/api/2025-07/graphql.json`, init: { method: 'POST', body: '{"query":"{ __typename }"}', headers: { 'content-type': 'application/json' } } },
623
+ { name: 'storefront GQL', url: `${storefrontApi.baseUrl}/api/2025-07/graphql.json`, init: { method: 'POST', body: '{"query":"{ __typename }"}', headers: { 'content-type': 'application/json' } } },
624
+ { name: 'storefront', url: `${storefrontMock.baseUrl}/`, init: { method: 'GET' } },
625
+ { name: 'storefront control', url: `${storefrontMock.baseUrl}/__test__/state/snapshot`, init: { method: 'GET' } },
626
+ ];
627
+ for (const p of probes) {
628
+ try {
629
+ const r = await fetch(p.url, p.init);
630
+ console.log(`[runOfflineFull] probe ${p.name}: ${r.status}`);
631
+ }
632
+ catch (e) {
633
+ console.log(`[runOfflineFull] probe ${p.name}: FAILED ${e.message}`);
634
+ }
635
+ }
636
+ // ── Edge proxy (HTTPS) ──────────────────────────────────────
637
+ // Single HTTPS endpoint that takes browser requests for Shopify
638
+ // hostnames (test-shop.myshopify.com, admin.shopify.com,
639
+ // cdn.shopify.com) and dispatches to the right internal mock by
640
+ // Host + path.
641
+ //
642
+ // The browser and Node both reach this proxy at port 443 because
643
+ // the container's `/etc/hosts` maps Shopify hostnames to 127.0.0.1
644
+ // (kernel-layer DNS rewrite — see tests/test-offline/docker/entrypoint.sh),
645
+ // and the edge's self-signed cert is installed as a system CA so
646
+ // TLS validates natively. No Chromium flags or Node monkey-patches
647
+ // required — DNS + TLS work the same way they would against real
648
+ // Shopify in production.
649
+ //
650
+ // Why an edge proxy at all (vs. running each mock on a unique port):
651
+ // because real Shopify serves storefront, admin shell, admin API,
652
+ // storefront API, and CDN all from a small set of well-known
653
+ // hostnames — different paths, same hosts. The edge replicates that
654
+ // dispatch, so tests use real-shaped URLs throughout.
655
+ //
656
+ // We bind dual-stack `::` so /etc/hosts entries for both 127.0.0.1
657
+ // and ::1 reach the same listener — Chrome inside the container
658
+ // sometimes prefers IPv6 when AAAA records exist for the hostname.
659
+ const edge = await startEdgeProxy({
660
+ backends: {
661
+ storefront: storefrontMock.baseUrl,
662
+ adminApi: adminApi.baseUrl,
663
+ storefrontApi: storefrontApi.baseUrl,
664
+ adminShell: adminShell.baseUrl,
665
+ },
666
+ shopDomain: DEFAULT_SHOP_DOMAIN,
667
+ // Previously bound dual-stack `::` so /etc/hosts entries for
668
+ // both 127.0.0.1 and ::1 hit the same listener — but on the
669
+ // VM kernel, listen() on `::` blocks forever
670
+ // (no 'error' event, no callback). 0.0.0.0 binds IPv4 only and
671
+ // works reliably. /etc/hosts uses 127.0.0.1 entries anyway, so
672
+ // IPv6 isn't needed in practice.
673
+ hostname: '0.0.0.0',
674
+ port: 443,
675
+ });
676
+ console.log(`[runOfflineFull] edge proxy: ${edge.baseUrl}`);
677
+ // ── Neon-local WS proxy (zero-app-touch) ──────────────────
678
+ // Bridge the app's @neondatabase/serverless driver to THIS run's local
679
+ // Postgres without the app changing its prisma client. The shim
680
+ // (preloaded into the backend below) points neonConfig.wsProxy here;
681
+ // the proxy reads the driver's `?address=` and forwards to Postgres.
682
+ const neonProxy = await startNeonWsProxy({ targetHost: PG_HOST, targetPort: Number(PG_PORT) });
683
+ console.log(`[runOfflineFull] neon ws-proxy: 127.0.0.1:${neonProxy.port} → Postgres ${PG_HOST}:${PG_PORT}`);
684
+ // ── Backend env ───────────────────────────────────────────
685
+ const mswLoader = resolveMswLoaderPath();
686
+ const neonShim = resolveNeonShimPath();
687
+ const backendEnv = {
688
+ // Forward the consuming app's declared test env. The orchestrator is
689
+ // launched with `node --env-file=.env.test`, so .env.test's values
690
+ // are already in `process.env`; spreading it lets the Remix backend
691
+ // see app-specific vars that modules read at IMPORT time — e.g. SDK
692
+ // clients constructed at module scope. Remix's server bundle eagerly
693
+ // evaluates every route module on boot, so an app whose
694
+ // `anthropic.server.ts` does `new Anthropic({ apiKey: process.env… })`
695
+ // at top level throws "Could not resolve authentication method" and
696
+ // the backend never boots. Every wiring-/security-critical key below
697
+ // is assigned AFTER the spread, so the mock wiring always wins (local
698
+ // Postgres DATABASE_URL, mock-JWT SHOPIFY_API_SECRET, MSW/neon
699
+ // NODE_OPTIONS, mock API URLs, TEST_OFFLINE). Also carries through
700
+ // PRISMA_QUERY_ENGINE_LIBRARY (the linux-arm64 engine pin) the
701
+ // backend's prisma client needs in-VM.
702
+ ...process.env,
703
+ PATH: process.env['PATH'] ?? '',
704
+ // Empty-string fallback would tell child processes "HOME is set but
705
+ // empty" — npm in particular then writes its cache to literal `~/.npm`
706
+ // relative to cwd. Inside the VM cwd is /workspace
707
+ // (host-mounted), so that materializes as a stray `~/` directory in
708
+ // the consuming project on the host. Use a real path instead.
709
+ HOME: process.env['HOME'] || '/root',
710
+ USER: process.env['USER'] ?? '',
711
+ NODE_ENV: 'test',
712
+ DATABASE_URL: dbConn,
713
+ DIRECT_URL: dbConn,
714
+ // Apps that keep a SECOND database (e.g. an analytics event store the
715
+ // app reaches with a different client — drizzle + @neondatabase/serverless
716
+ // at ANALYTICS_DATABASE_URL) point it at the same offline Postgres, like
717
+ // DATABASE_URL. Assigned AFTER the ...process.env spread so it overrides
718
+ // any real value carried in from .env.test — both so the offline client
719
+ // connects locally (via the same neon ws-proxy) and so a real analytics
720
+ // DB is never reachable from an offline run. Harmless for apps without it.
721
+ // The non-prisma tables this client expects are created by the optional
722
+ // extra-schema hook below (tests/test-offline/offline-extra-schema.sql).
723
+ ANALYTICS_DATABASE_URL: dbConn,
724
+ // Auth wired to the mock JWT secret — shopify-app-remix uses this
725
+ // to verify session JWTs minted by the mock App Bridge.
726
+ SHOPIFY_API_KEY: MOCK_CLIENT_ID,
727
+ SHOPIFY_API_SECRET: DEFAULT_MOCK_JWT_SECRET,
728
+ SCOPES: scopes,
729
+ SHOPIFY_APP_URL: backendUrl,
730
+ PORT: backendPort,
731
+ // MSW preload: the Remix process intercepts its outbound
732
+ // *.myshopify.com fetches and forwards to our Admin GraphQL mock.
733
+ // --trace-warnings turns the otherwise-stackless UNDICI-WS
734
+ // warning into something diagnosable. --unhandled-rejections=strict
735
+ // surfaces unhandled rejections as proper exits with stack.
736
+ NODE_OPTIONS: `--import ${JSON.stringify(`file://${mswLoader}`)} ` +
737
+ `--import ${JSON.stringify(`file://${neonShim}`)} ` +
738
+ `--trace-warnings --unhandled-rejections=strict`,
739
+ // Tells neonLocalShim where the in-VM Neon WS proxy listens. The
740
+ // shim no-ops if the app doesn't use @neondatabase/serverless, so
741
+ // this is harmless for apps that bypass Neon in test.
742
+ TEST_OFFLINE_NEON_WSPROXY_PORT: String(neonProxy.port),
743
+ // Surface every MSW-intercepted request in stderr so we can see
744
+ // whether admin.graphql calls actually reach the mock. Default
745
+ // off in normal runs (chatty); on for debugging.
746
+ ...(process.env['TEST_OFFLINE_MOCK_ADMIN_API_DEBUG']
747
+ ? { TEST_OFFLINE_MOCK_ADMIN_API_DEBUG: process.env['TEST_OFFLINE_MOCK_ADMIN_API_DEBUG'] }
748
+ : {}),
749
+ TEST_OFFLINE_MOCK_ADMIN_API_URL: adminApi.baseUrl,
750
+ // Partner API mock URL — when set, the MSW loader installs a
751
+ // second handler intercepting partners.shopify.com fetches.
752
+ // The consuming app reads `SHOPIFY_PARTNER_*` env vars at
753
+ // runtime to build the Partner API URL; we synthesize plausible
754
+ // values pointing at the mock instead of real Shopify.
755
+ TEST_OFFLINE_MOCK_PARTNER_API_URL: partnerApi.baseUrl,
756
+ SHOPIFY_APP_ID: process.env['SHOPIFY_APP_ID'] ?? '999000000',
757
+ SHOPIFY_PARTNER_ORGANIZATION_ID: process.env['SHOPIFY_PARTNER_ORGANIZATION_ID'] ?? '11111111',
758
+ SHOPIFY_PARTNER_ACCESS_TOKEN: process.env['SHOPIFY_PARTNER_ACCESS_TOKEN'] ?? 'mock-partner-token',
759
+ BILLING_IS_TEST: process.env['BILLING_IS_TEST'] ?? 'true',
760
+ TEST_OFFLINE: 'true',
761
+ // Theme app extension UUID (harmless if absent).
762
+ ...(process.env['SHOPIFY_UPSELLS_ID']
763
+ ? { SHOPIFY_UPSELLS_ID: process.env['SHOPIFY_UPSELLS_ID'] }
764
+ : {}),
765
+ };
766
+ console.log('[runOfflineFull] prisma migrate deploy…');
767
+ // Strip the MSW loader from NODE_OPTIONS for prisma's invocation.
768
+ // MSW shouldn't intercept Postgres connections (it's HTTP-only),
769
+ // but loading it from inside `npx prisma` triggers a silent
770
+ // failure inside the container (the loader's undici interception
771
+ // setup conflicts with prisma's CLI lifecycle in ways that don't
772
+ // reproduce on the host's Node 24.0). prisma's job is `migrate
773
+ // deploy` — pure DB work, no Shopify API calls — so dropping the
774
+ // MSW loader here is safe and not even semantically different.
775
+ const prismaEnv = { ...backendEnv };
776
+ delete prismaEnv['NODE_OPTIONS'];
777
+ execSync('npx prisma migrate deploy', { stdio: 'inherit', env: prismaEnv });
778
+ // Optional app-provided extra schema. prisma migrate only creates the
779
+ // prisma-managed tables; an app may also read a second database with a
780
+ // different ORM (e.g. drizzle for analytics) whose tables prisma doesn't
781
+ // know about. If the consuming app ships
782
+ // tests/test-offline/offline-extra-schema.sql, apply it to the SAME offline
783
+ // Postgres (idempotent CREATE TABLE IF NOT EXISTS), via psql with the
784
+ // MSW-loader-stripped env, exactly like migrate. Skipped if absent, so it's
785
+ // a no-op for apps that don't need it.
786
+ const extraSchema = resolve(process.cwd(), 'tests/test-offline/offline-extra-schema.sql');
787
+ if (existsSync(extraSchema)) {
788
+ console.log('[runOfflineFull] applying extra schema (tests/test-offline/offline-extra-schema.sql)…');
789
+ execSync(`psql -h ${PG_HOST} -p ${PG_PORT} -d ${dbName} -v ON_ERROR_STOP=1 -f ${JSON.stringify(extraSchema)}`, { stdio: 'inherit', env: prismaEnv });
790
+ }
791
+ console.log('[runOfflineFull] seeding session row…');
792
+ try {
793
+ seedSession(prismaEnv, DEFAULT_SHOP_DOMAIN);
794
+ }
795
+ catch (err) {
796
+ console.warn(`[runOfflineFull] session seed failed (continuing): ${err.message}`);
797
+ }
798
+ // ── Backend (Vite) ────────────────────────────────────────
799
+ let dev = null;
800
+ /**
801
+ * Kill the backend child + drop the per-run DB. Used by:
802
+ * - the normal `finally` block at end of `main()`
803
+ * - the SIGINT/SIGTERM handlers below (Ctrl+C, parent kill)
804
+ *
805
+ * Idempotent: safe to call from both paths during teardown.
806
+ * Synchronous so it works inside signal handlers (Node only
807
+ * allows sync work there before the process exits).
808
+ *
809
+ * `preserveDb` (TEST_PRESERVE_DB=true) keeps the DB for
810
+ * post-mortem inspection — useful when chasing test-only data
811
+ * drift after a failure.
812
+ */
813
+ const cleanup = () => {
814
+ if (dev && !dev.killed) {
815
+ try {
816
+ dev.kill('SIGTERM');
817
+ }
818
+ catch {
819
+ // ignore — child may have already exited
820
+ }
821
+ }
822
+ // Stop the Neon WS proxy. Fire-and-forget: cleanup runs in sync
823
+ // signal-handler context, and the process is exiting anyway.
824
+ try {
825
+ void neonProxy.close();
826
+ }
827
+ catch {
828
+ // ignore
829
+ }
830
+ if (!preserveDb) {
831
+ try {
832
+ // `--force` terminates straggler connections (the backend
833
+ // process may not have closed Prisma's pool yet). Falls
834
+ // back to plain dropdb on older psql installs.
835
+ execSync(`dropdb --force -h ${PG_HOST} -p ${PG_PORT} ${dbName} 2>/dev/null || dropdb -h ${PG_HOST} -p ${PG_PORT} ${dbName}`, { stdio: 'ignore' });
836
+ }
837
+ catch {
838
+ // Best-effort. Orphan cleanup at next run's start will get
839
+ // anything we leak here.
840
+ }
841
+ }
842
+ };
843
+ /**
844
+ * Signal handler: ensure the DB drops before we exit. Node would
845
+ * otherwise skip the `finally` block in `main()` for SIGINT/
846
+ * SIGTERM, leaking the DB. We run cleanup, then re-raise with
847
+ * the conventional exit code (`128 + signal`).
848
+ */
849
+ const onSignal = (sig) => {
850
+ console.log(`\n[runOfflineFull] received ${sig}, tearing down…`);
851
+ cleanup();
852
+ // 128 + signal number is the POSIX convention.
853
+ process.exit(sig === 'SIGINT' ? 130 : 143);
854
+ };
855
+ process.on('SIGINT', () => onSignal('SIGINT'));
856
+ process.on('SIGTERM', () => onSignal('SIGTERM'));
857
+ let exitCode = 1;
858
+ try {
859
+ // Production build → remix-serve (no Vite dev, no HMR). Vite dev
860
+ // mode injects HMR <link>/<script> tags client-side that aren't
861
+ // in the SSR HTML, breaking React 18's hydration check. The
862
+ // production build is a static SSR + client bundle pair with no
863
+ // such tag injection.
864
+ //
865
+ // Build-cache strategy: we hash the consuming app's source tree
866
+ // + package files + relevant configs. If that hash matches what
867
+ // we cached after the last successful build, skip the build.
868
+ // This makes CI-safe what `TEST_OFFLINE_SKIP_BUILD=true` does
869
+ // unsafely: the cache invalidates on ANY source change.
870
+ //
871
+ // Cache file lives in `build/.test-build-cache` (gitignored by
872
+ // virtue of being under the build/ output dir). Force a fresh
873
+ // build with `TEST_OFFLINE_FORCE_BUILD=true` (handy when
874
+ // suspecting the cache is stale, e.g. after a dependency
875
+ // update that didn't touch package.json hashes).
876
+ const buildCachePath = resolve(process.cwd(), 'build/.test-build-cache');
877
+ const currentBuildHash = await computeBuildHash(process.cwd());
878
+ const forceBuild = process.env['TEST_OFFLINE_FORCE_BUILD'] === 'true';
879
+ const cachedHash = !forceBuild && existsSync(buildCachePath) && existsSync('build/server/index.js')
880
+ ? readFileSync(buildCachePath, 'utf8').trim()
881
+ : null;
882
+ const skipBuildBecauseExplicit = skipBuild;
883
+ const skipBuildBecauseCache = cachedHash === currentBuildHash;
884
+ if (skipBuildBecauseExplicit) {
885
+ console.log('[runOfflineFull] skipping build (TEST_OFFLINE_SKIP_BUILD=true)');
886
+ }
887
+ else if (skipBuildBecauseCache) {
888
+ console.log(`[runOfflineFull] build cache HIT (${currentBuildHash.slice(0, 12)}) — skipping npm run build. ` +
889
+ `Force with TEST_OFFLINE_FORCE_BUILD=true.`);
890
+ }
891
+ else {
892
+ console.log(`[runOfflineFull] build cache MISS — running npm run build. ` +
893
+ `(${cachedHash ? `was ${cachedHash.slice(0, 12)}, now ` : 'no cache, '}${currentBuildHash.slice(0, 12)})`);
894
+ execSync('npm run build', {
895
+ stdio: 'inherit',
896
+ env: { ...backendEnv, NODE_ENV: 'production' },
897
+ });
898
+ // Only write the cache file AFTER the build succeeds — a
899
+ // crashed build leaves stale `build/` we shouldn't endorse.
900
+ writeFileSync(buildCachePath, currentBuildHash);
901
+ }
902
+ console.log('[runOfflineFull] booting Remix prod server (remix-serve)…');
903
+ // Resolve remix-serve's CLI path directly — `npx remix-serve`
904
+ // on some systems alters NODE_OPTIONS (it spawns a child via
905
+ // npm-cli with PATH manipulation), which silently drops our
906
+ // --import preload. `node ./node_modules/.bin/remix-serve` is
907
+ // a hard pointer.
908
+ const remixServeBin = resolve(process.cwd(), 'node_modules/.bin/remix-serve');
909
+ const backendRuntimeEnv = {
910
+ ...backendEnv,
911
+ PORT: backendPort,
912
+ // NODE_ENV stays 'test' at RUNTIME (we set 'production' only
913
+ // for the build step above). app/lib/prisma.server.ts swaps
914
+ // in the Neon serverless WebSocket adapter on
915
+ // NODE_ENV=production — that's incompatible with our local
916
+ // Postgres setup.
917
+ NODE_ENV: 'test',
918
+ };
919
+ // remix-serve binds to process.env.HOST as its LISTEN address
920
+ // (`app.listen(port, HOST)`). Apps commonly set HOST to their public
921
+ // URL in .env (harmless on Vercel/prod, which never runs remix-serve)
922
+ // — but a URL value makes app.listen throw `getaddrinfo ENOTFOUND
923
+ // https://…` so the backend never boots. Drop it here so the server
924
+ // binds all interfaces (loopback-reachable, like apps that don't set
925
+ // HOST at all). Zero-app-touch: the runner owns the remix-serve
926
+ // process, so it owns the bind address. (prismaEnv + the build step
927
+ // keep HOST — only the server-listen step is sensitive to it.)
928
+ delete backendRuntimeEnv.HOST;
929
+ dev = spawn(remixServeBin, ['./build/server/index.js'], {
930
+ // Route the backend's stderr to OUR stdout (fd 1). The VM runner
931
+ // only captures stdout, so plain 'inherit' loses backend stderr —
932
+ // including unhandled action throws and the MSW preload logs,
933
+ // which makes offline failures undebuggable.
934
+ stdio: ['inherit', 'inherit', 1],
935
+ env: backendRuntimeEnv,
936
+ });
937
+ console.log(`[runOfflineFull] waiting for backend at ${backendUrl}…`);
938
+ await waitForReady(backendUrl, READY_TIMEOUT_MS);
939
+ console.log('[runOfflineFull] backend ready.');
940
+ // ── Pre-warm Remix routes ──────────────────────────────────
941
+ // remix-serve lazy-compiles each route's loader/action module on
942
+ // first authenticated request. The first /app navigation in a
943
+ // test can take 5-15s on a cold backend (JIT for the route module,
944
+ // Prisma client init, GraphQL schema parse, mock MSW interception
945
+ // setup). For tests whose entire scope is "publish funnel +
946
+ // customer checkout", that one-time hit dominates the per-test
947
+ // budget — they spend 60-80s of a 90s timeout waiting for pages
948
+ // to render the first time.
949
+ //
950
+ // The warm-up REQUEST must pass shopify-app-remix's authenticate
951
+ // gate, otherwise we get a fast 302 to /auth/login and the route
952
+ // module never loads (we saw a 14ms total warm-up that touched
953
+ // nothing). Mint a real mock id_token signed with the same secret
954
+ // the backend is configured with.
955
+ //
956
+ // Failures are non-fatal — the next real test request will
957
+ // surface a clearer error than "warm-up failed".
958
+ console.log('[runOfflineFull] pre-warming hot Remix routes…');
959
+ const warmupToken = await mintIdToken({
960
+ shop: DEFAULT_SHOP_DOMAIN,
961
+ clientId: MOCK_CLIENT_ID,
962
+ secret: DEFAULT_MOCK_JWT_SECRET,
963
+ });
964
+ const warmupHost = Buffer.from(`${DEFAULT_SHOP_DOMAIN}/admin`).toString('base64');
965
+ const warmupQuery = `host=${encodeURIComponent(warmupHost)}` +
966
+ `&shop=${DEFAULT_SHOP_DOMAIN}` +
967
+ `&embedded=1` +
968
+ `&id_token=${warmupToken}`;
969
+ const warmupRoutes = [
970
+ // The embedded-app entry — every test's first iframe goto hits
971
+ // this. Loads `routes/app` (auth + layout) + `routes/app._index`
972
+ // (dashboard loader + Polaris components). Two big modules.
973
+ `/app?${warmupQuery}`,
974
+ `/app?${warmupQuery}&_data=routes%2Fapp`,
975
+ `/app?${warmupQuery}&_data=routes%2Fapp._index`,
976
+ // Funnel-creation form — every "publish via admin UI" flow.
977
+ `/app/funnels/new?${warmupQuery}`,
978
+ `/app/funnels/new?${warmupQuery}&_data=routes%2Fapp.funnels.new`,
979
+ ];
980
+ const t0 = Date.now();
981
+ const warmupResults = await Promise.all(warmupRoutes.map(async (path) => {
982
+ const tStart = Date.now();
983
+ try {
984
+ const resp = await fetch(`${backendUrl}${path}`, {
985
+ headers: {
986
+ authorization: `Bearer ${warmupToken}`,
987
+ // shopify-app-remix's `isbot` check returns 410 for
988
+ // Node's default User-Agent. Match the same UA the
989
+ // browser context uses in tests (see offlineFullStack
990
+ // fixture) so the request reaches the loader.
991
+ 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
992
+ '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
993
+ },
994
+ redirect: 'manual',
995
+ });
996
+ await resp.text().catch(() => '');
997
+ return {
998
+ path,
999
+ status: resp.status,
1000
+ ms: Date.now() - tStart,
1001
+ location: resp.headers.get('location') ?? null,
1002
+ };
1003
+ }
1004
+ catch (err) {
1005
+ return { path, error: err.message, ms: Date.now() - tStart };
1006
+ }
1007
+ }));
1008
+ console.log(`[runOfflineFull] route pre-warm done (${Date.now() - t0}ms):`);
1009
+ for (const r of warmupResults) {
1010
+ const status = 'error' in r ? `ERR ${r.error}` : `${r.status}${r.location ? ` → ${r.location}` : ''}`;
1011
+ console.log(` ${r.ms}ms ${status} ${r.path.split('?')[0]}`);
1012
+ }
1013
+ // ── Explore mode (no Playwright; manual click-around) ──
1014
+ // When `TEST_OFFLINE_EXPLORE=true` is set, skip Playwright
1015
+ // entirely. Instead, launch a single non-headless Chrome on the
1016
+ // container's Xvfb display, pointed at the admin app's entry
1017
+ // URL. The container is started with `--publish 5900:5900` +
1018
+ // `TEST_ONLINE_VNC=1`, so the developer can connect from macOS via
1019
+ // open vnc://localhost:5900 (password: `test`)
1020
+ // and click through the admin and storefront against the online
1021
+ // mock stack. The process holds open until SIGINT/SIGTERM.
1022
+ //
1023
+ // This is the "interactive Shopify dev store" mode — useful
1024
+ // for visually inspecting funnel rendering, debugging admin
1025
+ // UI flows, mutating state via Prisma Studio in another tab,
1026
+ // etc.
1027
+ if (process.env['TEST_OFFLINE_EXPLORE'] === 'true') {
1028
+ const adminUrl = process.env['TEST_OFFLINE_EXPLORE_URL'] ??
1029
+ `https://admin.shopify.com/store/test/apps/${DEFAULT_APP_HANDLE}/app`;
1030
+ console.log('[runOfflineFull] explore mode — launching Chrome at:');
1031
+ console.log(` ${adminUrl}`);
1032
+ console.log('');
1033
+ console.log(' Useful URLs you can navigate to (all routed via the edge proxy):');
1034
+ console.log(` Admin app: ${adminUrl}`);
1035
+ console.log(` Storefront: https://${DEFAULT_SHOP_DOMAIN}/`);
1036
+ console.log(` PDP example: https://${DEFAULT_SHOP_DOMAIN}/products/placeholder`);
1037
+ console.log(` Cart: https://${DEFAULT_SHOP_DOMAIN}/cart`);
1038
+ console.log('');
1039
+ console.log(' Connect: open vnc://localhost:5900 (password: test)');
1040
+ console.log(' Stop: Ctrl-C');
1041
+ // Drive Playwright's bundled chromium directly via its own
1042
+ // CLI, instead of the system google-chrome.
1043
+ //
1044
+ // Why: real google-chrome (v148 as of writing) crashes under
1045
+ // Apple `container`'s Rosetta amd64-on-arm64 translation with
1046
+ // `assertion failed [arm::is_brk_instruction(instruction)]:
1047
+ // SIGTRAP from kernel was not from a BRK`
1048
+ // after a few renderer signals. The same image runs Playwright
1049
+ // chromium (~v131) for tests with zero crashes, so the bug is
1050
+ // Chrome v148-specific, not a container/image bug. By using
1051
+ // Playwright's chromium for explore mode too, we get:
1052
+ // 1. No Rosetta crash (tests prove the binary is stable)
1053
+ // 2. Same browser tests use → identical behavior (a click
1054
+ // that works in a test works here)
1055
+ // 3. No need for system google-chrome in the image at all
1056
+ // (could be removed in a future image trim)
1057
+ //
1058
+ // Profile is wiped on every launch — persistent profiles
1059
+ // accumulate stale state (cookies pointing at old session
1060
+ // tokens, HSTS upgrades, cached error pages) that produced
1061
+ // mysterious "first page loads but click does nothing" bugs
1062
+ // across cert/proxy iterations. Fresh profile = same starting
1063
+ // point as a test.
1064
+ try {
1065
+ rmSync('/tmp/explore-profile', { recursive: true, force: true });
1066
+ }
1067
+ catch {
1068
+ /* ignore */
1069
+ }
1070
+ // Drive Playwright via its programmatic API directly — same
1071
+ // entrypoint the test fixture uses (`chromium.launchPersistentContext`).
1072
+ // The `playwright open` CLI is close but doesn't accept arbitrary
1073
+ // chromium flags (we need `--allow-running-insecure-content` and
1074
+ // the local-network-access disable-feature gang).
1075
+ // @playwright/test re-exports `chromium` (and friends) from
1076
+ // the underlying `playwright` package; using it avoids adding
1077
+ // a second top-level dep.
1078
+ const playwrightModule = await import('@playwright/test');
1079
+ const chromiumPkg = playwrightModule.chromium;
1080
+ assertInVm('launch a browser');
1081
+ const exploreCtx = await chromiumPkg.launchPersistentContext('/tmp/explore-profile', {
1082
+ headless: false,
1083
+ ignoreHTTPSErrors: true,
1084
+ // Slightly smaller than the Xvfb screen (1600x1000) so the
1085
+ // browser window plus its DevTools panel both fit without
1086
+ // clipping off the right edge of the virtual display.
1087
+ viewport: { width: 1560, height: 960 },
1088
+ // Match the test fixture's UA so isbot heuristics + any
1089
+ // UA-keyed app logic behave identically.
1090
+ userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
1091
+ '(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
1092
+ args: [
1093
+ '--no-sandbox',
1094
+ '--no-first-run',
1095
+ '--no-default-browser-check',
1096
+ '--ip-address-space-overrides=127.0.0.1:0=public,[::1]:0=public',
1097
+ '--disable-features=LocalNetworkAccessChecks,LocalNetworkAccessChecksWebSockets,LocalNetworkAccessChecksWebTransport,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessRespectPreflightResults,PrivateNetworkAccessSendPreflights',
1098
+ '--local-network-access-permissions-policy-default-enabled',
1099
+ '--allow-running-insecure-content',
1100
+ '--disable-features=BlockInsecurePrivateNetworkRequestsFromPrivate,BlockInsecurePrivateNetworkRequestsFromUnknown',
1101
+ // Don't use --start-maximized — under Xvfb it can ask
1102
+ // for more pixels than the virtual display has and the
1103
+ // right edge gets clipped. Playwright's viewport setting
1104
+ // sizes the inner window precisely instead.
1105
+ '--window-position=0,0',
1106
+ // Auto-open DevTools so the developer can inspect
1107
+ // Network/Console while clicking through. The mock
1108
+ // stack has many moving parts (edge cert, /etc/hosts,
1109
+ // App Bridge mock, JWT bounce); when something silently
1110
+ // fails, having Network + Console one click away cuts
1111
+ // diagnosis from hours to seconds. Pass
1112
+ // TEST_OFFLINE_EXPLORE_NO_DEVTOOLS=1 to disable.
1113
+ ...(process.env['TEST_OFFLINE_EXPLORE_NO_DEVTOOLS'] === '1'
1114
+ ? []
1115
+ : ['--auto-open-devtools-for-tabs']),
1116
+ ],
1117
+ });
1118
+ const explorePage = exploreCtx.pages()[0] ?? (await exploreCtx.newPage());
1119
+ await explorePage.goto(adminUrl, { waitUntil: 'domcontentloaded' });
1120
+ // Wait indefinitely. Exit triggers:
1121
+ // - Developer closes the browser window → context emits `close`.
1122
+ // - Container receives SIGINT/SIGTERM → we close the context
1123
+ // and resolve so the outer `finally` can tear down mocks.
1124
+ await new Promise((resolveExit) => {
1125
+ const onSignal = () => {
1126
+ exploreCtx.close().catch(() => { });
1127
+ resolveExit();
1128
+ };
1129
+ exploreCtx.on('close', () => resolveExit());
1130
+ process.on('SIGINT', onSignal);
1131
+ process.on('SIGTERM', onSignal);
1132
+ });
1133
+ exitCode = 0;
1134
+ return;
1135
+ }
1136
+ // ── Playwright ─────────────────────────────────────────
1137
+ console.log('[runOfflineFull] running Playwright…');
1138
+ const playwrightEnv = {
1139
+ ...process.env,
1140
+ // Expose the per-run test DB to specs so they can seed/inspect the
1141
+ // app's OWN Postgres directly (e.g. seed Article rows for
1142
+ // list/detail flows). The mock ShopState covers Shopify resources,
1143
+ // but the app DB has no control plane — and apps whose create flow
1144
+ // is gated behind external services (AI, queues) can't be seeded
1145
+ // via their action routes offline. The Playwright process runs in
1146
+ // the same VM as Postgres, so a plain `pg` client on this URL
1147
+ // reaches it directly (no Neon proxy needed).
1148
+ DATABASE_URL: dbConn,
1149
+ DIRECT_URL: dbConn,
1150
+ TEST_OFFLINE_ORCHESTRATED: 'true',
1151
+ TEST_OFFLINE_MOCK_ADMIN_SHELL_URL: adminShell.baseUrl,
1152
+ TEST_OFFLINE_MOCK_ADMIN_API_URL: adminApi.baseUrl,
1153
+ TEST_OFFLINE_MOCK_STOREFRONT_API_URL: storefrontApi.baseUrl,
1154
+ TEST_OFFLINE_MOCK_STOREFRONT_URL: storefrontMock.baseUrl,
1155
+ TEST_OFFLINE_MOCK_BACKEND_URL: backendUrl,
1156
+ TEST_OFFLINE_MOCK_PARTNER_API_URL: partnerApi.baseUrl,
1157
+ TEST_OFFLINE_MOCK_SHOP_DOMAIN: DEFAULT_SHOP_DOMAIN,
1158
+ TEST_OFFLINE_MOCK_APP_HANDLE: DEFAULT_APP_HANDLE,
1159
+ TEST_OFFLINE_MOCK_CLIENT_ID: MOCK_CLIENT_ID,
1160
+ TEST_OFFLINE_MOCK_EDGE_URL: edge.baseUrl,
1161
+ TEST_OFFLINE_MOCK_EDGE_PORT: String(edge.port),
1162
+ };
1163
+ // Use async spawn (NOT execSync) — execSync blocks the
1164
+ // orchestrator's event loop, so the in-process mock servers
1165
+ // can't accept connections from the Playwright child while
1166
+ // tests run. Subtle and ruins everything.
1167
+ // Build Playwright arg list. When this orchestrator is a child
1168
+ // shard, `--shard k/N` partitions the test files across all
1169
+ // shards (Playwright auto-distributes based on file count).
1170
+ // Per-shard HTML reports go to distinct dirs so they don't
1171
+ // collide on a shared workspace.
1172
+ const playwrightArgs = [
1173
+ 'playwright',
1174
+ 'test',
1175
+ '--config',
1176
+ playwrightConfig,
1177
+ ];
1178
+ if (isChildShard && requestedWorkers > 1) {
1179
+ playwrightArgs.push('--shard', `${shardIndex}/${requestedWorkers}`);
1180
+ // Per-shard output dirs (defaults would clobber each other).
1181
+ playwrightArgs.push('--output', `test-results-shard-${shardIndex}`);
1182
+ playwrightEnv['PLAYWRIGHT_HTML_REPORT'] = `playwright-report-shard-${shardIndex}`;
1183
+ }
1184
+ // Forward user-supplied argv to Playwright — `--grep "name"`,
1185
+ // a file path, `--repeat-each`, etc. The docker runner already
1186
+ // forwards user args verbatim into this script's argv; we just
1187
+ // pass them through to playwright so single-test iteration is
1188
+ // possible (`npm run test:offline-full -- --grep "PDP form"`).
1189
+ // Skip our own known flags (currently none after the launcher
1190
+ // strips them — process.argv[0] is `node`, argv[1] is this
1191
+ // script).
1192
+ const userArgs = process.argv.slice(2).filter((a) => a.length > 0);
1193
+ if (userArgs.length > 0) {
1194
+ playwrightArgs.push(...userArgs);
1195
+ }
1196
+ const playwrightExitCode = await new Promise((resolveCode, rejectCode) => {
1197
+ const proc = spawn('npx', playwrightArgs, { stdio: 'inherit', env: playwrightEnv });
1198
+ proc.on('exit', (code) => resolveCode(code ?? 1));
1199
+ proc.on('error', rejectCode);
1200
+ });
1201
+ if (playwrightExitCode !== 0) {
1202
+ throw new Error(`Playwright exited with code ${playwrightExitCode}`);
1203
+ }
1204
+ exitCode = 0;
1205
+ }
1206
+ catch (err) {
1207
+ console.error(`[runOfflineFull] failed: ${err.message}`);
1208
+ }
1209
+ finally {
1210
+ console.log('[runOfflineFull] tearing down…');
1211
+ // `cleanup()` kills the backend AND drops the DB (with
1212
+ // preserveDb honored). It's the same function the signal
1213
+ // handlers call, so the teardown path is identical whether
1214
+ // we exit gracefully or via Ctrl+C.
1215
+ cleanup();
1216
+ await sleep(1_000);
1217
+ // Close mocks. Errors here shouldn't mask test exit code.
1218
+ await Promise.allSettled([
1219
+ edge.close(),
1220
+ adminShell.close(),
1221
+ adminApi.close(),
1222
+ storefrontApi.close(),
1223
+ storefrontMock.close(),
1224
+ partnerApi.close(),
1225
+ ]);
1226
+ if (preserveDb) {
1227
+ console.log(`[runOfflineFull] DB preserved: ${dbName}`);
1228
+ }
1229
+ }
1230
+ process.exit(exitCode);
1231
+ }
1232
+ // computeBuildHash now lives in @essential-apps/shopify-test-core
1233
+ // (shared with runOffline's host build, so the host + in-VM build caches
1234
+ // agree). Imported at the top of this file.
1235
+ /**
1236
+ * Parse `TEST_OFFLINE_EXTENSIONS_JSON` into an array of ExtensionConfig.
1237
+ * Schema (JSON array):
1238
+ * [{ name: string, rootDir: string, enabled?: boolean }, ...]
1239
+ *
1240
+ * `rootDir` may be absolute or relative to `process.cwd()` (the
1241
+ * consuming app's checkout). Validates that each rootDir exists and
1242
+ * fails fast otherwise — silently dropping mis-spelled paths leads
1243
+ * to "extension didn't render" mysteries.
1244
+ */
1245
+ function loadExtensions() {
1246
+ const raw = process.env['TEST_OFFLINE_EXTENSIONS_JSON'];
1247
+ if (!raw)
1248
+ return [];
1249
+ let parsed;
1250
+ try {
1251
+ parsed = JSON.parse(raw);
1252
+ }
1253
+ catch (err) {
1254
+ throw new Error(`[runOfflineFull] TEST_OFFLINE_EXTENSIONS_JSON is not valid JSON: ${err.message}`);
1255
+ }
1256
+ if (!Array.isArray(parsed)) {
1257
+ throw new Error(`[runOfflineFull] TEST_OFFLINE_EXTENSIONS_JSON must be a JSON array; got ${typeof parsed}`);
1258
+ }
1259
+ const out = [];
1260
+ for (const entry of parsed) {
1261
+ if (typeof entry !== 'object' || entry === null) {
1262
+ throw new Error(`[runOfflineFull] extension entry must be an object: ${JSON.stringify(entry)}`);
1263
+ }
1264
+ const e = entry;
1265
+ if (typeof e.name !== 'string' || typeof e.rootDir !== 'string') {
1266
+ throw new Error(`[runOfflineFull] each extension needs { name: string, rootDir: string }; got ${JSON.stringify(entry)}`);
1267
+ }
1268
+ const rootDir = resolve(process.cwd(), e.rootDir);
1269
+ if (!existsSync(rootDir)) {
1270
+ throw new Error(`[runOfflineFull] extension rootDir does not exist: ${rootDir}`);
1271
+ }
1272
+ out.push({
1273
+ name: e.name,
1274
+ rootDir,
1275
+ enabled: e.enabled !== false,
1276
+ });
1277
+ }
1278
+ if (out.length > 0) {
1279
+ console.log(`[runOfflineFull] loaded ${out.length} extension(s): ${out.map((e) => `${e.name}=${e.rootDir}`).join(', ')}`);
1280
+ }
1281
+ return out;
1282
+ }
1283
+ /**
1284
+ * Supervisor mode — fans out N child orchestrator processes.
1285
+ * Each child runs the same script with `TEST_OFFLINE_SHARD_INDEX=k`
1286
+ * + `TEST_OFFLINE_WORKERS=N` so it picks its slot via the
1287
+ * conditional at the top of `main()`.
1288
+ *
1289
+ * We do the build ONCE (before fanning out) and pass
1290
+ * `TEST_OFFLINE_SKIP_BUILD=true` to children to avoid N parallel
1291
+ * builds racing on the same `build/` directory. Orphan DB cleanup
1292
+ * also runs once here.
1293
+ *
1294
+ * Output streams are inherited (children print directly to our
1295
+ * stdout/stderr). Lines aren't prefixed by shard — Playwright's
1296
+ * own output already includes test paths which disambiguate.
1297
+ *
1298
+ * Returns the worst child exit code so the supervisor's exit
1299
+ * accurately reflects failure.
1300
+ */
1301
+ /**
1302
+ * Seed a small product catalog + featured collection for explore
1303
+ * mode. Just enough variety so Dawn's homepage sections render
1304
+ * something realistic — different price points, a "compare at"
1305
+ * (for on-sale styling), a fake image URL each so card layout
1306
+ * exercises both with-image and without-image paths.
1307
+ *
1308
+ * Not used by tests (they seed what they assert on).
1309
+ */
1310
+ function seedExploreProducts(state) {
1311
+ // Image URLs point at the bundled Shopify-asset mirror — these are
1312
+ // the actual product photos from theme-dawn-demo.myshopify.com,
1313
+ // captured by the `mirror` probe. Browser requests are routed by
1314
+ // the edge proxy + `/__shopify-mirror/*` handler so they 200 with
1315
+ // real byte content (no broken-image placeholders during explore).
1316
+ //
1317
+ // Real Shopify normalizes uploaded image URLs to its CDN; we
1318
+ // emit `https://test-shop.myshopify.com/cdn/shop/products/<file>`
1319
+ // (per-shop CDN proxy shape) which our mirror handler resolves
1320
+ // host-agnostically by matching the `/cdn/shop/products/<file>`
1321
+ // suffix.
1322
+ const cdn = (file) => `https://test-shop.myshopify.com/cdn/shop/products/${file}`;
1323
+ // Seed product titles match the actual photos mirrored from the
1324
+ // Dawn-demo store (it's a handbag store; the images are bags).
1325
+ // Mismatching titles + handbag photos looked broken — the user
1326
+ // saw "Snowboard" text with a purse photo. Aligning them keeps
1327
+ // explore mode visually coherent. Image filenames reproduce the
1328
+ // exact mirrored basenames (Shopify hashes are preserved).
1329
+ const fixtures = [
1330
+ { title: 'Art Deco Bag', handle: 'art-deco-bag', price: 39900, description: 'Statement crossbody in cyclamen pink.', image: 'mlouye-art-deco-cyclamen-1_ec8e69b6-92ea-4c48-b8b6-34601cf3c070.jpg' },
1331
+ { title: 'Bo Ivy Bag', handle: 'bo-ivy-bag', price: 28900, compareAt: 34900, description: 'Sculpted shoulder bag in emerald green.', image: 'mlouye-bo-ivy-emerald-1_73c3987e-5ec7-4e72-879a-2ba2e560648f.jpg' },
1332
+ { title: 'Soft Strap Bag', handle: 'soft-strap-bag', price: 35900, description: 'Soft brown leather, structured strap.', image: 'mlouye-bo-soft-strap-brown-1.jpg' },
1333
+ { title: 'Brick Mini Bag', handle: 'brick-mini-bag', price: 22900, description: 'Oil-yellow rectangular mini.', image: 'mlouye-brick-oil-yellow-1.jpg' },
1334
+ { title: 'Business Bag', handle: 'business-bag', price: 49900, description: 'Black-and-grey carry-all.', image: 'mlouye-business-bag-black_grey-1.jpg' },
1335
+ { title: 'Helix Multi Bag', handle: 'helix-multi-bag', price: 41900, description: 'Statement multicolor helix shape.', image: 'mlouye-helix-multicolor-2_1800x1800_10c62242-6743-4d46-a251-defa246dd195.jpg' },
1336
+ ];
1337
+ for (const f of fixtures) {
1338
+ const src = cdn(f.image);
1339
+ const img = { src, alt: f.title, width: 800, height: 800 };
1340
+ state.addProduct({
1341
+ title: f.title,
1342
+ handle: f.handle,
1343
+ description: f.description,
1344
+ description_text: f.description,
1345
+ price: f.price,
1346
+ compare_at_price: f.compareAt ?? null,
1347
+ vendor: 'Snowboard Vendor',
1348
+ type: 'Snowboards',
1349
+ tags: ['Featured', 'New'],
1350
+ // Collection membership is wired by addCollection below.
1351
+ featured_image: img,
1352
+ // Dawn reads `card_product.featured_media`, NOT `featured_image`,
1353
+ // for product-card rendering — it's a Shopify "Media" object
1354
+ // with extra `.aspect_ratio` / `.preview_image` props. Without
1355
+ // this Dawn skips the image block on every product card.
1356
+ featured_media: {
1357
+ ...img,
1358
+ aspect_ratio: 1,
1359
+ media_type: 'image',
1360
+ preview_image: img,
1361
+ },
1362
+ // Dawn's PDP main-product section reads `product.media` (an
1363
+ // ARRAY of Media objects) to build the gallery. Without this
1364
+ // the product page shows no image. `position` is the 1-indexed
1365
+ // slot in the gallery; we only have one.
1366
+ media: [
1367
+ {
1368
+ id: img.width * 100 + 1,
1369
+ position: 1,
1370
+ media_type: 'image',
1371
+ aspect_ratio: 1,
1372
+ preview_image: img,
1373
+ ...img,
1374
+ },
1375
+ ],
1376
+ images: [img],
1377
+ });
1378
+ }
1379
+ // Seed BOTH collections Dawn's default templates reference:
1380
+ // - `all` — referenced by `templates/index.json`'s
1381
+ // featured-collection section by default
1382
+ // - `frontpage` — referenced by various sections that look
1383
+ // for the merchant's main landing collection
1384
+ // Add the products to both so each section renders its own
1385
+ // grid even before the merchant edits the template.
1386
+ const handles = fixtures.map((f) => f.handle);
1387
+ state.addCollection({ handle: 'all', title: 'All products', productHandles: handles });
1388
+ state.addCollection({ handle: 'frontpage', title: 'Home page', productHandles: handles });
1389
+ }
1390
+ async function runSupervisor(workers) {
1391
+ console.log(`[runOfflineFull] supervisor: spawning ${workers} shard(s)…`);
1392
+ // Pre-build: each child would otherwise duplicate the build (or
1393
+ // race on the cache). Build here once, then pass SKIP_BUILD=true
1394
+ // to children. The build cache check still runs (cheap), so this
1395
+ // is a no-op on warm caches.
1396
+ const appRoot = process.cwd();
1397
+ const buildCachePath = resolve(appRoot, 'build/.test-build-cache');
1398
+ const currentBuildHash = await computeBuildHash(appRoot);
1399
+ const cachedHash = existsSync(buildCachePath) && existsSync('build/server/index.js')
1400
+ ? readFileSync(buildCachePath, 'utf8').trim()
1401
+ : null;
1402
+ if (cachedHash === currentBuildHash) {
1403
+ console.log(`[runOfflineFull] supervisor: build cache HIT (${currentBuildHash.slice(0, 12)})`);
1404
+ }
1405
+ else {
1406
+ console.log(`[runOfflineFull] supervisor: build cache MISS — running npm run build…`);
1407
+ execSync('npm run build', {
1408
+ stdio: 'inherit',
1409
+ // `TEST_OFFLINE=true` switches the consuming app's
1410
+ // vite.config.ts off of any production deployment preset
1411
+ // (e.g. Vercel's `vercelPreset()`) and back to the plain
1412
+ // Remix build that produces `build/server/index.js`. Without
1413
+ // this, presets that shard the output by runtime (e.g.
1414
+ // `build/server/nodejs-…/`) leave the expected `index.js`
1415
+ // path empty and the child shard's `remix-serve` errors with
1416
+ // ENOENT. The non-supervisor path (line ~911) already passes
1417
+ // this through `backendEnv`; we mirror it here so both build
1418
+ // paths produce the same artifact layout.
1419
+ env: { ...process.env, NODE_ENV: 'production', TEST_OFFLINE: 'true' },
1420
+ });
1421
+ writeFileSync(buildCachePath, currentBuildHash);
1422
+ }
1423
+ // Orphan-DB cleanup once for the whole supervisor run.
1424
+ const appName = readAppName();
1425
+ const dbPrefix = `${appName}_offline`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
1426
+ dropOrphanDatabases(dbPrefix);
1427
+ // Spawn N children in parallel.
1428
+ const childScript = process.argv[1] ?? '';
1429
+ const children = [];
1430
+ for (let k = 1; k <= workers; k++) {
1431
+ const childEnv = {
1432
+ ...process.env,
1433
+ TEST_OFFLINE_SHARD_INDEX: String(k),
1434
+ TEST_OFFLINE_WORKERS: String(workers),
1435
+ TEST_OFFLINE_SKIP_BUILD: 'true',
1436
+ };
1437
+ children.push(new Promise((resolveChild, rejectChild) => {
1438
+ // Re-exec ourselves through `node --import tsx` so TS still
1439
+ // parses. argv[1] points at the user's `runOfflineFullTests.ts`
1440
+ // by the time we're invoked from the consuming app's
1441
+ // npm script.
1442
+ const proc = spawn(process.execPath, ['--import', 'tsx', childScript], { stdio: 'inherit', env: childEnv });
1443
+ proc.on('exit', (code) => resolveChild(code ?? 1));
1444
+ proc.on('error', rejectChild);
1445
+ }));
1446
+ }
1447
+ const exitCodes = await Promise.all(children);
1448
+ const worst = exitCodes.reduce((a, b) => Math.max(a, b), 0);
1449
+ console.log(`[runOfflineFull] supervisor: shard exit codes [${exitCodes.join(', ')}]; overall=${worst}`);
1450
+ return worst;
1451
+ }
1452
+ main().catch((err) => {
1453
+ console.error(err);
1454
+ process.exit(1);
1455
+ });
1456
+ //# sourceMappingURL=runOfflineFullTests.js.map