@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.
- package/dist/contracts/normalize.d.ts +61 -0
- package/dist/contracts/normalize.d.ts.map +1 -0
- package/dist/contracts/normalize.js +99 -0
- package/dist/contracts/normalize.js.map +1 -0
- package/dist/contracts/normalizeHtml.d.ts +37 -0
- package/dist/contracts/normalizeHtml.d.ts.map +1 -0
- package/dist/contracts/normalizeHtml.js +89 -0
- package/dist/contracts/normalizeHtml.js.map +1 -0
- package/dist/edge/cert.d.ts +44 -0
- package/dist/edge/cert.d.ts.map +1 -0
- package/dist/edge/cert.js +117 -0
- package/dist/edge/cert.js.map +1 -0
- package/dist/edge/edgeProxy.d.ts +43 -0
- package/dist/edge/edgeProxy.d.ts.map +1 -0
- package/dist/edge/edgeProxy.js +297 -0
- package/dist/edge/edgeProxy.js.map +1 -0
- package/dist/edge/nodeShim.d.ts +2 -0
- package/dist/edge/nodeShim.d.ts.map +1 -0
- package/dist/edge/nodeShim.js +217 -0
- package/dist/edge/nodeShim.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/buildSourceBundle.d.ts +56 -0
- package/dist/lib/buildSourceBundle.d.ts.map +1 -0
- package/dist/lib/buildSourceBundle.js +153 -0
- package/dist/lib/buildSourceBundle.js.map +1 -0
- package/dist/lib/freePort.d.ts +5 -0
- package/dist/lib/freePort.d.ts.map +1 -0
- package/dist/lib/freePort.js +33 -0
- package/dist/lib/freePort.js.map +1 -0
- package/dist/lib/functionBuild.d.ts +99 -0
- package/dist/lib/functionBuild.d.ts.map +1 -0
- package/dist/lib/functionBuild.js +413 -0
- package/dist/lib/functionBuild.js.map +1 -0
- package/dist/lib/neonWsProxy.d.ts +41 -0
- package/dist/lib/neonWsProxy.d.ts.map +1 -0
- package/dist/lib/neonWsProxy.js +101 -0
- package/dist/lib/neonWsProxy.js.map +1 -0
- package/dist/lib/sourceZipUpload.d.ts +45 -0
- package/dist/lib/sourceZipUpload.d.ts.map +1 -0
- package/dist/lib/sourceZipUpload.js +129 -0
- package/dist/lib/sourceZipUpload.js.map +1 -0
- package/dist/lib/stealthLaunch.d.ts +35 -0
- package/dist/lib/stealthLaunch.d.ts.map +1 -0
- package/dist/lib/stealthLaunch.js +46 -0
- package/dist/lib/stealthLaunch.js.map +1 -0
- package/dist/lib/storeAutomation.d.ts +22 -0
- package/dist/lib/storeAutomation.d.ts.map +1 -0
- package/dist/lib/storeAutomation.js +85 -0
- package/dist/lib/storeAutomation.js.map +1 -0
- package/dist/playwright/baseConfig.d.ts +62 -0
- package/dist/playwright/baseConfig.d.ts.map +1 -0
- package/dist/playwright/baseConfig.js +68 -0
- package/dist/playwright/baseConfig.js.map +1 -0
- package/dist/playwright/globalSetup.d.ts +2 -0
- package/dist/playwright/globalSetup.d.ts.map +1 -0
- package/dist/playwright/globalSetup.js +139 -0
- package/dist/playwright/globalSetup.js.map +1 -0
- package/dist/playwright/index.d.ts +9 -0
- package/dist/playwright/index.d.ts.map +1 -0
- package/dist/playwright/index.js +9 -0
- package/dist/playwright/index.js.map +1 -0
- package/dist/probes/fonts.d.ts +4 -0
- package/dist/probes/fonts.d.ts.map +1 -0
- package/dist/probes/fonts.js +255 -0
- package/dist/probes/fonts.js.map +1 -0
- package/dist/probes/mirror.d.ts +4 -0
- package/dist/probes/mirror.d.ts.map +1 -0
- package/dist/probes/mirror.js +260 -0
- package/dist/probes/mirror.js.map +1 -0
- package/dist/probes/runProbe.d.ts +3 -0
- package/dist/probes/runProbe.d.ts.map +1 -0
- package/dist/probes/runProbe.js +219 -0
- package/dist/probes/runProbe.js.map +1 -0
- package/dist/probes/types.d.ts +72 -0
- package/dist/probes/types.d.ts.map +1 -0
- package/dist/probes/types.js +2 -0
- package/dist/probes/types.js.map +1 -0
- package/dist/scripts/_probeSourceUrl.d.ts +3 -0
- package/dist/scripts/_probeSourceUrl.d.ts.map +1 -0
- package/dist/scripts/_probeSourceUrl.js +119 -0
- package/dist/scripts/_probeSourceUrl.js.map +1 -0
- package/dist/scripts/addStore.d.ts +3 -0
- package/dist/scripts/addStore.d.ts.map +1 -0
- package/dist/scripts/addStore.js +46 -0
- package/dist/scripts/addStore.js.map +1 -0
- package/dist/scripts/buildDockerImage.d.ts +3 -0
- package/dist/scripts/buildDockerImage.d.ts.map +1 -0
- package/dist/scripts/buildDockerImage.js +60 -0
- package/dist/scripts/buildDockerImage.js.map +1 -0
- package/dist/scripts/captureAuth.d.ts +3 -0
- package/dist/scripts/captureAuth.d.ts.map +1 -0
- package/dist/scripts/captureAuth.js +124 -0
- package/dist/scripts/captureAuth.js.map +1 -0
- package/dist/scripts/captureContracts.d.ts +3 -0
- package/dist/scripts/captureContracts.d.ts.map +1 -0
- package/dist/scripts/captureContracts.js +517 -0
- package/dist/scripts/captureContracts.js.map +1 -0
- package/dist/scripts/captureRestContracts.d.ts +3 -0
- package/dist/scripts/captureRestContracts.d.ts.map +1 -0
- package/dist/scripts/captureRestContracts.js +245 -0
- package/dist/scripts/captureRestContracts.js.map +1 -0
- package/dist/scripts/checkOperationCoverage.d.ts +3 -0
- package/dist/scripts/checkOperationCoverage.d.ts.map +1 -0
- package/dist/scripts/checkOperationCoverage.js +302 -0
- package/dist/scripts/checkOperationCoverage.js.map +1 -0
- package/dist/scripts/cleanupStores.d.ts +3 -0
- package/dist/scripts/cleanupStores.d.ts.map +1 -0
- package/dist/scripts/cleanupStores.js +77 -0
- package/dist/scripts/cleanupStores.js.map +1 -0
- package/dist/scripts/createStores.d.ts +3 -0
- package/dist/scripts/createStores.d.ts.map +1 -0
- package/dist/scripts/createStores.js +66 -0
- package/dist/scripts/createStores.js.map +1 -0
- package/dist/scripts/deployAppVersion.d.ts +3 -0
- package/dist/scripts/deployAppVersion.d.ts.map +1 -0
- package/dist/scripts/deployAppVersion.js +591 -0
- package/dist/scripts/deployAppVersion.js.map +1 -0
- package/dist/scripts/devE2eBackend.d.ts +3 -0
- package/dist/scripts/devE2eBackend.d.ts.map +1 -0
- package/dist/scripts/devE2eBackend.js +117 -0
- package/dist/scripts/devE2eBackend.js.map +1 -0
- package/dist/scripts/devOnlineBackend.d.ts +3 -0
- package/dist/scripts/devOnlineBackend.d.ts.map +1 -0
- package/dist/scripts/devOnlineBackend.js +117 -0
- package/dist/scripts/devOnlineBackend.js.map +1 -0
- package/dist/scripts/installApp.d.ts +3 -0
- package/dist/scripts/installApp.d.ts.map +1 -0
- package/dist/scripts/installApp.js +163 -0
- package/dist/scripts/installApp.js.map +1 -0
- package/dist/scripts/listStores.d.ts +3 -0
- package/dist/scripts/listStores.d.ts.map +1 -0
- package/dist/scripts/listStores.js +18 -0
- package/dist/scripts/listStores.js.map +1 -0
- package/dist/scripts/runDocker.d.ts +3 -0
- package/dist/scripts/runDocker.d.ts.map +1 -0
- package/dist/scripts/runDocker.js +88 -0
- package/dist/scripts/runDocker.js.map +1 -0
- package/dist/scripts/runDockerAuth.d.ts +3 -0
- package/dist/scripts/runDockerAuth.d.ts.map +1 -0
- package/dist/scripts/runDockerAuth.js +108 -0
- package/dist/scripts/runDockerAuth.js.map +1 -0
- package/dist/scripts/runDockerOffline.d.ts +3 -0
- package/dist/scripts/runDockerOffline.d.ts.map +1 -0
- package/dist/scripts/runDockerOffline.js +129 -0
- package/dist/scripts/runDockerOffline.js.map +1 -0
- package/dist/scripts/runDockerOfflineExplore.d.ts +3 -0
- package/dist/scripts/runDockerOfflineExplore.d.ts.map +1 -0
- package/dist/scripts/runDockerOfflineExplore.js +116 -0
- package/dist/scripts/runDockerOfflineExplore.js.map +1 -0
- package/dist/scripts/runIsolatedDockerOffline.d.ts +3 -0
- package/dist/scripts/runIsolatedDockerOffline.d.ts.map +1 -0
- package/dist/scripts/runIsolatedDockerOffline.js +351 -0
- package/dist/scripts/runIsolatedDockerOffline.js.map +1 -0
- package/dist/scripts/runOffline.d.ts +3 -0
- package/dist/scripts/runOffline.d.ts.map +1 -0
- package/dist/scripts/runOffline.js +521 -0
- package/dist/scripts/runOffline.js.map +1 -0
- package/dist/scripts/runOfflineE2e.d.ts +3 -0
- package/dist/scripts/runOfflineE2e.d.ts.map +1 -0
- package/dist/scripts/runOfflineE2e.js +408 -0
- package/dist/scripts/runOfflineE2e.js.map +1 -0
- package/dist/scripts/runOfflineFullTests.d.ts +3 -0
- package/dist/scripts/runOfflineFullTests.d.ts.map +1 -0
- package/dist/scripts/runOfflineFullTests.js +1456 -0
- package/dist/scripts/runOfflineFullTests.js.map +1 -0
- package/dist/scripts/runSupermachine.d.ts +3 -0
- package/dist/scripts/runSupermachine.d.ts.map +1 -0
- package/dist/scripts/runSupermachine.js +474 -0
- package/dist/scripts/runSupermachine.js.map +1 -0
- package/dist/scripts/runSupermachineAuth.d.ts +3 -0
- package/dist/scripts/runSupermachineAuth.d.ts.map +1 -0
- package/dist/scripts/runSupermachineAuth.js +454 -0
- package/dist/scripts/runSupermachineAuth.js.map +1 -0
- package/dist/scripts/runTests.d.ts +3 -0
- package/dist/scripts/runTests.d.ts.map +1 -0
- package/dist/scripts/runTests.js +278 -0
- package/dist/scripts/runTests.js.map +1 -0
- package/dist/scripts/runVm.d.ts +3 -0
- package/dist/scripts/runVm.d.ts.map +1 -0
- package/dist/scripts/runVm.js +524 -0
- package/dist/scripts/runVm.js.map +1 -0
- package/dist/scripts/runVmAuth.d.ts +3 -0
- package/dist/scripts/runVmAuth.d.ts.map +1 -0
- package/dist/scripts/runVmAuth.js +475 -0
- package/dist/scripts/runVmAuth.js.map +1 -0
- package/dist/scripts/runVmScript.d.ts +3 -0
- package/dist/scripts/runVmScript.d.ts.map +1 -0
- package/dist/scripts/runVmScript.js +242 -0
- package/dist/scripts/runVmScript.js.map +1 -0
- package/dist/scripts/setupTestDb.d.ts +3 -0
- package/dist/scripts/setupTestDb.d.ts.map +1 -0
- package/dist/scripts/setupTestDb.js +61 -0
- package/dist/scripts/setupTestDb.js.map +1 -0
- package/dist/scripts/verifyContracts.d.ts +3 -0
- package/dist/scripts/verifyContracts.d.ts.map +1 -0
- package/dist/scripts/verifyContracts.js +258 -0
- package/dist/scripts/verifyContracts.js.map +1 -0
- package/dist/scripts/verifyRestContracts.d.ts +3 -0
- package/dist/scripts/verifyRestContracts.d.ts.map +1 -0
- package/dist/scripts/verifyRestContracts.js +237 -0
- package/dist/scripts/verifyRestContracts.js.map +1 -0
- package/dist/vite/offlineConfig.d.ts +34 -0
- package/dist/vite/offlineConfig.d.ts.map +1 -0
- package/dist/vite/offlineConfig.js +61 -0
- package/dist/vite/offlineConfig.js.map +1 -0
- package/dist/vite/onlineConfig.d.ts +42 -0
- package/dist/vite/onlineConfig.d.ts.map +1 -0
- package/dist/vite/onlineConfig.js +56 -0
- package/dist/vite/onlineConfig.js.map +1 -0
- package/docker/Dockerfile +67 -0
- package/docker/Dockerfile.vm +137 -0
- package/docker/README.md +50 -0
- package/docker/entrypoint.sh +198 -0
- package/package.json +85 -0
- package/src/contracts/normalize.ts +96 -0
- package/src/contracts/normalizeHtml.ts +98 -0
- package/src/edge/ca.cnf +14 -0
- package/src/edge/ca.crt +22 -0
- package/src/edge/ca.key +28 -0
- package/src/edge/cert.ts +117 -0
- package/src/edge/edgeProxy.ts +390 -0
- package/src/edge/server.cnf +28 -0
- package/src/edge/server.crt +26 -0
- package/src/edge/server.key +28 -0
- package/src/index.ts +67 -0
- package/src/lib/buildSourceBundle.ts +197 -0
- package/src/lib/freePort.ts +33 -0
- package/src/lib/functionBuild.ts +490 -0
- package/src/lib/neonWsProxy.ts +124 -0
- package/src/lib/sourceZipUpload.ts +168 -0
- package/src/lib/stealthLaunch.ts +57 -0
- package/src/lib/storeAutomation.ts +110 -0
- package/src/playwright/baseConfig.ts +120 -0
- package/src/playwright/globalSetup.ts +179 -0
- package/src/playwright/index.ts +11 -0
- package/src/probes/fonts.ts +279 -0
- package/src/probes/mirror.ts +283 -0
- package/src/probes/runProbe.ts +257 -0
- package/src/probes/types.ts +73 -0
- package/src/scripts/addStore.ts +59 -0
- package/src/scripts/buildDockerImage.ts +66 -0
- package/src/scripts/captureAuth.ts +145 -0
- package/src/scripts/captureContracts.ts +675 -0
- package/src/scripts/captureRestContracts.ts +319 -0
- package/src/scripts/checkOperationCoverage.ts +365 -0
- package/src/scripts/cleanupStores.ts +91 -0
- package/src/scripts/createStores.ts +77 -0
- package/src/scripts/deployAppVersion.ts +692 -0
- package/src/scripts/devOnlineBackend.ts +141 -0
- package/src/scripts/installApp.ts +188 -0
- package/src/scripts/listStores.ts +19 -0
- package/src/scripts/runDockerAuth.ts +120 -0
- package/src/scripts/runOffline.ts +577 -0
- package/src/scripts/runOfflineFullTests.ts +1634 -0
- package/src/scripts/runTests.ts +306 -0
- package/src/scripts/runVm.ts +562 -0
- package/src/scripts/runVmAuth.ts +541 -0
- package/src/scripts/runVmScript.ts +282 -0
- package/src/scripts/setupTestDb.ts +71 -0
- package/src/scripts/verifyContracts.ts +310 -0
- package/src/scripts/verifyRestContracts.ts +275 -0
- 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';
|