@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,541 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-time interactive Shopify auth capture inside a VM.
4
+ *
5
+ * Why this exists: online tests run inside an HVF arm64
6
+ * microVM (via supermachine HVF) with chromium under a virtual Xvfb display. There's no way
7
+ * to interactively log in to Shopify from outside the VM, but the
8
+ * captured storage state (cookies — Partners session AND, crucially,
9
+ * Cloudflare's `cf_clearance` Turnstile-bypass cookie) needs to be
10
+ * captured INSIDE that same VM so the browser fingerprint matches at
11
+ * test time. Captures from the macOS host (different UA, different
12
+ * canvas/WebGL hashes) trigger CF re-challenge.
13
+ *
14
+ * Flow:
15
+ * 1. Bake / acquire the same VM the online suite uses (same image,
16
+ * same warmupTag → reuses the snapshot — warm acquires are
17
+ * ~3 s instead of ~4 min cold bake).
18
+ * 2. Mount the workspace so storageState.json writes land on the
19
+ * host filesystem.
20
+ * 3. Start x11vnc inside the guest, bound to 0.0.0.0:5900.
21
+ * 4. Forward host 127.0.0.1:5900 → guest:5900 via vm.exposeTcp.
22
+ * 5. Open macOS Screen Sharing pointed at vnc://localhost:5900.
23
+ * 6. Spawn captureAuth.js inside the guest. It opens chrome on
24
+ * DISPLAY=:99, navigates to accounts.shopify.com/lookup, and
25
+ * waits for a host-side Enter keystroke.
26
+ * 7. User logs in via VNC, ALSO visits the test store admin to
27
+ * pass any Turnstile (so cf_clearance gets captured), then
28
+ * hits Enter in the host terminal.
29
+ * 8. captureAuth writes storageState.json (via the mounted
30
+ * workspace → host disk), then exits. We release the VM.
31
+ *
32
+ * Usage (one-time per developer machine, or when cf_clearance / the
33
+ * Partner session expires):
34
+ *
35
+ * npm run test:online:auth
36
+ *
37
+ * macOS Screen Sharing opens automatically. If it doesn't, run
38
+ * `open vnc://localhost:5900` manually. VNC password is "test" by
39
+ * default; override with TEST_ONLINE_VNC_PASSWORD.
40
+ *
41
+ * Supersedes runDockerAuth.ts (libkrun-based; we no longer use
42
+ * libkrun for any test path).
43
+ */
44
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
45
+ import { homedir, platform as osPlatform } from 'node:os';
46
+ import { resolve } from 'node:path';
47
+ import { spawn as nodeSpawn } from 'node:child_process';
48
+ import { setTimeout as sleep } from 'node:timers/promises';
49
+ import { prepareOciArchive, envFileArgs } from '@essential-apps/shopify-test-core';
50
+
51
+ const repoRoot = process.cwd();
52
+ const VNC_PORT = 5900;
53
+
54
+ /**
55
+ * Resolve the VM image source the same way runVm.ts
56
+ * does. Without this, the VM runtime treats TEST_ONLINE_VM_IMAGE as
57
+ * a Docker Hub registry ref and tries to pull it — which 401s for
58
+ * locally-built `container build` images that never went to a
59
+ * registry. oci-archive mode is the only one that works for the
60
+ * Apple-`container`-built images we actually use.
61
+ */
62
+ async function imageSourceOptions(): Promise<Record<string, unknown>> {
63
+ const mode = process.env['TEST_ONLINE_VM_IMAGE_SOURCE'] ?? 'oci-archive';
64
+ if (mode === 'registry') return {};
65
+ const ref =
66
+ process.env['TEST_ONLINE_VM_IMAGE'] ??
67
+ 'essential-apps/shopify-test-vm:latest';
68
+ if (mode === 'oci-archive') {
69
+ const prep = await prepareOciArchive(ref);
70
+ if (prep.freshlySaved) {
71
+ console.error(
72
+ `[runVmAuth] saved ${ref} → ${prep.archivePath} (${(prep.sizeBytes / 1024 / 1024).toFixed(1)} MB)`,
73
+ );
74
+ }
75
+ return { source: 'oci-archive', sourcePath: prep.archivePath };
76
+ }
77
+ if (mode === 'oci-layout') {
78
+ const sourcePath = process.env['TEST_ONLINE_VM_IMAGE_LAYOUT_PATH'];
79
+ if (!sourcePath) {
80
+ throw new Error(
81
+ `TEST_ONLINE_VM_IMAGE_SOURCE=oci-layout requires TEST_ONLINE_VM_IMAGE_LAYOUT_PATH`,
82
+ );
83
+ }
84
+ return { source: 'oci-layout', sourcePath };
85
+ }
86
+ throw new Error(
87
+ `TEST_ONLINE_VM_IMAGE_SOURCE=${mode} not recognised. Valid: oci-archive | oci-layout | registry`,
88
+ );
89
+ }
90
+
91
+ interface PackageJson {
92
+ name?: string;
93
+ }
94
+
95
+ function readAppName(): string {
96
+ const pkgPath = resolve(repoRoot, 'package.json');
97
+ if (!existsSync(pkgPath)) {
98
+ throw new Error(
99
+ `No package.json at ${pkgPath}. Run this from the consuming app's repo root.`,
100
+ );
101
+ }
102
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageJson;
103
+ if (!pkg.name) throw new Error(`package.json at ${pkgPath} has no \`name\` field.`);
104
+ return pkg.name.replace(/^@[^/]+\//, '');
105
+ }
106
+
107
+ async function main(): Promise<void> {
108
+ const appName = readAppName();
109
+ // Same node_modules volume + tarball-manifest extraFile as
110
+ // runVm.ts. Sharing both means auth-capture and online
111
+ // tests use the SAME snapshot (same warmupTag), so a single bake
112
+ // serves both flows.
113
+ const linuxModulesVolume =
114
+ process.env['TEST_LINUX_NODE_MODULES_VOLUME'] ??
115
+ resolve(homedir(), `.cache/${appName}-test/node_modules.img`);
116
+ mkdirSync(resolve(linuxModulesVolume, '..'), { recursive: true });
117
+ const tarballManifest = resolve(
118
+ repoRoot,
119
+ 'vendor/essential-apps-shopify-test/.tarball-manifest',
120
+ );
121
+ const tarballManifestExists = existsSync(tarballManifest);
122
+
123
+ const vncPassword = process.env['TEST_ONLINE_VNC_PASSWORD'] ?? 'test';
124
+ const ref =
125
+ process.env['TEST_ONLINE_VM_IMAGE'] ?? 'essential-apps/shopify-test-vm:latest';
126
+ // amd64+Rosetta is the only path that has real Google Chrome
127
+ // installed (the Dockerfile installs google-chrome-stable amd64);
128
+ // we want real Chrome here so the captured fingerprint matches
129
+ // what online tests use (channel: 'chrome' via TEST_FORCE_REAL_CHROME,
130
+ // OR patchright bundled chromium — both run on this image).
131
+ const platform = process.env['TEST_ONLINE_VM_PLATFORM'] ?? 'linux/arm64';
132
+
133
+ console.error(`[runVmAuth] baking image…`);
134
+ const tBake = performance.now();
135
+ const { Image } = await import('@supermachine/core');
136
+ const imageOpts = await imageSourceOptions();
137
+ // Same warmup pattern as runVm.ts so this bake hits the
138
+ // existing snapshot (warmupTag must match). If runVm has
139
+ // never run cold yet, this will trigger that warmup — ~4 min once.
140
+ const image = await Image.build({
141
+ ref,
142
+ memoryMib: Number(process.env['TEST_ONLINE_VM_MEMORY_MIB'] ?? 8192),
143
+ vcpus: Number(process.env['TEST_ONLINE_VM_VCPUS'] ?? 4),
144
+ cmd: ['sleep', 'infinity'],
145
+ env: { SKIP_OFFLINE_HOST_HIJACK: '1' },
146
+ platform,
147
+ mounts: [
148
+ // VM runtime 0.7.28+ requires explicit guestPath and
149
+ // auto-mounts at boot before warmup fires.
150
+ { hostPath: repoRoot, guestTag: 'workspace', guestPath: '/workspace' },
151
+ ],
152
+ volumes: [
153
+ {
154
+ hostPath: linuxModulesVolume,
155
+ guestPath: '/workspace/node_modules',
156
+ sizeMib: 4096,
157
+ },
158
+ ],
159
+ extraFiles: tarballManifestExists
160
+ ? [
161
+ { hostPath: tarballManifest, guestPath: '/etc/.shopify-test-tarball-manifest' },
162
+ ]
163
+ : [],
164
+ // Independent auth-capture snapshot. (Despite the historical name
165
+ // this no longer shares runVm.ts's tag — that moved to
166
+ // `online-v15-<runtime>-<manifest>`.) This rarely-run flow keys on a
167
+ // static tag rather than auto-folding the @supermachine/core runtime
168
+ // version like the main runners, so bump it on warmup-script OR VM
169
+ // runtime changes. Bumped v3→v4 for the 0.7.62 runtime adoption.
170
+ warmupTag: 'online-v4',
171
+ // @ts-expect-error — @supermachine/core's BuildOptions type
172
+ // doesn't declare `warmup`, but the runtime accepts it. Tracked
173
+ // upstream; fix-or-augment when the types catch up.
174
+ warmup: async (vm: {
175
+ exec: (opts: { argv: string[]; timeoutMs?: number }) => Promise<{
176
+ exitCode: number;
177
+ stdout: Buffer;
178
+ stderr: Buffer;
179
+ }>;
180
+ }) => {
181
+ const r = await vm.exec({
182
+ argv: [
183
+ 'sh',
184
+ '-c',
185
+ `
186
+ echo "[warmup] >>> ENTERED warmup callback at \$(date +%H:%M:%S.%N)"
187
+ set -e
188
+ # VM runtime 0.7.28+ auto-mounts /workspace +
189
+ # /workspace/node_modules BEFORE warmup fires. Keep the
190
+ # defence-in-depth virtiofs check so a future auto-mount
191
+ # regression can't let rm-rf nuke the host bind.
192
+ NM_FS=\$(stat -f -c %T /workspace/node_modules 2>/dev/null || stat --file-system --format=%T /workspace/node_modules)
193
+ echo "[warmup] /workspace/node_modules FS type: \$NM_FS"
194
+ if [ "\$NM_FS" = "virtiofs" ] || [ "\$NM_FS" = "fuse.virtiofs" ]; then
195
+ echo "FATAL: /workspace/node_modules is \$NM_FS — auto-mount broken"
196
+ grep ' /workspace' /proc/mounts
197
+ exit 1
198
+ fi
199
+ chown -R postgres:postgres /var/lib/postgresql/data
200
+ chmod 700 /var/lib/postgresql/data
201
+ mkdir -p /var/run/postgresql
202
+ chown postgres:postgres /var/run/postgresql
203
+ chmod 775 /var/run/postgresql
204
+ cd /workspace
205
+ # Fresh install every rebake (tarball-manifest hash
206
+ # invalidates the snapshot when tarballs change). Wiping
207
+ # before install ensures stale tarballs can't linger.
208
+ rm -rf node_modules/* node_modules/.[!.]* 2>/dev/null || true
209
+ # Don't pipe npm to "tail" — the pipeline status is tail's,
210
+ # masking npm failures under "set -e", so a transient install
211
+ # error would bake a half-installed node_modules cache that
212
+ # warm restores then reuse. Check npm's own status so a failed
213
+ # install aborts the bake (no cache poisoned, next run rebakes).
214
+ if ! npm install --legacy-peer-deps --engine-strict=false > /tmp/npm-install.log 2>&1; then
215
+ echo "--- npm install FAILED - last 40 lines ---"
216
+ tail -40 /tmp/npm-install.log
217
+ exit 1
218
+ fi
219
+ tail -30 /tmp/npm-install.log
220
+ if ! grep -q "^127.0.0.1.*localhost" /etc/hosts; then
221
+ echo "127.0.0.1 localhost" >> /etc/hosts
222
+ echo "::1 localhost" >> /etc/hosts
223
+ fi
224
+ `,
225
+ ],
226
+ timeoutMs: 15 * 60 * 1000,
227
+ });
228
+ if (r.exitCode !== 0) {
229
+ console.error('[runVmAuth.warmup] failed:', r.stdout.toString(), r.stderr.toString());
230
+ throw new Error(`warmup failed (exit ${r.exitCode})`);
231
+ }
232
+ },
233
+ ...imageOpts,
234
+ });
235
+ console.error(
236
+ `[runVmAuth] bake: ${((performance.now() - tBake) / 1000).toFixed(1)} s`,
237
+ );
238
+
239
+ const pool = await image.pool({ min: 1, max: 1, restoreOnRelease: false });
240
+ const vm = await pool.acquire();
241
+
242
+ // Loose typing so we can call signal/writeStdin/readStdout/etc.
243
+ // without re-importing the full @supermachine/core types tree (vendor name).
244
+ let forwarder: { stop: () => Promise<void> } | null = null;
245
+ let captureChild:
246
+ | {
247
+ signal: (n: number) => Promise<void>;
248
+ writeStdin: (b: Buffer) => Promise<void>;
249
+ readStdout?: () => Promise<Buffer>;
250
+ readStderr?: () => Promise<Buffer>;
251
+ wait?: () => Promise<{ exitCode: number }>;
252
+ }
253
+ | null = null;
254
+
255
+ // Idempotent cleanup. Two design constraints:
256
+ // 1. SIGINT may fire multiple times (user hammers Ctrl-C), and
257
+ // cleanup() is async — a second invocation would otherwise
258
+ // race the first, calling signal/wait on already-disposed
259
+ // handles ("agent closed connection before sending EXIT").
260
+ // 2. Each step may throw on second call (already-closed sockets,
261
+ // already-released vm, etc.) — wrap individually so a later
262
+ // step still runs even if an earlier one's idempotency check
263
+ // threw something the catch missed.
264
+ // Implementation: a singleton promise — first caller does the
265
+ // work, every subsequent caller awaits the same promise.
266
+ let cleanupPromise: Promise<void> | null = null;
267
+ const cleanup = (): Promise<void> => {
268
+ if (cleanupPromise) return cleanupPromise;
269
+ cleanupPromise = (async () => {
270
+ // Null each handle out after using it so a stray reference
271
+ // elsewhere can't be re-used.
272
+ const child = captureChild;
273
+ captureChild = null;
274
+ if (child) await child.signal(15).catch(() => undefined);
275
+
276
+ const fwd = forwarder;
277
+ forwarder = null;
278
+ if (fwd) await fwd.stop().catch(() => undefined);
279
+
280
+ await vm.release().catch(() => undefined);
281
+ await pool.shutdown().catch(() => undefined);
282
+ })();
283
+ return cleanupPromise;
284
+ };
285
+
286
+ // SIGINT / SIGTERM teardown — without these, Ctrl-C leaves the VM
287
+ // pinned and the next bake hits "snapshot in use" errors.
288
+ // First signal triggers cleanup + clean exit; second signal escalates
289
+ // to immediate process.exit so the user can always bail out.
290
+ let signalCount = 0;
291
+ const onSignal = (sig: NodeJS.Signals): void => {
292
+ signalCount += 1;
293
+ if (signalCount >= 2) {
294
+ console.error(`\n[runVmAuth] received ${sig} again — forcing exit`);
295
+ process.exit(130);
296
+ }
297
+ console.error(`\n[runVmAuth] received ${sig}, cleaning up…`);
298
+ cleanup()
299
+ .catch(() => undefined)
300
+ .finally(() => process.exit(130));
301
+ };
302
+ process.on('SIGINT', onSignal);
303
+ process.on('SIGTERM', onSignal);
304
+
305
+ try {
306
+ // 0) Post-restore sanity. VM runtime 0.7.28+ captures the
307
+ // auto-mount tree in the snapshot, so /workspace and
308
+ // /workspace/node_modules are already mounted on restore.
309
+ // Assert + bail if anything's off.
310
+ const mountR = await vm.exec({
311
+ argv: ['sh', '-c', `
312
+ mountpoint -q /workspace || { echo "FATAL: /workspace not mounted"; exit 1; }
313
+ mountpoint -q /workspace/node_modules || { echo "FATAL: /workspace/node_modules not mounted"; exit 1; }
314
+ NM_FS=\$(stat -f -c %T /workspace/node_modules 2>/dev/null)
315
+ if [ "\$NM_FS" = "virtiofs" ] || [ "\$NM_FS" = "fuse.virtiofs" ]; then
316
+ echo "FATAL: /workspace/node_modules is \$NM_FS — auto-mount layering broken"
317
+ exit 1
318
+ fi
319
+ ls /workspace/node_modules/@essential-apps/shopify-test-runner/dist/scripts/captureAuth.js >/dev/null \
320
+ || { echo "FATAL: captureAuth.js missing in volume — snapshot/volume out of sync"; exit 1; }
321
+ `],
322
+ timeoutMs: 10_000,
323
+ });
324
+ if (mountR.exitCode !== 0) {
325
+ console.error(`[runVmAuth] mount failed:\n${mountR.stdout.toString()}${mountR.stderr.toString()}`);
326
+ throw new Error('mount setup failed');
327
+ }
328
+
329
+ // 1) Start x11vnc inside the guest. The image's entrypoint.sh
330
+ // has the same logic gated on TEST_ONLINE_VNC=1, but that env is
331
+ // bake-time only (already snapshotted). Running x11vnc here
332
+ // sidesteps the need for a separate baked snapshot.
333
+ console.error(`[runVmAuth] starting x11vnc on guest :5900…`);
334
+ const vncStart = await vm.exec({
335
+ argv: [
336
+ 'bash',
337
+ '-c',
338
+ `
339
+ # No set -e — too many of these commands have valid nonzero
340
+ # exits (pkill matching 0 procs, rm-of-missing-file, etc.) and
341
+ # masking them all with || true was both noisy and unreliable.
342
+ # We explicitly check exit conditions on the things that
343
+ # matter (Xvfb socket present, x11vnc listening).
344
+ # Restart Xvfb on :99 with -ac (disable X access control)
345
+ # so x11vnc — which runs in a fresh shell here, separate from
346
+ # the entrypoint shell that started the original Xvfb — can
347
+ # attach without an Xauthority cookie. The entrypoint's Xvfb
348
+ # is fine for Playwright (which sets DISPLAY=:99 and inherits
349
+ # the auth context from the same shell), but x11vnc launched
350
+ # via a separate vm.exec doesn't have that.
351
+ echo "[xvfb] stopping any existing Xvfb on :99…"
352
+ # IMPORTANT: do NOT use 'pkill -f' here — the -f flag matches
353
+ # the full command line, and our own bash script's argv
354
+ # contains the literal string we'd search for (it's an
355
+ # embedded heredoc), so pkill would kill its own parent
356
+ # shell. Match by process name only.
357
+ pkill -x Xvfb 2>/dev/null || true
358
+ rm -f /tmp/.X11-unix/X99 /tmp/.X99-lock
359
+ sleep 0.3
360
+ # -ac: disable access control (no auth required). Safe inside
361
+ # the VM — only x11vnc here is going to connect, and x11vnc's
362
+ # own VNC auth (-rfbauth) gates external access.
363
+ # -nolisten tcp: still no TCP listener on Xvfb itself.
364
+ echo "[xvfb] starting Xvfb :99 -ac …"
365
+ nohup Xvfb :99 -screen 0 1600x1000x24 -nolisten tcp -ac \
366
+ >/var/log/xvfb.log 2>&1 &
367
+ for _ in $(seq 1 50); do
368
+ [ -e /tmp/.X11-unix/X99 ] && break
369
+ sleep 0.1
370
+ done
371
+ if [ ! -e /tmp/.X11-unix/X99 ]; then
372
+ echo "[xvfb] FAILED to bind /tmp/.X11-unix/X99 within 5s"
373
+ cat /var/log/xvfb.log
374
+ exit 1
375
+ fi
376
+ echo "[xvfb] up."
377
+
378
+ mkdir -p /root/.vnc
379
+ x11vnc -storepasswd "${vncPassword}" /root/.vnc/passwd >/dev/null 2>&1
380
+ # Background via shell (&) not x11vnc's -bg (which has been
381
+ # observed to fork before the accept loop binds). nohup so
382
+ # the listener survives this exec call returning.
383
+ echo "[x11vnc] starting on :${VNC_PORT}…"
384
+ nohup x11vnc -display :99 -forever -shared \
385
+ -rfbauth /root/.vnc/passwd -rfbport ${VNC_PORT} -quiet \
386
+ >/var/log/x11vnc.log 2>&1 &
387
+ VNC_PID=$!
388
+ echo "[x11vnc] pid $VNC_PID"
389
+ # Verify the listener actually bound. Use netstat (from
390
+ # net-tools — installed in both the vm and libkrun
391
+ # images per the Dockerfiles). bash /dev/tcp was tried first
392
+ # but ubuntu/jammy bash sometimes ships without the
393
+ # /dev/tcp/* virtual device enabled, leading to silent probe
394
+ # failures even when x11vnc IS listening.
395
+ for _ in $(seq 1 30); do
396
+ if netstat -ltn 2>/dev/null | grep -q ":${VNC_PORT} "; then
397
+ exit 0
398
+ fi
399
+ sleep 0.1
400
+ done
401
+ echo "x11vnc didn't bind :${VNC_PORT} within 3s"
402
+ echo "--- netstat output ---"
403
+ netstat -ltn 2>&1 || echo "(netstat unavailable)"
404
+ echo "--- x11vnc log ---"
405
+ cat /var/log/x11vnc.log
406
+ exit 1
407
+ `,
408
+ ],
409
+ timeoutMs: 15_000,
410
+ });
411
+ if (vncStart.exitCode !== 0) {
412
+ console.error(
413
+ '[runVmAuth] x11vnc failed:',
414
+ vncStart.stdout.toString(),
415
+ vncStart.stderr.toString(),
416
+ );
417
+ throw new Error('x11vnc startup failed');
418
+ }
419
+
420
+ // 2) Forward host 127.0.0.1:5900 → guest :5900 so the macOS
421
+ // Screen Sharing client can reach the VNC server.
422
+ forwarder = await vm.exposeTcp(VNC_PORT, VNC_PORT);
423
+ console.error(
424
+ `[runVmAuth] forwarding host 127.0.0.1:${VNC_PORT} → guest :${VNC_PORT}`,
425
+ );
426
+
427
+ // 3) Open macOS Screen Sharing pointed at the forwarder. Detach
428
+ // so the parent process can carry on.
429
+ if (osPlatform() === 'darwin') {
430
+ console.error(
431
+ `[runVmAuth] VNC password (paste into Screen Sharing): ${vncPassword}`,
432
+ );
433
+ // Tiny delay so x11vnc accept-loop is warm before the client
434
+ // dials; otherwise Screen Sharing sometimes shows "connection
435
+ // refused" and the user has to retry.
436
+ await sleep(500);
437
+ // Embed the password in the URL (vnc://:<pw>@host) so macOS Screen
438
+ // Sharing connects WITHOUT prompting — prefilled, not typed. URL-encode
439
+ // in case TEST_ONLINE_VNC_PASSWORD has reserved chars.
440
+ const vncUrl = `vnc://:${encodeURIComponent(vncPassword)}@localhost:${VNC_PORT}`;
441
+ console.error(
442
+ `[runVmAuth] Opening macOS Screen Sharing → vnc://localhost:${VNC_PORT} (password prefilled)`,
443
+ );
444
+ console.error(`[runVmAuth] (If nothing opens: open '${vncUrl}')`);
445
+ nodeSpawn('open', [vncUrl], {
446
+ stdio: 'ignore',
447
+ detached: true,
448
+ }).unref();
449
+ } else {
450
+ console.error(
451
+ `[runVmAuth] Connect any VNC viewer to localhost:${VNC_PORT} (password: ${vncPassword}).`,
452
+ );
453
+ }
454
+
455
+ // 4) Spawn captureAuth.js inside the guest. It opens Chrome on
456
+ // DISPLAY=:99, prompts for Enter on stdin, then writes
457
+ // storageState.json (which lands on host disk via the
458
+ // workspace mount). We bridge host stdin to the guest child
459
+ // so the user's Enter keystroke reaches captureAuth's
460
+ // readline prompt.
461
+ console.error('');
462
+ console.error('━'.repeat(72));
463
+ console.error('Inside Screen Sharing:');
464
+ console.error(' 1. Log in to Shopify Partners (accounts.shopify.com/lookup).');
465
+ console.error(' 2. Then visit your test store admin');
466
+ console.error(' (e.g. https://admin.shopify.com/store/<your-store>).');
467
+ console.error(' If a Cloudflare "Verify you are human" page appears,');
468
+ console.error(' click through it. Without this step, cf_clearance');
469
+ console.error(' never lands in storageState and tests will still hit');
470
+ console.error(' Turnstile.');
471
+ console.error(' 3. Return here and press Enter to save & exit.');
472
+ console.error('━'.repeat(72));
473
+ console.error('');
474
+
475
+ captureChild = await vm.spawn({
476
+ argv: [
477
+ 'sh',
478
+ '-c',
479
+ // tty=false here (no PTY) because we want the readline prompt
480
+ // to read from a plain stdin pipe — Chrome doesn't care, it
481
+ // reads DISPLAY from env.
482
+ `cd /workspace && DISPLAY=:99 TEST_IN_CONTAINER=true node ${envFileArgs(repoRoot)} node_modules/@essential-apps/shopify-test-runner/dist/scripts/captureAuth.js`,
483
+ ],
484
+ env: { DISPLAY: ':99', TEST_IN_CONTAINER: 'true' },
485
+ });
486
+
487
+ // Bridge host stdin → guest captureAuth stdin (for the Enter
488
+ // prompt). Forwarder runs until the guest child exits.
489
+ process.stdin.setEncoding('utf8');
490
+ process.stdin.on('data', (chunk: string | Buffer) => {
491
+ const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
492
+ captureChild!.writeStdin(buf).catch(() => undefined);
493
+ });
494
+ process.stdin.resume();
495
+
496
+ // Stream guest stdout/stderr to host for visibility.
497
+ const streamGuest = async (
498
+ reader: (() => Promise<Buffer>) | undefined,
499
+ out: NodeJS.WriteStream,
500
+ ): Promise<void> => {
501
+ if (!reader) return;
502
+ // eslint-disable-next-line no-constant-condition
503
+ while (true) {
504
+ const chunk = await reader().catch(() => null);
505
+ if (!chunk || chunk.length === 0) return;
506
+ out.write(chunk);
507
+ }
508
+ };
509
+ // captureChild has readStdout / readStderr — both Promise<Buffer>
510
+ // chunks until EOF (zero-length).
511
+ const childAny = captureChild as unknown as {
512
+ readStdout?: () => Promise<Buffer>;
513
+ readStderr?: () => Promise<Buffer>;
514
+ wait?: () => Promise<{ exitCode: number }>;
515
+ };
516
+ void streamGuest(childAny.readStdout, process.stdout);
517
+ void streamGuest(childAny.readStderr, process.stderr);
518
+
519
+ const result = await (childAny.wait?.() ?? Promise.resolve({ exitCode: 0 }));
520
+ if (result.exitCode === 0) {
521
+ console.error('');
522
+ console.error('[runVmAuth] ✓ captureAuth completed successfully.');
523
+ console.error('[runVmAuth] storageState.json written via workspace mount.');
524
+ } else {
525
+ console.error(
526
+ `[runVmAuth] captureAuth exited with code ${result.exitCode}`,
527
+ );
528
+ }
529
+ await cleanup();
530
+ process.exit(result.exitCode);
531
+ } catch (err) {
532
+ console.error('[runVmAuth] fatal:', (err as Error).message);
533
+ await cleanup();
534
+ process.exit(1);
535
+ }
536
+ }
537
+
538
+ main().catch(async (err) => {
539
+ console.error(err);
540
+ process.exit(1);
541
+ });