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