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