@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,137 @@
1
+ # Self-contained test image targeting an HVF arm64 microVM
2
+ # (via supermachine HVF) — sibling of `Dockerfile` (which targets libkrun-via-
3
+ # Rosetta amd64). The two are deliberately separate files rather
4
+ # than a single multi-arch Dockerfile because the diff between them
5
+ # is meaningful, not just an ARCH var:
6
+ #
7
+ # - libkrun (`Dockerfile`): amd64, bind-mounted workspace,
8
+ # Google Chrome stable amd64 for
9
+ # Cloudflare Turnstile.
10
+ # - vm (this file): arm64 native (Apple HVF), workspace
11
+ # mounted via virtio-fs DAX once that
12
+ # lands (until then, baked-in or
13
+ # vm.writeFile'd), arm64 Chromium
14
+ # bundled with Playwright — passes
15
+ # Turnstile too (verified against
16
+ # nowsecure.nl, the public canary).
17
+ #
18
+ # Built via either `container build --arch arm64 -t essential-apps/
19
+ # shopify-test-vm:latest -f Dockerfile.vm .` (Apple
20
+ # `container`) or `docker buildx build --platform linux/arm64 ...`
21
+ # once the VM runtime adds local-OCI-archive `Image.build` support.
22
+ # Then consumed by the VM-side runner as:
23
+ #
24
+ # const image = await Image.build({
25
+ # ref: 'essential-apps/shopify-test-vm:latest',
26
+ # source: 'local-oci-archive', // future API
27
+ # memoryMib: 4096, // for safeFetch's patchright
28
+ # // pre-warm; 2 GiB OOM-killed it.
29
+ # vcpus: 2,
30
+ # });
31
+ #
32
+ # Until local-OCI-archive lands, push to ghcr.io as a workaround.
33
+ #
34
+ # Workspace shape inside the guest (matches the libkrun side so
35
+ # probe code is identical):
36
+ # /workspace ← virtio-fs mount from host (or
37
+ # baked snapshot if mount missing)
38
+ # /var/lib/postgresql/data ← per-VM Postgres data dir (NOT
39
+ # persisted; VMs are
40
+ # ephemeral, fresh per acquire)
41
+ FROM mcr.microsoft.com/playwright:v1.59.1-jammy
42
+
43
+ ENV DEBIAN_FRONTEND=noninteractive
44
+
45
+ # Base toolchain. Same set as the amd64 image MINUS:
46
+ # - Google Chrome stable (no arm64 build; not needed because
47
+ # Playwright's bundled arm64 Chromium passes Turnstile)
48
+ #
49
+ # Kept:
50
+ # - postgresql-14 + client (mock backend for offline-full tests)
51
+ # - openssh-client (localhost.run SSH tunnel fallback when
52
+ # cloudflared 429s — verified working in VMs)
53
+ # - cloudflared (primary webhook tunnel; arm64 .deb below)
54
+ # - net-tools, procps (debugging + process inspection)
55
+ # - libnss3-tools (Playwright's NSS for client cert handling)
56
+ # - x11vnc — auth-capture flow (runVmAuth.ts) starts it
57
+ # post-acquire to expose Xvfb :99 to host via vm.exposeTcp,
58
+ # so a human can interactively log in to Shopify Partners.
59
+ #
60
+ # Xvfb ships in the Playwright base image (it's how MS expects you
61
+ # to run headful flows under their image); no extra install needed.
62
+ #
63
+ # build-essential (make + g++): consuming apps declare native deps (e.g.
64
+ # bufferutil + utf-8-validate — DIRECT deps, not optional) that have no
65
+ # prebuilt for the guest's Node on arm64, so the warmup `npm install`
66
+ # compiles them via node-gyp, which needs make + a C++ toolchain. Without
67
+ # it the warmup fails ("not found: make") and a fresh bake never boots.
68
+ # (The compile is serialized via MAKEFLAGS=-j1 in the warmup to avoid OOM.)
69
+ RUN apt-get update -qq \
70
+ && apt-get install -y -qq \
71
+ build-essential \
72
+ postgresql-14 \
73
+ postgresql-client-14 \
74
+ openssh-client \
75
+ sudo \
76
+ procps \
77
+ net-tools \
78
+ wget \
79
+ gnupg \
80
+ ca-certificates \
81
+ libnss3-tools \
82
+ x11vnc \
83
+ && rm -rf /var/lib/apt/lists/*
84
+
85
+ # cloudflared — arm64 .deb (vs amd64 in the sibling Dockerfile).
86
+ # Used by webhook-envelope probes to expose the in-VM Hono receiver
87
+ # on a public *.trycloudflare.com URL so real Shopify can POST
88
+ # webhooks during conformance live capture.
89
+ #
90
+ # Note: when running INSIDE the VM, cloudflared still spawns
91
+ # fine because it's an outbound connection (to Cloudflare's edge)
92
+ # — the VM runtime's vsock-based TSI doesn't block outbound
93
+ # AF_INET on real interfaces. The in-VM 127.0.0.1 loopback for
94
+ # guest-local listeners is the bit that has the accept-queue
95
+ # split (known limitation, deferred); cloudflared's connect to the
96
+ # receiver via 127.0.0.1 WOULD hit that, so we either:
97
+ # (a) run cloudflared on host + use vm.exposeTcp(N, receiverPort)
98
+ # so cloudflared sees `host:N` and forwards from there, OR
99
+ # (b) bake cloudflared into the VM and accept that it'll work
100
+ # once the TSI accept-queue split is fixed upstream.
101
+ # We bake it here (option b) so the image is self-sufficient; the
102
+ # host-side runner (`vmProbe.ts`, TBD) picks the topology.
103
+ RUN wget -q -O /tmp/cloudflared.deb \
104
+ https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb \
105
+ && apt-get update -qq \
106
+ && apt-get install -y -qq /tmp/cloudflared.deb \
107
+ && rm -f /tmp/cloudflared.deb \
108
+ && rm -rf /var/lib/apt/lists/*
109
+
110
+ # Native-addon build toolchain. Consuming apps may pull npm deps with
111
+ # native bindings that lack a linux-arm64 / current-Node prebuild (e.g.
112
+ # utf-8-validate, bufferutil — WebSocket accelerators). The in-VM
113
+ # `npm install` (offline warmup) then falls back to compiling them with
114
+ # node-gyp, which needs make + a C/C++ compiler + python3. Without these
115
+ # the build fails and aborts the whole bake. Added as a dedicated late
116
+ # layer so rebuilds stay cache-friendly (the base/Chrome/cloudflared
117
+ # layers above don't re-run).
118
+ RUN apt-get update -qq \
119
+ && apt-get install -y -qq \
120
+ build-essential \
121
+ python3 \
122
+ && rm -rf /var/lib/apt/lists/*
123
+
124
+ ENV PGDATA=/var/lib/postgresql/data
125
+ ENV PG_BIN=/usr/lib/postgresql/14/bin
126
+
127
+ RUN mkdir -p "$PGDATA" \
128
+ && chown -R postgres:postgres "$PGDATA" \
129
+ && chmod 700 "$PGDATA"
130
+
131
+ # VM quirk: `cmd` is set by the host-side runner at
132
+ # Image.build time, not by Dockerfile CMD/ENTRYPOINT. Whatever we
133
+ # put here is overridden. Leaving CMD as `bash` so the image is
134
+ # still useful for ad-hoc `docker run` / `container run` debugging.
135
+ WORKDIR /workspace
136
+
137
+ CMD ["bash"]
@@ -0,0 +1,50 @@
1
+ # `essential-apps/shopify-test` container image
2
+
3
+ Canonical Linux Chrome + Postgres + VNC image used by every layer of
4
+ the test stack. The tag mirrors the npm scope (`@essential-apps/shopify-test`)
5
+ so anyone inspecting `container images` sees the same name they
6
+ `npm install`.
7
+
8
+ Consumers (current):
9
+
10
+ - **Conformance Partner-session capture** — `conformance:auth` boots
11
+ this image and exposes VNC so an operator can log in once. Bootstrap
12
+ (`conformance:bootstrap`) reuses it headlessly to mint per-store
13
+ admin tokens.
14
+ - **Online-test fallback** — `dev:online` / online specs were historically
15
+ run from inside this image so real Google Chrome amd64 could clear
16
+ Cloudflare Turnstile on `admin.shopify.com`. The `runDocker*.ts`
17
+ drivers were removed in the May 2026 rename; the image lingers as
18
+ a fallback only — the vm is the default, but the vm runtime
19
+ ships only arm64 chromium and patchright doesn't clear Turnstile
20
+ (see `packages/conformance/docs/AUTH.md` — escalation pending).
21
+
22
+ ## Build
23
+
24
+ From the shopify-test workspace root:
25
+
26
+ ```bash
27
+ npm run image:build
28
+ ```
29
+
30
+ Tags the image as `essential-apps/shopify-test:latest`. Override
31
+ with `SHOPIFY_TEST_IMAGE_TAG=...` env var. Build is amd64
32
+ (Rosetta-emulated on Apple Silicon) because Google only ships
33
+ Chrome stable for Linux amd64.
34
+
35
+ ## Why amd64, not arm64
36
+
37
+ Bundled Playwright chromium hits Cloudflare's Turnstile challenge
38
+ on Linux even with patchright stealth patches. Real Google Chrome
39
+ (stable) renders Turnstile the same way it does on macOS host
40
+ Chrome, where the challenge auto-clicks cleanly. Google doesn't
41
+ ship an arm64 Linux Chrome, so the image is amd64-only.
42
+
43
+ ## Migrating existing consumers
44
+
45
+ Apps that built their own per-app image (e.g. `essential-upsell-online:latest`)
46
+ can either:
47
+
48
+ 1. **Retag** — `container image tag essential-apps/shopify-test:latest essential-upsell-online:latest` — zero changes to their scripts.
49
+ 2. **Point at the shared image** — update `TEST_IMAGE` (or equivalent env var)
50
+ in their `.env.test` to `essential-apps/shopify-test:latest`.
@@ -0,0 +1,198 @@
1
+ #!/bin/bash
2
+ # Container entrypoint: initialize/start Postgres, then exec the user's
3
+ # command. Postgres data lives at $PGDATA (bind-mounted from the host
4
+ # at runtime, so it survives container restarts).
5
+ set -euo pipefail
6
+
7
+ # Initialize cluster on first run
8
+ if [ ! -s "$PGDATA/PG_VERSION" ]; then
9
+ echo "[entrypoint] Initializing fresh Postgres cluster at $PGDATA"
10
+ chown -R postgres:postgres "$PGDATA"
11
+ sudo -u postgres "$PG_BIN/initdb" \
12
+ -D "$PGDATA" \
13
+ --auth-local=trust \
14
+ --auth-host=trust \
15
+ --no-locale \
16
+ --encoding=UTF8 \
17
+ >/dev/null
18
+ # Allow connections from any IP within the container's loopback range.
19
+ echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
20
+ echo "host all all ::1/128 trust" >> "$PGDATA/pg_hba.conf"
21
+ fi
22
+
23
+ echo "[entrypoint] Starting Postgres on 127.0.0.1:5432"
24
+ sudo -u postgres "$PG_BIN/pg_ctl" \
25
+ -D "$PGDATA" \
26
+ -l "$PGDATA/postgres.log" \
27
+ -o "-h 127.0.0.1 -p 5432 -k /tmp" \
28
+ start
29
+
30
+ # Wait for pg ready
31
+ for _ in $(seq 1 30); do
32
+ if "$PG_BIN/pg_isready" -h 127.0.0.1 -p 5432 -q; then break; fi
33
+ sleep 0.5
34
+ done
35
+
36
+ # Create a superuser role matching the container's running user (root by
37
+ # default) so createdb/psql work without explicit -U postgres flags. The
38
+ # OR-true tolerates the role already existing on warm starts.
39
+ # Using -h /tmp because pg started with -k /tmp (custom socket dir).
40
+ CURRENT_USER=$(id -un)
41
+ sudo -u postgres "$PG_BIN/psql" -h /tmp -d postgres -c \
42
+ "CREATE ROLE \"$CURRENT_USER\" SUPERUSER LOGIN" 2>&1 | grep -v "already exists" || true
43
+
44
+ # Stop pg cleanly when the container exits
45
+ trap 'sudo -u postgres "$PG_BIN/pg_ctl" -D "$PGDATA" stop -m fast >/dev/null 2>&1 || true' EXIT
46
+
47
+ # Mark to test runner that we're running inside the container so the
48
+ # storePool fixture uses vanilla @playwright/test (matches the bundled
49
+ # chromium in this image) instead of patchright (which wants a newer
50
+ # Playwright image version).
51
+ export TEST_IN_CONTAINER=true
52
+
53
+ # ── Offline-mode wiring: /etc/hosts + system CA trust ───────────────
54
+ #
55
+ # When the OFFLINE runner is invoked inside this container, it boots a
56
+ # tiny HTTPS reverse-proxy ("edge") on :443 that fronts the per-process
57
+ # mock Storefront / Admin / Storefront-API / Admin-Shell servers. We
58
+ # need:
59
+ #
60
+ # 1. DNS: Shopify hostnames must resolve to 127.0.0.1 so the browser
61
+ # AND Node both reach the edge instead of real Shopify on the
62
+ # internet. /etc/hosts works for both — it's read by glibc's
63
+ # getaddrinfo, which is what `dns.lookup` (and therefore Node's
64
+ # HTTP stack) calls, and Chrome reads it on Linux via its host
65
+ # resolver.
66
+ #
67
+ # 2. TLS: the edge presents a self-signed cert with broad SANs
68
+ # (*.myshopify.com, *.shopify.com, etc.) shipped at
69
+ # `node_modules/@essential-apps/shopify-test-runner/src/edge/cert.pem`.
70
+ # We install it as a system CA so Chrome AND Node trust it
71
+ # natively — no --ignore-certificate-errors, no
72
+ # NODE_TLS_REJECT_UNAUTHORIZED=0.
73
+ #
74
+ # We do this in the entrypoint (rather than at image-build time)
75
+ # because (a) /etc/hosts is mounted by the container runtime at
76
+ # start, not baked into the image; and (b) the cert lives in the
77
+ # bind-mounted node_modules, not in the image. Both restrictions push
78
+ # this work to runtime.
79
+ #
80
+ # Idempotent: re-running the entrypoint (warm container restart) is
81
+ # safe — the hosts entries dedupe, and update-ca-certificates is
82
+ # a no-op if the cert is already trusted.
83
+ # Conformance (and any future "talk to real Shopify" mode) skips
84
+ # offline wiring entirely — it needs real DNS for *.shopify.com and
85
+ # the real Shopify TLS cert chain. Set SKIP_OFFLINE_HOST_HIJACK=1 in
86
+ # `container run --env ...` from such callers; the rest of the
87
+ # entrypoint (Postgres, x11vnc) still runs.
88
+ EDGE_CERT_SRC="/workspace/node_modules/@essential-apps/shopify-test-runner/src/edge/ca.crt"
89
+ if [ "${SKIP_OFFLINE_HOST_HIJACK:-0}" = "1" ]; then
90
+ echo "[entrypoint] SKIP_OFFLINE_HOST_HIJACK=1 — leaving /etc/hosts and CA store untouched."
91
+ elif [ -f "$EDGE_CERT_SRC" ]; then
92
+ # /etc/hosts entries — preserve any existing content (e.g. container
93
+ # runtime's auto-injected hostname line). Need BOTH IPv4 (127.0.0.1)
94
+ # and IPv6 (::1) entries: glibc's getaddrinfo consults /etc/hosts for
95
+ # both address families, but if only an IPv4 entry exists for a host
96
+ # that ALSO has IPv6 DNS records (real test-shop.myshopify.com has
97
+ # AAAA records), the IPv6 lookup falls through to DNS and resolves
98
+ # to real Shopify. Chrome prefers IPv6 when available and would
99
+ # connect there, bypassing our /etc/hosts redirect entirely.
100
+ for HOST in test-shop.myshopify.com admin.shopify.com cdn.shopify.com fonts.shopifycdn.com; do
101
+ if ! grep -q "^127\.0\.0\.1[[:space:]]\+$HOST\$" /etc/hosts 2>/dev/null; then
102
+ echo "127.0.0.1 $HOST" >> /etc/hosts
103
+ fi
104
+ if ! grep -q "^::1[[:space:]]\+$HOST\$" /etc/hosts 2>/dev/null; then
105
+ echo "::1 $HOST" >> /etc/hosts
106
+ fi
107
+ done
108
+ # Trust the edge's CA cert. /usr/local/share/ca-certificates/*.crt
109
+ # is the canonical location; update-ca-certificates appends to
110
+ # /etc/ssl/certs/ca-certificates.crt (which Node + glibc OpenSSL +
111
+ # Chrome's bundled NSS all read).
112
+ cp -f "$EDGE_CERT_SRC" /usr/local/share/ca-certificates/edge-ca.crt
113
+ update-ca-certificates >/dev/null 2>&1 || true
114
+ # Tell Node explicitly too — NODE_EXTRA_CA_CERTS is the
115
+ # supported way to extend Node's TLS trust without touching
116
+ # built-in defaults.
117
+ export NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/edge-ca.crt
118
+ echo "[entrypoint] Offline mode: /etc/hosts + system CA configured for *.myshopify.com / *.shopify.com → 127.0.0.1"
119
+ fi
120
+
121
+ # Prepend the consuming app's node_modules/.bin so locally-installed
122
+ # CLIs (e.g. `shopify`, used by the storefront mock to run discount
123
+ # function WASM via `@shopify/cli`) resolve from PATH without
124
+ # requiring an `npx` wrapper. npm scripts already do this; child
125
+ # processes spawned from in-process mocks don't go through npm and
126
+ # inherit only PATH.
127
+ if [ -d /workspace/node_modules/.bin ]; then
128
+ export PATH="/workspace/node_modules/.bin:$PATH"
129
+ fi
130
+
131
+ # Newer Shopify CLI versions (3.90+) refuse to run `shopify app
132
+ # function run` if no `shopify.app.toml` exists in the app root.
133
+ # Consuming apps typically check in variant configs only
134
+ # (`shopify.app.dev.toml`, `shopify.app.production.toml`, etc.) and
135
+ # select one via `shopify app config use` — that picks one and
136
+ # symlinks it as `shopify.app.toml`. Container starts can't run
137
+ # interactive `config use`, so do the symlink here: pick a sensible
138
+ # default (prefer an online-named variant if present, else any
139
+ # variant). The actual config CONTENT doesn't matter for `function
140
+ # run` — it just needs to find the file to satisfy its preflight.
141
+ if [ -d /workspace ] && [ ! -e /workspace/shopify.app.toml ]; then
142
+ cd /workspace
143
+ CANDIDATE=""
144
+ for pattern in shopify.app.online*.toml shopify.app.dev*.toml shopify.app.*.toml; do
145
+ for f in $pattern; do
146
+ if [ -f "$f" ]; then CANDIDATE="$f"; break; fi
147
+ done
148
+ [ -n "$CANDIDATE" ] && break
149
+ done
150
+ if [ -n "$CANDIDATE" ]; then
151
+ ln -s "$CANDIDATE" shopify.app.toml
152
+ echo "[entrypoint] Symlinked $CANDIDATE → shopify.app.toml (for Shopify CLI compatibility)"
153
+ fi
154
+ cd /
155
+ fi
156
+
157
+ # Start Xvfb as a background daemon so headed Chromium has a virtual
158
+ # display. This avoids xvfb-run's process-wrapping behavior (which has
159
+ # been observed to hang under Apple `container`). We just need DISPLAY
160
+ # to point at a live X server.
161
+ if [ -z "${DISPLAY:-}" ] && command -v Xvfb >/dev/null 2>&1; then
162
+ # 1600x1000 matches modern laptop screens better than 1400x900 and
163
+ # gives explore-mode Chrome enough room for DevTools-open layout
164
+ # without clipping. Tests use Playwright's own viewport setting
165
+ # (1400x900) so this only affects what's visible via VNC.
166
+ Xvfb :99 -screen 0 1600x1000x24 -nolisten tcp &
167
+ export DISPLAY=:99
168
+ # Tiny wait for the X server to be ready before any X client connects.
169
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
170
+ [ -S /tmp/.X11-unix/X99 ] && break
171
+ sleep 0.1
172
+ done
173
+ fi
174
+
175
+ # Optional: expose the Xvfb display via VNC for the one-time interactive
176
+ # auth-capture flow. The wrapper (scripts/test/runDockerAuth.ts) starts
177
+ # the container with TEST_ONLINE_VNC=1 and --publish 5900:5900, then opens
178
+ # macOS Screen Sharing pointing at vnc://localhost:5900. After auth is
179
+ # captured, normal test runs don't set this and the VNC server doesn't
180
+ # start — Xvfb stays purely virtual.
181
+ if [ "${TEST_ONLINE_VNC:-}" = "1" ] && command -v x11vnc >/dev/null 2>&1; then
182
+ # macOS Screen Sharing.app prompts for a password even against a
183
+ # -nopw VNC server, so set a static token. Port is only published
184
+ # on host loopback (--publish 127.0.0.1:5900:5900), so this is just
185
+ # to satisfy Screen Sharing's UI, not real security.
186
+ VNC_PASSWORD="${TEST_ONLINE_VNC_PASSWORD:-test}"
187
+ mkdir -p /root/.vnc
188
+ x11vnc -storepasswd "$VNC_PASSWORD" /root/.vnc/passwd >/dev/null 2>&1
189
+ echo "[entrypoint] Starting x11vnc on :5900 (display :99); password: $VNC_PASSWORD"
190
+ # x11vnc must bind 0.0.0.0 inside the container or the host-side
191
+ # port forward can't reach it. -shared/-forever: allow disconnect &
192
+ # reconnect.
193
+ x11vnc -display :99 -forever -shared -rfbauth /root/.vnc/passwd \
194
+ -rfbport 5900 -bg -quiet \
195
+ >/var/log/x11vnc.log 2>&1
196
+ fi
197
+
198
+ exec "$@"
package/package.json ADDED
@@ -0,0 +1,85 @@
1
+ {
2
+ "name": "@essential-apps/shopify-test-runner",
3
+ "version": "1.0.0",
4
+ "description": "Orchestration scripts (container, auth capture, install) and Playwright config preset for Essential Apps' Shopify test suites. Internal use only.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./playwright": {
14
+ "types": "./dist/playwright/index.d.ts",
15
+ "import": "./dist/playwright/index.js"
16
+ },
17
+ "./contracts/normalize": {
18
+ "types": "./dist/contracts/normalize.d.ts",
19
+ "import": "./dist/contracts/normalize.js"
20
+ },
21
+ "./contracts/normalize-html": {
22
+ "types": "./dist/contracts/normalizeHtml.d.ts",
23
+ "import": "./dist/contracts/normalizeHtml.js"
24
+ }
25
+ },
26
+ "bin": {
27
+ "shopify-test-run-online": "./dist/scripts/runVm.js",
28
+ "shopify-test-run-offline": "./dist/scripts/runOffline.js",
29
+ "shopify-test-run-tests": "./dist/scripts/runTests.js",
30
+ "shopify-test-capture-online-auth": "./dist/scripts/runVmAuth.js",
31
+ "shopify-test-capture-online-auth-libkrun": "./dist/scripts/runDockerAuth.js",
32
+ "shopify-test-capture-auth": "./dist/scripts/captureAuth.js",
33
+ "shopify-test-deploy-app-version": "./dist/scripts/deployAppVersion.js",
34
+ "shopify-test-install-app": "./dist/scripts/installApp.js",
35
+ "shopify-test-add-store": "./dist/scripts/addStore.js",
36
+ "shopify-test-list-stores": "./dist/scripts/listStores.js",
37
+ "shopify-test-create-stores": "./dist/scripts/createStores.js",
38
+ "shopify-test-cleanup-stores": "./dist/scripts/cleanupStores.js",
39
+ "shopify-test-setup-db": "./dist/scripts/setupTestDb.js",
40
+ "shopify-test-dev-backend": "./dist/scripts/devOnlineBackend.js",
41
+ "shopify-test-run-offline-full-tests": "./dist/scripts/runOfflineFullTests.js",
42
+ "shopify-test-probe": "./dist/probes/runProbe.js",
43
+ "shopify-test-build-image": "./dist/scripts/buildDockerImage.js",
44
+ "shopify-test-check-operation-coverage": "./dist/scripts/checkOperationCoverage.js",
45
+ "shopify-test-capture-contracts": "./dist/scripts/captureContracts.js",
46
+ "shopify-test-verify-contracts": "./dist/scripts/verifyContracts.js",
47
+ "shopify-test-capture-rest-contracts": "./dist/scripts/captureRestContracts.js",
48
+ "shopify-test-verify-rest-contracts": "./dist/scripts/verifyRestContracts.js"
49
+ },
50
+ "files": [
51
+ "dist",
52
+ "docker",
53
+ "src"
54
+ ],
55
+ "scripts": {
56
+ "build": "tsc -p tsconfig.json",
57
+ "clean": "rm -rf dist"
58
+ },
59
+ "dependencies": {
60
+ "@essential-apps/shopify-test-core": "^1.0.0",
61
+ "@essential-apps/shopify-test-mock-admin": "^1.0.0",
62
+ "@essential-apps/shopify-test-shopify-api": "^1.0.0",
63
+ "@essential-apps/shopify-test-storefront": "^1.0.0",
64
+ "@essential-apps/shopify-test-themes": "^1.0.0",
65
+ "@playwright/test": "^1.49.0",
66
+ "@types/node": "^20.19.40",
67
+ "@types/tar-stream": "^3.1.4",
68
+ "esbuild": "^0.25.0",
69
+ "graphql": "^16.8.1",
70
+ "jszip": "^3.10.1",
71
+ "patchright": "^1.59.4",
72
+ "tar-stream": "^3.2.0",
73
+ "undici": "^7.0.0",
74
+ "ws": "^8.18.0",
75
+ "@supermachine/core": "^0.7.67"
76
+ },
77
+ "devDependencies": {
78
+ "@types/ws": "^8.5.0",
79
+ "vite": "^5.4.0"
80
+ },
81
+ "license": "MIT",
82
+ "publishConfig": {
83
+ "access": "public"
84
+ }
85
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Response normalisation for operation contracts.
3
+ *
4
+ * The contract-conformance system (see docs/CONTRACTS.md) compares
5
+ * GraphQL responses captured from two different sources — the
6
+ * offline mock and live Shopify — and asserts they're "the same".
7
+ * But "the same" can mean two different things:
8
+ *
9
+ * - **Shape-equal**: same keys, same value types, same array
10
+ * lengths. Ignores actual values that Shopify generates per
11
+ * request (IDs, timestamps, CDN-cache-busted image URLs).
12
+ * - **Value-equal**: byte-for-byte JSON equality.
13
+ *
14
+ * Live verification REQUIRES shape-equal — real Shopify hands out
15
+ * different numeric IDs and cache-bust hashes on every store. If
16
+ * we compare raw values, every live diff is noise.
17
+ *
18
+ * This module implements shape-equal by walking a response tree
19
+ * and replacing volatile substrings with stable tokens. Two
20
+ * responses that differ ONLY in IDs/timestamps/CDN-URLs become
21
+ * byte-equal after normalisation; real structural drift survives.
22
+ *
23
+ * Replacement rules (each preserves the surrounding context so the
24
+ * normalised value remains parseable):
25
+ *
26
+ * gid://shopify/<Type>/<digits> → gid://shopify/<Type>/<ID>
27
+ * gid://shopify/<Type>/<digits>?<rest> → gid://shopify/<Type>/<ID>?<rest>
28
+ * 2026-05-14T12:34:56Z → <DATETIME>
29
+ * 2026-05-14T12:34:56.789Z → <DATETIME>
30
+ * 2026-05-14 → <DATE>
31
+ * https://cdn.shopify.com/... → <CDN_URL>
32
+ * https://<shop>.myshopify.com/cdn/... → <CDN_URL>
33
+ * base64-ish cursor (eyJ... starting) → <CURSOR>
34
+ *
35
+ * The replacement is INTENTIONALLY GENEROUS. We'd rather over-
36
+ * normalise (treat two distinct values as equal) than under (treat
37
+ * two equivalent values as different and produce noisy diffs).
38
+ * Genuine drift (a field that's now missing, a type that changed)
39
+ * survives because we only rewrite well-formed volatile patterns.
40
+ *
41
+ * For offline-only verification (Layer 3), normalisation is a
42
+ * no-op — the offline mock returns deterministic values, so raw
43
+ * equality already works. For live verification (Layer 4),
44
+ * normalisation is essential.
45
+ */
46
+
47
+ const ID_GID_RE = /^(gid:\/\/shopify\/[A-Za-z]+)\/(\d+)(\?.*)?$/;
48
+ const ISO_DATE_TIME_RE =
49
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
50
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
51
+ const CDN_URL_RE =
52
+ /^https?:\/\/(?:cdn\.shopify\.com|[^./]+\.myshopify\.com\/cdn\/)/;
53
+ // Base64-ish cursors are tough to detect without false-positives. A
54
+ // loose heuristic: starts with `eyJ` (the base64 prefix of `{"...`)
55
+ // and is at least 40 chars. Real Shopify cursors are JWT-ish.
56
+ const CURSOR_RE = /^eyJ[A-Za-z0-9_+/=-]{37,}$/;
57
+
58
+ export function normaliseString(s: string): string {
59
+ const gid = s.match(ID_GID_RE);
60
+ if (gid) {
61
+ const [, typePrefix, , suffix] = gid;
62
+ return `${typePrefix}/<ID>${suffix ?? ''}`;
63
+ }
64
+ if (ISO_DATE_TIME_RE.test(s)) return '<DATETIME>';
65
+ if (ISO_DATE_RE.test(s)) return '<DATE>';
66
+ if (CDN_URL_RE.test(s)) return '<CDN_URL>';
67
+ if (CURSOR_RE.test(s)) return '<CURSOR>';
68
+ return s;
69
+ }
70
+
71
+ /**
72
+ * Walk a JSON-able value and replace volatile substrings with
73
+ * stable tokens. Returns a deep-cloned tree — never mutates the
74
+ * input. Objects and arrays recurse; scalars (string / number /
75
+ * boolean / null) get inspected directly.
76
+ *
77
+ * Number IDs (e.g. `legacyResourceId: 12345`) — we DON'T normalise
78
+ * these. They're explicit values the consuming app may compare on.
79
+ * If a contract relies on a specific numeric ID, the live and
80
+ * offline values won't match and the diff fires (correctly — it's
81
+ * a fixture-mismatch issue, fix via fixtures.json or by re-
82
+ * capturing against a known seeded live store).
83
+ */
84
+ export function normaliseResponse(value: unknown): unknown {
85
+ if (value === null || value === undefined) return value;
86
+ if (typeof value === 'string') return normaliseString(value);
87
+ if (Array.isArray(value)) return value.map(normaliseResponse);
88
+ if (typeof value === 'object') {
89
+ const out: Record<string, unknown> = {};
90
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
91
+ out[k] = normaliseResponse(v);
92
+ }
93
+ return out;
94
+ }
95
+ return value;
96
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * HTML normalisation for Liquid output contracts.
3
+ *
4
+ * Liquid renders produce HTML with three kinds of volatile content
5
+ * that would otherwise diff-spam every capture against live:
6
+ *
7
+ * 1. Generated identifiers — UUIDs in data-* attributes,
8
+ * auto-incremented `id="..."` values, cursor-y opaque tokens.
9
+ * 2. Asset URLs with cache-busters — `?v=1234567890` on every
10
+ * CDN-served image / CSS.
11
+ * 3. Whitespace + attribute order — both sides may render the
12
+ * same logical HTML with different formatting.
13
+ *
14
+ * The normaliser collapses these to stable tokens, preserving the
15
+ * structural skeleton that conformance actually cares about. Two
16
+ * outputs that differ ONLY in these volatile dimensions become
17
+ * byte-equal after normalisation; genuine structural drift
18
+ * (a missing element, a renamed attribute) survives.
19
+ *
20
+ * Implementation: regex passes on the raw HTML string. We could
21
+ * use a real HTML parser (parse5 / node-html-parser) for attribute
22
+ * sorting, but that's a substantial dep for marginal gain — both
23
+ * the offline mock and live Shopify emit attributes in source-
24
+ * declaration order, so post-render orders match anyway. Switch
25
+ * to a parser later if a real ordering mismatch shows up.
26
+ *
27
+ * Keep the patterns in `normaliseHtmlString` aligned with whatever
28
+ * volatile values surface during live captures — drift caught here
29
+ * (false positive) means add a rule; drift missed (false negative)
30
+ * means tighten an existing one.
31
+ */
32
+
33
+ /** UUID v4-ish — case-insensitive, hyphenated. Used for funnel / variant / extension IDs. */
34
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
35
+
36
+ /** Long base-N numeric IDs — Shopify uses these everywhere (product IDs, variant IDs). */
37
+ const NUMERIC_ID_8PLUS_RE = /\b\d{8,}\b/g;
38
+
39
+ /** Asset cache-buster `?v=12345` (with surrounding URL chars preserved). */
40
+ const CACHE_BUSTER_RE = /\?v=\d+/g;
41
+
42
+ /** Token used by Shopify analytics — `__st.*` payloads in inline script tags. */
43
+ const ANALYTICS_TOKEN_RE = /"(s|t|a|p|c|e)t":\s*\d+/g;
44
+
45
+ /** Long base64-ish blob (40+ chars) — cursors, signed URLs, etc. */
46
+ const LONG_BASE64_RE = /\b[A-Za-z0-9+/]{40,}={0,2}\b/g;
47
+
48
+ /**
49
+ * Replace the value of any `data-…="…"` attribute whose key looks
50
+ * "id-like" with a token. Catches `data-funnel-id="abc-..."`,
51
+ * `data-product-id="123"`, etc. without us having to enumerate every
52
+ * possible attribute name. Conservative — only matches keys ending
53
+ * in `-id` to avoid stomping on legitimate data attributes like
54
+ * `data-trigger-type`.
55
+ */
56
+ const DATA_ID_ATTR_RE = /(data-[\w-]*-id)="[^"]+"/gi;
57
+
58
+ /** `id="..."` value — any HTML id attribute. */
59
+ const ID_ATTR_RE = /\bid="([^"]+)"/g;
60
+
61
+ /**
62
+ * Collapse runs of whitespace BETWEEN HTML tags. Preserves
63
+ * whitespace WITHIN text content (which can be semantically
64
+ * meaningful, e.g. between words in a sentence).
65
+ */
66
+ function collapseInterTagWhitespace(html: string): string {
67
+ return html.replace(/>\s+</g, '><');
68
+ }
69
+
70
+ /**
71
+ * Trim leading/trailing whitespace and collapse all-whitespace
72
+ * runs to single spaces inside text nodes that span attribute
73
+ * boundaries. Safe: applied after the inter-tag pass.
74
+ */
75
+ function collapseTextWhitespace(html: string): string {
76
+ return html.replace(/\s{2,}/g, ' ').trim();
77
+ }
78
+
79
+ /**
80
+ * Single entry point. Apply each normalisation in a deterministic
81
+ * order — later passes assume earlier ones already ran.
82
+ */
83
+ export function normaliseHtmlString(html: string): string {
84
+ let out = html;
85
+ // Order matters — narrower patterns first, so they consume the
86
+ // input before generic ones (`NUMERIC_ID_8PLUS_RE`) eat the
87
+ // numeric tail of a cache-buster or UUID.
88
+ out = out.replace(CACHE_BUSTER_RE, '?v=<CACHE_BUSTER>');
89
+ out = out.replace(UUID_RE, '<UUID>');
90
+ out = out.replace(DATA_ID_ATTR_RE, '$1="<ID>"');
91
+ out = out.replace(ID_ATTR_RE, 'id="<ID>"');
92
+ out = out.replace(LONG_BASE64_RE, '<BASE64>');
93
+ out = out.replace(ANALYTICS_TOKEN_RE, (_m, k) => `"${k}t":<TS>`);
94
+ out = out.replace(NUMERIC_ID_8PLUS_RE, '<NUMERIC_ID>');
95
+ out = collapseInterTagWhitespace(out);
96
+ out = collapseTextWhitespace(out);
97
+ return out;
98
+ }