@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,168 @@
1
+ /**
2
+ * Upload an app-source zip to Shopify's signed-URL bucket, so we can
3
+ * reference it via `AppVersionInput.sourceUrl` in `appVersionCreate`.
4
+ *
5
+ * Why we need this
6
+ * ----------------
7
+ * Theme app extensions can be deployed via `appVersionCreate(version:
8
+ * { source: <manifest with files inline as base64> })` — the file
9
+ * contents fit comfortably in the GraphQL request body. Function
10
+ * (WASM) and post-purchase (JS bundle) extensions, however, are
11
+ * uploaded out-of-band:
12
+ *
13
+ * 1. Call `appRequestSourceUploadUrl(sourceExtension: ZIP, …)` —
14
+ * Shopify returns a 1-hour GCS v4-signed PUT URL.
15
+ * 2. PUT the zip bytes to that URL.
16
+ * 3. Call `appVersionCreate(version: { sourceUrl: <same URL> })`.
17
+ *
18
+ * The CLI does the same — see
19
+ * `Shopify/cli@packages/app/src/cli/services/bundle.ts` (`getUploadURL`
20
+ * + `uploadToGCS`). We confirmed empirically that:
21
+ * - The mutation accepts `sourceExtension: ZIP | BR`
22
+ * (we only emit ZIP).
23
+ * - The returned `sourceUploadUrl` is a GCS v4 presigned URL with
24
+ * `X-Goog-Algorithm=GOOG4-RSA-SHA256` and `X-Goog-Expires=3600`.
25
+ * - `appVersionCreate` validates the URL against an allowlist —
26
+ * URLs from other hosts are rejected with HTTP 500
27
+ * `"URL path is not valid"`. So you can ONLY use a sourceUrl
28
+ * minted by `appRequestSourceUploadUrl`.
29
+ *
30
+ * Quirks
31
+ * ------
32
+ * - The CLI ostensibly builds a multipart form to derive the upload
33
+ * headers, then sends the raw buffer as body. Empirically, a plain
34
+ * PUT with `Content-Type: application/zip` works against the GCS
35
+ * v4 presigned URL — that's what we do.
36
+ * - There's no MAX_SIZE error message in the API response, but the
37
+ * CLI hard-codes a 100 MB cap. Our zips are far below that.
38
+ */
39
+ import { createReadStream, statSync } from 'node:fs';
40
+ import { Readable } from 'node:stream';
41
+ import { appManagementGraphQL } from '@essential-apps/shopify-test-core';
42
+
43
+ /**
44
+ * GraphQL mutation to mint a signed upload URL.
45
+ *
46
+ * Defaults to `sourceExtension: BR` (tar+brotli archive) — that's the
47
+ * format that actually works for full-fidelity deploys including
48
+ * functions. The legacy `ZIP` option exists in the schema and returns
49
+ * a working signed URL, but the server 500s on zips containing per-
50
+ * extension folder layouts. See lib/buildSourceBundle.ts.
51
+ *
52
+ * NOTE on schema discovery: schema introspection against the
53
+ * `/unstable/` endpoint is rejected with `404 "Cannot find a valid
54
+ * organization"` for tokens minted via App-automation-token exchange
55
+ * — only targeted queries scoped to an app/org work. Mutation name +
56
+ * arg shape were confirmed empirically.
57
+ */
58
+ const REQUEST_SOURCE_UPLOAD_URL_MUTATION = `
59
+ mutation RequestSourceUploadUrl(
60
+ $organizationId: ID!
61
+ $sourceExtension: SourceExtension!
62
+ ) {
63
+ appRequestSourceUploadUrl(
64
+ sourceExtension: $sourceExtension
65
+ organizationId: $organizationId
66
+ ) {
67
+ sourceUploadUrl
68
+ userErrors { field message }
69
+ }
70
+ }
71
+ `;
72
+
73
+ export interface RequestSourceUploadUrlOptions {
74
+ /** Bearer token from `exchangeAutomationToken`. */
75
+ accessToken: string;
76
+ /** Numeric Partner-org ID. Required — the endpoint won't infer it
77
+ * from the access token, even though it's already scoped to one
78
+ * org. (Discovered empirically: omitting it yields a generic 404.) */
79
+ organizationId: string;
80
+ /**
81
+ * Archive format. Defaults to 'BR' (tar+brotli) which the
82
+ * App Management server fully supports including function
83
+ * extensions. 'ZIP' is accepted for legacy callers but the
84
+ * server's function-deploy path 500s on zip-with-folders.
85
+ */
86
+ sourceExtension?: 'BR' | 'ZIP';
87
+ }
88
+
89
+ /**
90
+ * Mint a fresh signed upload URL. The URL is valid for 3600 seconds;
91
+ * upload + version-create should both happen well within that window.
92
+ *
93
+ * Returns the GCS URL verbatim — you pass the SAME URL to
94
+ * `appVersionCreate` as `sourceUrl`. Shopify maintains the mapping
95
+ * server-side between the signed URL and the uploaded object.
96
+ */
97
+ export async function requestSourceUploadUrl(
98
+ opts: RequestSourceUploadUrlOptions,
99
+ ): Promise<string> {
100
+ const data = await appManagementGraphQL<{
101
+ appRequestSourceUploadUrl: {
102
+ sourceUploadUrl?: string;
103
+ userErrors: Array<{ field?: string[]; message: string }>;
104
+ };
105
+ }>(opts.accessToken, REQUEST_SOURCE_UPLOAD_URL_MUTATION, {
106
+ organizationId: `gid://shopify/Organization/${opts.organizationId}`,
107
+ sourceExtension: opts.sourceExtension ?? 'BR',
108
+ });
109
+ const errs = data.appRequestSourceUploadUrl.userErrors;
110
+ if (errs.length > 0) {
111
+ throw new Error(
112
+ 'appRequestSourceUploadUrl userErrors:\n' +
113
+ errs.map((e) => ` • ${(e.field ?? []).join('.')}: ${e.message}`).join('\n'),
114
+ );
115
+ }
116
+ const url = data.appRequestSourceUploadUrl.sourceUploadUrl;
117
+ if (!url) {
118
+ throw new Error('appRequestSourceUploadUrl returned no sourceUploadUrl');
119
+ }
120
+ return url;
121
+ }
122
+
123
+ export interface UploadSourceOptions {
124
+ uploadUrl: string;
125
+ /** Path on disk to the archive file (typically a `.tar.br` from
126
+ * `buildSourceBundle`, or a legacy `.zip`). */
127
+ archivePath: string;
128
+ }
129
+
130
+ /**
131
+ * PUT an archive file to the signed GCS URL minted by
132
+ * `requestSourceUploadUrl`. The URL embeds its own auth (signed
133
+ * `X-Goog-*` query params), so no bearer is needed on this request.
134
+ *
135
+ * Headers: we send only `Content-Length`. The CLI doesn't set a
136
+ * Content-Type for these uploads (it builds a multipart form to
137
+ * derive headers but sends the raw buffer as body, and GCS ignores
138
+ * the content-type anyway because the signed URL pins everything
139
+ * server-side). We match that for parity.
140
+ *
141
+ * Streams the file so a multi-MB archive doesn't sit in memory.
142
+ */
143
+ export async function uploadSourceToSignedUrl(
144
+ opts: UploadSourceOptions,
145
+ ): Promise<void> {
146
+ const size = statSync(opts.archivePath).size;
147
+ const stream = createReadStream(opts.archivePath);
148
+ // Convert Node Readable -> Web ReadableStream for the global
149
+ // `fetch` API. Node 18+ exposes `Readable.toWeb`.
150
+ const body = Readable.toWeb(stream) as unknown as ReadableStream<Uint8Array>;
151
+ const r = await fetch(opts.uploadUrl, {
152
+ method: 'PUT',
153
+ body,
154
+ headers: { 'Content-Length': String(size) },
155
+ // Node's fetch needs duplex when streaming a body.
156
+ // @ts-expect-error — `duplex` is a recent option not yet in some
157
+ // @types/node releases.
158
+ duplex: 'half',
159
+ });
160
+ if (!r.ok) {
161
+ const text = await r.text().catch(() => '<no body>');
162
+ throw new Error(
163
+ `Source upload to signed URL failed: HTTP ${r.status} ${r.statusText}\n` +
164
+ ` ${text.slice(0, 400)}`,
165
+ );
166
+ }
167
+ }
168
+
@@ -0,0 +1,57 @@
1
+ import { createRequire } from 'node:module';
2
+ import type { Browser } from '@playwright/test';
3
+ import { assertInVm } from '@essential-apps/shopify-test-core';
4
+
5
+ // patchright is published CJS-only; this file is ESM (tsx/Node).
6
+ const require = createRequire(import.meta.url);
7
+
8
+ /**
9
+ * Canonical Cloudflare / Private-Network-Access bypass flags.
10
+ *
11
+ * Kept byte-identical to the admin `storePool` fixture's launch args:
12
+ * cf_clearance binds to the (engine, UA, flags) fingerprint, so any
13
+ * browser that navigates the real Shopify admin — installApp, the
14
+ * globalSetup auth pre-flight — MUST launch the same way the online
15
+ * tests do, or a perfectly good cf_clearance trips Turnstile.
16
+ */
17
+ export const stealthLaunchArgs = [
18
+ '--ip-address-space-overrides=127.0.0.1:0=public,[::1]:0=public',
19
+ '--disable-features=LocalNetworkAccessChecks,LocalNetworkAccessChecksWebSockets,LocalNetworkAccessChecksWebTransport,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessRespectPreflightResults,PrivateNetworkAccessSendPreflights',
20
+ '--local-network-access-permissions-policy-default-enabled',
21
+ ];
22
+
23
+ /**
24
+ * Launch patchright Chromium — the ONE stealth engine used across the
25
+ * whole suite (online `storePool` fixture, `captureAuth`, every
26
+ * conformance probe). patchright patches the `Runtime.enable` CDP leak
27
+ * Cloudflare's bot detection keys on, so its bundled Chromium passes
28
+ * Turnstile headless — no StealthPlugin, no real-Chrome channel.
29
+ *
30
+ * This replaces the old playwright-extra + puppeteer-extra-plugin-stealth
31
+ * launcher (real Chrome via `channel: 'chrome'`), which was the lone
32
+ * outlier engine. Standalone scripts (installApp) and the globalSetup
33
+ * auth pre-flight call this; @playwright/test-managed tests get their
34
+ * browser from the `storePool` fixture, which launches patchright the
35
+ * same way.
36
+ */
37
+ export async function launchStealthBrowser(opts: {
38
+ headless: boolean;
39
+ /**
40
+ * Park the window far offscreen (headed-but-hidden on host) — mirrors
41
+ * storePool's behaviour for headless:false-on-host launches. Ignored
42
+ * under Xvfb in-container (no real display there).
43
+ */
44
+ hideWindow?: boolean;
45
+ }): Promise<Browser> {
46
+ assertInVm('launch a stealth browser');
47
+ const { chromium } = require('patchright') as typeof import('@playwright/test');
48
+ return (await chromium.launch({
49
+ headless: opts.headless,
50
+ args: [
51
+ ...stealthLaunchArgs,
52
+ ...(opts.hideWindow
53
+ ? ['--window-position=-3000,-3000', '--window-size=1400,900']
54
+ : []),
55
+ ],
56
+ })) as unknown as Browser;
57
+ }
@@ -0,0 +1,110 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { type BrowserContext, type Page } from '@playwright/test';
3
+ import { storageStatePath } from '@essential-apps/shopify-test-core';
4
+ import { launchStealthBrowser } from './stealthLaunch.js';
5
+
6
+ export async function launchContext(opts: {
7
+ headless: boolean;
8
+ useStorageState: boolean;
9
+ }): Promise<BrowserContext> {
10
+ if (opts.useStorageState && !existsSync(storageStatePath)) {
11
+ throw new Error(
12
+ `Auth state not found at ${storageStatePath}.\n` + `Run: npm run test:online:auth`,
13
+ );
14
+ }
15
+ // patchright (bundled Chromium) via the shared launcher — the one stealth
16
+ // engine, and the only one that works in the arm64 VM (no real Chrome
17
+ // there). assertInVm() inside it refuses a host launch.
18
+ const browser = await launchStealthBrowser({ headless: opts.headless });
19
+ const context = await browser.newContext({
20
+ ...(opts.useStorageState ? { storageState: storageStatePath } : {}),
21
+ viewport: { width: 1400, height: 900 },
22
+ });
23
+ await context.addInitScript(() => {
24
+ Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
25
+ });
26
+ return context;
27
+ }
28
+
29
+ export function generateStoreName(workerIndex: number): string {
30
+ return `online-w${workerIndex}-${Date.now()}`;
31
+ }
32
+
33
+ const PLAN_LABEL: Record<string, string> = {
34
+ BASIC_APP_DEVELOPMENT: 'Basic',
35
+ PROFESSIONAL_APP_DEVELOPMENT: 'Professional',
36
+ UNLIMITED_APP_DEVELOPMENT: 'Unlimited',
37
+ SHOPIFY_PLUS_APP_DEVELOPMENT: 'Shopify Plus',
38
+ };
39
+
40
+ /**
41
+ * Mirrors Shopify CLI's online/setup/store.ts createDevStore flow.
42
+ * Selectors target stable s-internal-* shadow-DOM hooks that survive
43
+ * Polaris churn.
44
+ */
45
+ export async function createDevStore(
46
+ page: Page,
47
+ opts: { orgId: string; storeName: string; plan: string },
48
+ ): Promise<{ shop: string; slug: string }> {
49
+ const url = `https://admin.shopify.com/store-create/organization/${opts.orgId}`;
50
+ await page.goto(url, { waitUntil: 'domcontentloaded' });
51
+
52
+ const nameField = page.locator('s-internal-text-field[label="Store name"]').first();
53
+ await nameField.waitFor({ state: 'visible', timeout: 30_000 });
54
+ await nameField.locator('input').fill(opts.storeName);
55
+
56
+ const planSelect = page.locator('s-internal-select[label="Shopify plan"]').first();
57
+ await planSelect.click();
58
+ const planLabel = PLAN_LABEL[opts.plan];
59
+ if (!planLabel) throw new Error(`Unknown plan: ${opts.plan}`);
60
+ await page.getByRole('option', { name: new RegExp(planLabel, 'i') }).first().click();
61
+
62
+ await page.locator('s-internal-button[variant="primary"]').first().click();
63
+
64
+ await page.waitForURL(/admin\.shopify\.com\/store\/[^/]+/, { timeout: 90_000 });
65
+ const m = page.url().match(/admin\.shopify\.com\/store\/([^/?#]+)/);
66
+ if (!m) throw new Error(`Unexpected post-create URL: ${page.url()}`);
67
+ const slug = m[1]!;
68
+ return { slug, shop: `${slug}.myshopify.com` };
69
+ }
70
+
71
+ export async function uninstallApp(
72
+ page: Page,
73
+ slug: string,
74
+ appHandle: string,
75
+ ): Promise<boolean> {
76
+ await page.goto(`https://admin.shopify.com/store/${slug}/settings/apps`, {
77
+ waitUntil: 'domcontentloaded',
78
+ });
79
+
80
+ const row = page.getByRole('link', { name: new RegExp(appHandle, 'i') }).first();
81
+ if ((await row.count()) === 0) return false;
82
+
83
+ await row.click();
84
+ await page.waitForLoadState('domcontentloaded');
85
+
86
+ const uninstallBtn = page.getByRole('button', { name: /uninstall/i }).first();
87
+ if ((await uninstallBtn.count()) === 0) return false;
88
+ await uninstallBtn.click();
89
+
90
+ const confirmBtn = page.getByRole('button', { name: /uninstall app|confirm|delete/i }).last();
91
+ await confirmBtn.click();
92
+ await page.waitForLoadState('networkidle').catch(() => {});
93
+ return true;
94
+ }
95
+
96
+ export async function deleteStore(page: Page, slug: string): Promise<void> {
97
+ await page.goto(`https://admin.shopify.com/store/${slug}/settings/plan/cancel`, {
98
+ waitUntil: 'domcontentloaded',
99
+ });
100
+
101
+ const checkbox = page.getByRole('checkbox').first();
102
+ await checkbox.waitFor({ state: 'visible', timeout: 30_000 });
103
+ await checkbox.check();
104
+
105
+ await page.getByRole('button', { name: /delete store/i }).click();
106
+
107
+ await page
108
+ .waitForURL((u: URL) => !u.toString().includes(slug), { timeout: 60_000 })
109
+ .catch(() => {});
110
+ }
@@ -0,0 +1,120 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { defineConfig, type PlaywrightTestConfig } from '@playwright/test';
4
+ import { storageStatePath } from '@essential-apps/shopify-test-core';
5
+
6
+ /**
7
+ * Resolve the absolute path to our globalSetup file. Playwright wants
8
+ * a path string for `globalSetup`; from the consuming app's repo this
9
+ * is somewhere inside `node_modules/@essential-apps/shopify-test-runner/dist`.
10
+ *
11
+ * `.js` (not `.ts`) because this file ships to consumers as built JS.
12
+ */
13
+ const globalSetupPath = fileURLToPath(new URL('./globalSetup.js', import.meta.url));
14
+
15
+ export interface DefinePlaywrightConfigOptions {
16
+ /**
17
+ * Absolute path to the directory containing the app's `*.spec.ts`
18
+ * files. Conventionally `tests/test-online/` or `tests/test-offline/`.
19
+ */
20
+ testDir: string;
21
+ /**
22
+ * Optional: override the Playwright test glob.
23
+ * Defaults to `**​/*.spec.ts`.
24
+ */
25
+ testMatch?: string | string[];
26
+ /**
27
+ * Optional: globs to exclude. Defaults to excluding `*offline*.spec.ts`
28
+ * in online mode (those run via a sibling
29
+ * `playwright.offline-full.config.ts`), and to `[]` in offline mode
30
+ * (where testMatch already filters in only the offline specs).
31
+ */
32
+ testIgnore?: string | string[];
33
+ /** Override default workers (default: env TEST_WORKERS or 1). */
34
+ workers?: number;
35
+ /** Override default retries (default: env TEST_RETRIES or 1). */
36
+ retries?: number;
37
+ /** Per-test timeout in ms. Default 90s. */
38
+ timeout?: number;
39
+ /** Per-expect timeout in ms. Default 15s. */
40
+ expectTimeout?: number;
41
+ /**
42
+ * Extra options that will be merged into the returned config —
43
+ * useful for app-specific reporter tweaks, projects, etc.
44
+ */
45
+ override?: PlaywrightTestConfig;
46
+ /**
47
+ * Offline-only mode: skip every assertion that exists for online
48
+ * cloud runs (Shopify Partner storageState, store registry,
49
+ * NODE_ENV=test, local Postgres). Use this for specs that talk
50
+ * exclusively to the offline storefront mock — they don't touch
51
+ * Cloudflare, Shopify auth, or the app backend, so the online
52
+ * preconditions don't apply.
53
+ *
54
+ * Typical setup: dedicated `playwright.config.offline.ts` in the
55
+ * consuming app, sibling to the online `playwright.config.ts`.
56
+ */
57
+ offline?: boolean;
58
+ }
59
+
60
+ /**
61
+ * Playwright config preset for Essential Apps' Shopify test suites.
62
+ *
63
+ * The consuming app's `tests/test-online/playwright.config.ts` should be a
64
+ * thin wrapper:
65
+ *
66
+ * ```ts
67
+ * import { definePlaywrightConfig } from '@essential-apps/shopify-test-runner/playwright';
68
+ * import { fileURLToPath } from 'node:url';
69
+ *
70
+ * export default definePlaywrightConfig({
71
+ * testDir: fileURLToPath(new URL('.', import.meta.url)),
72
+ * });
73
+ * ```
74
+ */
75
+ export function definePlaywrightConfig(
76
+ opts: DefinePlaywrightConfigOptions,
77
+ ): PlaywrightTestConfig {
78
+ // Online-mode prerequisites. Offline mode skips the storageState
79
+ // requirement entirely (offline tests never touch Shopify auth).
80
+ if (!opts.offline && !existsSync(storageStatePath)) {
81
+ throw new Error(
82
+ `Auth state not found at ${storageStatePath}. Run: npm run test:online:auth`,
83
+ );
84
+ }
85
+
86
+ // In online mode, exclude *offline*.spec.ts by default — those need
87
+ // the offline runner's distinct fixtures and would either be wasted
88
+ // (no backend needed) or fail under the online globalSetup. Offline
89
+ // mode doesn't get a default ignore since its testMatch already
90
+ // narrows in.
91
+ const defaultTestIgnore = opts.offline ? [] : ['**/*offline*.spec.ts'];
92
+ const base: PlaywrightTestConfig = {
93
+ testDir: opts.testDir,
94
+ testMatch: opts.testMatch ?? ['**/*.spec.ts'],
95
+ testIgnore: opts.testIgnore ?? defaultTestIgnore,
96
+ // No globalSetup in offline mode — its checks (NODE_ENV=test,
97
+ // local Postgres, store registry with installed apps) are all
98
+ // online-only invariants.
99
+ ...(opts.offline ? {} : { globalSetup: globalSetupPath }),
100
+ timeout: opts.timeout ?? 90_000,
101
+ expect: { timeout: opts.expectTimeout ?? 15_000 },
102
+ fullyParallel: false,
103
+ // Cloudflare flags concurrent contexts from one IP. Bump TEST_WORKERS=2
104
+ // only after the cf_clearance cookie is established in each worker's
105
+ // persistent profile (run once with TEST_WORKERS=N to seed profiles).
106
+ workers: opts.workers ?? Number(process.env['TEST_WORKERS'] ?? 1),
107
+ // CF's bot challenge can fire on profile creation; one retry is enough.
108
+ retries: opts.retries ?? Number(process.env['TEST_RETRIES'] ?? 1),
109
+ reporter: [['list'], ['html', { open: 'never' }]],
110
+ use: {
111
+ trace: 'retain-on-failure',
112
+ screenshot: 'only-on-failure',
113
+ },
114
+ // Browser launch is handled by the per-worker `persistentContext`
115
+ // fixture in @essential-apps/shopify-test-admin (uses
116
+ // launchPersistentContext so cf_clearance + auth cookies persist).
117
+ };
118
+
119
+ return defineConfig({ ...base, ...opts.override });
120
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Playwright global setup. Runs ONCE before the suite.
3
+ *
4
+ * Hard guards against running tests against non-isolated databases.
5
+ * The runTests.ts orchestrator sets NODE_ENV=test and DATABASE_URL
6
+ * pointing at a fresh UUID-named local Postgres DB before invoking
7
+ * Playwright. We verify both before any test runs.
8
+ *
9
+ * Consuming apps reference this from their `playwright.config.ts` via
10
+ * `definePlaywrightConfig` (which sets `globalSetup` to point at this
11
+ * file's path).
12
+ */
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { registryPath, storageStatePath } from '@essential-apps/shopify-test-core';
15
+ import { launchStealthBrowser } from '../lib/stealthLaunch.js';
16
+
17
+ interface RegistryShape {
18
+ stores: Array<Record<string, unknown>>;
19
+ }
20
+
21
+ export default async function globalSetup(): Promise<void> {
22
+ if (!existsSync(registryPath)) {
23
+ throw new Error(
24
+ `${registryPath} missing. Run \`npm run test:online:add\` and \`npm run test:online:install\`.`,
25
+ );
26
+ }
27
+ const pool = JSON.parse(readFileSync(registryPath, 'utf8')) as RegistryShape;
28
+ const installed = pool.stores.filter((s) => s['appInstalled'] === true);
29
+ if (installed.length === 0) {
30
+ throw new Error(
31
+ `No stores in pool have appInstalled: true. Run \`npm run test:online:install\`.`,
32
+ );
33
+ }
34
+
35
+ const dbUrl = process.env['DATABASE_URL'] ?? '';
36
+ const nodeEnv = process.env['NODE_ENV'] ?? '';
37
+
38
+ if (nodeEnv !== 'test') {
39
+ throw new Error(
40
+ `NODE_ENV must be "test" for tests. Got "${nodeEnv}". ` +
41
+ `Run via \`npm run test:online\` (uses shopify-test-run-tests), not the bare ` +
42
+ `Playwright entrypoint.`,
43
+ );
44
+ }
45
+
46
+ if (dbUrl && (dbUrl.includes('neon.tech') || dbUrl.includes('aws.neon'))) {
47
+ throw new Error(
48
+ `DATABASE_URL points at Neon (${dbUrl.replace(/:[^:@]+@/, ':***@')}). ` +
49
+ `Tests must use a local Postgres DB for isolation. Run via \`npm run test:online\`.`,
50
+ );
51
+ }
52
+
53
+ if (!dbUrl.includes('localhost') && !dbUrl.includes('127.0.0.1')) {
54
+ throw new Error(
55
+ `DATABASE_URL must be localhost for test isolation. Got: ${dbUrl.replace(
56
+ /:[^:@]+@/,
57
+ ':***@',
58
+ )}`,
59
+ );
60
+ }
61
+
62
+ console.log(`[test] ${installed.length} installed store(s) in pool.`);
63
+ console.log(`[test] Test DB: ${dbUrl}`);
64
+ console.log(`[test] NODE_ENV: ${nodeEnv}`);
65
+
66
+ // ── Auth validity pre-flight ──────────────────────────────
67
+ // The checks above prove the storageState FILE exists and the registry
68
+ // is populated — but NOT that the captured Shopify session is still
69
+ // VALID. A stale session (or a rotated cf_clearance) makes every online
70
+ // test dead-end identically: openAppFrame navigates to admin.shopify.com,
71
+ // Shopify bounces to login (or Cloudflare shows Turnstile), the embedded
72
+ // app iframe never appears, and the test times out after 30s. With N
73
+ // tests that's N identical, cryptic "waiting for iframe[src*=localhost]"
74
+ // failures with no hint that the real cause is auth.
75
+ //
76
+ // So navigate ONCE here, exactly as the tests do, and abort the whole run
77
+ // with a clear, actionable message if the session is dead. A throw in
78
+ // globalSetup stops everything before any test starts — one message
79
+ // instead of N timeouts. Opt out with TEST_ONLINE_SKIP_AUTH_PREFLIGHT=true.
80
+ if ((process.env['TEST_ONLINE_SKIP_AUTH_PREFLIGHT'] ?? '').toLowerCase() !== 'true') {
81
+ const slug = (installed[0]?.['slug'] as string | undefined) ?? '';
82
+ if (slug) {
83
+ console.log('[test] auth pre-flight: verifying the captured session is still valid…');
84
+ await assertAuthValid(slug);
85
+ console.log('[test] auth pre-flight: session OK.');
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Navigate to an installed store's embedded-app admin URL with the
92
+ * captured storageState — the SAME patchright launch the online tests use
93
+ * (storePool) — and classify the outcome:
94
+ * - app iframe appears, or we reach the signed-in account picker → OK
95
+ * - redirected to the Shopify login page → session expired
96
+ * - Cloudflare Turnstile wall → cf_clearance stale
97
+ * Throws a clear "re-capture auth" message for the two failure modes so the
98
+ * whole run aborts with one diagnosis instead of N iframe-timeout failures.
99
+ */
100
+ async function assertAuthValid(slug: string): Promise<void> {
101
+ const inContainer = (process.env['TEST_IN_CONTAINER'] ?? '').toLowerCase() === 'true';
102
+ const visible = (process.env['TEST_VISIBLE'] ?? '').toLowerCase() === 'true';
103
+ const headless = inContainer ? false : !visible;
104
+ const appHandle = process.env['SHOPIFY_APP_HANDLE'] ?? 'essential-app';
105
+ const expectedBackend = new URL(
106
+ process.env['SHOPIFY_APP_URL'] ?? 'https://localhost:8181',
107
+ ).hostname;
108
+ const url = `https://admin.shopify.com/store/${slug}/apps/${appHandle}`;
109
+
110
+ const fix =
111
+ `Re-run interactive auth capture (\`npm run test:online:auth\`): a browser opens — ` +
112
+ `sign in and pass Turnstile once, and the fresh session + cf_clearance are saved ` +
113
+ `back into storageState.json. Then re-run \`npm run test:online\`. ` +
114
+ `(Set TEST_ONLINE_SKIP_AUTH_PREFLIGHT=true to skip this check.)`;
115
+
116
+ const browser = await launchStealthBrowser({
117
+ headless,
118
+ hideWindow: !visible && !inContainer,
119
+ });
120
+ try {
121
+ const context = await browser.newContext({
122
+ storageState: storageStatePath,
123
+ viewport: { width: 1400, height: 900 },
124
+ ignoreHTTPSErrors: true,
125
+ });
126
+ const page = await context.newPage();
127
+ await page.goto(url, { waitUntil: 'domcontentloaded' }).catch(() => {});
128
+
129
+ const deadline = Date.now() + 30_000;
130
+ while (Date.now() < deadline) {
131
+ const u = page.url();
132
+ // Cloudflare Turnstile wall (same probe text openApp uses).
133
+ if (
134
+ await page
135
+ .getByText('Your connection needs to be verified')
136
+ .isVisible()
137
+ .catch(() => false)
138
+ ) {
139
+ throw new Error(
140
+ `\n\n❌ Online auth pre-flight: Cloudflare Turnstile is blocking ${url}.\n` +
141
+ ` Your captured cf_clearance is missing or expired.\n ${fix}\n`,
142
+ );
143
+ }
144
+ // Stale session → bounced to the Shopify login / account lookup.
145
+ // (accounts.shopify.com/select is the SIGNED-IN account picker — OK.)
146
+ if (
147
+ /accounts\.shopify\.com\/(lookup|signin|login|store_login|authentication)/.test(u) ||
148
+ /identity\.myshopify\.com/.test(u)
149
+ ) {
150
+ throw new Error(
151
+ `\n\n❌ Online auth pre-flight: redirected to the Shopify login page (${u}).\n` +
152
+ ` Your captured session has expired.\n ${fix}\n`,
153
+ );
154
+ }
155
+ // Signed-in account picker — valid session.
156
+ if (u.includes('accounts.shopify.com/select')) return;
157
+ // Embedded app iframe present — signed in AND app reachable.
158
+ if ((await page.locator(`iframe[src*="${expectedBackend}"]`).count()) > 0) return;
159
+ await page.waitForTimeout(500);
160
+ }
161
+
162
+ // 30s without a definitive verdict. If parked on a login page, it's
163
+ // stale; otherwise it's likely a backend/app problem (NOT auth) — warn
164
+ // and let the per-test diagnostics speak rather than block on a guess.
165
+ const finalUrl = page.url();
166
+ if (/accounts\.shopify\.com/.test(finalUrl) && !finalUrl.includes('/select')) {
167
+ throw new Error(
168
+ `\n\n❌ Online auth pre-flight: stuck on the Shopify login page (${finalUrl}).\n ${fix}\n`,
169
+ );
170
+ }
171
+ console.warn(
172
+ `[test] auth pre-flight inconclusive (last URL: ${finalUrl}). The session looks ` +
173
+ `valid (no login redirect / Turnstile) but the app iframe didn't appear within ` +
174
+ `30s — if tests fail on iframe load, check the app backend, not auth.`,
175
+ );
176
+ } finally {
177
+ await browser.close().catch(() => {});
178
+ }
179
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Public Playwright preset for @essential-apps/shopify-test-runner.
3
+ * Imported by the consuming app's `playwright.config.ts`:
4
+ *
5
+ * import { definePlaywrightConfig } from
6
+ * '@essential-apps/shopify-test-runner/playwright';
7
+ */
8
+ export {
9
+ definePlaywrightConfig,
10
+ type DefinePlaywrightConfigOptions,
11
+ } from './baseConfig.js';