@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,577 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Run the OFFLINE-FULL suite inside a microVM.
4
+ *
5
+ * Sibling of `runVm.ts` (which runs the ONLINE Shopify suite).
6
+ * Both use the same arm64 microVM image; this one drives the offline
7
+ * orchestrator (`runOfflineFullTests.js`) instead of the online one
8
+ * (`runTests.js`). The runOfflineFullTests script bootstraps the full
9
+ * mock stack (Remix backend + MSW Admin + storefront + mock admin
10
+ * shell + per-run Postgres DB) entirely inside the VM — no Shopify
11
+ * round-trips, no Cloudflare, no auth.
12
+ *
13
+ * Architecture (matches the online runner where the shape overlaps):
14
+ *
15
+ * - Same image (`essential-apps/shopify-test-vm:latest`)
16
+ * as the online runner — one image to bake, both runners restore
17
+ * from the same snapshot family.
18
+ * - Bind mounts (virtio-fs DAX):
19
+ * - consuming app repo → /workspace
20
+ * - linux node_modules cache → /workspace/node_modules
21
+ * - Postgres autostart + /etc/hosts patch + npm install folded
22
+ * into the warmup callback. Warm restores skip all of that.
23
+ * - `runOfflineFullTests.js` spawned inside; its in-VM Vite +
24
+ * Playwright + mocks all dial localhost.
25
+ *
26
+ * Why a host build of `build/server/index.js` is required:
27
+ * the in-VM Vite build is markedly slower than the host one (~5 min
28
+ * vs ~6 s) and hits a virtio-fs perf cliff during the Rollup small-
29
+ * file emission phase. Building on the host + skipping the in-VM
30
+ * build with TEST_OFFLINE_SKIP_BUILD=true is the supported path.
31
+ * runOfflineFullTests.js still spins up Vite for non-build needs.
32
+ *
33
+ * Override env vars (mirrors runVm where applicable; same
34
+ * TEST_ONLINE_VM_* knobs):
35
+ * TEST_ONLINE_VM_IMAGE / TEST_ONLINE_VM_IMAGE_SOURCE
36
+ * TEST_ONLINE_VM_IMAGE_LAYOUT_PATH
37
+ * TEST_ONLINE_VM_MEMORY_MIB Default: 8192.
38
+ * TEST_ONLINE_VM_VCPUS Default: 4.
39
+ * TEST_LINUX_NODE_MODULES Default: ~/.cache/${appName}-test/node_modules
40
+ * TEST_ONLINE_VM_TIMEOUT_MS Default: 10 min.
41
+ * TEST_OFFLINE_EXTENSIONS_JSON forwarded to runOfflineFullTests.
42
+ * TEST_OFFLINE_POST_PURCHASE_EXT forwarded.
43
+ * LIQUID_CONTRACTS_CAPTURE set to 'true' to regenerate
44
+ * tests/test-offline/contracts/liquid/*.html
45
+ * in full-suite context. See
46
+ * tests/README.md.
47
+ * PLAYWRIGHT_GREP forwarded as Playwright --grep,
48
+ * e.g. PLAYWRIGHT_GREP='cart flow'
49
+ */
50
+ import { createHash } from 'node:crypto';
51
+ import { execSync } from 'node:child_process';
52
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
53
+ import { createRequire } from 'node:module';
54
+ import { homedir } from 'node:os';
55
+ import { dirname, resolve } from 'node:path';
56
+ import { fileURLToPath } from 'node:url';
57
+ import { pickApp, prepareOciArchive, envFileArgs, computeBuildHash } from '@essential-apps/shopify-test-core';
58
+
59
+ const repoRoot = process.cwd();
60
+
61
+ interface PackageJson {
62
+ name?: string;
63
+ }
64
+
65
+ function readAppName(): string {
66
+ const pkgPath = resolve(repoRoot, 'package.json');
67
+ if (!existsSync(pkgPath)) {
68
+ throw new Error(
69
+ `No package.json at ${pkgPath}. Run this from the consuming app's repo root.`,
70
+ );
71
+ }
72
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageJson;
73
+ if (!pkg.name) {
74
+ throw new Error(`package.json at ${pkgPath} has no \`name\` field.`);
75
+ }
76
+ return pkg.name.replace(/^@[^/]+\//, '');
77
+ }
78
+
79
+ /**
80
+ * Ensure the VM base image exists in the local container store, building
81
+ * it once from the vendored Dockerfile if not. No external registry: the
82
+ * runner package ships docker/Dockerfile.vm (via its package.json
83
+ * "files"), so a fresh clone builds the image on first run with the same
84
+ * `container build` the conformance:image:build script uses. Slow (~mins:
85
+ * apt + browser base) but one-time — the image then persists in the
86
+ * store. Only the oci-archive source mode calls this (registry /
87
+ * oci-layout source the image elsewhere).
88
+ */
89
+ function ensureVmImageBuilt(ref: string): void {
90
+ try {
91
+ execSync(`container image inspect ${JSON.stringify(ref)}`, { stdio: 'ignore' });
92
+ return; // already present
93
+ } catch {
94
+ // not found → build below
95
+ }
96
+ const dockerCtx = resolve(dirname(fileURLToPath(import.meta.url)), '../../docker');
97
+ const dockerfile = resolve(dockerCtx, 'Dockerfile.vm');
98
+ if (!existsSync(dockerfile)) {
99
+ throw new Error(
100
+ `[runOffline] VM image "${ref}" is not in the local container store, ` +
101
+ `and the vendored Dockerfile is missing at ${dockerfile} (expected ` +
102
+ `under the @essential-apps/shopify-test-runner package's docker/).`,
103
+ );
104
+ }
105
+ console.error(
106
+ `[runOffline] VM image "${ref}" not found — building it once from ` +
107
+ `${dockerfile}. This takes a few minutes (apt + browser base); it is ` +
108
+ `cached in the container store afterward, so later runs skip to bake.`,
109
+ );
110
+ const t = performance.now();
111
+ execSync(
112
+ `container build --arch arm64 -t ${JSON.stringify(ref)} ` +
113
+ `-f ${JSON.stringify(dockerfile)} ${JSON.stringify(dockerCtx)}`,
114
+ { stdio: 'inherit' },
115
+ );
116
+ console.error(
117
+ `[runOffline] VM image built in ${((performance.now() - t) / 1000).toFixed(1)} s.`,
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Resolve the VM image source the same way the conformance
123
+ * runner does. Duplicated here intentionally — extracting into a
124
+ * shared helper would create a runner→conformance dep cycle (runner
125
+ * is allowed to depend on conformance, but in practice this is the
126
+ * single use-site and the indirection isn't worth a new dep).
127
+ */
128
+ async function imageSourceOptions(): Promise<Record<string, unknown>> {
129
+ const mode = process.env['TEST_ONLINE_VM_IMAGE_SOURCE'] ?? 'oci-archive';
130
+ if (mode === 'registry') return {};
131
+ const ref =
132
+ process.env['TEST_ONLINE_VM_IMAGE'] ??
133
+ 'essential-apps/shopify-test-vm:latest';
134
+ if (mode === 'oci-archive') {
135
+ ensureVmImageBuilt(ref);
136
+ const prep = await prepareOciArchive(ref);
137
+ if (prep.freshlySaved) {
138
+ console.error(
139
+ `[runOffline] saved ${ref} → ${prep.archivePath} (${(prep.sizeBytes / 1024 / 1024).toFixed(1)} MB)`,
140
+ );
141
+ }
142
+ return { source: 'oci-archive', sourcePath: prep.archivePath };
143
+ }
144
+ if (mode === 'oci-layout') {
145
+ const sourcePath = process.env['TEST_ONLINE_VM_IMAGE_LAYOUT_PATH'];
146
+ if (!sourcePath) {
147
+ throw new Error(
148
+ `TEST_ONLINE_VM_IMAGE_SOURCE=oci-layout requires TEST_ONLINE_VM_IMAGE_LAYOUT_PATH`,
149
+ );
150
+ }
151
+ return { source: 'oci-layout', sourcePath };
152
+ }
153
+ throw new Error(
154
+ `TEST_ONLINE_VM_IMAGE_SOURCE=${mode} not recognised. Valid: oci-archive | oci-layout | registry`,
155
+ );
156
+ }
157
+
158
+ async function main(): Promise<void> {
159
+ const appName = readAppName();
160
+ const linuxModules =
161
+ process.env['TEST_LINUX_NODE_MODULES'] ??
162
+ resolve(homedir(), `.cache/${appName}-test/node_modules`);
163
+ mkdirSync(linuxModules, { recursive: true });
164
+
165
+ // ── Host build (gated by app-source hash) ───────────────────
166
+ // The in-VM runner skips building (TEST_OFFLINE_SKIP_BUILD, set below)
167
+ // and serves build/server/index.js from the bind-mounted workspace — so
168
+ // we build it HERE on the host (much faster than in-VM), but only when
169
+ // the app's sources actually changed. computeBuildHash hashes app/ plus
170
+ // the package/config files; we cache it next to the output. Net: editing
171
+ // app code transparently rebuilds, and a fresh clone (no build/) builds
172
+ // on first run — no manual `npm run build`. Force with
173
+ // TEST_OFFLINE_FORCE_BUILD=true.
174
+ const buildCachePath = resolve(repoRoot, 'build/.test-build-cache');
175
+ const serverBundlePath = resolve(repoRoot, 'build/server/index.js');
176
+ const currentBuildHash = await computeBuildHash(repoRoot);
177
+ const forceBuild = process.env['TEST_OFFLINE_FORCE_BUILD'] === 'true';
178
+ const cachedBuildHash =
179
+ !forceBuild && existsSync(buildCachePath) && existsSync(serverBundlePath)
180
+ ? readFileSync(buildCachePath, 'utf8').trim()
181
+ : null;
182
+ if (cachedBuildHash === currentBuildHash) {
183
+ console.error(
184
+ `[runOffline] build cache HIT (${currentBuildHash.slice(0, 12)}) — skipping host build.`,
185
+ );
186
+ } else {
187
+ console.error(
188
+ `[runOffline] build cache MISS — host-building app (TEST_OFFLINE=true)… ` +
189
+ `(${cachedBuildHash ? `was ${cachedBuildHash.slice(0, 12)}` : 'no cache'} → ${currentBuildHash.slice(0, 12)})`,
190
+ );
191
+ const tBuild = performance.now();
192
+ // TEST_OFFLINE gates off vercelPreset (→ a remix-serve-compatible
193
+ // build); the app reads runtime secrets at request time, so no
194
+ // build-time env is required. NODE_ENV=production for a prod bundle.
195
+ execSync('npm run build', {
196
+ stdio: 'inherit',
197
+ env: { ...process.env, TEST_OFFLINE: 'true', NODE_ENV: 'production' },
198
+ });
199
+ writeFileSync(buildCachePath, currentBuildHash);
200
+ console.error(
201
+ `[runOffline] host build: ${((performance.now() - tBuild) / 1000).toFixed(1)} s`,
202
+ );
203
+ }
204
+
205
+ // Tarball-manifest written by `scripts/pack-into.sh` — a hash list
206
+ // of the vendored @essential-apps/* tarballs. Passed as an
207
+ // `extraFile` so its bytes fold into the snapshot's bake input-hash:
208
+ // any tarball change (= someone ran pack-into.sh) changes the
209
+ // manifest, which invalidates the snapshot, which re-runs warmup,
210
+ // which reinstalls. This is what makes a tarball refresh "just work"
211
+ // WITHOUT a manual warmupTag bump or a `rm -rf ~/.cache/<app>-test`.
212
+ //
213
+ // Before this, the only snapshot key was the static `warmupTag`
214
+ // string, so a tarball refresh left a stale snapshot + a stale
215
+ // bind-mounted node_modules cache serving old code — the classic
216
+ // "I repacked but the test still runs the old build" footgun.
217
+ // (Mirrors the online runner runVm.ts, which already does this.)
218
+ const tarballManifest = resolve(
219
+ repoRoot,
220
+ 'vendor/essential-apps-shopify-test/.tarball-manifest',
221
+ );
222
+ const tarballManifestExists = existsSync(tarballManifest);
223
+
224
+ // Fold a hash of the manifest into the warmupTag. The `extraFiles`
225
+ // approach alone (below) does NOT reliably invalidate the snapshot
226
+ // in this VM-runtime version — observed: manifest changed but bake
227
+ // was still a 0.0s warm restore. The warmupTag, by contrast,
228
+ // DEFINITIVELY keys the snapshot (a tag bump always rebakes). So we
229
+ // hash the manifest (itself a sha256 list of the vendored tarballs)
230
+ // into the tag: any tarball change → manifest change → tag change →
231
+ // rebake → warmup reinstalls. The leading `v12` is the WARMUP-SCRIPT
232
+ // version — bump it only when changing the warmup steps below
233
+ // (perms/hosts/install), not for tarball refreshes.
234
+ const manifestHash = tarballManifestExists
235
+ ? createHash('sha256').update(readFileSync(tarballManifest)).digest('hex').slice(0, 12)
236
+ : 'no-manifest';
237
+
238
+ // Fold the @supermachine/core (VM runtime) version into the snapshot
239
+ // key as well. A snapshot is produced by a specific VMM version, and
240
+ // restoring one baked by a different runtime is unsound — but the bake
241
+ // hash keys only on GUEST inputs (image + tarballs), not the host VMM,
242
+ // so a runtime bump would otherwise warm-restore a stale snapshot. Key
243
+ // on the installed version so a bump auto-rebakes — same philosophy as
244
+ // the manifest hash (no manual tag bump, no `rm -rf ~/.cache/<app>`).
245
+ // Resolved from the consumer root; degrades to a stable bucket (never
246
+ // crashes) if the version can't be read.
247
+ const runtimeVersion = ((): string => {
248
+ try {
249
+ const requirePkg = createRequire(resolve(repoRoot, 'package.json'));
250
+ return (
251
+ requirePkg('@supermachine/core/package.json') as { version: string }
252
+ ).version;
253
+ } catch {
254
+ return 'unknown';
255
+ }
256
+ })();
257
+ const runtimeTag = `sm${runtimeVersion.replace(/[^0-9a-z]+/gi, '')}`;
258
+ // Scope the snapshot to the consuming app. supermachine keys WARM
259
+ // snapshots by (image + warmupTag), but the image is shared across
260
+ // apps and the rest of the tag is identical for any two apps vendoring
261
+ // the SAME shopify-test build (same manifestHash + runtime). The warm
262
+ // snapshot captures THIS app's `npm install`, so without appName, app
263
+ // B would restore app A's warm snapshot — wrong node_modules + app
264
+ // identity (observed: essential-upsell booting with essential-seo's
265
+ // DB). The cache paths are already appName-namespaced; this aligns the
266
+ // snapshot key with them.
267
+ const warmupTag = `offline-v13-${appName}-${runtimeTag}-${manifestHash}`;
268
+
269
+ // Dynamic import — @supermachine/core is heavy and only needed
270
+ // when actually running tests, not for `--help` etc.
271
+ const { Image } = await import('@supermachine/core');
272
+
273
+ const imageOpts = await imageSourceOptions();
274
+ console.error(`[runOffline] baking image… (warmupTag=${warmupTag})`);
275
+ const tBake = performance.now();
276
+ const image = await Image.build({
277
+ ref:
278
+ process.env['TEST_ONLINE_VM_IMAGE'] ??
279
+ 'essential-apps/shopify-test-vm:latest',
280
+ memoryMib: Number(process.env['TEST_ONLINE_VM_MEMORY_MIB'] ?? 8192),
281
+ vcpus: Number(process.env['TEST_ONLINE_VM_VCPUS'] ?? 4),
282
+ cmd: ['sleep', 'infinity'],
283
+ mounts: [
284
+ // VM runtime 0.7.28+ requires explicit guestPath and
285
+ // auto-mounts at boot before warmup fires.
286
+ { hostPath: repoRoot, guestTag: 'workspace', guestPath: '/workspace' },
287
+ { hostPath: linuxModules, guestTag: 'lmod', guestPath: '/workspace/node_modules' },
288
+ ],
289
+ extraFiles: tarballManifestExists
290
+ ? [
291
+ // Belt-and-suspenders alongside the manifest-hashed
292
+ // warmupTag (the tag is what actually drives invalidation;
293
+ // see warmupTag note above). Harmless if the runtime ignores
294
+ // it; matches the online runner runVm.ts.
295
+ { hostPath: tarballManifest, guestPath: '/etc/.shopify-test-tarball-manifest' },
296
+ ]
297
+ : [],
298
+ // Snapshot key. `warmupTag` is computed from the tarball-manifest
299
+ // hash (see above), so a tarball refresh auto-rebakes WITHOUT a
300
+ // manual bump or a `rm -rf ~/.cache/<app>-test`. Warmup runs ONCE
301
+ // per bake; warm acquires skip it.
302
+ warmupTag,
303
+ // @ts-expect-error — @supermachine/core's BuildOptions type
304
+ // doesn't declare `warmup`, but the runtime accepts it. Tracked
305
+ // upstream; fix-or-augment when the types catch up.
306
+ warmup: async (vm: {
307
+ exec: (opts: { argv: string[]; timeoutMs?: number }) => Promise<{
308
+ exitCode: number;
309
+ stdout: Buffer;
310
+ stderr: Buffer;
311
+ }>;
312
+ }) => {
313
+ const r = await vm.exec({
314
+ argv: ['sh', '-c', `
315
+ set -e
316
+ # VM runtime 0.7.28+ auto-mounts declared mounts/volumes
317
+ # at boot before warmup fires — no manual mount needed.
318
+ echo "--- repair perms for postgres ---"
319
+ chown -R postgres:postgres /var/lib/postgresql/data
320
+ chmod 700 /var/lib/postgresql/data
321
+ mkdir -p /var/run/postgresql
322
+ chown postgres:postgres /var/run/postgresql
323
+ chmod 775 /var/run/postgresql
324
+
325
+ echo "--- npm install (fresh per rebake) ---"
326
+ # Warmup runs EXACTLY when the snapshot rebakes — which now
327
+ # happens only when the tarball-manifest extraFile changes
328
+ # (pack-into.sh) or the warmupTag bumps. Either way we want a
329
+ # clean install, so wipe the bind-mounted cache first; npm
330
+ # can't reuse a stale tarball that way.
331
+ #
332
+ # NB: this is a DEDICATED cache dir (~/.cache/<app>-test/
333
+ # node_modules), NOT the repo's node_modules, so the rm-rf is
334
+ # safe. Replaces the old "skip if .package-lock.json exists"
335
+ # check, which left stale code in the cache across tarball
336
+ # refreshes (the bind mount served old bytes because warmup
337
+ # never re-ran the install).
338
+ cd /workspace
339
+ rm -rf node_modules/* node_modules/.[!.]* 2>/dev/null || true
340
+ # Don't pipe npm to "tail" — the pipeline status is tail's,
341
+ # masking npm failures under "set -e", so a transient install
342
+ # error would bake a half-installed node_modules cache that
343
+ # warm restores then reuse. Check npm's own status so a failed
344
+ # install aborts the bake (no cache poisoned, next run rebakes).
345
+ # Low-memory install. The 8 GiB guest intermittently
346
+ # OOM-killed npm (exit 137) once build-essential let native
347
+ # deps (utf-8-validate / bufferutil) compile — parallel g++
348
+ # spiked peak memory. Serialize node-gyp (jobs=1, make -j1)
349
+ # and skip audit/fund work to keep the install under the cap.
350
+ if ! npm_config_jobs=1 MAKEFLAGS=-j1 npm install --legacy-peer-deps --engine-strict=false --no-audit --no-fund > /tmp/npm-install.log 2>&1; then
351
+ echo "--- npm install FAILED - last 40 lines ---"
352
+ tail -40 /tmp/npm-install.log
353
+ exit 1
354
+ fi
355
+ tail -10 /tmp/npm-install.log
356
+
357
+ echo "--- patch /etc/hosts for localhost ---"
358
+ if ! grep -q "^127.0.0.1.*localhost" /etc/hosts; then
359
+ echo "127.0.0.1 localhost" >> /etc/hosts
360
+ echo "::1 localhost" >> /etc/hosts
361
+ fi
362
+
363
+ echo "--- warmup done ---"
364
+ `],
365
+ timeoutMs: 15 * 60 * 1000,
366
+ });
367
+ if (r.exitCode !== 0) {
368
+ throw new Error(
369
+ `warmup failed (exit ${r.exitCode}): ${r.stderr.toString().slice(-2000)}\n${r.stdout.toString().slice(-2000)}`,
370
+ );
371
+ }
372
+ },
373
+ ...imageOpts,
374
+ });
375
+ console.error(`[runOffline] bake: ${((performance.now() - tBake) / 1000).toFixed(1)} s`);
376
+
377
+ const pool = await image.pool({ min: 1, max: 1, restoreOnRelease: false });
378
+ const vm = await pool.acquire();
379
+ try {
380
+ // Postgres startup — fast because warmup already chowned the data dir.
381
+ const tPg = performance.now();
382
+ const pgR = await vm.exec({
383
+ argv: ['sh', '-c', `
384
+ export PGDATA=/var/lib/postgresql/data
385
+ export PG_BIN=/usr/lib/postgresql/14/bin
386
+ if su postgres -c "\${PG_BIN}/pg_ctl -D \${PGDATA} status" >/dev/null 2>&1; then echo running; exit 0; fi
387
+ if [ -z "$(ls -A \${PGDATA} 2>/dev/null)" ]; then su postgres -c "\${PG_BIN}/initdb -D \${PGDATA} -A trust" >/dev/null; fi
388
+ su postgres -c "\${PG_BIN}/pg_ctl -D \${PGDATA} -l /tmp/pg.log -o '-h 127.0.0.1 -p 5432' start" >/dev/null
389
+ for _ in $(seq 1 50); do
390
+ if su postgres -c "\${PG_BIN}/pg_isready -h 127.0.0.1 -p 5432" >/dev/null 2>&1; then
391
+ # Create 'root' superuser to match the libkrun image entrypoint
392
+ # — runTests.js connects as 'root'.
393
+ su postgres -c "\${PG_BIN}/createuser -h 127.0.0.1 -p 5432 -s root" 2>&1 | grep -v 'already exists' || true
394
+ exit 0
395
+ fi
396
+ sleep 0.1
397
+ done
398
+ cat /tmp/pg.log
399
+ exit 1
400
+ `],
401
+ timeoutMs: 30_000,
402
+ });
403
+ if (pgR.exitCode !== 0) {
404
+ console.error('[runOffline] postgres start failed:', pgR.stderr.toString());
405
+ process.exit(1);
406
+ }
407
+ console.error(`[runOffline] postgres: ${((performance.now() - tPg) / 1000).toFixed(2)} s`);
408
+
409
+ // Forward a curated env subset. Mostly the same as the online
410
+ // runner, plus the offline-specific extension list + post-
411
+ // purchase host + the SKIP_BUILD escape hatch the host-build
412
+ // path requires + LIQUID_CONTRACTS_CAPTURE for regenerating
413
+ // the stored Liquid snapshots.
414
+ const forwardEnv: Record<string, string> = {};
415
+ for (const k of [
416
+ 'TEST_VISIBLE',
417
+ 'TEST_WORKERS',
418
+ 'TEST_PRESERVE_DB',
419
+ 'TEST_OFFLINE_STRICT_GRAPHQL',
420
+ 'TEST_OFFLINE_EXTENSIONS_JSON',
421
+ 'TEST_OFFLINE_POST_PURCHASE_EXT',
422
+ 'LIQUID_CONTRACTS_CAPTURE',
423
+ // Playwright reads this natively to update visual-regression
424
+ // PNG baselines (`all` | `missing` | `none`). Forward so
425
+ // teammates can run `PLAYWRIGHT_UPDATE_SNAPSHOTS=all npm run
426
+ // test:offline` after intentional UI changes.
427
+ 'PLAYWRIGHT_UPDATE_SNAPSHOTS',
428
+ 'CI',
429
+ 'DEBUG',
430
+ ]) {
431
+ const v = process.env[k];
432
+ if (v !== undefined) forwardEnv[k] = v;
433
+ }
434
+ forwardEnv['TEST_IN_CONTAINER'] = 'true';
435
+
436
+ // Auto-wire the consuming app's theme app extensions into the
437
+ // storefront mock. Without this, the orchestrator's `loadExtensions`
438
+ // returns [] and no app-embed Liquid gets injected into rendered
439
+ // pages — symptoms: widget tests time out waiting for
440
+ // `<essential-upsell-app-embed>` to attach or
441
+ // `[data-essential-upsell-funnel-id]` to portal.
442
+ //
443
+ // Source of truth is `tests/test-online/.apps.json` — same file the
444
+ // online tier reads (one app registry, shared across tiers). Each
445
+ // extension entry needs a host-side `rootDir`; we translate it to
446
+ // the VM-side `/workspace/<rel>` path so the orchestrator sees the
447
+ // extension at the same logical location it lives in the repo.
448
+ //
449
+ // Honour a pre-set `TEST_OFFLINE_EXTENSIONS_JSON` (e.g. the user
450
+ // wants a custom mix) — only auto-populate when the env var is
451
+ // missing. Same pattern for `TEST_OFFLINE_POST_PURCHASE_EXT`.
452
+ if (!forwardEnv['TEST_OFFLINE_EXTENSIONS_JSON']) {
453
+ try {
454
+ const app = pickApp();
455
+ if (app.extensions && app.extensions.length > 0) {
456
+ const vmExtensions = app.extensions
457
+ .filter((e) => e.rootDir)
458
+ .map((e) => ({
459
+ name: e.name,
460
+ // rootDir in .apps.json is relative to the consumer repo
461
+ // (e.g. `app_extensions/upsells`). Inside the VM, the
462
+ // repo is bind-mounted at `/workspace`.
463
+ rootDir: `/workspace/${e.rootDir!.replace(/^\.?\//, '')}`,
464
+ }));
465
+ if (vmExtensions.length > 0) {
466
+ forwardEnv['TEST_OFFLINE_EXTENSIONS_JSON'] = JSON.stringify(vmExtensions);
467
+ }
468
+ // Post-purchase bundle (separate env — orchestrator loads it
469
+ // via a different code path from theme app extensions).
470
+ if (!forwardEnv['TEST_OFFLINE_POST_PURCHASE_EXT']) {
471
+ const postPurchase = app.extensions.find((e) => e.postPurchaseBundle);
472
+ if (postPurchase?.postPurchaseBundle) {
473
+ forwardEnv['TEST_OFFLINE_POST_PURCHASE_EXT'] = `/workspace/${postPurchase.postPurchaseBundle.replace(/^\.?\//, '')}`;
474
+ }
475
+ }
476
+ }
477
+ } catch (err) {
478
+ // .apps.json missing or malformed — skip auto-wiring rather than
479
+ // crash. Tests that don't depend on extensions still run.
480
+ console.error(
481
+ `[runOffline] couldn't auto-load extensions from .apps.json: ${(err as Error).message}`,
482
+ );
483
+ }
484
+ }
485
+
486
+ // The host-built bundle in build/server/index.js was generated
487
+ // with the host's Prisma client. Skip the in-VM rebuild — see
488
+ // the runOfflineFullTests source for the full picture. The
489
+ // host build must exist and be fresh; `npm run build` in the
490
+ // consuming app with TEST_OFFLINE=true NODE_ENV=production
491
+ // before invoking this script.
492
+ forwardEnv['TEST_OFFLINE_SKIP_BUILD'] = 'true';
493
+ // Pin Prisma to the linux-arm64 query engine that lives in
494
+ // LMOD's node_modules/.prisma/client/. The bundled client's
495
+ // built-in resolver gets confused by the host-vs-guest engine
496
+ // mismatch unless we tell it exactly which .so.node to dlopen.
497
+ forwardEnv['PRISMA_QUERY_ENGINE_LIBRARY'] =
498
+ '/workspace/node_modules/.prisma/client/libquery_engine-linux-arm64-openssl-3.0.x.so.node';
499
+
500
+ // Optional --grep forwarding for fast iteration on a single
501
+ // scenario or spec without writing a one-off bench script.
502
+ const grep = process.env['PLAYWRIGHT_GREP'];
503
+ const grepArg = grep ? `--grep ${JSON.stringify(grep)}` : '';
504
+
505
+ // Spawn the test runner. Streaming stdout/stderr via spawn so
506
+ // the user sees Playwright output as it lands rather than at end.
507
+ console.error('[runOffline] launching runOfflineFullTests.js…');
508
+ const tTests = performance.now();
509
+ const testProc = await vm.spawn({
510
+ argv: ['sh', '-c', `
511
+ cd /workspace
512
+ # node_modules/.bin on PATH so test code can shell out to
513
+ # local CLIs (shopify, prisma, playwright).
514
+ export PATH=/workspace/node_modules/.bin:$PATH
515
+ node ${envFileArgs(repoRoot)} node_modules/@essential-apps/shopify-test-runner/dist/scripts/runOfflineFullTests.js ${grepArg}
516
+ `],
517
+ env: forwardEnv,
518
+ });
519
+
520
+ // Stream + collect output. Hard 10-min cap by default, with a
521
+ // no-output stall guard for hung processes.
522
+ //
523
+ // The stall guard has to outlast legitimate cold-start silence:
524
+ // - edge proxy bind (instant)
525
+ // - prisma migrate deploy (~5-15 s cold)
526
+ // - Remix backend boot via remix-serve (~10-30 s cold)
527
+ // - Playwright spawning chromium (~10-30 s cold first time)
528
+ // - testDir discovery + worker boot
529
+ // Cumulative cold-start silence can reach ~60-90 s before any test
530
+ // produces stdout. Default 180 s lives comfortably outside that
531
+ // window. Override via env if a test legitimately needs more.
532
+ const maxMs = Number(process.env['TEST_ONLINE_VM_TIMEOUT_MS'] ?? 10 * 60 * 1000);
533
+ const stallMs = Number(process.env['TEST_OFFLINE_STALL_MS'] ?? 180_000);
534
+ const start = Date.now();
535
+ let lastByteAt = Date.now();
536
+ while (true) {
537
+ const out = await testProc.readStdout(64 * 1024);
538
+ const now = Date.now();
539
+ if (out.length === 0) {
540
+ if (now - start > maxMs) {
541
+ console.error(`\n[runOffline] HARD TIMEOUT ${maxMs / 1000}s — killing`);
542
+ await testProc.signal(15).catch(() => {});
543
+ break;
544
+ }
545
+ if (now - lastByteAt > stallMs) {
546
+ console.error(`\n[runOffline] no output for ${stallMs / 1000}s — killing`);
547
+ await testProc.signal(15).catch(() => {});
548
+ break;
549
+ }
550
+ await new Promise((r) => setTimeout(r, 200));
551
+ continue;
552
+ }
553
+ lastByteAt = now;
554
+ process.stdout.write(out);
555
+ // Playwright summary lines look like " 17 passed (45.6s)" or
556
+ // " 3 failed". Once we see one, drain the tail and break.
557
+ if (/\b\d+ (passed|failed)\b/.test(out.toString())) {
558
+ await new Promise((r) => setTimeout(r, 500));
559
+ const tail = await testProc.readStdout(64 * 1024);
560
+ if (tail.length > 0) process.stdout.write(tail);
561
+ break;
562
+ }
563
+ }
564
+ const wait = await testProc.wait();
565
+ console.error(`[runOffline] tests: ${((performance.now() - tTests) / 1000).toFixed(1)} s`);
566
+ console.error(`[runOffline] grand: ${((performance.now() - tBake) / 1000).toFixed(1)} s`);
567
+ process.exit(wait.exitCode ?? 0);
568
+ } finally {
569
+ await vm.release().catch(() => {});
570
+ await pool.shutdown().catch(() => {});
571
+ }
572
+ }
573
+
574
+ main().catch((err) => {
575
+ console.error(err);
576
+ process.exit(1);
577
+ });