@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,675 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * captureContracts — bootstrap step for the operation-contract
4
+ * conformance system. See docs/CONTRACTS.md for the full design.
5
+ *
6
+ * What it does:
7
+ * 1. Walk every `.ts/.tsx/.js/.jsx` file under `--glob` (default
8
+ * `./app`).
9
+ * 2. Extract every `#graphql` template literal.
10
+ * 3. Execute each operation against an IN-PROCESS offline mock
11
+ * (Admin GraphQL or Storefront GraphQL, per `--api`).
12
+ * 4. Write one JSON-per-operation under `tests/test-offline/contracts/<api>/`.
13
+ *
14
+ * The captured response is `capturedFrom: "offline"` — a tentative
15
+ * bootstrap. The conformance suite's `verify-contracts-from-live`
16
+ * probe later re-captures from real Shopify; on each successful
17
+ * live-capture the contract is overwritten with `capturedFrom: "live"`
18
+ * — the authoritative ground truth.
19
+ *
20
+ * Variables: an operation with required variables (`ID!`, `String!`,
21
+ * etc.) needs sample values to execute. We auto-synthesise sensible
22
+ * defaults for primitive types (`ID!` → first seeded product GID;
23
+ * `String!` → `"sample"`; `Int!` → `1`; `Boolean!` → `true`). Complex
24
+ * input objects are NOT synthesised — the operation is captured
25
+ * without execution and `response: null` + `error: "needs fixtures"`
26
+ * is recorded. A consuming app can hand-write `tests/test-offline/contracts/
27
+ * fixtures.json` mapping operation names to variables to cover those.
28
+ *
29
+ * Output is idempotent — same input + same offline mock = same
30
+ * contracts. Commit them.
31
+ */
32
+ import {
33
+ readdirSync,
34
+ readFileSync,
35
+ statSync,
36
+ writeFileSync,
37
+ mkdirSync,
38
+ existsSync,
39
+ } from 'node:fs';
40
+ import { extname, resolve, dirname, basename, relative } from 'node:path';
41
+ import {
42
+ parse as parseGraphql,
43
+ validate as validateGraphql,
44
+ buildSchema,
45
+ typeFromAST,
46
+ isNonNullType,
47
+ isListType,
48
+ isScalarType,
49
+ isEnumType,
50
+ isInputObjectType,
51
+ TypeInfo,
52
+ visit,
53
+ visitWithTypeInfo,
54
+ Kind,
55
+ type GraphQLSchema,
56
+ type DocumentNode,
57
+ type OperationDefinitionNode,
58
+ type TypeNode,
59
+ type GraphQLType,
60
+ } from 'graphql';
61
+ import {
62
+ loadAdminSdl,
63
+ loadStorefrontSdl,
64
+ createAdminApi,
65
+ createStorefrontApi,
66
+ } from '@essential-apps/shopify-test-shopify-api';
67
+ import { ShopState } from '@essential-apps/shopify-test-storefront';
68
+
69
+ type ApiType = 'admin' | 'storefront';
70
+
71
+ interface Args {
72
+ api: ApiType;
73
+ glob: string;
74
+ outDir: string;
75
+ fixturesPath: string;
76
+ cwd: string;
77
+ quiet: boolean;
78
+ }
79
+
80
+ function parseArgs(): Args {
81
+ const argv = process.argv.slice(2);
82
+ const out: Args = {
83
+ api: 'admin',
84
+ glob: './app',
85
+ outDir: '',
86
+ fixturesPath: '',
87
+ cwd: process.cwd(),
88
+ quiet: false,
89
+ };
90
+ for (let i = 0; i < argv.length; i++) {
91
+ const a = argv[i] ?? '';
92
+ if (a === '--api' && i + 1 < argv.length) {
93
+ const next = argv[++i] ?? '';
94
+ if (next !== 'admin' && next !== 'storefront') {
95
+ console.error(`--api must be "admin" or "storefront"`);
96
+ process.exit(2);
97
+ }
98
+ out.api = next;
99
+ } else if (a === '--glob' && i + 1 < argv.length) {
100
+ out.glob = argv[++i] ?? out.glob;
101
+ } else if (a === '--out' && i + 1 < argv.length) {
102
+ out.outDir = argv[++i] ?? '';
103
+ } else if (a === '--fixtures' && i + 1 < argv.length) {
104
+ out.fixturesPath = 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
+ // Default output dir scopes contracts under the consuming app's
113
+ // tests/test-offline/contracts/<api>/ — co-located with the spec files.
114
+ if (!out.outDir) {
115
+ out.outDir = resolve(out.cwd, 'tests/test-offline/contracts', out.api);
116
+ } else {
117
+ out.outDir = resolve(out.cwd, out.outDir);
118
+ }
119
+ if (!out.fixturesPath) {
120
+ out.fixturesPath = resolve(out.cwd, 'tests/test-offline/contracts/fixtures.json');
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * Walk a directory tree, yielding paths whose extension is a JS/TS
127
+ * source file. Skips `node_modules`, `dist`, `.git`, `build` — the
128
+ * usual non-source noise.
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 (
139
+ name === 'node_modules' ||
140
+ name === 'dist' ||
141
+ name === '.git' ||
142
+ name === 'build'
143
+ )
144
+ continue;
145
+ const full = resolve(root, name);
146
+ let st;
147
+ try {
148
+ st = statSync(full);
149
+ } catch {
150
+ continue;
151
+ }
152
+ if (st.isDirectory()) {
153
+ yield* walkSources(full);
154
+ } else if (st.isFile()) {
155
+ const ext = extname(name);
156
+ if (ext === '.ts' || ext === '.tsx' || ext === '.js' || ext === '.jsx') {
157
+ yield full;
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ interface ExtractedOp {
164
+ file: string;
165
+ line: number;
166
+ source: string;
167
+ /** Operation name extracted from the document (or '__anon_<line>__'). */
168
+ name: string;
169
+ }
170
+
171
+ /**
172
+ * Extract every `#graphql ...` template literal from a file. Parses
173
+ * each to discover its operation name (so contracts get filenames
174
+ * that match `getShop` rather than `__anon_42__`).
175
+ */
176
+ function extractOperations(file: string, content: string): ExtractedOp[] {
177
+ const ops: ExtractedOp[] = [];
178
+ const regex = /`\s*#graphql\b([^`]*)`/g;
179
+ let m: RegExpExecArray | null;
180
+ while ((m = regex.exec(content)) !== null) {
181
+ const line = content.slice(0, m.index).split('\n').length;
182
+ const source = m[1] ?? '';
183
+ const name = deriveOperationName(source, file, line);
184
+ ops.push({ file, line, source, name });
185
+ }
186
+ return ops;
187
+ }
188
+
189
+ /**
190
+ * Pick a stable, filesystem-safe slug for an operation. Preference
191
+ * order:
192
+ *
193
+ * 1. The operation's declared name —
194
+ * `query getShop { ... }` → `getShop`.
195
+ * 2. A type+field slug derived from the first top-level selection
196
+ * — `query { shop { name } }` → `anon_query_shop`. Stable across
197
+ * file renames (only the first selected field is in the slug).
198
+ * 3. Filename+line fallback when the document is unparseable.
199
+ *
200
+ * Why anon slugs matter: contracts are committed to the consuming
201
+ * app; the slug is the filename. Anon ops whose slug depends on
202
+ * the file path produce noisy diffs whenever the call site moves.
203
+ * Type+field slugs survive refactors as long as the operation
204
+ * keeps its first selection.
205
+ */
206
+ function deriveOperationName(source: string, file: string, line: number): string {
207
+ const fileBase =
208
+ file.split('/').pop()?.replace(/[^a-zA-Z0-9_-]/g, '_') ?? 'unknown';
209
+ const fallback = `__anon_${fileBase}_L${line}__`;
210
+ let doc: DocumentNode;
211
+ try {
212
+ doc = parseGraphql(source);
213
+ } catch {
214
+ return fallback;
215
+ }
216
+ const op = doc.definitions.find(
217
+ (d): d is OperationDefinitionNode => d.kind === Kind.OPERATION_DEFINITION,
218
+ );
219
+ if (!op) return fallback;
220
+ if (op.name?.value) return op.name.value;
221
+ // Anonymous — derive from first selection.
222
+ const firstField = op.selectionSet.selections.find(
223
+ (s) => s.kind === Kind.FIELD,
224
+ );
225
+ if (firstField && firstField.kind === Kind.FIELD) {
226
+ const opType = op.operation; // 'query' | 'mutation' | 'subscription'
227
+ const fieldName = firstField.name.value;
228
+ return `anon_${opType}_${fieldName}`;
229
+ }
230
+ return fallback;
231
+ }
232
+
233
+ /**
234
+ * Default per-test seed for the offline ShopState. Stable IDs so
235
+ * contracts captured from offline are deterministic across runs.
236
+ * Tests that need different data still seed via their own factories;
237
+ * contracts use this snapshot.
238
+ */
239
+ function buildSeededState(): ShopState {
240
+ const state = new ShopState({
241
+ shop: {
242
+ domain: 'test-shop.myshopify.com',
243
+ permanent_domain: 'test-shop.myshopify.com',
244
+ },
245
+ });
246
+ state.addProduct({
247
+ id: 900_000_001,
248
+ handle: 'sample-product',
249
+ title: 'Sample Product',
250
+ description: 'Used by contract capture as a deterministic fixture.',
251
+ price: 1000,
252
+ vendor: 'Sample Vendor',
253
+ type: 'Sample',
254
+ variants: [
255
+ {
256
+ id: 900_010_001,
257
+ title: 'Default Title',
258
+ price: 1000,
259
+ available: true,
260
+ sku: 'SAMPLE-1',
261
+ inventory_quantity: 100,
262
+ selected_options: ['Default Title'],
263
+ },
264
+ ],
265
+ tags: [],
266
+ });
267
+ state.addCollection({
268
+ id: 900_020_001,
269
+ handle: 'sample-collection',
270
+ title: 'Sample Collection',
271
+ productHandles: ['sample-product'],
272
+ });
273
+ return state;
274
+ }
275
+
276
+ interface SynthesisedVariables {
277
+ values: Record<string, unknown>;
278
+ /** Variables we couldn't synthesise — operation will be marked partial. */
279
+ unhandled: string[];
280
+ }
281
+
282
+ /**
283
+ * Synthesise plausible variable values for an operation's variable
284
+ * definitions. Handles ID / String / Int / Float / Boolean scalars
285
+ * and their nullable + list variants. Complex input objects fall
286
+ * through to `unhandled` — the user can override via fixtures.json.
287
+ */
288
+ function synthesiseVariables(
289
+ op: OperationDefinitionNode,
290
+ schema: GraphQLSchema,
291
+ state: ShopState,
292
+ override: Record<string, unknown>,
293
+ ): SynthesisedVariables {
294
+ const values: Record<string, unknown> = {};
295
+ const unhandled: string[] = [];
296
+ for (const def of op.variableDefinitions ?? []) {
297
+ const name = def.variable.name.value;
298
+ if (name in override) {
299
+ values[name] = override[name];
300
+ continue;
301
+ }
302
+ const synthValue = synthesiseTypeNode(def.type, schema, state);
303
+ if (synthValue === SYNTH_UNHANDLED) {
304
+ unhandled.push(name);
305
+ } else {
306
+ values[name] = synthValue;
307
+ }
308
+ }
309
+ return { values, unhandled };
310
+ }
311
+
312
+ const SYNTH_UNHANDLED = Symbol('unhandled');
313
+
314
+ /**
315
+ * Convert an AST type-node (from a variable definition) to a
316
+ * resolved `GraphQLType`, then defer to the type-driven synthesiser.
317
+ *
318
+ * The two-step approach (AST → GraphQLType → value) is so that
319
+ * Input objects can be walked by their FIELD definitions, not by
320
+ * raw AST. Field types know exactly what's required vs optional, so
321
+ * we only fill required fields and leave optional ones absent.
322
+ */
323
+ function synthesiseTypeNode(
324
+ typeNode: TypeNode,
325
+ schema: GraphQLSchema,
326
+ state: ShopState,
327
+ ): unknown | typeof SYNTH_UNHANDLED {
328
+ const resolved = typeFromAST(schema, typeNode);
329
+ if (!resolved) return SYNTH_UNHANDLED;
330
+ return synthesiseGraphQLType(resolved, state);
331
+ }
332
+
333
+ /**
334
+ * Recursively synthesise a sensible default for a GraphQLType.
335
+ *
336
+ * - NonNull → required; recurse into inner type.
337
+ * - List → empty array (valid for any list).
338
+ * - Scalar → primitive defaults (ID = first seeded product GID,
339
+ * others sensible per name).
340
+ * - Enum → first value of the enum.
341
+ * - InputObject → object with only its required fields filled;
342
+ * optional fields left absent.
343
+ *
344
+ * Returns `SYNTH_UNHANDLED` only for types we genuinely can't
345
+ * produce a value for (custom scalars with non-obvious shapes etc.).
346
+ * Callers should treat that as "user must override via fixtures.json".
347
+ */
348
+ function synthesiseGraphQLType(
349
+ type: GraphQLType,
350
+ state: ShopState,
351
+ ): unknown | typeof SYNTH_UNHANDLED {
352
+ if (isNonNullType(type)) {
353
+ return synthesiseGraphQLType(type.ofType, state);
354
+ }
355
+ if (isListType(type)) {
356
+ return [];
357
+ }
358
+ if (isScalarType(type)) {
359
+ const name = type.name;
360
+ if (name === 'ID') {
361
+ const first = Array.from(state.products.values())[0];
362
+ return first
363
+ ? `gid://shopify/Product/${first.id}`
364
+ : 'gid://shopify/Resource/1';
365
+ }
366
+ if (name === 'String') return 'sample';
367
+ if (name === 'Int') return 1;
368
+ if (name === 'Float') return 1.0;
369
+ if (name === 'Boolean') return true;
370
+ // Shopify-specific custom scalars we can stub with a primitive
371
+ // — these all serialise as strings on the wire.
372
+ if (
373
+ name === 'URL' ||
374
+ name === 'DateTime' ||
375
+ name === 'Date' ||
376
+ name === 'Decimal' ||
377
+ name === 'Money' ||
378
+ name === 'HTML' ||
379
+ name === 'JSON' ||
380
+ name === 'JSONString' ||
381
+ name === 'FormattedString' ||
382
+ name === 'StorefrontID' ||
383
+ name === 'UnsignedInt64'
384
+ ) {
385
+ if (name === 'URL') return 'https://example.com';
386
+ if (name === 'DateTime') return '2026-01-01T00:00:00Z';
387
+ if (name === 'Date') return '2026-01-01';
388
+ if (name === 'JSON' || name === 'JSONString') return '{}';
389
+ if (name === 'UnsignedInt64') return '1';
390
+ return '1.00'; // Decimal / Money / HTML / FormattedString — primitive default
391
+ }
392
+ return SYNTH_UNHANDLED;
393
+ }
394
+ if (isEnumType(type)) {
395
+ const values = type.getValues();
396
+ return values[0]?.value ?? SYNTH_UNHANDLED;
397
+ }
398
+ if (isInputObjectType(type)) {
399
+ const fields = type.getFields();
400
+ const out: Record<string, unknown> = {};
401
+ let anyRequired = false;
402
+ for (const [fieldName, field] of Object.entries(fields)) {
403
+ // Only fill REQUIRED fields. Optional ones get omitted —
404
+ // keeps the variable payload minimal + valid.
405
+ if (!isNonNullType(field.type)) continue;
406
+ anyRequired = true;
407
+ const value = synthesiseGraphQLType(field.type, state);
408
+ if (value === SYNTH_UNHANDLED) return SYNTH_UNHANDLED;
409
+ out[fieldName] = value;
410
+ }
411
+ // Edge case: input object with NO required fields. Empty object
412
+ // is a valid value.
413
+ return anyRequired ? out : {};
414
+ }
415
+ // Interfaces / Unions / custom scalars not covered above.
416
+ return SYNTH_UNHANDLED;
417
+ }
418
+
419
+ interface CapturedContract {
420
+ operationName: string;
421
+ source: string;
422
+ variables: Record<string, unknown>;
423
+ response: unknown;
424
+ capturedFrom: 'offline' | 'live';
425
+ capturedAt: string;
426
+ /** Diagnostic — set when capture didn't run cleanly. */
427
+ warning?: string;
428
+ }
429
+
430
+ /**
431
+ * Offline-only executor — POSTs the operation to an in-process
432
+ * Hono mock for the consuming app's API surface (admin or
433
+ * storefront GraphQL).
434
+ *
435
+ * No live target by design: contract capture at the consuming-app
436
+ * layer answers "what does the mock return for my operation?".
437
+ * The separate question — "does the mock match real Shopify on
438
+ * platform primitives?" — is owned by
439
+ * `@essential-apps/shopify-test-conformance` and runs against a
440
+ * canonical operation matrix, not per-app operation sets. An
441
+ * earlier version of this script had a `--target live` mode that
442
+ * mixed the two layers; removed for clarity.
443
+ */
444
+ type Executor = (
445
+ source: string,
446
+ variables: Record<string, unknown>,
447
+ ) => Promise<unknown>;
448
+
449
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hono types are loose
450
+ function buildOfflineExecutor(api: ApiType, state: ShopState): Executor {
451
+ const app =
452
+ api === 'admin' ? createAdminApi({ state }) : createStorefrontApi({ state });
453
+ const endpoint =
454
+ api === 'admin'
455
+ ? '/admin/api/2025-07/graphql.json'
456
+ : '/api/2025-07/graphql.json';
457
+ return async (source, variables) => {
458
+ const resp = await app.request(endpoint, {
459
+ method: 'POST',
460
+ headers: {
461
+ 'Content-Type': 'application/json',
462
+ 'X-Shopify-Access-Token': 'mock-access-token',
463
+ },
464
+ body: JSON.stringify({ query: source, variables }),
465
+ });
466
+ return resp.json();
467
+ };
468
+ }
469
+
470
+ async function main(): Promise<void> {
471
+ const args = parseArgs();
472
+
473
+ const sdl = args.api === 'admin' ? loadAdminSdl() : loadStorefrontSdl();
474
+ const schema = buildSchema(sdl);
475
+
476
+ // Load per-app fixtures if present (gives users a way to override
477
+ // synthesised variables for specific operations).
478
+ let fixtures: Record<string, Record<string, unknown>> = {};
479
+ if (existsSync(args.fixturesPath)) {
480
+ fixtures = JSON.parse(readFileSync(args.fixturesPath, 'utf8')) as Record<
481
+ string,
482
+ Record<string, unknown>
483
+ >;
484
+ }
485
+
486
+ const sourceDir = resolve(args.cwd, args.glob);
487
+ const files = Array.from(walkSources(sourceDir));
488
+ if (files.length === 0) {
489
+ console.error(`[capture-contracts] no source files under ${sourceDir}`);
490
+ process.exit(2);
491
+ }
492
+
493
+ // Collect operations + dedup by source (the same #graphql may
494
+ // appear in multiple files if shared via a util).
495
+ const opsByName = new Map<string, ExtractedOp>();
496
+ for (const f of files) {
497
+ const content = readFileSync(f, 'utf8');
498
+ for (const op of extractOperations(f, content)) {
499
+ const existing = opsByName.get(op.name);
500
+ if (!existing) opsByName.set(op.name, op);
501
+ }
502
+ }
503
+ if (!args.quiet) {
504
+ console.log(
505
+ `[capture-contracts] api=${args.api} ${files.length} file(s) scanned, ${opsByName.size} unique operation(s)`,
506
+ );
507
+ }
508
+
509
+ const state = buildSeededState();
510
+ const executor: Executor = buildOfflineExecutor(args.api, state);
511
+
512
+ mkdirSync(args.outDir, { recursive: true });
513
+
514
+ let captured = 0;
515
+ let skipped = 0;
516
+ let drift = 0;
517
+
518
+ for (const op of opsByName.values()) {
519
+ let doc: DocumentNode;
520
+ try {
521
+ doc = parseGraphql(op.source);
522
+ } catch (err) {
523
+ writeFileSync(
524
+ resolve(args.outDir, `${op.name}.json`),
525
+ JSON.stringify(
526
+ {
527
+ operationName: op.name,
528
+ source: op.source,
529
+ variables: {},
530
+ response: null,
531
+ capturedFrom: 'offline',
532
+ capturedAt: new Date().toISOString(),
533
+ warning: `parse failed: ${(err as Error).message}`,
534
+ } satisfies CapturedContract,
535
+ null,
536
+ 2,
537
+ ) + '\n',
538
+ );
539
+ skipped++;
540
+ continue;
541
+ }
542
+
543
+ // Validate against schema before executing — operations that
544
+ // reference fields the SDL doesn't have are recorded as drift.
545
+ const validationErrors = validateGraphql(schema, doc);
546
+ if (validationErrors.length > 0) {
547
+ writeFileSync(
548
+ resolve(args.outDir, `${op.name}.json`),
549
+ JSON.stringify(
550
+ {
551
+ operationName: op.name,
552
+ source: op.source,
553
+ variables: {},
554
+ response: null,
555
+ capturedFrom: 'offline',
556
+ capturedAt: new Date().toISOString(),
557
+ warning: `schema validation failed: ${validationErrors
558
+ .map((e) => e.message)
559
+ .join(' | ')}`,
560
+ } satisfies CapturedContract,
561
+ null,
562
+ 2,
563
+ ) + '\n',
564
+ );
565
+ drift++;
566
+ continue;
567
+ }
568
+
569
+ // Synthesise variables (with fixture overrides).
570
+ const opDef = doc.definitions.find(
571
+ (d): d is OperationDefinitionNode =>
572
+ d.kind === Kind.OPERATION_DEFINITION,
573
+ );
574
+ let variables: Record<string, unknown> = {};
575
+ let warning: string | undefined;
576
+ if (opDef) {
577
+ const synth = synthesiseVariables(
578
+ opDef,
579
+ schema,
580
+ state,
581
+ fixtures[op.name] ?? {},
582
+ );
583
+ variables = synth.values;
584
+ if (synth.unhandled.length > 0) {
585
+ warning =
586
+ `variables not synthesised: ${synth.unhandled.join(', ')}. ` +
587
+ `Add to ${relative(args.cwd, args.fixturesPath)} under "${op.name}" to capture this operation.`;
588
+ }
589
+ }
590
+
591
+ // If we couldn't fill required vars, record + skip execution.
592
+ if (warning) {
593
+ writeFileSync(
594
+ resolve(args.outDir, `${op.name}.json`),
595
+ JSON.stringify(
596
+ {
597
+ operationName: op.name,
598
+ source: op.source,
599
+ variables,
600
+ response: null,
601
+ capturedFrom: 'offline',
602
+ capturedAt: new Date().toISOString(),
603
+ warning,
604
+ } satisfies CapturedContract,
605
+ null,
606
+ 2,
607
+ ) + '\n',
608
+ );
609
+ skipped++;
610
+ continue;
611
+ }
612
+
613
+ // Execute and capture.
614
+ let response: unknown;
615
+ try {
616
+ response = await executor(op.source, variables);
617
+ } catch (err) {
618
+ writeFileSync(
619
+ resolve(args.outDir, `${op.name}.json`),
620
+ JSON.stringify(
621
+ {
622
+ operationName: op.name,
623
+ source: op.source,
624
+ variables,
625
+ response: null,
626
+ capturedFrom: 'offline',
627
+ capturedAt: new Date().toISOString(),
628
+ warning: `execution failed: ${(err as Error).message}`,
629
+ } satisfies CapturedContract,
630
+ null,
631
+ 2,
632
+ ) + '\n',
633
+ );
634
+ drift++;
635
+ continue;
636
+ }
637
+
638
+ writeFileSync(
639
+ resolve(args.outDir, `${op.name}.json`),
640
+ JSON.stringify(
641
+ {
642
+ operationName: op.name,
643
+ source: op.source,
644
+ variables,
645
+ response,
646
+ capturedFrom: 'offline',
647
+ capturedAt: new Date().toISOString(),
648
+ } satisfies CapturedContract,
649
+ null,
650
+ 2,
651
+ ) + '\n',
652
+ );
653
+ captured++;
654
+ }
655
+
656
+ if (!args.quiet) {
657
+ console.log(
658
+ `[capture-contracts] ${captured} captured, ${skipped} skipped (need fixtures), ${drift} drift. Output: ${relative(args.cwd, args.outDir)}/`,
659
+ );
660
+ }
661
+ }
662
+
663
+ main().catch((err) => {
664
+ console.error(err);
665
+ process.exit(2);
666
+ });
667
+
668
+ // Silence the unused-typeinfo warning — the imports are used by the
669
+ // validate / TypeInfo path the script extends in follow-ups.
670
+ void TypeInfo;
671
+ void visit;
672
+ void visitWithTypeInfo;
673
+ void dirname;
674
+ void basename;
675
+ type _UseT = GraphQLType;