@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,282 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Generic NON-interactive in-VM script runner.
|
|
4
|
+
*
|
|
5
|
+
* Boots the same VM the online suite uses and runs
|
|
6
|
+
* `dist/scripts/<name>.js [args]` inside it, headed under Xvfb :99 (the
|
|
7
|
+
* Cloudflare-passing config the tests use), with TEST_IN_CONTAINER=true.
|
|
8
|
+
* This is how the store-setup scripts that drive a browser — installApp,
|
|
9
|
+
* createStores — run: in the VM, never on the host. (For the INTERACTIVE
|
|
10
|
+
* human-login flow, see runVmAuth.ts, which adds VNC.)
|
|
11
|
+
*
|
|
12
|
+
* Usage (wired via package.json):
|
|
13
|
+
* node --import tsx .../runVmScript.ts <script-name> [-- <args…>]
|
|
14
|
+
* e.g.
|
|
15
|
+
* npm run test:online:install -- --shop my-store.myshopify.com
|
|
16
|
+
* → runVmScript installApp --shop my-store.myshopify.com
|
|
17
|
+
*
|
|
18
|
+
* Shares the node_modules volume + tarball-manifest extraFile with runVm /
|
|
19
|
+
* runVmAuth, and keys on a static warmupTag (rarely-run flow; bump on
|
|
20
|
+
* warmup-script or VM-runtime changes).
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { resolve } from 'node:path';
|
|
25
|
+
import { prepareOciArchive, envFileArgs } from '@essential-apps/shopify-test-core';
|
|
26
|
+
|
|
27
|
+
const repoRoot = process.cwd();
|
|
28
|
+
|
|
29
|
+
/** Resolve the VM image source the same way runVm.ts / runVmAuth.ts do. */
|
|
30
|
+
async function imageSourceOptions(): Promise<Record<string, unknown>> {
|
|
31
|
+
const mode = process.env['TEST_ONLINE_VM_IMAGE_SOURCE'] ?? 'oci-archive';
|
|
32
|
+
if (mode === 'registry') return {};
|
|
33
|
+
const ref =
|
|
34
|
+
process.env['TEST_ONLINE_VM_IMAGE'] ?? 'essential-apps/shopify-test-vm:latest';
|
|
35
|
+
if (mode === 'oci-archive') {
|
|
36
|
+
const prep = await prepareOciArchive(ref);
|
|
37
|
+
if (prep.freshlySaved) {
|
|
38
|
+
console.error(
|
|
39
|
+
`[runVmScript] saved ${ref} → ${prep.archivePath} (${(prep.sizeBytes / 1024 / 1024).toFixed(1)} MB)`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return { source: 'oci-archive', sourcePath: prep.archivePath };
|
|
43
|
+
}
|
|
44
|
+
if (mode === 'oci-layout') {
|
|
45
|
+
const sourcePath = process.env['TEST_ONLINE_VM_IMAGE_LAYOUT_PATH'];
|
|
46
|
+
if (!sourcePath) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`TEST_ONLINE_VM_IMAGE_SOURCE=oci-layout requires TEST_ONLINE_VM_IMAGE_LAYOUT_PATH`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
return { source: 'oci-layout', sourcePath };
|
|
52
|
+
}
|
|
53
|
+
throw new Error(
|
|
54
|
+
`TEST_ONLINE_VM_IMAGE_SOURCE=${mode} not recognised. Valid: oci-archive | oci-layout | registry`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface PackageJson {
|
|
59
|
+
name?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readAppName(): string {
|
|
63
|
+
const pkgPath = resolve(repoRoot, 'package.json');
|
|
64
|
+
if (!existsSync(pkgPath)) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`No package.json at ${pkgPath}. Run this from the consuming app's repo root.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageJson;
|
|
70
|
+
if (!pkg.name) throw new Error(`package.json at ${pkgPath} has no \`name\` field.`);
|
|
71
|
+
return pkg.name.replace(/^@[^/]+\//, '');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Single-quote-escape an arg for safe interpolation into `sh -c`. */
|
|
75
|
+
function shq(arg: string): string {
|
|
76
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function main(): Promise<void> {
|
|
80
|
+
const scriptName = process.argv[2];
|
|
81
|
+
const scriptArgs = process.argv.slice(3);
|
|
82
|
+
if (!scriptName || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(scriptName)) {
|
|
83
|
+
console.error(
|
|
84
|
+
`Usage: runVmScript <script-name> [args…]\n` +
|
|
85
|
+
` <script-name> must be a bare script name (e.g. installApp), no path.`,
|
|
86
|
+
);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const appName = readAppName();
|
|
91
|
+
const linuxModulesVolume =
|
|
92
|
+
process.env['TEST_LINUX_NODE_MODULES_VOLUME'] ??
|
|
93
|
+
resolve(homedir(), `.cache/${appName}-test/node_modules.img`);
|
|
94
|
+
mkdirSync(resolve(linuxModulesVolume, '..'), { recursive: true });
|
|
95
|
+
const tarballManifest = resolve(
|
|
96
|
+
repoRoot,
|
|
97
|
+
'vendor/essential-apps-shopify-test/.tarball-manifest',
|
|
98
|
+
);
|
|
99
|
+
const tarballManifestExists = existsSync(tarballManifest);
|
|
100
|
+
const ref =
|
|
101
|
+
process.env['TEST_ONLINE_VM_IMAGE'] ?? 'essential-apps/shopify-test-vm:latest';
|
|
102
|
+
const platform = process.env['TEST_ONLINE_VM_PLATFORM'] ?? 'linux/arm64';
|
|
103
|
+
|
|
104
|
+
console.error(`[runVmScript] baking image…`);
|
|
105
|
+
const tBake = performance.now();
|
|
106
|
+
const { Image } = await import('@supermachine/core');
|
|
107
|
+
const imageOpts = await imageSourceOptions();
|
|
108
|
+
const image = await Image.build({
|
|
109
|
+
ref,
|
|
110
|
+
memoryMib: Number(process.env['TEST_ONLINE_VM_MEMORY_MIB'] ?? 8192),
|
|
111
|
+
vcpus: Number(process.env['TEST_ONLINE_VM_VCPUS'] ?? 4),
|
|
112
|
+
cmd: ['sleep', 'infinity'],
|
|
113
|
+
env: { SKIP_OFFLINE_HOST_HIJACK: '1' },
|
|
114
|
+
platform,
|
|
115
|
+
mounts: [{ hostPath: repoRoot, guestTag: 'workspace', guestPath: '/workspace' }],
|
|
116
|
+
volumes: [
|
|
117
|
+
{
|
|
118
|
+
hostPath: linuxModulesVolume,
|
|
119
|
+
guestPath: '/workspace/node_modules',
|
|
120
|
+
sizeMib: 4096,
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
extraFiles: tarballManifestExists
|
|
124
|
+
? [{ hostPath: tarballManifest, guestPath: '/etc/.shopify-test-tarball-manifest' }]
|
|
125
|
+
: [],
|
|
126
|
+
// Static tag (rarely-run setup flow). Bump on warmup-script / VM-runtime
|
|
127
|
+
// changes. Kept distinct from runVm's online-v15 (manifest-keyed) and
|
|
128
|
+
// runVmAuth's online-v4 so a tweak here can't disturb those snapshots.
|
|
129
|
+
warmupTag: 'online-script-v1',
|
|
130
|
+
// @ts-expect-error — @supermachine/core's BuildOptions type doesn't declare
|
|
131
|
+
// `warmup`, but the runtime accepts it (matches runVm/runVmAuth).
|
|
132
|
+
warmup: async (vm: {
|
|
133
|
+
exec: (opts: { argv: string[]; timeoutMs?: number }) => Promise<{
|
|
134
|
+
exitCode: number;
|
|
135
|
+
stdout: Buffer;
|
|
136
|
+
stderr: Buffer;
|
|
137
|
+
}>;
|
|
138
|
+
}) => {
|
|
139
|
+
const r = await vm.exec({
|
|
140
|
+
argv: [
|
|
141
|
+
'sh',
|
|
142
|
+
'-c',
|
|
143
|
+
`
|
|
144
|
+
set -e
|
|
145
|
+
NM_FS=\$(stat -f -c %T /workspace/node_modules 2>/dev/null || stat --file-system --format=%T /workspace/node_modules)
|
|
146
|
+
echo "[warmup] /workspace/node_modules FS type: \$NM_FS"
|
|
147
|
+
if [ "\$NM_FS" = "virtiofs" ] || [ "\$NM_FS" = "fuse.virtiofs" ]; then
|
|
148
|
+
echo "FATAL: /workspace/node_modules is \$NM_FS — auto-mount broken"
|
|
149
|
+
exit 1
|
|
150
|
+
fi
|
|
151
|
+
cd /workspace
|
|
152
|
+
rm -rf node_modules/* node_modules/.[!.]* 2>/dev/null || true
|
|
153
|
+
if ! npm install --legacy-peer-deps --engine-strict=false > /tmp/npm-install.log 2>&1; then
|
|
154
|
+
echo "--- npm install FAILED - last 40 lines ---"
|
|
155
|
+
tail -40 /tmp/npm-install.log
|
|
156
|
+
exit 1
|
|
157
|
+
fi
|
|
158
|
+
tail -10 /tmp/npm-install.log
|
|
159
|
+
if ! grep -q "^127.0.0.1.*localhost" /etc/hosts; then
|
|
160
|
+
echo "127.0.0.1 localhost" >> /etc/hosts
|
|
161
|
+
echo "::1 localhost" >> /etc/hosts
|
|
162
|
+
fi
|
|
163
|
+
`,
|
|
164
|
+
],
|
|
165
|
+
timeoutMs: 15 * 60 * 1000,
|
|
166
|
+
});
|
|
167
|
+
if (r.exitCode !== 0) {
|
|
168
|
+
console.error('[runVmScript.warmup] failed:', r.stdout.toString(), r.stderr.toString());
|
|
169
|
+
throw new Error(`warmup failed (exit ${r.exitCode})`);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
...imageOpts,
|
|
173
|
+
});
|
|
174
|
+
console.error(
|
|
175
|
+
`[runVmScript] bake: ${((performance.now() - tBake) / 1000).toFixed(1)} s`,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const pool = await image.pool({ min: 1, max: 1, restoreOnRelease: false });
|
|
179
|
+
const vm = await pool.acquire();
|
|
180
|
+
|
|
181
|
+
let cleanupPromise: Promise<void> | null = null;
|
|
182
|
+
const cleanup = (): Promise<void> => {
|
|
183
|
+
if (cleanupPromise) return cleanupPromise;
|
|
184
|
+
cleanupPromise = (async () => {
|
|
185
|
+
await vm.release().catch(() => undefined);
|
|
186
|
+
await pool.shutdown().catch(() => undefined);
|
|
187
|
+
})();
|
|
188
|
+
return cleanupPromise;
|
|
189
|
+
};
|
|
190
|
+
let signalCount = 0;
|
|
191
|
+
const onSignal = (sig: NodeJS.Signals): void => {
|
|
192
|
+
signalCount += 1;
|
|
193
|
+
if (signalCount >= 2) process.exit(130);
|
|
194
|
+
console.error(`\n[runVmScript] received ${sig}, cleaning up…`);
|
|
195
|
+
cleanup()
|
|
196
|
+
.catch(() => undefined)
|
|
197
|
+
.finally(() => process.exit(130));
|
|
198
|
+
};
|
|
199
|
+
process.on('SIGINT', onSignal);
|
|
200
|
+
process.on('SIGTERM', onSignal);
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
// Post-restore sanity + start Xvfb :99 so headed patchright (the
|
|
204
|
+
// CF-passing config) has a display. Browser launches in the guest
|
|
205
|
+
// still go through assertInVm(), which passes because we set
|
|
206
|
+
// TEST_IN_CONTAINER=true on the spawned process below.
|
|
207
|
+
const setup = await vm.exec({
|
|
208
|
+
argv: [
|
|
209
|
+
'sh',
|
|
210
|
+
'-c',
|
|
211
|
+
`
|
|
212
|
+
mountpoint -q /workspace/node_modules || { echo "FATAL: /workspace/node_modules not mounted"; exit 1; }
|
|
213
|
+
BASE=/workspace/node_modules/@essential-apps/shopify-test-runner/dist
|
|
214
|
+
if [ -f "\$BASE/scripts/${scriptName}.js" ]; then S="\$BASE/scripts/${scriptName}.js"; else S="\$BASE/probes/${scriptName}.js"; fi
|
|
215
|
+
ls "\$S" >/dev/null \
|
|
216
|
+
|| { echo "FATAL: ${scriptName}.js missing in volume (checked scripts/ and probes/) — snapshot/volume out of sync"; exit 1; }
|
|
217
|
+
pkill -x Xvfb 2>/dev/null || true
|
|
218
|
+
rm -f /tmp/.X11-unix/X99 /tmp/.X99-lock
|
|
219
|
+
sleep 0.3
|
|
220
|
+
nohup Xvfb :99 -screen 0 1600x1000x24 -nolisten tcp -ac >/var/log/xvfb.log 2>&1 &
|
|
221
|
+
for _ in $(seq 1 50); do [ -e /tmp/.X11-unix/X99 ] && break; sleep 0.1; done
|
|
222
|
+
[ -e /tmp/.X11-unix/X99 ] || { echo "FATAL: Xvfb :99 did not start"; cat /var/log/xvfb.log; exit 1; }
|
|
223
|
+
echo "[runVmScript] Xvfb :99 up."
|
|
224
|
+
`,
|
|
225
|
+
],
|
|
226
|
+
timeoutMs: 30_000,
|
|
227
|
+
});
|
|
228
|
+
if (setup.exitCode !== 0) {
|
|
229
|
+
console.error(`[runVmScript] setup failed:\n${setup.stdout.toString()}${setup.stderr.toString()}`);
|
|
230
|
+
throw new Error('VM setup failed');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const argsStr = scriptArgs.map(shq).join(' ');
|
|
234
|
+
console.error(`[runVmScript] running ${scriptName} ${argsStr} in the VM…`);
|
|
235
|
+
const child = await vm.spawn({
|
|
236
|
+
argv: [
|
|
237
|
+
'sh',
|
|
238
|
+
'-c',
|
|
239
|
+
`cd /workspace && BASE=node_modules/@essential-apps/shopify-test-runner/dist; if [ -f "\$BASE/scripts/${scriptName}.js" ]; then S="\$BASE/scripts/${scriptName}.js"; else S="\$BASE/probes/${scriptName}.js"; fi; DISPLAY=:99 TEST_IN_CONTAINER=true node ${envFileArgs(repoRoot)} "\$S" ${argsStr}`,
|
|
240
|
+
],
|
|
241
|
+
env: { DISPLAY: ':99', TEST_IN_CONTAINER: 'true' },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const childAny = child as unknown as {
|
|
245
|
+
readStdout?: () => Promise<Buffer>;
|
|
246
|
+
readStderr?: () => Promise<Buffer>;
|
|
247
|
+
wait?: () => Promise<{ exitCode: number }>;
|
|
248
|
+
};
|
|
249
|
+
const streamGuest = async (
|
|
250
|
+
reader: (() => Promise<Buffer>) | undefined,
|
|
251
|
+
out: NodeJS.WriteStream,
|
|
252
|
+
): Promise<void> => {
|
|
253
|
+
if (!reader) return;
|
|
254
|
+
// eslint-disable-next-line no-constant-condition
|
|
255
|
+
while (true) {
|
|
256
|
+
const chunk = await reader().catch(() => null);
|
|
257
|
+
if (!chunk || chunk.length === 0) return;
|
|
258
|
+
out.write(chunk);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
void streamGuest(childAny.readStdout, process.stdout);
|
|
262
|
+
void streamGuest(childAny.readStderr, process.stderr);
|
|
263
|
+
|
|
264
|
+
const result = await (childAny.wait?.() ?? Promise.resolve({ exitCode: 0 }));
|
|
265
|
+
await cleanup();
|
|
266
|
+
if (result.exitCode === 0) {
|
|
267
|
+
console.error(`[runVmScript] ✓ ${scriptName} completed.`);
|
|
268
|
+
} else {
|
|
269
|
+
console.error(`[runVmScript] ${scriptName} exited with code ${result.exitCode}`);
|
|
270
|
+
}
|
|
271
|
+
process.exit(result.exitCode);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error('[runVmScript] fatal:', (err as Error).message);
|
|
274
|
+
await cleanup();
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
main().catch(async (err) => {
|
|
280
|
+
console.error(err);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* One-time / on-demand local Postgres prep for tests.
|
|
4
|
+
*
|
|
5
|
+
* - Smoke-tests that local Postgres is reachable
|
|
6
|
+
* - Runs `prisma migrate deploy` against an ephemeral DB and drops it
|
|
7
|
+
*
|
|
8
|
+
* The actual test runner (runTests.ts) creates a fresh UUID-named DB
|
|
9
|
+
* per run and applies migrations there. This script is just for
|
|
10
|
+
* diagnostics — confirms the toolchain works before you try to run
|
|
11
|
+
* tests.
|
|
12
|
+
*/
|
|
13
|
+
import { execSync } from 'node:child_process';
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { resolve } from 'node:path';
|
|
17
|
+
import { loadEnv, printEnvSummary } from '@essential-apps/shopify-test-core';
|
|
18
|
+
|
|
19
|
+
const PG_HOST = 'localhost';
|
|
20
|
+
const PG_PORT = '5432';
|
|
21
|
+
|
|
22
|
+
interface PackageJson {
|
|
23
|
+
name?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readAppName(): string {
|
|
27
|
+
const pkgPath = resolve(process.cwd(), 'package.json');
|
|
28
|
+
if (!existsSync(pkgPath)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`No package.json at ${pkgPath}. Run this from the consuming app's repo root.`,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageJson;
|
|
34
|
+
if (!pkg.name) throw new Error(`package.json at ${pkgPath} has no \`name\` field.`);
|
|
35
|
+
return pkg.name.replace(/^@[^/]+\//, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function main(): Promise<void> {
|
|
39
|
+
const env = loadEnv();
|
|
40
|
+
printEnvSummary(env);
|
|
41
|
+
|
|
42
|
+
console.log('[setup-db] checking local Postgres reachability…');
|
|
43
|
+
execSync(`pg_isready -h ${PG_HOST} -p ${PG_PORT}`, { stdio: 'inherit' });
|
|
44
|
+
|
|
45
|
+
const appName = readAppName();
|
|
46
|
+
const dbPrefix = (process.env['TEST_ONLINE_DB_NAME_PREFIX'] ?? `${appName}_online`)
|
|
47
|
+
.toLowerCase()
|
|
48
|
+
.replace(/[^a-z0-9_]/g, '_');
|
|
49
|
+
const dbName = `${dbPrefix}_smoke_${randomUUID().replace(/-/g, '_')}`;
|
|
50
|
+
const url = `postgresql://${PG_HOST}:${PG_PORT}/${dbName}`;
|
|
51
|
+
console.log(`[setup-db] creating throwaway DB: ${dbName}`);
|
|
52
|
+
execSync(`createdb -h ${PG_HOST} -p ${PG_PORT} ${dbName}`, { stdio: 'inherit' });
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
console.log('[setup-db] running prisma migrate deploy…');
|
|
56
|
+
execSync('npx prisma migrate deploy', {
|
|
57
|
+
stdio: 'inherit',
|
|
58
|
+
env: { ...process.env, DATABASE_URL: url, DIRECT_URL: url, NODE_ENV: 'test' },
|
|
59
|
+
});
|
|
60
|
+
console.log('');
|
|
61
|
+
console.log('✓ Local Postgres + migrations work. You can now run: npm run test:online');
|
|
62
|
+
} finally {
|
|
63
|
+
console.log(`[setup-db] dropping throwaway DB: ${dbName}`);
|
|
64
|
+
execSync(`dropdb -h ${PG_HOST} -p ${PG_PORT} ${dbName}`, { stdio: 'inherit' });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
main().catch((err) => {
|
|
69
|
+
console.error(err);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* verifyContracts — the offline-side conformance check.
|
|
4
|
+
*
|
|
5
|
+
* Reads every vendored contract under `tests/test-offline/contracts/<api>/`,
|
|
6
|
+
* re-runs its operation against an in-process offline mock with the
|
|
7
|
+
* vendored `variables`, and asserts the response equals the
|
|
8
|
+
* vendored `response`. Any drift fails the check.
|
|
9
|
+
*
|
|
10
|
+
* See `docs/CONTRACTS.md` for the full architecture. This is one
|
|
11
|
+
* of three checks that together ensure the offline test suite
|
|
12
|
+
* cannot drift from real Shopify undetected:
|
|
13
|
+
*
|
|
14
|
+
* 1. check-operation-coverage — static: every prod root field has a resolver
|
|
15
|
+
* 2. verify-contracts — this script: mock matches vendored contracts
|
|
16
|
+
* 3. verify-contracts-from-live — (conformance suite) live matches vendored contracts
|
|
17
|
+
*
|
|
18
|
+
* Drift modes this catches:
|
|
19
|
+
* - A resolver was removed or its return shape changed.
|
|
20
|
+
* - A new field was added to a contract without the matching mock.
|
|
21
|
+
* - The mock's seeded ShopState shape diverged from the contract's
|
|
22
|
+
* expectation.
|
|
23
|
+
*
|
|
24
|
+
* What it doesn't catch (by design — owned by other checks):
|
|
25
|
+
* - The contract itself is wrong vs live Shopify. That's
|
|
26
|
+
* `verify-contracts-from-live`'s job.
|
|
27
|
+
* - Production code calls a field with no contract. That's
|
|
28
|
+
* `check-operation-coverage`.
|
|
29
|
+
*
|
|
30
|
+
* Run: `npm run test:online:verify-contracts`.
|
|
31
|
+
*/
|
|
32
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
33
|
+
import { resolve, relative } from 'node:path';
|
|
34
|
+
import {
|
|
35
|
+
createAdminApi,
|
|
36
|
+
createStorefrontApi,
|
|
37
|
+
} from '@essential-apps/shopify-test-shopify-api';
|
|
38
|
+
import { ShopState } from '@essential-apps/shopify-test-storefront';
|
|
39
|
+
import { normaliseResponse } from '../contracts/normalize.js';
|
|
40
|
+
|
|
41
|
+
type ApiType = 'admin' | 'storefront';
|
|
42
|
+
|
|
43
|
+
interface Args {
|
|
44
|
+
api: ApiType;
|
|
45
|
+
contractsDir: string;
|
|
46
|
+
cwd: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseArgs(): Args {
|
|
50
|
+
const argv = process.argv.slice(2);
|
|
51
|
+
const out: Args = {
|
|
52
|
+
api: 'admin',
|
|
53
|
+
contractsDir: '',
|
|
54
|
+
cwd: process.cwd(),
|
|
55
|
+
};
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i] ?? '';
|
|
58
|
+
if (a === '--api' && i + 1 < argv.length) {
|
|
59
|
+
const next = argv[++i] ?? '';
|
|
60
|
+
if (next !== 'admin' && next !== 'storefront') {
|
|
61
|
+
console.error(`--api must be "admin" or "storefront"`);
|
|
62
|
+
process.exit(2);
|
|
63
|
+
}
|
|
64
|
+
out.api = next;
|
|
65
|
+
} else if (a === '--dir' && i + 1 < argv.length) {
|
|
66
|
+
out.contractsDir = argv[++i] ?? '';
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!out.contractsDir) {
|
|
70
|
+
out.contractsDir = resolve(out.cwd, 'tests/test-offline/contracts', out.api);
|
|
71
|
+
} else {
|
|
72
|
+
out.contractsDir = resolve(out.cwd, out.contractsDir);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Offline-only executor — POSTs the operation to an in-process
|
|
79
|
+
* Hono mock for the consuming app's API surface (admin or
|
|
80
|
+
* storefront GraphQL). No live target by design: contract
|
|
81
|
+
* verification at the consuming-app layer answers "does the mock
|
|
82
|
+
* satisfy my operations?". The separate question — "does the mock
|
|
83
|
+
* match real Shopify?" — is owned by `@essential-apps/shopify-test-conformance`
|
|
84
|
+
* and runs against canonical platform primitives, not per-app
|
|
85
|
+
* operation sets. Mixing the two layers (the original `--target live`
|
|
86
|
+
* here) conflated app-level coverage with platform parity. Kept
|
|
87
|
+
* the layering clean by removing it.
|
|
88
|
+
*/
|
|
89
|
+
type Executor = (
|
|
90
|
+
source: string,
|
|
91
|
+
variables: Record<string, unknown>,
|
|
92
|
+
) => Promise<unknown>;
|
|
93
|
+
|
|
94
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hono types are loose
|
|
95
|
+
function buildOfflineExecutor(api: ApiType, state: ShopState): Executor {
|
|
96
|
+
const app =
|
|
97
|
+
api === 'admin' ? createAdminApi({ state }) : createStorefrontApi({ state });
|
|
98
|
+
const endpoint =
|
|
99
|
+
api === 'admin'
|
|
100
|
+
? '/admin/api/2025-07/graphql.json'
|
|
101
|
+
: '/api/2025-07/graphql.json';
|
|
102
|
+
return async (source, variables) => {
|
|
103
|
+
const resp = await app.request(endpoint, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: {
|
|
106
|
+
'Content-Type': 'application/json',
|
|
107
|
+
'X-Shopify-Access-Token': 'mock-access-token',
|
|
108
|
+
},
|
|
109
|
+
body: JSON.stringify({ query: source, variables }),
|
|
110
|
+
});
|
|
111
|
+
return resp.json();
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
interface Contract {
|
|
116
|
+
operationName: string;
|
|
117
|
+
source: string;
|
|
118
|
+
variables: Record<string, unknown>;
|
|
119
|
+
response: unknown;
|
|
120
|
+
capturedFrom: 'offline' | 'live';
|
|
121
|
+
capturedAt: string;
|
|
122
|
+
warning?: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Same deterministic ShopState seed `captureContracts` uses. The
|
|
127
|
+
* contracts were captured against this state; verification must
|
|
128
|
+
* replay against the identical state for results to match.
|
|
129
|
+
*
|
|
130
|
+
* Keep this in lockstep with `captureContracts.ts`'s
|
|
131
|
+
* `buildSeededState`. If you change one, change the other.
|
|
132
|
+
*/
|
|
133
|
+
function buildSeededState(): ShopState {
|
|
134
|
+
const state = new ShopState({
|
|
135
|
+
shop: {
|
|
136
|
+
domain: 'test-shop.myshopify.com',
|
|
137
|
+
permanent_domain: 'test-shop.myshopify.com',
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
state.addProduct({
|
|
141
|
+
id: 900_000_001,
|
|
142
|
+
handle: 'sample-product',
|
|
143
|
+
title: 'Sample Product',
|
|
144
|
+
description: 'Used by contract capture as a deterministic fixture.',
|
|
145
|
+
price: 1000,
|
|
146
|
+
vendor: 'Sample Vendor',
|
|
147
|
+
type: 'Sample',
|
|
148
|
+
variants: [
|
|
149
|
+
{
|
|
150
|
+
id: 900_010_001,
|
|
151
|
+
title: 'Default Title',
|
|
152
|
+
price: 1000,
|
|
153
|
+
available: true,
|
|
154
|
+
sku: 'SAMPLE-1',
|
|
155
|
+
inventory_quantity: 100,
|
|
156
|
+
selected_options: ['Default Title'],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
tags: [],
|
|
160
|
+
});
|
|
161
|
+
state.addCollection({
|
|
162
|
+
id: 900_020_001,
|
|
163
|
+
handle: 'sample-collection',
|
|
164
|
+
title: 'Sample Collection',
|
|
165
|
+
productHandles: ['sample-product'],
|
|
166
|
+
});
|
|
167
|
+
return state;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Deep-equal that returns a human-readable diff path on the first
|
|
172
|
+
* mismatch. Not a perfect serialiser — sufficient for asserting
|
|
173
|
+
* captured JSON equals re-run JSON.
|
|
174
|
+
*/
|
|
175
|
+
function diff(
|
|
176
|
+
expected: unknown,
|
|
177
|
+
actual: unknown,
|
|
178
|
+
path = '$',
|
|
179
|
+
): string | null {
|
|
180
|
+
if (expected === actual) return null;
|
|
181
|
+
if (
|
|
182
|
+
typeof expected !== typeof actual ||
|
|
183
|
+
expected === null ||
|
|
184
|
+
actual === null
|
|
185
|
+
) {
|
|
186
|
+
return `${path}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`;
|
|
187
|
+
}
|
|
188
|
+
if (Array.isArray(expected)) {
|
|
189
|
+
if (!Array.isArray(actual)) return `${path}: expected array, got object`;
|
|
190
|
+
if (expected.length !== actual.length) {
|
|
191
|
+
return `${path}: expected length ${expected.length}, got ${actual.length}`;
|
|
192
|
+
}
|
|
193
|
+
for (let i = 0; i < expected.length; i++) {
|
|
194
|
+
const d = diff(expected[i], actual[i], `${path}[${i}]`);
|
|
195
|
+
if (d) return d;
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (typeof expected === 'object') {
|
|
200
|
+
const e = expected as Record<string, unknown>;
|
|
201
|
+
const a = actual as Record<string, unknown>;
|
|
202
|
+
const allKeys = new Set([...Object.keys(e), ...Object.keys(a)]);
|
|
203
|
+
for (const k of allKeys) {
|
|
204
|
+
if (!(k in e)) return `${path}.${k}: unexpected key`;
|
|
205
|
+
if (!(k in a)) return `${path}.${k}: missing key`;
|
|
206
|
+
const d = diff(e[k], a[k], `${path}.${k}`);
|
|
207
|
+
if (d) return d;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
return `${path}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function main(): Promise<void> {
|
|
215
|
+
const args = parseArgs();
|
|
216
|
+
|
|
217
|
+
let entries: string[];
|
|
218
|
+
try {
|
|
219
|
+
entries = readdirSync(args.contractsDir);
|
|
220
|
+
} catch {
|
|
221
|
+
console.error(
|
|
222
|
+
`[verify-contracts] no contracts directory at ${args.contractsDir}. ` +
|
|
223
|
+
`Run \`npm run test:online:capture-contracts\` first.`,
|
|
224
|
+
);
|
|
225
|
+
process.exit(2);
|
|
226
|
+
}
|
|
227
|
+
const contractFiles = entries.filter((f) => f.endsWith('.json'));
|
|
228
|
+
if (contractFiles.length === 0) {
|
|
229
|
+
console.error(`[verify-contracts] no contracts found in ${args.contractsDir}`);
|
|
230
|
+
process.exit(2);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const state = buildSeededState();
|
|
234
|
+
const executor: Executor =
|
|
235
|
+
buildOfflineExecutor(args.api, state);
|
|
236
|
+
|
|
237
|
+
let pass = 0;
|
|
238
|
+
let drift = 0;
|
|
239
|
+
let skipped = 0;
|
|
240
|
+
const failures: { contract: string; diff: string }[] = [];
|
|
241
|
+
|
|
242
|
+
for (const f of contractFiles) {
|
|
243
|
+
const path = resolve(args.contractsDir, f);
|
|
244
|
+
let st;
|
|
245
|
+
try {
|
|
246
|
+
st = statSync(path);
|
|
247
|
+
} catch {
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (!st.isFile()) continue;
|
|
251
|
+
const contract = JSON.parse(readFileSync(path, 'utf8')) as Contract;
|
|
252
|
+
// Contracts captured with a warning never executed cleanly;
|
|
253
|
+
// skip them in verify (they need fixtures.json before they can
|
|
254
|
+
// be verified).
|
|
255
|
+
if (contract.warning) {
|
|
256
|
+
skipped++;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
let actual: unknown;
|
|
260
|
+
try {
|
|
261
|
+
actual = await executor(contract.source, contract.variables);
|
|
262
|
+
} catch (err) {
|
|
263
|
+
drift++;
|
|
264
|
+
failures.push({
|
|
265
|
+
contract: f,
|
|
266
|
+
diff: `executor threw: ${(err as Error).message}`,
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
// Normalise both sides before diffing. Volatile values
|
|
271
|
+
// (Shopify GIDs, timestamps, CDN URLs, cursors) reduce to
|
|
272
|
+
// stable tokens so live verification doesn't drown in
|
|
273
|
+
// expected-vs-real-ID noise. Offline verification is also
|
|
274
|
+
// safe — deterministic values still normalise to the same
|
|
275
|
+
// tokens on both sides. Genuine structural drift survives.
|
|
276
|
+
const expectedNorm = normaliseResponse(contract.response);
|
|
277
|
+
const actualNorm = normaliseResponse(actual);
|
|
278
|
+
const d = diff(expectedNorm, actualNorm);
|
|
279
|
+
if (d === null) {
|
|
280
|
+
pass++;
|
|
281
|
+
} else {
|
|
282
|
+
drift++;
|
|
283
|
+
failures.push({ contract: f, diff: d });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
console.log(
|
|
288
|
+
`[verify-contracts] api=${args.api}: ${pass} pass, ${drift} drift, ${skipped} skipped (fixtures needed)`,
|
|
289
|
+
);
|
|
290
|
+
if (failures.length > 0) {
|
|
291
|
+
console.log('');
|
|
292
|
+
console.log('[verify-contracts] drift:');
|
|
293
|
+
for (const f of failures) {
|
|
294
|
+
console.log(
|
|
295
|
+
` ${relative(args.cwd, resolve(args.contractsDir, f.contract))}`,
|
|
296
|
+
);
|
|
297
|
+
console.log(` ${f.diff}`);
|
|
298
|
+
}
|
|
299
|
+
console.log('');
|
|
300
|
+
console.log(
|
|
301
|
+
'If the mock is correct and the contract is stale, regenerate with `npm run test:online:capture-contracts`.',
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
process.exit(drift === 0 ? 0 : 1);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
main().catch((err) => {
|
|
308
|
+
console.error(err);
|
|
309
|
+
process.exit(2);
|
|
310
|
+
});
|