@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,319 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * captureRestContracts — REST analog of `captureContracts`.
4
+ *
5
+ * Where the GraphQL capture extracts operations from `#graphql`
6
+ * template literals scattered through source, REST capture reads
7
+ * an explicit MANIFEST that the consuming app maintains at
8
+ * `tests/test-offline/contracts/rest.manifest.json`. Why manifest rather
9
+ * than static analysis:
10
+ *
11
+ * 1. Shopify apps overwhelmingly use the SDK resource-class
12
+ * pattern: `admin.rest.resources.Theme.all({...})`. The
13
+ * method-name → HTTP-path mapping lives inside
14
+ * `@shopify/shopify-api`'s resource classes; reconstructing it
15
+ * from app source requires reproducing that mapping table.
16
+ * The manifest sidesteps the problem — devs declare the wire-
17
+ * shape they actually want pinned.
18
+ * 2. REST endpoints aren't always tied to one call site (apps
19
+ * may build paths dynamically). The manifest is the canonical
20
+ * "this is the REST surface our app depends on" — same role
21
+ * the GraphQL #graphql literals play.
22
+ * 3. The manifest is small (most apps have <20 REST endpoints).
23
+ * Auto-extraction is a polish item; explicit manifest works
24
+ * today.
25
+ *
26
+ * Manifest format:
27
+ *
28
+ * {
29
+ * "operations": [
30
+ * {
31
+ * "name": "listThemes",
32
+ * "method": "GET",
33
+ * "path": "/admin/api/{version}/themes.json",
34
+ * "query": { "role": "main" }
35
+ * },
36
+ * {
37
+ * "name": "getThemeAssets",
38
+ * "method": "GET",
39
+ * "path": "/admin/api/{version}/themes/{themeId}/assets.json",
40
+ * "pathParams": { "themeId": "1" },
41
+ * "query": { "asset[key]": "config/settings_data.json" }
42
+ * }
43
+ * ]
44
+ * }
45
+ *
46
+ * `{version}` is substituted at run-time from the API version the
47
+ * mock serves; `{...}` other path params are substituted from
48
+ * `pathParams`. Bodies (for POST/PUT) live in `body`.
49
+ *
50
+ * Capture executes each entry against offline or live, vendors
51
+ * the response under `tests/test-offline/contracts/admin-rest/<name>.json`
52
+ * (alongside the GraphQL contracts) in the same shape
53
+ * `verifyContracts` consumes.
54
+ */
55
+ import {
56
+ readFileSync,
57
+ writeFileSync,
58
+ mkdirSync,
59
+ existsSync,
60
+ statSync,
61
+ } from 'node:fs';
62
+ import { resolve, relative } from 'node:path';
63
+ import {
64
+ createAdminApi,
65
+ } from '@essential-apps/shopify-test-shopify-api';
66
+ import { ShopState } from '@essential-apps/shopify-test-storefront';
67
+
68
+ interface Args {
69
+ manifestPath: string;
70
+ outDir: string;
71
+ cwd: string;
72
+ quiet: boolean;
73
+ }
74
+
75
+ interface RestOperation {
76
+ name: string;
77
+ method: 'GET' | 'POST' | 'PUT' | 'DELETE';
78
+ /** Path template. `{version}` substituted with apiVersion; other `{x}` from pathParams. */
79
+ path: string;
80
+ pathParams?: Record<string, string>;
81
+ query?: Record<string, string>;
82
+ body?: unknown;
83
+ }
84
+
85
+ interface RestManifest {
86
+ operations: RestOperation[];
87
+ }
88
+
89
+ const ADMIN_API_VERSION = '2025-07';
90
+
91
+ function parseArgs(): Args {
92
+ const argv = process.argv.slice(2);
93
+ const out: Args = {
94
+ manifestPath: '',
95
+ outDir: '',
96
+ cwd: process.cwd(),
97
+ quiet: false,
98
+ };
99
+ for (let i = 0; i < argv.length; i++) {
100
+ const a = argv[i] ?? '';
101
+ if (a === '--manifest' && i + 1 < argv.length) {
102
+ out.manifestPath = argv[++i] ?? '';
103
+ } else if (a === '--out' && i + 1 < argv.length) {
104
+ out.outDir = argv[++i] ?? '';
105
+ } else if (a === '--quiet') {
106
+ out.quiet = true;
107
+ } else {
108
+ console.error(`unknown arg: ${a}`);
109
+ process.exit(2);
110
+ }
111
+ }
112
+ if (!out.manifestPath) {
113
+ out.manifestPath = resolve(out.cwd, 'tests/test-offline/contracts/rest.manifest.json');
114
+ } else {
115
+ out.manifestPath = resolve(out.cwd, out.manifestPath);
116
+ }
117
+ if (!out.outDir) {
118
+ out.outDir = resolve(out.cwd, 'tests/test-offline/contracts/admin-rest');
119
+ } else {
120
+ out.outDir = resolve(out.cwd, out.outDir);
121
+ }
122
+ return out;
123
+ }
124
+
125
+ interface RestExecutor {
126
+ (op: RestOperation): Promise<{ status: number; headers: Record<string, string>; body: unknown }>;
127
+ }
128
+
129
+ /** Substitute `{x}` placeholders in a path. */
130
+ function fillPath(template: string, params: Record<string, string>): string {
131
+ return template.replace(/\{([^}]+)\}/g, (_, key) => {
132
+ if (!(key in params)) {
133
+ throw new Error(`path template references {${key}} but no value in pathParams`);
134
+ }
135
+ return encodeURIComponent(params[key] ?? '');
136
+ });
137
+ }
138
+
139
+ function appendQuery(path: string, query?: Record<string, string>): string {
140
+ if (!query || Object.keys(query).length === 0) return path;
141
+ const params = new URLSearchParams();
142
+ for (const [k, v] of Object.entries(query)) params.set(k, v);
143
+ const joiner = path.includes('?') ? '&' : '?';
144
+ return `${path}${joiner}${params.toString()}`;
145
+ }
146
+
147
+ /**
148
+ * In-process Hono executor — hits the mock-admin REST handlers
149
+ * directly via `app.request(...)`. No port binding, no auth gate
150
+ * (the mock accepts any X-Shopify-Access-Token).
151
+ */
152
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hono is loosely typed
153
+ function buildOfflineExecutor(state: ShopState): RestExecutor {
154
+ const app = createAdminApi({ state });
155
+ return async (op) => {
156
+ const filled = fillPath(op.path, {
157
+ version: ADMIN_API_VERSION,
158
+ ...(op.pathParams ?? {}),
159
+ });
160
+ const finalPath = appendQuery(filled, op.query);
161
+ const init: RequestInit = {
162
+ method: op.method,
163
+ headers: {
164
+ 'Content-Type': 'application/json',
165
+ 'X-Shopify-Access-Token': 'mock-access-token',
166
+ },
167
+ };
168
+ if (op.body !== undefined) {
169
+ init.body = JSON.stringify(op.body);
170
+ }
171
+ const resp: Response = await (app.request as any)(finalPath, init);
172
+ const headers: Record<string, string> = {};
173
+ resp.headers.forEach((v, k) => { headers[k] = v; });
174
+ let body: unknown = null;
175
+ try {
176
+ body = await resp.json();
177
+ } catch {
178
+ // Some endpoints return empty/non-JSON. Capture as null.
179
+ }
180
+ return { status: resp.status, headers, body };
181
+ };
182
+ }
183
+
184
+ // Live executor intentionally removed — platform parity (does the
185
+ // offline mock match real Shopify) is owned by
186
+ // `@essential-apps/shopify-test-conformance`. Consuming-app contract
187
+ // capture only targets the offline mock.
188
+
189
+ interface CapturedRestContract {
190
+ operationName: string;
191
+ protocol: 'rest';
192
+ method: string;
193
+ path: string;
194
+ pathParams?: Record<string, string>;
195
+ query?: Record<string, string>;
196
+ body?: unknown;
197
+ response: {
198
+ status: number;
199
+ body: unknown;
200
+ };
201
+ capturedFrom: 'offline';
202
+ capturedAt: string;
203
+ }
204
+
205
+ async function main(): Promise<void> {
206
+ const args = parseArgs();
207
+ if (!existsSync(args.manifestPath)) {
208
+ console.error(
209
+ `[capture-rest] manifest not found at ${relative(args.cwd, args.manifestPath)}\n` +
210
+ `Create one — see docs/CONTRACTS.md for the schema.`,
211
+ );
212
+ process.exit(2);
213
+ }
214
+ let st;
215
+ try {
216
+ st = statSync(args.manifestPath);
217
+ } catch {
218
+ console.error(`[capture-rest] cannot stat ${args.manifestPath}`);
219
+ process.exit(2);
220
+ }
221
+ if (!st.isFile()) {
222
+ console.error(`[capture-rest] ${args.manifestPath} is not a file`);
223
+ process.exit(2);
224
+ }
225
+ const manifest = JSON.parse(readFileSync(args.manifestPath, 'utf8')) as RestManifest;
226
+ if (!manifest.operations || !Array.isArray(manifest.operations)) {
227
+ console.error(`[capture-rest] manifest must have an "operations" array`);
228
+ process.exit(2);
229
+ }
230
+
231
+ const state = buildSeededState();
232
+ const executor: RestExecutor =
233
+ buildOfflineExecutor(state);
234
+
235
+ mkdirSync(args.outDir, { recursive: true });
236
+
237
+ if (!args.quiet) {
238
+ console.log(
239
+ `[capture-rest] ${manifest.operations.length} operation(s) in manifest`,
240
+ );
241
+ }
242
+
243
+ let captured = 0;
244
+ let failed = 0;
245
+
246
+ for (const op of manifest.operations) {
247
+ let response;
248
+ try {
249
+ response = await executor(op);
250
+ } catch (err) {
251
+ failed++;
252
+ writeFileSync(
253
+ resolve(args.outDir, `${op.name}.json`),
254
+ JSON.stringify(
255
+ {
256
+ operationName: op.name,
257
+ protocol: 'rest' as const,
258
+ method: op.method,
259
+ path: op.path,
260
+ ...(op.pathParams ? { pathParams: op.pathParams } : {}),
261
+ ...(op.query ? { query: op.query } : {}),
262
+ ...(op.body !== undefined ? { body: op.body } : {}),
263
+ response: { status: 0, body: null },
264
+ capturedFrom: 'offline',
265
+ capturedAt: new Date().toISOString(),
266
+ warning: `executor failed: ${(err as Error).message}`,
267
+ },
268
+ null,
269
+ 2,
270
+ ) + '\n',
271
+ );
272
+ continue;
273
+ }
274
+ const contract: CapturedRestContract = {
275
+ operationName: op.name,
276
+ protocol: 'rest',
277
+ method: op.method,
278
+ path: op.path,
279
+ ...(op.pathParams ? { pathParams: op.pathParams } : {}),
280
+ ...(op.query ? { query: op.query } : {}),
281
+ ...(op.body !== undefined ? { body: op.body } : {}),
282
+ response: { status: response.status, body: response.body },
283
+ capturedFrom: 'offline',
284
+ capturedAt: new Date().toISOString(),
285
+ };
286
+ writeFileSync(
287
+ resolve(args.outDir, `${op.name}.json`),
288
+ JSON.stringify(contract, null, 2) + '\n',
289
+ );
290
+ captured++;
291
+ }
292
+
293
+ if (!args.quiet) {
294
+ console.log(
295
+ `[capture-rest] ${captured} captured, ${failed} failed. Output: ${relative(args.cwd, args.outDir)}/`,
296
+ );
297
+ }
298
+ }
299
+
300
+ /**
301
+ * Deterministic ShopState seed — same baseline as
302
+ * captureContracts.ts (the GraphQL counterpart). The REST handlers
303
+ * in mock-admin already serve sensible defaults regardless of state
304
+ * (themes endpoint returns a stub theme even with empty state); we
305
+ * still construct a state in case future endpoints depend on it.
306
+ */
307
+ function buildSeededState(): ShopState {
308
+ return new ShopState({
309
+ shop: {
310
+ domain: 'test-shop.myshopify.com',
311
+ permanent_domain: 'test-shop.myshopify.com',
312
+ },
313
+ });
314
+ }
315
+
316
+ main().catch((err) => {
317
+ console.error(err);
318
+ process.exit(2);
319
+ });
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * checkOperationCoverage — drift check between a consuming app's
4
+ * GraphQL operations and the offline mock's resolver coverage.
5
+ *
6
+ * The problem this catches: production code calls
7
+ * `admin.graphql(\`#graphql query { product(id: $id) { title } }\`)`
8
+ * but the offline mock has no `Query.product` resolver. Real Shopify
9
+ * answers fine — offline returns `null` silently, and the test
10
+ * fails several layers downstream (form sits in a skeleton-loading
11
+ * state, etc.) with no hint that the root cause is a missing mock.
12
+ *
13
+ * The fix this enforces: every Query/Mutation root field referenced
14
+ * by production code MUST have a resolver in the corresponding
15
+ * offline mock's resolver registry. Run this in CI before tests
16
+ * and the gap surfaces at design time, not at test failure time.
17
+ *
18
+ * Single source of truth: the SDL files in
19
+ * `@essential-apps/shopify-test-shopify-api` (which the conformance
20
+ * suite proves match live Shopify) plus the consuming app's own
21
+ * source code. The mock resolver registry is what gets validated —
22
+ * if conformance says Shopify has Field X and your app calls X but
23
+ * we have no X resolver, this surfaces it.
24
+ *
25
+ * Usage (from a consuming app's repo root):
26
+ *
27
+ * npx shopify-test-check-operation-coverage
28
+ * npx shopify-test-check-operation-coverage --api admin
29
+ * npx shopify-test-check-operation-coverage --api storefront
30
+ * npx shopify-test-check-operation-coverage --glob "./app/**\/*.ts"
31
+ *
32
+ * Exit codes:
33
+ * 0 — all referenced root fields have resolvers
34
+ * 1 — gaps found (printed with file:line citations)
35
+ * 2 — bad invocation / parse error
36
+ *
37
+ * Scope of the check: Query/Mutation root fields only. Nested object
38
+ * fields are not checked — graphql-tools defaults to property-access
39
+ * for fields without an explicit resolver, so they're "covered" as
40
+ * long as the parent resolver returned a shape with that key. The
41
+ * silent-null failure mode only affects ROOT resolvers (Query.X
42
+ * with no resolver returns null even though the field is nullable
43
+ * — exactly the bug this catches).
44
+ *
45
+ * Why a CLI vs a Vitest test: this needs to read the consuming
46
+ * app's source (path-dependent) AND the mock's resolvers (package
47
+ * import). A CLI is the natural shape — every consuming app's CI
48
+ * runs it the same way.
49
+ */
50
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
51
+ import { extname, relative, resolve } from 'node:path';
52
+ import {
53
+ buildSchema,
54
+ parse,
55
+ TypeInfo,
56
+ visit,
57
+ visitWithTypeInfo,
58
+ type GraphQLSchema,
59
+ } from 'graphql';
60
+ import {
61
+ adminResolvers,
62
+ storefrontResolvers,
63
+ loadAdminSdl,
64
+ loadStorefrontSdl,
65
+ } from '@essential-apps/shopify-test-shopify-api';
66
+
67
+ type ApiType = 'admin' | 'storefront';
68
+
69
+ interface Args {
70
+ api: ApiType;
71
+ glob: string;
72
+ cwd: string;
73
+ json: boolean;
74
+ }
75
+
76
+ function parseArgs(): Args {
77
+ const argv = process.argv.slice(2);
78
+ const out: Args = {
79
+ api: 'admin',
80
+ glob: process.env['SHOPIFY_TEST_OPS_GLOB'] ?? './app',
81
+ cwd: process.cwd(),
82
+ json: false,
83
+ };
84
+ for (let i = 0; i < argv.length; i++) {
85
+ const a = argv[i] ?? '';
86
+ if (a === '--api' && i + 1 < argv.length) {
87
+ const next = argv[++i] ?? '';
88
+ if (next !== 'admin' && next !== 'storefront') {
89
+ console.error(`--api must be "admin" or "storefront" (got "${next}")`);
90
+ process.exit(2);
91
+ }
92
+ out.api = next;
93
+ } else if (a === '--glob' && i + 1 < argv.length) {
94
+ out.glob = argv[++i] ?? out.glob;
95
+ } else if (a === '--json') {
96
+ out.json = true;
97
+ } else if (a === '--help' || a === '-h') {
98
+ printUsage();
99
+ process.exit(0);
100
+ } else {
101
+ console.error(`unknown arg: ${a}`);
102
+ printUsage();
103
+ process.exit(2);
104
+ }
105
+ }
106
+ return out;
107
+ }
108
+
109
+ function printUsage(): void {
110
+ console.error(
111
+ [
112
+ 'usage: shopify-test-check-operation-coverage [--api admin|storefront] [--glob <dir>] [--json]',
113
+ '',
114
+ 'Walks every .ts/.tsx/.js/.jsx file under --glob, extracts inline',
115
+ '#graphql template literals, and verifies every Query/Mutation root',
116
+ 'field referenced has a resolver in the offline mock registry.',
117
+ '',
118
+ '--api which mock to check (default: admin)',
119
+ '--glob directory to scan (default: ./app)',
120
+ '--json emit a structured JSON report on stdout',
121
+ ].join('\n'),
122
+ );
123
+ }
124
+
125
+ /**
126
+ * Walk a directory recursively, returning every file whose extension
127
+ * matches `.ts | .tsx | .js | .jsx`. We don't depend on `glob` — the
128
+ * extra dep would be overkill for "walk one tree and filter".
129
+ */
130
+ function* walkSources(root: string): Generator<string> {
131
+ let entries: string[];
132
+ try {
133
+ entries = readdirSync(root);
134
+ } catch {
135
+ return;
136
+ }
137
+ for (const name of entries) {
138
+ if (name === 'node_modules' || name === 'dist' || name === '.git') continue;
139
+ const full = resolve(root, name);
140
+ let st;
141
+ try {
142
+ st = statSync(full);
143
+ } catch {
144
+ continue;
145
+ }
146
+ if (st.isDirectory()) {
147
+ yield* walkSources(full);
148
+ } else if (st.isFile()) {
149
+ const ext = extname(name);
150
+ if (ext === '.ts' || ext === '.tsx' || ext === '.js' || ext === '.jsx') {
151
+ yield full;
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ interface ExtractedOp {
158
+ file: string;
159
+ line: number;
160
+ source: string;
161
+ }
162
+
163
+ /**
164
+ * Extract every `#graphql ...` tagged template literal from a source
165
+ * file. We accept three common conventions:
166
+ *
167
+ * admin.graphql(`#graphql ...`) // shopify-app-remix
168
+ * gql`#graphql ...` // codegen tag
169
+ * `#graphql ...` // raw template
170
+ *
171
+ * Real-world false negatives we accept: operations not tagged
172
+ * `#graphql`, operations split across template-string interpolation,
173
+ * dynamic strings built at runtime. Those bypass codegen too, so
174
+ * the consuming app is already non-conformant — the warning is OK.
175
+ */
176
+ function extractOperations(file: string, content: string): ExtractedOp[] {
177
+ const ops: ExtractedOp[] = [];
178
+ // Match a backtick-delimited string whose FIRST non-whitespace token
179
+ // is `#graphql`. The `[^\`]*` keeps it simple — operations with
180
+ // backticks inside string values aren't valid GraphQL, so this is
181
+ // safe in practice.
182
+ const regex = /`\s*#graphql\b([^`]*)`/g;
183
+ let m: RegExpExecArray | null;
184
+ while ((m = regex.exec(content)) !== null) {
185
+ const line = content.slice(0, m.index).split('\n').length;
186
+ ops.push({ file, line, source: m[1] ?? '' });
187
+ }
188
+ return ops;
189
+ }
190
+
191
+ interface FieldRef {
192
+ type: string;
193
+ field: string;
194
+ /** Where this field was first referenced (file:line). */
195
+ origin: { file: string; line: number };
196
+ }
197
+
198
+ /**
199
+ * Walk a GraphQL document and return every (Type, Field) pair the
200
+ * selection set touches. We use `TypeInfo` to track the parent type
201
+ * at each `Field` visit, which is the only way to know the
202
+ * abstract-type a field belongs to without re-implementing the
203
+ * resolver lookup logic.
204
+ */
205
+ function collectFieldRefs(
206
+ source: string,
207
+ schema: GraphQLSchema,
208
+ file: string,
209
+ line: number,
210
+ out: Map<string, FieldRef>,
211
+ parseErrors: { file: string; line: number; message: string }[],
212
+ ): void {
213
+ let doc;
214
+ try {
215
+ doc = parse(source);
216
+ } catch (err) {
217
+ parseErrors.push({ file, line, message: (err as Error).message });
218
+ return;
219
+ }
220
+ const typeInfo = new TypeInfo(schema);
221
+ visit(
222
+ doc,
223
+ visitWithTypeInfo(typeInfo, {
224
+ Field() {
225
+ const parentType = typeInfo.getParentType();
226
+ const fieldDef = typeInfo.getFieldDef();
227
+ if (!parentType || !fieldDef) return;
228
+ const key = `${parentType.name}.${fieldDef.name}`;
229
+ if (!out.has(key)) {
230
+ out.set(key, {
231
+ type: parentType.name,
232
+ field: fieldDef.name,
233
+ origin: { file, line },
234
+ });
235
+ }
236
+ },
237
+ }),
238
+ );
239
+ }
240
+
241
+ /**
242
+ * Resolver registries are `{ [TypeName]: { [FieldName]: ResolverFn } }`.
243
+ * Build a flat set of "TypeName.FieldName" strings for fast lookup.
244
+ */
245
+ function flattenResolvers(
246
+ resolvers: Record<string, Record<string, unknown>>,
247
+ ): Set<string> {
248
+ const out = new Set<string>();
249
+ for (const [typeName, fields] of Object.entries(resolvers)) {
250
+ if (!fields || typeof fields !== 'object') continue;
251
+ for (const fieldName of Object.keys(fields)) {
252
+ out.add(`${typeName}.${fieldName}`);
253
+ }
254
+ }
255
+ return out;
256
+ }
257
+
258
+ async function main(): Promise<void> {
259
+ const args = parseArgs();
260
+
261
+ const sdl = args.api === 'admin' ? loadAdminSdl() : loadStorefrontSdl();
262
+ const schema = buildSchema(sdl);
263
+
264
+ const sourceDir = resolve(args.cwd, args.glob);
265
+ const files = Array.from(walkSources(sourceDir));
266
+ if (files.length === 0) {
267
+ console.error(`[coverage] no source files under ${sourceDir}`);
268
+ process.exit(2);
269
+ }
270
+
271
+ const refs = new Map<string, FieldRef>();
272
+ const parseErrors: { file: string; line: number; message: string }[] = [];
273
+ let opCount = 0;
274
+ for (const f of files) {
275
+ const content = readFileSync(f, 'utf8');
276
+ for (const op of extractOperations(f, content)) {
277
+ opCount += 1;
278
+ collectFieldRefs(op.source, schema, op.file, op.line, refs, parseErrors);
279
+ }
280
+ }
281
+
282
+ const resolvers =
283
+ args.api === 'admin'
284
+ ? (adminResolvers as Record<string, Record<string, unknown>>)
285
+ : (storefrontResolvers as Record<string, Record<string, unknown>>);
286
+ const implemented = flattenResolvers(resolvers);
287
+
288
+ // Root types in the Shopify schemas: Admin's Query type is named
289
+ // `QueryRoot` and Mutation is `Mutation`; Storefront uses `QueryRoot`
290
+ // / `MutationRoot`. We get the actual names from the schema.
291
+ const queryRoot = schema.getQueryType()?.name;
292
+ const mutationRoot = schema.getMutationType()?.name;
293
+ const rootTypes = new Set<string>([
294
+ ...(queryRoot ? [queryRoot] : []),
295
+ ...(mutationRoot ? [mutationRoot] : []),
296
+ ]);
297
+
298
+ const uncoveredRoot: FieldRef[] = [];
299
+ for (const ref of refs.values()) {
300
+ if (!rootTypes.has(ref.type)) continue; // ignore nested fields
301
+ if (!implemented.has(`${ref.type}.${ref.field}`)) {
302
+ uncoveredRoot.push(ref);
303
+ }
304
+ }
305
+ uncoveredRoot.sort((a, b) => `${a.type}.${a.field}`.localeCompare(`${b.type}.${b.field}`));
306
+
307
+ if (args.json) {
308
+ process.stdout.write(
309
+ JSON.stringify(
310
+ {
311
+ api: args.api,
312
+ filesScanned: files.length,
313
+ opsFound: opCount,
314
+ rootFieldsReferenced: [...refs.values()].filter((r) =>
315
+ rootTypes.has(r.type),
316
+ ).length,
317
+ rootFieldsImplemented: [...implemented].filter((k) => {
318
+ const root = k.split('.')[0];
319
+ return root !== undefined && rootTypes.has(root);
320
+ }).length,
321
+ uncoveredRoot: uncoveredRoot.map((u) => ({
322
+ type: u.type,
323
+ field: u.field,
324
+ origin: u.origin,
325
+ })),
326
+ parseErrors,
327
+ },
328
+ null,
329
+ 2,
330
+ ),
331
+ );
332
+ process.stdout.write('\n');
333
+ } else {
334
+ const cwd = args.cwd;
335
+ console.log(
336
+ `[coverage] api=${args.api}, scanned ${files.length} file(s), found ${opCount} operation(s)`,
337
+ );
338
+ if (parseErrors.length > 0) {
339
+ console.log(`[coverage] ${parseErrors.length} parse error(s):`);
340
+ for (const e of parseErrors) {
341
+ console.log(` ! ${relative(cwd, e.file)}:${e.line} — ${e.message}`);
342
+ }
343
+ }
344
+ if (uncoveredRoot.length === 0) {
345
+ console.log('[coverage] ✓ every referenced root field has a resolver');
346
+ } else {
347
+ console.log(`[coverage] ✗ ${uncoveredRoot.length} uncovered root field(s):`);
348
+ for (const u of uncoveredRoot) {
349
+ console.log(
350
+ ` ${u.type}.${u.field} (${relative(cwd, u.origin.file)}:${u.origin.line})`,
351
+ );
352
+ }
353
+ console.log('');
354
+ console.log(
355
+ `Fix: add a resolver in @essential-apps/shopify-test-shopify-api/src/${args.api}/resolvers/<file>.ts`,
356
+ );
357
+ }
358
+ }
359
+ process.exit(uncoveredRoot.length === 0 ? 0 : 1);
360
+ }
361
+
362
+ main().catch((err) => {
363
+ console.error(err);
364
+ process.exit(2);
365
+ });