@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,282 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Generic NON-interactive in-VM script runner.
4
+ *
5
+ * Boots the same VM the online suite uses and runs
6
+ * `dist/scripts/<name>.js [args]` inside it, headed under Xvfb :99 (the
7
+ * Cloudflare-passing config the tests use), with TEST_IN_CONTAINER=true.
8
+ * This is how the store-setup scripts that drive a browser — installApp,
9
+ * createStores — run: in the VM, never on the host. (For the INTERACTIVE
10
+ * human-login flow, see runVmAuth.ts, which adds VNC.)
11
+ *
12
+ * Usage (wired via package.json):
13
+ * node --import tsx .../runVmScript.ts <script-name> [-- <args…>]
14
+ * e.g.
15
+ * npm run test:online:install -- --shop my-store.myshopify.com
16
+ * → runVmScript installApp --shop my-store.myshopify.com
17
+ *
18
+ * Shares the node_modules volume + tarball-manifest extraFile with runVm /
19
+ * runVmAuth, and keys on a static warmupTag (rarely-run flow; bump on
20
+ * warmup-script or VM-runtime changes).
21
+ */
22
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
23
+ import { homedir } from 'node:os';
24
+ import { resolve } from 'node:path';
25
+ import { prepareOciArchive, envFileArgs } from '@essential-apps/shopify-test-core';
26
+
27
+ const repoRoot = process.cwd();
28
+
29
+ /** Resolve the VM image source the same way runVm.ts / runVmAuth.ts do. */
30
+ async function imageSourceOptions(): Promise<Record<string, unknown>> {
31
+ const mode = process.env['TEST_ONLINE_VM_IMAGE_SOURCE'] ?? 'oci-archive';
32
+ if (mode === 'registry') return {};
33
+ const ref =
34
+ process.env['TEST_ONLINE_VM_IMAGE'] ?? 'essential-apps/shopify-test-vm:latest';
35
+ if (mode === 'oci-archive') {
36
+ const prep = await prepareOciArchive(ref);
37
+ if (prep.freshlySaved) {
38
+ console.error(
39
+ `[runVmScript] saved ${ref} → ${prep.archivePath} (${(prep.sizeBytes / 1024 / 1024).toFixed(1)} MB)`,
40
+ );
41
+ }
42
+ return { source: 'oci-archive', sourcePath: prep.archivePath };
43
+ }
44
+ if (mode === 'oci-layout') {
45
+ const sourcePath = process.env['TEST_ONLINE_VM_IMAGE_LAYOUT_PATH'];
46
+ if (!sourcePath) {
47
+ throw new Error(
48
+ `TEST_ONLINE_VM_IMAGE_SOURCE=oci-layout requires TEST_ONLINE_VM_IMAGE_LAYOUT_PATH`,
49
+ );
50
+ }
51
+ return { source: 'oci-layout', sourcePath };
52
+ }
53
+ throw new Error(
54
+ `TEST_ONLINE_VM_IMAGE_SOURCE=${mode} not recognised. Valid: oci-archive | oci-layout | registry`,
55
+ );
56
+ }
57
+
58
+ interface PackageJson {
59
+ name?: string;
60
+ }
61
+
62
+ function readAppName(): string {
63
+ const pkgPath = resolve(repoRoot, 'package.json');
64
+ if (!existsSync(pkgPath)) {
65
+ throw new Error(
66
+ `No package.json at ${pkgPath}. Run this from the consuming app's repo root.`,
67
+ );
68
+ }
69
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageJson;
70
+ if (!pkg.name) throw new Error(`package.json at ${pkgPath} has no \`name\` field.`);
71
+ return pkg.name.replace(/^@[^/]+\//, '');
72
+ }
73
+
74
+ /** Single-quote-escape an arg for safe interpolation into `sh -c`. */
75
+ function shq(arg: string): string {
76
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
77
+ }
78
+
79
+ async function main(): Promise<void> {
80
+ const scriptName = process.argv[2];
81
+ const scriptArgs = process.argv.slice(3);
82
+ if (!scriptName || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(scriptName)) {
83
+ console.error(
84
+ `Usage: runVmScript <script-name> [args…]\n` +
85
+ ` <script-name> must be a bare script name (e.g. installApp), no path.`,
86
+ );
87
+ process.exit(1);
88
+ }
89
+
90
+ const appName = readAppName();
91
+ const linuxModulesVolume =
92
+ process.env['TEST_LINUX_NODE_MODULES_VOLUME'] ??
93
+ resolve(homedir(), `.cache/${appName}-test/node_modules.img`);
94
+ mkdirSync(resolve(linuxModulesVolume, '..'), { recursive: true });
95
+ const tarballManifest = resolve(
96
+ repoRoot,
97
+ 'vendor/essential-apps-shopify-test/.tarball-manifest',
98
+ );
99
+ const tarballManifestExists = existsSync(tarballManifest);
100
+ const ref =
101
+ process.env['TEST_ONLINE_VM_IMAGE'] ?? 'essential-apps/shopify-test-vm:latest';
102
+ const platform = process.env['TEST_ONLINE_VM_PLATFORM'] ?? 'linux/arm64';
103
+
104
+ console.error(`[runVmScript] baking image…`);
105
+ const tBake = performance.now();
106
+ const { Image } = await import('@supermachine/core');
107
+ const imageOpts = await imageSourceOptions();
108
+ const image = await Image.build({
109
+ ref,
110
+ memoryMib: Number(process.env['TEST_ONLINE_VM_MEMORY_MIB'] ?? 8192),
111
+ vcpus: Number(process.env['TEST_ONLINE_VM_VCPUS'] ?? 4),
112
+ cmd: ['sleep', 'infinity'],
113
+ env: { SKIP_OFFLINE_HOST_HIJACK: '1' },
114
+ platform,
115
+ mounts: [{ hostPath: repoRoot, guestTag: 'workspace', guestPath: '/workspace' }],
116
+ volumes: [
117
+ {
118
+ hostPath: linuxModulesVolume,
119
+ guestPath: '/workspace/node_modules',
120
+ sizeMib: 4096,
121
+ },
122
+ ],
123
+ extraFiles: tarballManifestExists
124
+ ? [{ hostPath: tarballManifest, guestPath: '/etc/.shopify-test-tarball-manifest' }]
125
+ : [],
126
+ // Static tag (rarely-run setup flow). Bump on warmup-script / VM-runtime
127
+ // changes. Kept distinct from runVm's online-v15 (manifest-keyed) and
128
+ // runVmAuth's online-v4 so a tweak here can't disturb those snapshots.
129
+ warmupTag: 'online-script-v1',
130
+ // @ts-expect-error — @supermachine/core's BuildOptions type doesn't declare
131
+ // `warmup`, but the runtime accepts it (matches runVm/runVmAuth).
132
+ warmup: async (vm: {
133
+ exec: (opts: { argv: string[]; timeoutMs?: number }) => Promise<{
134
+ exitCode: number;
135
+ stdout: Buffer;
136
+ stderr: Buffer;
137
+ }>;
138
+ }) => {
139
+ const r = await vm.exec({
140
+ argv: [
141
+ 'sh',
142
+ '-c',
143
+ `
144
+ set -e
145
+ NM_FS=\$(stat -f -c %T /workspace/node_modules 2>/dev/null || stat --file-system --format=%T /workspace/node_modules)
146
+ echo "[warmup] /workspace/node_modules FS type: \$NM_FS"
147
+ if [ "\$NM_FS" = "virtiofs" ] || [ "\$NM_FS" = "fuse.virtiofs" ]; then
148
+ echo "FATAL: /workspace/node_modules is \$NM_FS — auto-mount broken"
149
+ exit 1
150
+ fi
151
+ cd /workspace
152
+ rm -rf node_modules/* node_modules/.[!.]* 2>/dev/null || true
153
+ if ! npm install --legacy-peer-deps --engine-strict=false > /tmp/npm-install.log 2>&1; then
154
+ echo "--- npm install FAILED - last 40 lines ---"
155
+ tail -40 /tmp/npm-install.log
156
+ exit 1
157
+ fi
158
+ tail -10 /tmp/npm-install.log
159
+ if ! grep -q "^127.0.0.1.*localhost" /etc/hosts; then
160
+ echo "127.0.0.1 localhost" >> /etc/hosts
161
+ echo "::1 localhost" >> /etc/hosts
162
+ fi
163
+ `,
164
+ ],
165
+ timeoutMs: 15 * 60 * 1000,
166
+ });
167
+ if (r.exitCode !== 0) {
168
+ console.error('[runVmScript.warmup] failed:', r.stdout.toString(), r.stderr.toString());
169
+ throw new Error(`warmup failed (exit ${r.exitCode})`);
170
+ }
171
+ },
172
+ ...imageOpts,
173
+ });
174
+ console.error(
175
+ `[runVmScript] bake: ${((performance.now() - tBake) / 1000).toFixed(1)} s`,
176
+ );
177
+
178
+ const pool = await image.pool({ min: 1, max: 1, restoreOnRelease: false });
179
+ const vm = await pool.acquire();
180
+
181
+ let cleanupPromise: Promise<void> | null = null;
182
+ const cleanup = (): Promise<void> => {
183
+ if (cleanupPromise) return cleanupPromise;
184
+ cleanupPromise = (async () => {
185
+ await vm.release().catch(() => undefined);
186
+ await pool.shutdown().catch(() => undefined);
187
+ })();
188
+ return cleanupPromise;
189
+ };
190
+ let signalCount = 0;
191
+ const onSignal = (sig: NodeJS.Signals): void => {
192
+ signalCount += 1;
193
+ if (signalCount >= 2) process.exit(130);
194
+ console.error(`\n[runVmScript] received ${sig}, cleaning up…`);
195
+ cleanup()
196
+ .catch(() => undefined)
197
+ .finally(() => process.exit(130));
198
+ };
199
+ process.on('SIGINT', onSignal);
200
+ process.on('SIGTERM', onSignal);
201
+
202
+ try {
203
+ // Post-restore sanity + start Xvfb :99 so headed patchright (the
204
+ // CF-passing config) has a display. Browser launches in the guest
205
+ // still go through assertInVm(), which passes because we set
206
+ // TEST_IN_CONTAINER=true on the spawned process below.
207
+ const setup = await vm.exec({
208
+ argv: [
209
+ 'sh',
210
+ '-c',
211
+ `
212
+ mountpoint -q /workspace/node_modules || { echo "FATAL: /workspace/node_modules not mounted"; exit 1; }
213
+ BASE=/workspace/node_modules/@essential-apps/shopify-test-runner/dist
214
+ if [ -f "\$BASE/scripts/${scriptName}.js" ]; then S="\$BASE/scripts/${scriptName}.js"; else S="\$BASE/probes/${scriptName}.js"; fi
215
+ ls "\$S" >/dev/null \
216
+ || { echo "FATAL: ${scriptName}.js missing in volume (checked scripts/ and probes/) — snapshot/volume out of sync"; exit 1; }
217
+ pkill -x Xvfb 2>/dev/null || true
218
+ rm -f /tmp/.X11-unix/X99 /tmp/.X99-lock
219
+ sleep 0.3
220
+ nohup Xvfb :99 -screen 0 1600x1000x24 -nolisten tcp -ac >/var/log/xvfb.log 2>&1 &
221
+ for _ in $(seq 1 50); do [ -e /tmp/.X11-unix/X99 ] && break; sleep 0.1; done
222
+ [ -e /tmp/.X11-unix/X99 ] || { echo "FATAL: Xvfb :99 did not start"; cat /var/log/xvfb.log; exit 1; }
223
+ echo "[runVmScript] Xvfb :99 up."
224
+ `,
225
+ ],
226
+ timeoutMs: 30_000,
227
+ });
228
+ if (setup.exitCode !== 0) {
229
+ console.error(`[runVmScript] setup failed:\n${setup.stdout.toString()}${setup.stderr.toString()}`);
230
+ throw new Error('VM setup failed');
231
+ }
232
+
233
+ const argsStr = scriptArgs.map(shq).join(' ');
234
+ console.error(`[runVmScript] running ${scriptName} ${argsStr} in the VM…`);
235
+ const child = await vm.spawn({
236
+ argv: [
237
+ 'sh',
238
+ '-c',
239
+ `cd /workspace && BASE=node_modules/@essential-apps/shopify-test-runner/dist; if [ -f "\$BASE/scripts/${scriptName}.js" ]; then S="\$BASE/scripts/${scriptName}.js"; else S="\$BASE/probes/${scriptName}.js"; fi; DISPLAY=:99 TEST_IN_CONTAINER=true node ${envFileArgs(repoRoot)} "\$S" ${argsStr}`,
240
+ ],
241
+ env: { DISPLAY: ':99', TEST_IN_CONTAINER: 'true' },
242
+ });
243
+
244
+ const childAny = child as unknown as {
245
+ readStdout?: () => Promise<Buffer>;
246
+ readStderr?: () => Promise<Buffer>;
247
+ wait?: () => Promise<{ exitCode: number }>;
248
+ };
249
+ const streamGuest = async (
250
+ reader: (() => Promise<Buffer>) | undefined,
251
+ out: NodeJS.WriteStream,
252
+ ): Promise<void> => {
253
+ if (!reader) return;
254
+ // eslint-disable-next-line no-constant-condition
255
+ while (true) {
256
+ const chunk = await reader().catch(() => null);
257
+ if (!chunk || chunk.length === 0) return;
258
+ out.write(chunk);
259
+ }
260
+ };
261
+ void streamGuest(childAny.readStdout, process.stdout);
262
+ void streamGuest(childAny.readStderr, process.stderr);
263
+
264
+ const result = await (childAny.wait?.() ?? Promise.resolve({ exitCode: 0 }));
265
+ await cleanup();
266
+ if (result.exitCode === 0) {
267
+ console.error(`[runVmScript] ✓ ${scriptName} completed.`);
268
+ } else {
269
+ console.error(`[runVmScript] ${scriptName} exited with code ${result.exitCode}`);
270
+ }
271
+ process.exit(result.exitCode);
272
+ } catch (err) {
273
+ console.error('[runVmScript] fatal:', (err as Error).message);
274
+ await cleanup();
275
+ process.exit(1);
276
+ }
277
+ }
278
+
279
+ main().catch(async (err) => {
280
+ console.error(err);
281
+ process.exit(1);
282
+ });
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * One-time / on-demand local Postgres prep for tests.
4
+ *
5
+ * - Smoke-tests that local Postgres is reachable
6
+ * - Runs `prisma migrate deploy` against an ephemeral DB and drops it
7
+ *
8
+ * The actual test runner (runTests.ts) creates a fresh UUID-named DB
9
+ * per run and applies migrations there. This script is just for
10
+ * diagnostics — confirms the toolchain works before you try to run
11
+ * tests.
12
+ */
13
+ import { execSync } from 'node:child_process';
14
+ import { randomUUID } from 'node:crypto';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { resolve } from 'node:path';
17
+ import { loadEnv, printEnvSummary } from '@essential-apps/shopify-test-core';
18
+
19
+ const PG_HOST = 'localhost';
20
+ const PG_PORT = '5432';
21
+
22
+ interface PackageJson {
23
+ name?: string;
24
+ }
25
+
26
+ function readAppName(): string {
27
+ const pkgPath = resolve(process.cwd(), 'package.json');
28
+ if (!existsSync(pkgPath)) {
29
+ throw new Error(
30
+ `No package.json at ${pkgPath}. Run this from the consuming app's repo root.`,
31
+ );
32
+ }
33
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageJson;
34
+ if (!pkg.name) throw new Error(`package.json at ${pkgPath} has no \`name\` field.`);
35
+ return pkg.name.replace(/^@[^/]+\//, '');
36
+ }
37
+
38
+ async function main(): Promise<void> {
39
+ const env = loadEnv();
40
+ printEnvSummary(env);
41
+
42
+ console.log('[setup-db] checking local Postgres reachability…');
43
+ execSync(`pg_isready -h ${PG_HOST} -p ${PG_PORT}`, { stdio: 'inherit' });
44
+
45
+ const appName = readAppName();
46
+ const dbPrefix = (process.env['TEST_ONLINE_DB_NAME_PREFIX'] ?? `${appName}_online`)
47
+ .toLowerCase()
48
+ .replace(/[^a-z0-9_]/g, '_');
49
+ const dbName = `${dbPrefix}_smoke_${randomUUID().replace(/-/g, '_')}`;
50
+ const url = `postgresql://${PG_HOST}:${PG_PORT}/${dbName}`;
51
+ console.log(`[setup-db] creating throwaway DB: ${dbName}`);
52
+ execSync(`createdb -h ${PG_HOST} -p ${PG_PORT} ${dbName}`, { stdio: 'inherit' });
53
+
54
+ try {
55
+ console.log('[setup-db] running prisma migrate deploy…');
56
+ execSync('npx prisma migrate deploy', {
57
+ stdio: 'inherit',
58
+ env: { ...process.env, DATABASE_URL: url, DIRECT_URL: url, NODE_ENV: 'test' },
59
+ });
60
+ console.log('');
61
+ console.log('✓ Local Postgres + migrations work. You can now run: npm run test:online');
62
+ } finally {
63
+ console.log(`[setup-db] dropping throwaway DB: ${dbName}`);
64
+ execSync(`dropdb -h ${PG_HOST} -p ${PG_PORT} ${dbName}`, { stdio: 'inherit' });
65
+ }
66
+ }
67
+
68
+ main().catch((err) => {
69
+ console.error(err);
70
+ process.exit(1);
71
+ });
@@ -0,0 +1,310 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * verifyContracts — the offline-side conformance check.
4
+ *
5
+ * Reads every vendored contract under `tests/test-offline/contracts/<api>/`,
6
+ * re-runs its operation against an in-process offline mock with the
7
+ * vendored `variables`, and asserts the response equals the
8
+ * vendored `response`. Any drift fails the check.
9
+ *
10
+ * See `docs/CONTRACTS.md` for the full architecture. This is one
11
+ * of three checks that together ensure the offline test suite
12
+ * cannot drift from real Shopify undetected:
13
+ *
14
+ * 1. check-operation-coverage — static: every prod root field has a resolver
15
+ * 2. verify-contracts — this script: mock matches vendored contracts
16
+ * 3. verify-contracts-from-live — (conformance suite) live matches vendored contracts
17
+ *
18
+ * Drift modes this catches:
19
+ * - A resolver was removed or its return shape changed.
20
+ * - A new field was added to a contract without the matching mock.
21
+ * - The mock's seeded ShopState shape diverged from the contract's
22
+ * expectation.
23
+ *
24
+ * What it doesn't catch (by design — owned by other checks):
25
+ * - The contract itself is wrong vs live Shopify. That's
26
+ * `verify-contracts-from-live`'s job.
27
+ * - Production code calls a field with no contract. That's
28
+ * `check-operation-coverage`.
29
+ *
30
+ * Run: `npm run test:online:verify-contracts`.
31
+ */
32
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
33
+ import { resolve, relative } from 'node:path';
34
+ import {
35
+ createAdminApi,
36
+ createStorefrontApi,
37
+ } from '@essential-apps/shopify-test-shopify-api';
38
+ import { ShopState } from '@essential-apps/shopify-test-storefront';
39
+ import { normaliseResponse } from '../contracts/normalize.js';
40
+
41
+ type ApiType = 'admin' | 'storefront';
42
+
43
+ interface Args {
44
+ api: ApiType;
45
+ contractsDir: string;
46
+ cwd: string;
47
+ }
48
+
49
+ function parseArgs(): Args {
50
+ const argv = process.argv.slice(2);
51
+ const out: Args = {
52
+ api: 'admin',
53
+ contractsDir: '',
54
+ cwd: process.cwd(),
55
+ };
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i] ?? '';
58
+ if (a === '--api' && i + 1 < argv.length) {
59
+ const next = argv[++i] ?? '';
60
+ if (next !== 'admin' && next !== 'storefront') {
61
+ console.error(`--api must be "admin" or "storefront"`);
62
+ process.exit(2);
63
+ }
64
+ out.api = next;
65
+ } else if (a === '--dir' && i + 1 < argv.length) {
66
+ out.contractsDir = argv[++i] ?? '';
67
+ }
68
+ }
69
+ if (!out.contractsDir) {
70
+ out.contractsDir = resolve(out.cwd, 'tests/test-offline/contracts', out.api);
71
+ } else {
72
+ out.contractsDir = resolve(out.cwd, out.contractsDir);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /**
78
+ * Offline-only executor — POSTs the operation to an in-process
79
+ * Hono mock for the consuming app's API surface (admin or
80
+ * storefront GraphQL). No live target by design: contract
81
+ * verification at the consuming-app layer answers "does the mock
82
+ * satisfy my operations?". The separate question — "does the mock
83
+ * match real Shopify?" — is owned by `@essential-apps/shopify-test-conformance`
84
+ * and runs against canonical platform primitives, not per-app
85
+ * operation sets. Mixing the two layers (the original `--target live`
86
+ * here) conflated app-level coverage with platform parity. Kept
87
+ * the layering clean by removing it.
88
+ */
89
+ type Executor = (
90
+ source: string,
91
+ variables: Record<string, unknown>,
92
+ ) => Promise<unknown>;
93
+
94
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hono types are loose
95
+ function buildOfflineExecutor(api: ApiType, state: ShopState): Executor {
96
+ const app =
97
+ api === 'admin' ? createAdminApi({ state }) : createStorefrontApi({ state });
98
+ const endpoint =
99
+ api === 'admin'
100
+ ? '/admin/api/2025-07/graphql.json'
101
+ : '/api/2025-07/graphql.json';
102
+ return async (source, variables) => {
103
+ const resp = await app.request(endpoint, {
104
+ method: 'POST',
105
+ headers: {
106
+ 'Content-Type': 'application/json',
107
+ 'X-Shopify-Access-Token': 'mock-access-token',
108
+ },
109
+ body: JSON.stringify({ query: source, variables }),
110
+ });
111
+ return resp.json();
112
+ };
113
+ }
114
+
115
+ interface Contract {
116
+ operationName: string;
117
+ source: string;
118
+ variables: Record<string, unknown>;
119
+ response: unknown;
120
+ capturedFrom: 'offline' | 'live';
121
+ capturedAt: string;
122
+ warning?: string;
123
+ }
124
+
125
+ /**
126
+ * Same deterministic ShopState seed `captureContracts` uses. The
127
+ * contracts were captured against this state; verification must
128
+ * replay against the identical state for results to match.
129
+ *
130
+ * Keep this in lockstep with `captureContracts.ts`'s
131
+ * `buildSeededState`. If you change one, change the other.
132
+ */
133
+ function buildSeededState(): ShopState {
134
+ const state = new ShopState({
135
+ shop: {
136
+ domain: 'test-shop.myshopify.com',
137
+ permanent_domain: 'test-shop.myshopify.com',
138
+ },
139
+ });
140
+ state.addProduct({
141
+ id: 900_000_001,
142
+ handle: 'sample-product',
143
+ title: 'Sample Product',
144
+ description: 'Used by contract capture as a deterministic fixture.',
145
+ price: 1000,
146
+ vendor: 'Sample Vendor',
147
+ type: 'Sample',
148
+ variants: [
149
+ {
150
+ id: 900_010_001,
151
+ title: 'Default Title',
152
+ price: 1000,
153
+ available: true,
154
+ sku: 'SAMPLE-1',
155
+ inventory_quantity: 100,
156
+ selected_options: ['Default Title'],
157
+ },
158
+ ],
159
+ tags: [],
160
+ });
161
+ state.addCollection({
162
+ id: 900_020_001,
163
+ handle: 'sample-collection',
164
+ title: 'Sample Collection',
165
+ productHandles: ['sample-product'],
166
+ });
167
+ return state;
168
+ }
169
+
170
+ /**
171
+ * Deep-equal that returns a human-readable diff path on the first
172
+ * mismatch. Not a perfect serialiser — sufficient for asserting
173
+ * captured JSON equals re-run JSON.
174
+ */
175
+ function diff(
176
+ expected: unknown,
177
+ actual: unknown,
178
+ path = '$',
179
+ ): string | null {
180
+ if (expected === actual) return null;
181
+ if (
182
+ typeof expected !== typeof actual ||
183
+ expected === null ||
184
+ actual === null
185
+ ) {
186
+ return `${path}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`;
187
+ }
188
+ if (Array.isArray(expected)) {
189
+ if (!Array.isArray(actual)) return `${path}: expected array, got object`;
190
+ if (expected.length !== actual.length) {
191
+ return `${path}: expected length ${expected.length}, got ${actual.length}`;
192
+ }
193
+ for (let i = 0; i < expected.length; i++) {
194
+ const d = diff(expected[i], actual[i], `${path}[${i}]`);
195
+ if (d) return d;
196
+ }
197
+ return null;
198
+ }
199
+ if (typeof expected === 'object') {
200
+ const e = expected as Record<string, unknown>;
201
+ const a = actual as Record<string, unknown>;
202
+ const allKeys = new Set([...Object.keys(e), ...Object.keys(a)]);
203
+ for (const k of allKeys) {
204
+ if (!(k in e)) return `${path}.${k}: unexpected key`;
205
+ if (!(k in a)) return `${path}.${k}: missing key`;
206
+ const d = diff(e[k], a[k], `${path}.${k}`);
207
+ if (d) return d;
208
+ }
209
+ return null;
210
+ }
211
+ return `${path}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`;
212
+ }
213
+
214
+ async function main(): Promise<void> {
215
+ const args = parseArgs();
216
+
217
+ let entries: string[];
218
+ try {
219
+ entries = readdirSync(args.contractsDir);
220
+ } catch {
221
+ console.error(
222
+ `[verify-contracts] no contracts directory at ${args.contractsDir}. ` +
223
+ `Run \`npm run test:online:capture-contracts\` first.`,
224
+ );
225
+ process.exit(2);
226
+ }
227
+ const contractFiles = entries.filter((f) => f.endsWith('.json'));
228
+ if (contractFiles.length === 0) {
229
+ console.error(`[verify-contracts] no contracts found in ${args.contractsDir}`);
230
+ process.exit(2);
231
+ }
232
+
233
+ const state = buildSeededState();
234
+ const executor: Executor =
235
+ buildOfflineExecutor(args.api, state);
236
+
237
+ let pass = 0;
238
+ let drift = 0;
239
+ let skipped = 0;
240
+ const failures: { contract: string; diff: string }[] = [];
241
+
242
+ for (const f of contractFiles) {
243
+ const path = resolve(args.contractsDir, f);
244
+ let st;
245
+ try {
246
+ st = statSync(path);
247
+ } catch {
248
+ continue;
249
+ }
250
+ if (!st.isFile()) continue;
251
+ const contract = JSON.parse(readFileSync(path, 'utf8')) as Contract;
252
+ // Contracts captured with a warning never executed cleanly;
253
+ // skip them in verify (they need fixtures.json before they can
254
+ // be verified).
255
+ if (contract.warning) {
256
+ skipped++;
257
+ continue;
258
+ }
259
+ let actual: unknown;
260
+ try {
261
+ actual = await executor(contract.source, contract.variables);
262
+ } catch (err) {
263
+ drift++;
264
+ failures.push({
265
+ contract: f,
266
+ diff: `executor threw: ${(err as Error).message}`,
267
+ });
268
+ continue;
269
+ }
270
+ // Normalise both sides before diffing. Volatile values
271
+ // (Shopify GIDs, timestamps, CDN URLs, cursors) reduce to
272
+ // stable tokens so live verification doesn't drown in
273
+ // expected-vs-real-ID noise. Offline verification is also
274
+ // safe — deterministic values still normalise to the same
275
+ // tokens on both sides. Genuine structural drift survives.
276
+ const expectedNorm = normaliseResponse(contract.response);
277
+ const actualNorm = normaliseResponse(actual);
278
+ const d = diff(expectedNorm, actualNorm);
279
+ if (d === null) {
280
+ pass++;
281
+ } else {
282
+ drift++;
283
+ failures.push({ contract: f, diff: d });
284
+ }
285
+ }
286
+
287
+ console.log(
288
+ `[verify-contracts] api=${args.api}: ${pass} pass, ${drift} drift, ${skipped} skipped (fixtures needed)`,
289
+ );
290
+ if (failures.length > 0) {
291
+ console.log('');
292
+ console.log('[verify-contracts] drift:');
293
+ for (const f of failures) {
294
+ console.log(
295
+ ` ${relative(args.cwd, resolve(args.contractsDir, f.contract))}`,
296
+ );
297
+ console.log(` ${f.diff}`);
298
+ }
299
+ console.log('');
300
+ console.log(
301
+ 'If the mock is correct and the contract is stale, regenerate with `npm run test:online:capture-contracts`.',
302
+ );
303
+ }
304
+ process.exit(drift === 0 ? 0 : 1);
305
+ }
306
+
307
+ main().catch((err) => {
308
+ console.error(err);
309
+ process.exit(2);
310
+ });