@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,1634 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* runOfflineFullTests — full-stack offline orchestrator.
|
|
4
|
+
*
|
|
5
|
+
* Boots the entire offline stack in ONE Node process:
|
|
6
|
+
*
|
|
7
|
+
* - Postgres (per-run UUID DB, migrated)
|
|
8
|
+
* - 4 mock servers in-process: admin shell, Admin GraphQL,
|
|
9
|
+
* Storefront GraphQL, mock storefront (Liquid) — all reading
|
|
10
|
+
* ONE shared ShopState (in-memory, owned by this process)
|
|
11
|
+
* - Remix backend (Vite) as a child process, with NODE_OPTIONS
|
|
12
|
+
* preloading MSW so its outbound `fetch()` to *.myshopify.com
|
|
13
|
+
* reaches our Admin GraphQL mock
|
|
14
|
+
* - Playwright as a child process, with mock URLs in env
|
|
15
|
+
*
|
|
16
|
+
* The Playwright fixture (`offlineFullTest`, orchestrated mode)
|
|
17
|
+
* reads the mock URLs from env and routes browser requests via
|
|
18
|
+
* `page.route()`. Tests mutate ShopState via control-plane HTTP
|
|
19
|
+
* endpoints exposed by the storefront mock (mounted under
|
|
20
|
+
* `/__test__/state/*`).
|
|
21
|
+
*
|
|
22
|
+
* This is the offline counterpart to runTests.ts (online mode).
|
|
23
|
+
* Online tests still go via runTests.ts; offline-full tests via this.
|
|
24
|
+
*
|
|
25
|
+
* Env it sets in the spawned Remix backend:
|
|
26
|
+
* SHOPIFY_API_SECRET = mock JWT secret (so JWTs validate)
|
|
27
|
+
* NODE_OPTIONS = --import @essential-apps/shopify-test-shopify-api/msw-loader
|
|
28
|
+
* TEST_OFFLINE_MOCK_ADMIN_API_URL = mock GraphQL baseUrl (used by MSW loader)
|
|
29
|
+
* TEST_OFFLINE = true (app-side opt-in flag for any code that
|
|
30
|
+
* wants to no-op real Shopify side-effects)
|
|
31
|
+
*
|
|
32
|
+
* Env it sets in the Playwright run:
|
|
33
|
+
* TEST_OFFLINE_ORCHESTRATED = true
|
|
34
|
+
* TEST_OFFLINE_MOCK_ADMIN_SHELL_URL = http://127.0.0.1:N
|
|
35
|
+
* TEST_OFFLINE_MOCK_ADMIN_API_URL = http://127.0.0.1:N
|
|
36
|
+
* TEST_OFFLINE_MOCK_STOREFRONT_API_URL = http://127.0.0.1:N
|
|
37
|
+
* TEST_OFFLINE_MOCK_STOREFRONT_URL = http://127.0.0.1:N
|
|
38
|
+
* TEST_OFFLINE_MOCK_BACKEND_URL = the Remix backend's URL
|
|
39
|
+
* TEST_OFFLINE_MOCK_SHOP_DOMAIN = test-shop.myshopify.com
|
|
40
|
+
*/
|
|
41
|
+
import { spawn, execSync, type ChildProcess } from 'node:child_process';
|
|
42
|
+
import { assertInVm, computeBuildHash } from '@essential-apps/shopify-test-core';
|
|
43
|
+
import { startEdgeProxy } from '../edge/edgeProxy.js';
|
|
44
|
+
import { startNeonWsProxy } from '../lib/neonWsProxy.js';
|
|
45
|
+
import { TEST_CA_CERT_PEM } from '../edge/cert.js';
|
|
46
|
+
import { randomUUID } from 'node:crypto';
|
|
47
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync, unlinkSync } from 'node:fs';
|
|
48
|
+
import { resolve } from 'node:path';
|
|
49
|
+
import { tmpdir } from 'node:os';
|
|
50
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
51
|
+
import { Agent } from 'undici';
|
|
52
|
+
import {
|
|
53
|
+
ShopState,
|
|
54
|
+
createStorefrontApp,
|
|
55
|
+
startStorefront,
|
|
56
|
+
type StartedStorefront,
|
|
57
|
+
type ExtensionConfig,
|
|
58
|
+
} from '@essential-apps/shopify-test-storefront';
|
|
59
|
+
import { dawn } from '@essential-apps/shopify-test-themes';
|
|
60
|
+
import {
|
|
61
|
+
createAdminApi,
|
|
62
|
+
startAdminApi,
|
|
63
|
+
createStorefrontApi,
|
|
64
|
+
startStorefrontApi,
|
|
65
|
+
createPartnerApi,
|
|
66
|
+
startPartnerApi,
|
|
67
|
+
type StartedAdminApi,
|
|
68
|
+
type StartedStorefrontApi,
|
|
69
|
+
type StartedPartnerApi,
|
|
70
|
+
} from '@essential-apps/shopify-test-shopify-api';
|
|
71
|
+
import {
|
|
72
|
+
createMockAdminApp,
|
|
73
|
+
startMockAdmin,
|
|
74
|
+
DEFAULT_MOCK_JWT_SECRET,
|
|
75
|
+
mintIdToken,
|
|
76
|
+
type StartedMockAdmin,
|
|
77
|
+
} from '@essential-apps/shopify-test-mock-admin';
|
|
78
|
+
|
|
79
|
+
const insecureFetchDispatcher = new Agent({ connect: { rejectUnauthorized: false } });
|
|
80
|
+
const PG_HOST = 'localhost';
|
|
81
|
+
const PG_PORT = '5432';
|
|
82
|
+
const READY_TIMEOUT_MS = 120_000;
|
|
83
|
+
|
|
84
|
+
const DEFAULT_SHOP_DOMAIN = 'test-shop.myshopify.com';
|
|
85
|
+
const DEFAULT_APP_HANDLE = process.env['SHOPIFY_APP_HANDLE'] ?? 'essential-app';
|
|
86
|
+
// Mock client id — must match what the Remix backend reads as
|
|
87
|
+
// SHOPIFY_API_KEY for App Bridge validation paths to align. We use a
|
|
88
|
+
// fixed value so seeded sessions and JWT audience claims line up.
|
|
89
|
+
// Matches the default clientId in the offline mock-admin / shopState
|
|
90
|
+
// fixtures. If those defaults change, change this too.
|
|
91
|
+
const MOCK_CLIENT_ID = 'mock-api-key';
|
|
92
|
+
|
|
93
|
+
interface PackageJson {
|
|
94
|
+
name?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function readAppName(): string {
|
|
98
|
+
const pkgPath = resolve(process.cwd(), 'package.json');
|
|
99
|
+
if (!existsSync(pkgPath)) {
|
|
100
|
+
throw new Error(`No package.json at ${pkgPath}.`);
|
|
101
|
+
}
|
|
102
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageJson;
|
|
103
|
+
if (!pkg.name) throw new Error(`package.json has no name field.`);
|
|
104
|
+
return pkg.name.replace(/^@[^/]+\//, '');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function dbUrl(name: string): string {
|
|
108
|
+
const user = process.env['PGUSER'] || process.env['USER'] || 'root';
|
|
109
|
+
return `postgresql://${user}@${PG_HOST}:${PG_PORT}/${name}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Drop any DBs left over from previous crashed orchestrator runs.
|
|
114
|
+
* We match on the per-app naming pattern `${app}_offline_*`. A
|
|
115
|
+
* graceful exit drops the DB in the `finally` block; a SIGKILL
|
|
116
|
+
* (or test runner timeout) leaks it. Cleanup at the START of the
|
|
117
|
+
* next run keeps the dev's Postgres from accumulating dozens of
|
|
118
|
+
* dead databases over months.
|
|
119
|
+
*
|
|
120
|
+
* Safe: only deletes DBs matching our prefix; never touches anything
|
|
121
|
+
* the user might have manually named.
|
|
122
|
+
*/
|
|
123
|
+
function dropOrphanDatabases(prefix: string): void {
|
|
124
|
+
try {
|
|
125
|
+
const output = execSync(
|
|
126
|
+
`psql -h ${PG_HOST} -p ${PG_PORT} -d postgres -At -c "SELECT datname FROM pg_database WHERE datname LIKE '${prefix}_%'"`,
|
|
127
|
+
{ encoding: 'utf8' },
|
|
128
|
+
);
|
|
129
|
+
const orphans = output
|
|
130
|
+
.split('\n')
|
|
131
|
+
.map((s) => s.trim())
|
|
132
|
+
.filter((s) => s.length > 0);
|
|
133
|
+
if (orphans.length === 0) return;
|
|
134
|
+
console.log(`[runOfflineFull] cleanup: dropping ${orphans.length} orphan DB(s)…`);
|
|
135
|
+
for (const name of orphans) {
|
|
136
|
+
try {
|
|
137
|
+
// `--force` (psql 13+) terminates any leftover connections
|
|
138
|
+
// before drop. Falls back to plain dropdb if --force isn't
|
|
139
|
+
// supported in the local psql.
|
|
140
|
+
execSync(`dropdb --force -h ${PG_HOST} -p ${PG_PORT} ${name} 2>/dev/null || dropdb -h ${PG_HOST} -p ${PG_PORT} ${name}`, {
|
|
141
|
+
stdio: 'ignore',
|
|
142
|
+
});
|
|
143
|
+
} catch {
|
|
144
|
+
// Couldn't drop (active connection from another orchestrator
|
|
145
|
+
// run, perhaps). Skip silently — we'll get it next time.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
} catch (err) {
|
|
149
|
+
// If psql isn't available at all we just skip cleanup; the
|
|
150
|
+
// primary createdb below will fail loudly anyway.
|
|
151
|
+
console.warn(`[runOfflineFull] orphan-DB cleanup failed: ${(err as Error).message}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build a `resetDb` hook the storefront control plane can call
|
|
157
|
+
* between tests. Runs:
|
|
158
|
+
* 1. TRUNCATE every user-defined table in `public` (CASCADE,
|
|
159
|
+
* RESTART IDENTITY) — discovered dynamically from pg_tables
|
|
160
|
+
* so adding a model doesn't break the hook.
|
|
161
|
+
* 2. Re-seed the consuming app's Session row.
|
|
162
|
+
*
|
|
163
|
+
* Why TRUNCATE-then-reseed vs. literal per-test database:
|
|
164
|
+
* - Per-test DB would require restarting the backend (a single
|
|
165
|
+
* long-running process with a fixed DATABASE_URL) on every
|
|
166
|
+
* test, ~5s overhead per test → multiple minutes for the
|
|
167
|
+
* current suite. TRUNCATE runs in ~5ms and is observationally
|
|
168
|
+
* identical for the consuming code.
|
|
169
|
+
* - This hook is ORCHESTRATOR-owned, so the consuming app no
|
|
170
|
+
* longer needs a `/test-internal/reset-db` route. The backend
|
|
171
|
+
* stays test-unaware.
|
|
172
|
+
*/
|
|
173
|
+
function makeResetDbHook(dbName: string, shopDomain: string, scopes: string): () => Promise<void> {
|
|
174
|
+
return async () => {
|
|
175
|
+
// Build the SQL as a single transaction: one round-trip, atomic.
|
|
176
|
+
// The DO block iterates pg_tables for `public` schema, skipping
|
|
177
|
+
// Prisma's migration metadata. RESTART IDENTITY resets serial
|
|
178
|
+
// sequences so per-test IDs start from 1 (predictable in
|
|
179
|
+
// snapshots / assertions).
|
|
180
|
+
const seedSql =
|
|
181
|
+
// Insert the seeded session row. We pass scopes as a literal
|
|
182
|
+
// here (escaped) because the `psql -c` interface doesn't have
|
|
183
|
+
// proper parameterized queries.
|
|
184
|
+
`INSERT INTO "Session" (id, shop, state, "isOnline", "accessToken", scope) ` +
|
|
185
|
+
`VALUES ('offline_${shopDomain}', '${shopDomain}', 'offline-mock', false, 'mock-access-token', '${scopes.replace(/'/g, "''")}') ` +
|
|
186
|
+
`ON CONFLICT (id) DO UPDATE SET ` +
|
|
187
|
+
`shop = EXCLUDED.shop, state = EXCLUDED.state, "accessToken" = EXCLUDED."accessToken", scope = EXCLUDED.scope;`;
|
|
188
|
+
const sql = `
|
|
189
|
+
BEGIN;
|
|
190
|
+
DO $$
|
|
191
|
+
DECLARE
|
|
192
|
+
t text;
|
|
193
|
+
BEGIN
|
|
194
|
+
FOR t IN
|
|
195
|
+
SELECT tablename FROM pg_tables
|
|
196
|
+
WHERE schemaname = 'public' AND tablename <> '_prisma_migrations'
|
|
197
|
+
LOOP
|
|
198
|
+
EXECUTE 'TRUNCATE TABLE "' || t || '" RESTART IDENTITY CASCADE';
|
|
199
|
+
END LOOP;
|
|
200
|
+
END $$;
|
|
201
|
+
${seedSql}
|
|
202
|
+
COMMIT;
|
|
203
|
+
`;
|
|
204
|
+
// Use a tempfile + `psql -f` rather than `-c "..."`. The
|
|
205
|
+
// inline form fights shell quoting on multi-line SQL that
|
|
206
|
+
// contains dollar-quoted DO blocks ($$ ... $$). A file is
|
|
207
|
+
// also easier to inspect when debugging.
|
|
208
|
+
const tmpFile = resolve(
|
|
209
|
+
tmpdir(),
|
|
210
|
+
`offline-reset-db-${randomUUID()}.sql`,
|
|
211
|
+
);
|
|
212
|
+
writeFileSync(tmpFile, sql, 'utf8');
|
|
213
|
+
try {
|
|
214
|
+
execSync(
|
|
215
|
+
`psql -h ${PG_HOST} -p ${PG_PORT} -d ${dbName} -v ON_ERROR_STOP=1 -f ${tmpFile}`,
|
|
216
|
+
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
217
|
+
);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
const stderr = (err as { stderr?: Buffer }).stderr?.toString() ?? '';
|
|
220
|
+
const stdout = (err as { stdout?: Buffer }).stdout?.toString() ?? '';
|
|
221
|
+
throw new Error(
|
|
222
|
+
`[resetDb] psql failed: ${(err as Error).message}\n` +
|
|
223
|
+
`stdout: ${stdout}\nstderr: ${stderr}\nSQL file (preserved for inspection): ${tmpFile}`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
unlinkSync(tmpFile);
|
|
228
|
+
} catch {
|
|
229
|
+
/* best-effort */
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Probe a URL until it responds (any status). Used for backend readiness. */
|
|
235
|
+
async function waitForReady(url: string, timeoutMs: number): Promise<void> {
|
|
236
|
+
const deadline = Date.now() + timeoutMs;
|
|
237
|
+
let lastErr = '';
|
|
238
|
+
while (Date.now() < deadline) {
|
|
239
|
+
try {
|
|
240
|
+
const res = await fetch(url, {
|
|
241
|
+
redirect: 'manual',
|
|
242
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
243
|
+
dispatcher: insecureFetchDispatcher as any,
|
|
244
|
+
} as RequestInit);
|
|
245
|
+
if (res.status > 0) return;
|
|
246
|
+
} catch (e) {
|
|
247
|
+
lastErr = (e as Error).message;
|
|
248
|
+
}
|
|
249
|
+
await sleep(1_000);
|
|
250
|
+
}
|
|
251
|
+
throw new Error(`Backend never became ready at ${url}. Last error: ${lastErr}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Configure the container for offline mode: write Shopify hostname
|
|
256
|
+
* entries into /etc/hosts and install the edge proxy's self-signed
|
|
257
|
+
* cert as a system CA. The container entrypoint
|
|
258
|
+
* (tests/test-offline/docker/entrypoint.sh) does the same thing when
|
|
259
|
+
* node_modules is warm at container start; on a cold cache, the
|
|
260
|
+
* entrypoint can't (cert file doesn't exist yet) and this is the
|
|
261
|
+
* post-install backstop.
|
|
262
|
+
*
|
|
263
|
+
* Idempotent — safe to call multiple times. The `grep -q` guards
|
|
264
|
+
* make /etc/hosts edits skip if entries are already present;
|
|
265
|
+
* `update-ca-certificates` is a no-op if the cert is already
|
|
266
|
+
* trusted.
|
|
267
|
+
*/
|
|
268
|
+
function ensureOfflineSetup(): void {
|
|
269
|
+
// Source of truth for the CA cert: imported from edge/cert.js
|
|
270
|
+
// (statically imported at the top of this file). `certutil -A -i`
|
|
271
|
+
// requires an on-disk path so we write the CA to a temp file
|
|
272
|
+
// before importing into NSS; the system CA store install (which
|
|
273
|
+
// uses /usr/local/share/ca-certificates/) writes the PEM string
|
|
274
|
+
// directly.
|
|
275
|
+
|
|
276
|
+
// /etc/hosts entries — IPv4 ONLY. The edge proxy binds on 0.0.0.0
|
|
277
|
+
// (IPv4); listen() on `::` (IPv6 dual-stack) hangs forever on the
|
|
278
|
+
// VM kernel, see commit 603594e. If we also add `::1`
|
|
279
|
+
// entries, Chrome resolves AAAA → ::1, connects to nothing, and
|
|
280
|
+
// returns "Failed to fetch" inside page.evaluate(). IPv4-only
|
|
281
|
+
// mapping forces Chrome down the IPv4 path that actually reaches
|
|
282
|
+
// the edge proxy.
|
|
283
|
+
const hostsPath = '/etc/hosts';
|
|
284
|
+
let hostsContent = '';
|
|
285
|
+
try {
|
|
286
|
+
hostsContent = readFileSync(hostsPath, 'utf8');
|
|
287
|
+
} catch {
|
|
288
|
+
console.warn(`[runOfflineFull] could not read ${hostsPath}; skipping /etc/hosts setup`);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const hostsToAdd: string[] = [];
|
|
292
|
+
for (const host of [
|
|
293
|
+
'test-shop.myshopify.com',
|
|
294
|
+
'admin.shopify.com',
|
|
295
|
+
'cdn.shopify.com',
|
|
296
|
+
// Real Shopify font CDN — routed by the edge proxy to the
|
|
297
|
+
// storefront mock's /__shopify-fonts/* handler so the live
|
|
298
|
+
// `font_face` filter output (which contains real
|
|
299
|
+
// fonts.shopifycdn.com URLs) just works in-browser.
|
|
300
|
+
'fonts.shopifycdn.com',
|
|
301
|
+
]) {
|
|
302
|
+
if (!new RegExp(`^127\\.0\\.0\\.1\\s+${host.replace(/\./g, '\\.')}\\s*$`, 'm').test(hostsContent)) {
|
|
303
|
+
hostsToAdd.push(`127.0.0.1 ${host}`);
|
|
304
|
+
}
|
|
305
|
+
// NOTE: deliberately NOT adding `::1 <host>` entries — see the
|
|
306
|
+
// comment block above for why.
|
|
307
|
+
}
|
|
308
|
+
if (hostsToAdd.length > 0) {
|
|
309
|
+
try {
|
|
310
|
+
writeFileSync(hostsPath, hostsContent + '\n' + hostsToAdd.join('\n') + '\n');
|
|
311
|
+
console.log(`[runOfflineFull] wrote ${hostsToAdd.length} /etc/hosts entries`);
|
|
312
|
+
} catch (err) {
|
|
313
|
+
console.warn(
|
|
314
|
+
`[runOfflineFull] could not write ${hostsPath}: ${(err as Error).message}. ` +
|
|
315
|
+
`Browser may resolve Shopify hostnames to real Shopify on the internet.`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// System CA trust (Node). `/usr/local/share/ca-certificates/*.crt`
|
|
321
|
+
// is the canonical apt-style location; update-ca-certificates
|
|
322
|
+
// appends to /etc/ssl/certs/ca-certificates.crt which Node +
|
|
323
|
+
// glibc OpenSSL read. NODE_EXTRA_CA_CERTS belt-and-suspenders.
|
|
324
|
+
const systemCertDest = '/usr/local/share/ca-certificates/edge-ca.crt';
|
|
325
|
+
try {
|
|
326
|
+
let existing = '';
|
|
327
|
+
try {
|
|
328
|
+
existing = readFileSync(systemCertDest, 'utf8');
|
|
329
|
+
} catch {
|
|
330
|
+
/* not installed yet */
|
|
331
|
+
}
|
|
332
|
+
if (existing !== TEST_CA_CERT_PEM) {
|
|
333
|
+
writeFileSync(systemCertDest, TEST_CA_CERT_PEM);
|
|
334
|
+
execSync('update-ca-certificates >/dev/null 2>&1 || true', { stdio: 'ignore' });
|
|
335
|
+
console.log('[runOfflineFull] installed edge CA into system trust store');
|
|
336
|
+
}
|
|
337
|
+
process.env['NODE_EXTRA_CA_CERTS'] = systemCertDest;
|
|
338
|
+
} catch (err) {
|
|
339
|
+
console.warn(
|
|
340
|
+
`[runOfflineFull] could not install edge CA: ${(err as Error).message}. ` +
|
|
341
|
+
`TLS connections to the edge may fail.`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Chrome / Chromium on Linux verifies certs against NSS DB
|
|
346
|
+
// (`~/.pki/nssdb`), NOT the system CA store. We import the CA
|
|
347
|
+
// cert (NOT the server cert) — Chrome's chain validation walks
|
|
348
|
+
// server cert → CA in NSS → trust anchor. `certutil` (from
|
|
349
|
+
// libnss3-tools) is the canonical tool. NSS DB is per-user; we
|
|
350
|
+
// run as root so it lives at /root/.pki/nssdb and is shared
|
|
351
|
+
// across every Chromium instance (test runs + explore mode +
|
|
352
|
+
// any future headed-Chrome workflow).
|
|
353
|
+
//
|
|
354
|
+
// Trust attribute "C,,":
|
|
355
|
+
// First slot C = trusted CA for SSL/TLS server cert validation
|
|
356
|
+
// Second slot empty = not trusted for email
|
|
357
|
+
// Third slot empty = not trusted for code/object signing
|
|
358
|
+
// Minimum-privilege scope for what we need.
|
|
359
|
+
try {
|
|
360
|
+
const nssDir = `${process.env['HOME'] ?? '/root'}/.pki/nssdb`;
|
|
361
|
+
if (!existsSync(nssDir)) {
|
|
362
|
+
mkdirSync(nssDir, { recursive: true });
|
|
363
|
+
execSync(`certutil -N --empty-password -d sql:${nssDir}`, {
|
|
364
|
+
stdio: 'ignore',
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
// Write CA cert to a temp file so certutil -i can read it.
|
|
368
|
+
const tmpCaPath = '/tmp/edge-ca.crt';
|
|
369
|
+
writeFileSync(tmpCaPath, TEST_CA_CERT_PEM);
|
|
370
|
+
// Delete-then-add for idempotency (avoids "already exists").
|
|
371
|
+
execSync(
|
|
372
|
+
`certutil -d sql:${nssDir} -D -n edge-ca >/dev/null 2>&1 || true`,
|
|
373
|
+
{ stdio: 'ignore' },
|
|
374
|
+
);
|
|
375
|
+
execSync(
|
|
376
|
+
`certutil -d sql:${nssDir} -A -t "C,," -n edge-ca -i ${tmpCaPath}`,
|
|
377
|
+
{ stdio: 'ignore' },
|
|
378
|
+
);
|
|
379
|
+
unlinkSync(tmpCaPath);
|
|
380
|
+
console.log('[runOfflineFull] installed edge CA into NSS DB (Chrome)');
|
|
381
|
+
} catch (err) {
|
|
382
|
+
console.warn(
|
|
383
|
+
`[runOfflineFull] could not install edge CA into NSS DB: ${(err as Error).message}. ` +
|
|
384
|
+
`Chrome will reject the edge's cert. Verify libnss3-tools is installed in the image.`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Build the absolute path to the MSW loader inside the consuming
|
|
391
|
+
* app's node_modules. NODE_OPTIONS=--import requires either an absolute
|
|
392
|
+
* path or a Node-resolvable specifier; an absolute path is the
|
|
393
|
+
* safest bet across Node versions.
|
|
394
|
+
*/
|
|
395
|
+
function resolveMswLoaderPath(): string {
|
|
396
|
+
const candidate = resolve(
|
|
397
|
+
process.cwd(),
|
|
398
|
+
'node_modules/@essential-apps/shopify-test-shopify-api/dist/admin/mswLoader.js',
|
|
399
|
+
);
|
|
400
|
+
if (!existsSync(candidate)) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`MSW loader not found at ${candidate}. Did you install ` +
|
|
403
|
+
`@essential-apps/shopify-test-shopify-api as a dependency of the ` +
|
|
404
|
+
`consuming app?`,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return candidate;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Absolute path to the Neon-local preload shim (ships in
|
|
412
|
+
* shopify-test-shopify-api, beside the MSW loader). Preloaded into the
|
|
413
|
+
* backend so an app whose prisma client uses @neondatabase/serverless
|
|
414
|
+
* reaches THIS run's local Postgres via the WS proxy — no app change.
|
|
415
|
+
*/
|
|
416
|
+
function resolveNeonShimPath(): string {
|
|
417
|
+
const candidate = resolve(
|
|
418
|
+
process.cwd(),
|
|
419
|
+
'node_modules/@essential-apps/shopify-test-shopify-api/dist/admin/neonLocalShim.js',
|
|
420
|
+
);
|
|
421
|
+
if (!existsSync(candidate)) {
|
|
422
|
+
throw new Error(
|
|
423
|
+
`Neon-local shim not found at ${candidate}. Did you install ` +
|
|
424
|
+
`@essential-apps/shopify-test-shopify-api as a dependency of the ` +
|
|
425
|
+
`consuming app?`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
return candidate;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Seed a Session row directly via prisma so authenticate.admin()
|
|
433
|
+
* finds the merchant. We invoke a tiny inline TS script via the app's
|
|
434
|
+
* own node_modules so we get the same Prisma client + schema.
|
|
435
|
+
*/
|
|
436
|
+
function seedSession(env: Record<string, string>, shopDomain: string): void {
|
|
437
|
+
// Write the seed to a temp file rather than --eval to avoid having
|
|
438
|
+
// to escape `$` in the inline script (which gets eaten by various
|
|
439
|
+
// levels of shell/template-literal interpretation).
|
|
440
|
+
const tmpFile = resolve(
|
|
441
|
+
process.cwd(),
|
|
442
|
+
`.offline-seed-${randomUUID()}.mjs`,
|
|
443
|
+
);
|
|
444
|
+
const script = [
|
|
445
|
+
`import { PrismaClient } from '@prisma/client';`,
|
|
446
|
+
`const prisma = new PrismaClient();`,
|
|
447
|
+
`await prisma.session.upsert({`,
|
|
448
|
+
` where: { id: 'offline_${shopDomain}' },`,
|
|
449
|
+
` update: {},`,
|
|
450
|
+
` create: {`,
|
|
451
|
+
` id: 'offline_${shopDomain}',`,
|
|
452
|
+
` shop: '${shopDomain}',`,
|
|
453
|
+
` state: 'offline-mock',`,
|
|
454
|
+
` isOnline: false,`,
|
|
455
|
+
` accessToken: 'mock-access-token',`,
|
|
456
|
+
` scope: process.env.SCOPES || '',`,
|
|
457
|
+
` },`,
|
|
458
|
+
`});`,
|
|
459
|
+
`await prisma.$disconnect();`,
|
|
460
|
+
`console.log('[seedSession] OK');`,
|
|
461
|
+
].join('\n');
|
|
462
|
+
try {
|
|
463
|
+
writeFileSync(tmpFile, script);
|
|
464
|
+
execSync(`node ${tmpFile}`, { stdio: 'inherit', env });
|
|
465
|
+
} finally {
|
|
466
|
+
try {
|
|
467
|
+
unlinkSync(tmpFile);
|
|
468
|
+
} catch {
|
|
469
|
+
/* ignore */
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Multi-worker via the shard-supervisor pattern.
|
|
476
|
+
*
|
|
477
|
+
* Setting `TEST_OFFLINE_WORKERS=N` runs N parallel processes, each
|
|
478
|
+
* isolated end-to-end:
|
|
479
|
+
* - own Postgres DB (random UUID, no collision)
|
|
480
|
+
* - own Remix backend on `BASE_PORT + shardIndex`
|
|
481
|
+
* - own 5 mock servers on OS-assigned ports
|
|
482
|
+
* - own Playwright invocation with `--shard k/N` to partition tests
|
|
483
|
+
*
|
|
484
|
+
* Architecture:
|
|
485
|
+
* - Supervisor (no `TEST_OFFLINE_SHARD_INDEX` set, N>1):
|
|
486
|
+
* 1. Build cache check + build once (so shards don't race)
|
|
487
|
+
* 2. Orphan-DB cleanup once
|
|
488
|
+
* 3. Spawn N child processes (`node ... runOfflineFullTests.ts`)
|
|
489
|
+
* each with `TEST_OFFLINE_SHARD_INDEX=k`, `TEST_OFFLINE_SKIP_BUILD=true`
|
|
490
|
+
* 4. Stream child stdout/stderr prefixed with [shard k]
|
|
491
|
+
* 5. Wait for all, exit with worst exit code
|
|
492
|
+
* - Child (`TEST_OFFLINE_SHARD_INDEX` set):
|
|
493
|
+
* 1. Skip build (supervisor did it)
|
|
494
|
+
* 2. Skip orphan cleanup (supervisor did it)
|
|
495
|
+
* 3. Use BASE_PORT + (shardIndex-1) for backend
|
|
496
|
+
* 4. Per-shard Playwright HTML report dir
|
|
497
|
+
* 5. Append `--shard k/N` to playwright args
|
|
498
|
+
*
|
|
499
|
+
* Why a process-per-shard vs. multi-slot-in-one-process:
|
|
500
|
+
* - Avoids ~500-line refactor of main() to manage N parallel
|
|
501
|
+
* boots / closes / signal handlers
|
|
502
|
+
* - Keeps Playwright's "one worker per process" model intact
|
|
503
|
+
* - Each shard is a clean unit; crashes don't cross-contaminate
|
|
504
|
+
* - Cost: ~5s extra per shard for backend boot (parallelized)
|
|
505
|
+
*/
|
|
506
|
+
async function main(): Promise<void> {
|
|
507
|
+
// Hard gate: this orchestrator only runs inside the offline container.
|
|
508
|
+
// The whole offline architecture (edge proxy on :443, /etc/hosts
|
|
509
|
+
// for *.myshopify.com, system-CA-trusted self-signed cert, port
|
|
510
|
+
// 443 bind) depends on running in a Linux microVM where:
|
|
511
|
+
// - /etc/hosts modifications stick for the test session
|
|
512
|
+
// - port 443 is bindable (we're root in the container)
|
|
513
|
+
// - the system CA store can be updated without sudo theater
|
|
514
|
+
// - a fresh DB + Postgres come with the image
|
|
515
|
+
//
|
|
516
|
+
// Running host-native would either need a different cert/port/DNS
|
|
517
|
+
// strategy or invasive Chromium/Node bypasses. We tried that path
|
|
518
|
+
// (page.route() + host-resolver-rules + a Node socket-layer shim);
|
|
519
|
+
// it worked but maintained ~400 lines of bypass code in parallel.
|
|
520
|
+
// Use `npm run test:offline-full` from the consuming app — it
|
|
521
|
+
// routes through `runDockerOffline.ts` which spawns the
|
|
522
|
+
// `essential-upsell-test:latest` image where TEST_IN_CONTAINER=true
|
|
523
|
+
// is set by docker/entrypoint.sh.
|
|
524
|
+
if (process.env['TEST_IN_CONTAINER'] !== 'true') {
|
|
525
|
+
console.error(
|
|
526
|
+
'[runOfflineFull] FATAL: must run inside the offline container.\n' +
|
|
527
|
+
' Run: `npm run test:offline-full` (which invokes runDockerOffline.ts)\n' +
|
|
528
|
+
' If you are seeing this from inside what should be a container, the\n' +
|
|
529
|
+
" entrypoint script (tests/test-offline/docker/entrypoint.sh) didn't run or\n" +
|
|
530
|
+
" didn't export TEST_IN_CONTAINER=true.",
|
|
531
|
+
);
|
|
532
|
+
process.exit(2);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ── Offline-mode setup (idempotent, survives cold cache) ──
|
|
536
|
+
// The container entrypoint (tests/test-offline/docker/entrypoint.sh) does
|
|
537
|
+
// this when node_modules is already populated at container start.
|
|
538
|
+
// But on a FRESH Linux node_modules cache, the entrypoint runs
|
|
539
|
+
// BEFORE `npm install`, so the cert isn't on disk yet and the
|
|
540
|
+
// entrypoint's offline-setup block is skipped. Re-apply it here
|
|
541
|
+
// now that we know node_modules exists.
|
|
542
|
+
ensureOfflineSetup();
|
|
543
|
+
|
|
544
|
+
// ── Supervisor branch ─────────────────────────────────────
|
|
545
|
+
// Default to 1 (serial). The supervisor model spawns N child
|
|
546
|
+
// processes inside ONE libkrun container; each child boots its
|
|
547
|
+
// OWN edge proxy on :443 for the *.myshopify.com → 127.0.0.1
|
|
548
|
+
// hosts-file redirect. Inside a single network namespace only one
|
|
549
|
+
// bind to :443 wins — N>1 here fails with EADDRINUSE.
|
|
550
|
+
//
|
|
551
|
+
// For real parallelism, use `runIsolatedDockerOffline.ts` (one
|
|
552
|
+
// libkrun microVM per test) — each VM has its own network ns so
|
|
553
|
+
// :443 is per-VM. That's what `npm run test:offline-full:isolated`
|
|
554
|
+
// invokes; set `TEST_PARALLEL_VMS=N` to scale.
|
|
555
|
+
//
|
|
556
|
+
// Set `TEST_OFFLINE_WORKERS=N` (≥2) here only when the supervisor's
|
|
557
|
+
// edge-proxy-per-shard collision is fixed; today it's a footgun.
|
|
558
|
+
const requestedWorkers = Math.max(
|
|
559
|
+
1,
|
|
560
|
+
Number(process.env['TEST_OFFLINE_WORKERS'] ?? '1'),
|
|
561
|
+
);
|
|
562
|
+
const shardIndex = process.env['TEST_OFFLINE_SHARD_INDEX']
|
|
563
|
+
? Number(process.env['TEST_OFFLINE_SHARD_INDEX'])
|
|
564
|
+
: null;
|
|
565
|
+
if (requestedWorkers > 1 && shardIndex === null) {
|
|
566
|
+
process.exit(await runSupervisor(requestedWorkers));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// From here on we're either a single-worker run (shardIndex
|
|
570
|
+
// null, requestedWorkers === 1) OR a child shard. Same code
|
|
571
|
+
// path; child uses shardIndex to compute its port.
|
|
572
|
+
|
|
573
|
+
// Minimal env probe: we don't need the full online-mode `.env.test`
|
|
574
|
+
// schema, but we DO want SCOPES to match what the app expects.
|
|
575
|
+
const scopes = process.env['SCOPES'] ?? 'write_products,read_products,write_discounts,read_discounts';
|
|
576
|
+
const playwrightConfig =
|
|
577
|
+
process.env['TEST_PLAYWRIGHT_CONFIG'] ??
|
|
578
|
+
'tests/test-offline/playwright.offline-full.config.ts';
|
|
579
|
+
const isChildShard = shardIndex !== null;
|
|
580
|
+
// Backend port: shard k uses BASE + (k-1); single-worker uses BASE.
|
|
581
|
+
const baseBackendPort = Number(process.env['TEST_OFFLINE_BACKEND_PORT'] ?? '8181');
|
|
582
|
+
const backendPort = String(
|
|
583
|
+
isChildShard ? baseBackendPort + (shardIndex - 1) : baseBackendPort,
|
|
584
|
+
);
|
|
585
|
+
// Production Remix serves via plain HTTP (remix-serve has no TLS).
|
|
586
|
+
// Vite dev had self-signed HTTPS; offline-full uses HTTP. The mock
|
|
587
|
+
// admin shell serves the iframe pointing here; mixed-content
|
|
588
|
+
// warnings are silenced via Chrome flags in the offlineFullStack
|
|
589
|
+
// fixture.
|
|
590
|
+
const backendUrl = `http://localhost:${backendPort}`;
|
|
591
|
+
// Skip the build if TEST_OFFLINE_SKIP_BUILD=true and a recent build
|
|
592
|
+
// exists — saves ~30s on iterative runs. Default: always build for
|
|
593
|
+
// a fresh, deterministic result.
|
|
594
|
+
const skipBuild = process.env['TEST_OFFLINE_SKIP_BUILD'] === 'true';
|
|
595
|
+
|
|
596
|
+
// ── Postgres ──────────────────────────────────────────────
|
|
597
|
+
const appName = readAppName();
|
|
598
|
+
const dbPrefix = `${appName}_offline`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
599
|
+
const dbName = `${dbPrefix}_${randomUUID().replace(/-/g, '_')}`;
|
|
600
|
+
const dbConn = dbUrl(dbName);
|
|
601
|
+
const preserveDb = process.env['TEST_PRESERVE_DB'] === 'true';
|
|
602
|
+
|
|
603
|
+
console.log(`[runOfflineFull] Test DB: ${dbConn}`);
|
|
604
|
+
console.log(`[runOfflineFull] Backend: ${backendUrl}`);
|
|
605
|
+
|
|
606
|
+
// Drop orphan DBs from previous crashed runs. The supervisor
|
|
607
|
+
// already did this once — children skip.
|
|
608
|
+
if (!isChildShard) {
|
|
609
|
+
dropOrphanDatabases(dbPrefix);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
console.log('[runOfflineFull] createdb…');
|
|
613
|
+
execSync(`createdb -h ${PG_HOST} -p ${PG_PORT} ${dbName}`, { stdio: 'inherit' });
|
|
614
|
+
|
|
615
|
+
// ── Mock servers (in-process, sharing one ShopState) ─────
|
|
616
|
+
const state = new ShopState({
|
|
617
|
+
shop: { domain: DEFAULT_SHOP_DOMAIN, permanent_domain: DEFAULT_SHOP_DOMAIN },
|
|
618
|
+
});
|
|
619
|
+
const shopOrigin = `https://${DEFAULT_SHOP_DOMAIN}`;
|
|
620
|
+
|
|
621
|
+
// Populate `state.settings` from the active theme's
|
|
622
|
+
// `config/settings_data.json` so Dawn's CSS-variable scaffolding
|
|
623
|
+
// (`{{ settings.color_schemes }}`, `{{ settings.type_body_font }}`,
|
|
624
|
+
// etc.) renders proper CSS. Without this every storefront page
|
|
625
|
+
// ships with empty CSS variables and looks unstyled.
|
|
626
|
+
state.loadThemeSettingsFrom(dawn);
|
|
627
|
+
|
|
628
|
+
// Explore mode shows the storefront to a human, so populate it
|
|
629
|
+
// with a small set of products + a `frontpage` collection so
|
|
630
|
+
// Dawn's featured-collection section renders something other
|
|
631
|
+
// than its onboarding placeholders. Tests don't get this seed —
|
|
632
|
+
// they seed exactly what they need to assert on, which is the
|
|
633
|
+
// entire point of fresh state per test.
|
|
634
|
+
if (process.env['TEST_OFFLINE_EXPLORE'] === 'true') {
|
|
635
|
+
seedExploreProducts(state);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
console.log('[runOfflineFull] booting mock servers…');
|
|
639
|
+
// 127.0.0.1 binding is the safe default — orchestrator and child
|
|
640
|
+
// both reach the mocks on loopback. Used to be subtly broken
|
|
641
|
+
// because we ran Playwright via execSync (sync, blocks the event
|
|
642
|
+
// loop), so the mocks couldn't accept connections during the
|
|
643
|
+
// test run. Now we use spawn() which keeps the loop spinning.
|
|
644
|
+
const HOSTNAME = '127.0.0.1';
|
|
645
|
+
const startOpts = { port: 0, hostname: HOSTNAME };
|
|
646
|
+
|
|
647
|
+
// Optional theme-app-extensions to inject into the storefront. Tests
|
|
648
|
+
// that exercise the storefront-rendering side of the app
|
|
649
|
+
// (e.g. funnel published in admin → visible on PDP) set this so
|
|
650
|
+
// the extension's blocks/app-embed.liquid actually runs.
|
|
651
|
+
//
|
|
652
|
+
// The env var `TEST_OFFLINE_EXTENSIONS_JSON` is a JSON array of
|
|
653
|
+
// `{ name, rootDir, enabled }` entries. Paths may be absolute or
|
|
654
|
+
// relative to the consuming app's cwd. Empty/unset → no extensions
|
|
655
|
+
// (legacy behavior: smoke tests don't need this).
|
|
656
|
+
const extensions = loadExtensions();
|
|
657
|
+
|
|
658
|
+
// Optional path to the built post-purchase extension JS. When
|
|
659
|
+
// set, the storefront mock serves a synthetic post-purchase
|
|
660
|
+
// host at `/checkouts/:token/post_purchase` that loads the JS
|
|
661
|
+
// and drives its ShouldRender callback. Env-driven so the
|
|
662
|
+
// shopify-test framework stays app-agnostic.
|
|
663
|
+
const postPurchaseExtensionPath = process.env['TEST_OFFLINE_POST_PURCHASE_EXT']
|
|
664
|
+
? resolve(process.cwd(), process.env['TEST_OFFLINE_POST_PURCHASE_EXT']!)
|
|
665
|
+
: undefined;
|
|
666
|
+
if (postPurchaseExtensionPath && !existsSync(postPurchaseExtensionPath)) {
|
|
667
|
+
throw new Error(
|
|
668
|
+
`[runOfflineFull] TEST_OFFLINE_POST_PURCHASE_EXT points at ${postPurchaseExtensionPath} but the file doesn't exist. ` +
|
|
669
|
+
`Build the extension first — the bundled .js must exist on disk before the test run.`,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// The orchestrator owns the DB lifecycle: we build the
|
|
674
|
+
// reset-DB hook here (it captures the per-run dbName) and pass
|
|
675
|
+
// it into the storefront mock. The control plane exposes it at
|
|
676
|
+
// `POST /__test__/reset-db`. This keeps the consuming app
|
|
677
|
+
// test-agnostic — no special routes shipped in the backend.
|
|
678
|
+
// `scopes` is computed at the top of main(); reuse it here.
|
|
679
|
+
const resetDbHook = makeResetDbHook(dbName, DEFAULT_SHOP_DOMAIN, scopes);
|
|
680
|
+
|
|
681
|
+
const storefrontMock: StartedStorefront = await startStorefront(
|
|
682
|
+
createStorefrontApp({
|
|
683
|
+
state,
|
|
684
|
+
theme: dawn,
|
|
685
|
+
originForRender: shopOrigin,
|
|
686
|
+
enableControlPlane: true,
|
|
687
|
+
extensions,
|
|
688
|
+
hooks: { resetDb: resetDbHook },
|
|
689
|
+
...(postPurchaseExtensionPath
|
|
690
|
+
? { postPurchaseExtensionPath }
|
|
691
|
+
: {}),
|
|
692
|
+
}),
|
|
693
|
+
startOpts,
|
|
694
|
+
);
|
|
695
|
+
const adminApi: StartedAdminApi = await startAdminApi(createAdminApi({ state }), startOpts);
|
|
696
|
+
const storefrontApi: StartedStorefrontApi = await startStorefrontApi(
|
|
697
|
+
createStorefrontApi({ state }),
|
|
698
|
+
startOpts,
|
|
699
|
+
);
|
|
700
|
+
const partnerApi: StartedPartnerApi = await startPartnerApi(
|
|
701
|
+
createPartnerApi({ state }),
|
|
702
|
+
startOpts,
|
|
703
|
+
);
|
|
704
|
+
const adminShell: StartedMockAdmin = await startMockAdmin(
|
|
705
|
+
createMockAdminApp({
|
|
706
|
+
state,
|
|
707
|
+
appUrl: backendUrl,
|
|
708
|
+
clientId: MOCK_CLIENT_ID,
|
|
709
|
+
jwtSecret: DEFAULT_MOCK_JWT_SECRET,
|
|
710
|
+
}),
|
|
711
|
+
startOpts,
|
|
712
|
+
);
|
|
713
|
+
console.log(
|
|
714
|
+
`[runOfflineFull] mocks ready:\n` +
|
|
715
|
+
` shell: ${adminShell.baseUrl}\n` +
|
|
716
|
+
` admin GQL: ${adminApi.baseUrl}\n` +
|
|
717
|
+
` storefront GQL: ${storefrontApi.baseUrl}\n` +
|
|
718
|
+
` storefront: ${storefrontMock.baseUrl}\n` +
|
|
719
|
+
` partner GQL: ${partnerApi.baseUrl}`,
|
|
720
|
+
);
|
|
721
|
+
// Self-probe: confirm each mock is reachable from this process.
|
|
722
|
+
const probes: Array<{ name: string; url: string; init: RequestInit }> = [
|
|
723
|
+
{ name: 'shell', url: `${adminShell.baseUrl}/store/test/apps/x`, init: { method: 'GET' } },
|
|
724
|
+
{ name: 'admin GQL', url: `${adminApi.baseUrl}/admin/api/2025-07/graphql.json`, init: { method: 'POST', body: '{"query":"{ __typename }"}', headers: { 'content-type': 'application/json' } } },
|
|
725
|
+
{ name: 'storefront GQL', url: `${storefrontApi.baseUrl}/api/2025-07/graphql.json`, init: { method: 'POST', body: '{"query":"{ __typename }"}', headers: { 'content-type': 'application/json' } } },
|
|
726
|
+
{ name: 'storefront', url: `${storefrontMock.baseUrl}/`, init: { method: 'GET' } },
|
|
727
|
+
{ name: 'storefront control', url: `${storefrontMock.baseUrl}/__test__/state/snapshot`, init: { method: 'GET' } },
|
|
728
|
+
];
|
|
729
|
+
for (const p of probes) {
|
|
730
|
+
try {
|
|
731
|
+
const r = await fetch(p.url, p.init);
|
|
732
|
+
console.log(`[runOfflineFull] probe ${p.name}: ${r.status}`);
|
|
733
|
+
} catch (e) {
|
|
734
|
+
console.log(`[runOfflineFull] probe ${p.name}: FAILED ${(e as Error).message}`);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// ── Edge proxy (HTTPS) ──────────────────────────────────────
|
|
739
|
+
// Single HTTPS endpoint that takes browser requests for Shopify
|
|
740
|
+
// hostnames (test-shop.myshopify.com, admin.shopify.com,
|
|
741
|
+
// cdn.shopify.com) and dispatches to the right internal mock by
|
|
742
|
+
// Host + path.
|
|
743
|
+
//
|
|
744
|
+
// The browser and Node both reach this proxy at port 443 because
|
|
745
|
+
// the container's `/etc/hosts` maps Shopify hostnames to 127.0.0.1
|
|
746
|
+
// (kernel-layer DNS rewrite — see tests/test-offline/docker/entrypoint.sh),
|
|
747
|
+
// and the edge's self-signed cert is installed as a system CA so
|
|
748
|
+
// TLS validates natively. No Chromium flags or Node monkey-patches
|
|
749
|
+
// required — DNS + TLS work the same way they would against real
|
|
750
|
+
// Shopify in production.
|
|
751
|
+
//
|
|
752
|
+
// Why an edge proxy at all (vs. running each mock on a unique port):
|
|
753
|
+
// because real Shopify serves storefront, admin shell, admin API,
|
|
754
|
+
// storefront API, and CDN all from a small set of well-known
|
|
755
|
+
// hostnames — different paths, same hosts. The edge replicates that
|
|
756
|
+
// dispatch, so tests use real-shaped URLs throughout.
|
|
757
|
+
//
|
|
758
|
+
// We bind dual-stack `::` so /etc/hosts entries for both 127.0.0.1
|
|
759
|
+
// and ::1 reach the same listener — Chrome inside the container
|
|
760
|
+
// sometimes prefers IPv6 when AAAA records exist for the hostname.
|
|
761
|
+
const edge = await startEdgeProxy({
|
|
762
|
+
backends: {
|
|
763
|
+
storefront: storefrontMock.baseUrl,
|
|
764
|
+
adminApi: adminApi.baseUrl,
|
|
765
|
+
storefrontApi: storefrontApi.baseUrl,
|
|
766
|
+
adminShell: adminShell.baseUrl,
|
|
767
|
+
},
|
|
768
|
+
shopDomain: DEFAULT_SHOP_DOMAIN,
|
|
769
|
+
// Previously bound dual-stack `::` so /etc/hosts entries for
|
|
770
|
+
// both 127.0.0.1 and ::1 hit the same listener — but on the
|
|
771
|
+
// VM kernel, listen() on `::` blocks forever
|
|
772
|
+
// (no 'error' event, no callback). 0.0.0.0 binds IPv4 only and
|
|
773
|
+
// works reliably. /etc/hosts uses 127.0.0.1 entries anyway, so
|
|
774
|
+
// IPv6 isn't needed in practice.
|
|
775
|
+
hostname: '0.0.0.0',
|
|
776
|
+
port: 443,
|
|
777
|
+
});
|
|
778
|
+
console.log(`[runOfflineFull] edge proxy: ${edge.baseUrl}`);
|
|
779
|
+
|
|
780
|
+
// ── Neon-local WS proxy (zero-app-touch) ──────────────────
|
|
781
|
+
// Bridge the app's @neondatabase/serverless driver to THIS run's local
|
|
782
|
+
// Postgres without the app changing its prisma client. The shim
|
|
783
|
+
// (preloaded into the backend below) points neonConfig.wsProxy here;
|
|
784
|
+
// the proxy reads the driver's `?address=` and forwards to Postgres.
|
|
785
|
+
const neonProxy = await startNeonWsProxy({ targetHost: PG_HOST, targetPort: Number(PG_PORT) });
|
|
786
|
+
console.log(
|
|
787
|
+
`[runOfflineFull] neon ws-proxy: 127.0.0.1:${neonProxy.port} → Postgres ${PG_HOST}:${PG_PORT}`,
|
|
788
|
+
);
|
|
789
|
+
|
|
790
|
+
// ── Backend env ───────────────────────────────────────────
|
|
791
|
+
const mswLoader = resolveMswLoaderPath();
|
|
792
|
+
const neonShim = resolveNeonShimPath();
|
|
793
|
+
const backendEnv: NodeJS.ProcessEnv = {
|
|
794
|
+
// Forward the consuming app's declared test env. The orchestrator is
|
|
795
|
+
// launched with `node --env-file=.env.test`, so .env.test's values
|
|
796
|
+
// are already in `process.env`; spreading it lets the Remix backend
|
|
797
|
+
// see app-specific vars that modules read at IMPORT time — e.g. SDK
|
|
798
|
+
// clients constructed at module scope. Remix's server bundle eagerly
|
|
799
|
+
// evaluates every route module on boot, so an app whose
|
|
800
|
+
// `anthropic.server.ts` does `new Anthropic({ apiKey: process.env… })`
|
|
801
|
+
// at top level throws "Could not resolve authentication method" and
|
|
802
|
+
// the backend never boots. Every wiring-/security-critical key below
|
|
803
|
+
// is assigned AFTER the spread, so the mock wiring always wins (local
|
|
804
|
+
// Postgres DATABASE_URL, mock-JWT SHOPIFY_API_SECRET, MSW/neon
|
|
805
|
+
// NODE_OPTIONS, mock API URLs, TEST_OFFLINE). Also carries through
|
|
806
|
+
// PRISMA_QUERY_ENGINE_LIBRARY (the linux-arm64 engine pin) the
|
|
807
|
+
// backend's prisma client needs in-VM.
|
|
808
|
+
...process.env,
|
|
809
|
+
PATH: process.env['PATH'] ?? '',
|
|
810
|
+
// Empty-string fallback would tell child processes "HOME is set but
|
|
811
|
+
// empty" — npm in particular then writes its cache to literal `~/.npm`
|
|
812
|
+
// relative to cwd. Inside the VM cwd is /workspace
|
|
813
|
+
// (host-mounted), so that materializes as a stray `~/` directory in
|
|
814
|
+
// the consuming project on the host. Use a real path instead.
|
|
815
|
+
HOME: process.env['HOME'] || '/root',
|
|
816
|
+
USER: process.env['USER'] ?? '',
|
|
817
|
+
NODE_ENV: 'test',
|
|
818
|
+
DATABASE_URL: dbConn,
|
|
819
|
+
DIRECT_URL: dbConn,
|
|
820
|
+
// Apps that keep a SECOND database (e.g. an analytics event store the
|
|
821
|
+
// app reaches with a different client — drizzle + @neondatabase/serverless
|
|
822
|
+
// at ANALYTICS_DATABASE_URL) point it at the same offline Postgres, like
|
|
823
|
+
// DATABASE_URL. Assigned AFTER the ...process.env spread so it overrides
|
|
824
|
+
// any real value carried in from .env.test — both so the offline client
|
|
825
|
+
// connects locally (via the same neon ws-proxy) and so a real analytics
|
|
826
|
+
// DB is never reachable from an offline run. Harmless for apps without it.
|
|
827
|
+
// The non-prisma tables this client expects are created by the optional
|
|
828
|
+
// extra-schema hook below (tests/test-offline/offline-extra-schema.sql).
|
|
829
|
+
ANALYTICS_DATABASE_URL: dbConn,
|
|
830
|
+
// Auth wired to the mock JWT secret — shopify-app-remix uses this
|
|
831
|
+
// to verify session JWTs minted by the mock App Bridge.
|
|
832
|
+
SHOPIFY_API_KEY: MOCK_CLIENT_ID,
|
|
833
|
+
SHOPIFY_API_SECRET: DEFAULT_MOCK_JWT_SECRET,
|
|
834
|
+
SCOPES: scopes,
|
|
835
|
+
SHOPIFY_APP_URL: backendUrl,
|
|
836
|
+
PORT: backendPort,
|
|
837
|
+
// MSW preload: the Remix process intercepts its outbound
|
|
838
|
+
// *.myshopify.com fetches and forwards to our Admin GraphQL mock.
|
|
839
|
+
// --trace-warnings turns the otherwise-stackless UNDICI-WS
|
|
840
|
+
// warning into something diagnosable. --unhandled-rejections=strict
|
|
841
|
+
// surfaces unhandled rejections as proper exits with stack.
|
|
842
|
+
NODE_OPTIONS:
|
|
843
|
+
`--import ${JSON.stringify(`file://${mswLoader}`)} ` +
|
|
844
|
+
`--import ${JSON.stringify(`file://${neonShim}`)} ` +
|
|
845
|
+
`--trace-warnings --unhandled-rejections=strict`,
|
|
846
|
+
// Tells neonLocalShim where the in-VM Neon WS proxy listens. The
|
|
847
|
+
// shim no-ops if the app doesn't use @neondatabase/serverless, so
|
|
848
|
+
// this is harmless for apps that bypass Neon in test.
|
|
849
|
+
TEST_OFFLINE_NEON_WSPROXY_PORT: String(neonProxy.port),
|
|
850
|
+
// Surface every MSW-intercepted request in stderr so we can see
|
|
851
|
+
// whether admin.graphql calls actually reach the mock. Default
|
|
852
|
+
// off in normal runs (chatty); on for debugging.
|
|
853
|
+
...(process.env['TEST_OFFLINE_MOCK_ADMIN_API_DEBUG']
|
|
854
|
+
? { TEST_OFFLINE_MOCK_ADMIN_API_DEBUG: process.env['TEST_OFFLINE_MOCK_ADMIN_API_DEBUG'] }
|
|
855
|
+
: {}),
|
|
856
|
+
TEST_OFFLINE_MOCK_ADMIN_API_URL: adminApi.baseUrl,
|
|
857
|
+
// Partner API mock URL — when set, the MSW loader installs a
|
|
858
|
+
// second handler intercepting partners.shopify.com fetches.
|
|
859
|
+
// The consuming app reads `SHOPIFY_PARTNER_*` env vars at
|
|
860
|
+
// runtime to build the Partner API URL; we synthesize plausible
|
|
861
|
+
// values pointing at the mock instead of real Shopify.
|
|
862
|
+
TEST_OFFLINE_MOCK_PARTNER_API_URL: partnerApi.baseUrl,
|
|
863
|
+
SHOPIFY_APP_ID: process.env['SHOPIFY_APP_ID'] ?? '999000000',
|
|
864
|
+
SHOPIFY_PARTNER_ORGANIZATION_ID:
|
|
865
|
+
process.env['SHOPIFY_PARTNER_ORGANIZATION_ID'] ?? '11111111',
|
|
866
|
+
SHOPIFY_PARTNER_ACCESS_TOKEN:
|
|
867
|
+
process.env['SHOPIFY_PARTNER_ACCESS_TOKEN'] ?? 'mock-partner-token',
|
|
868
|
+
BILLING_IS_TEST: process.env['BILLING_IS_TEST'] ?? 'true',
|
|
869
|
+
TEST_OFFLINE: 'true',
|
|
870
|
+
// Theme app extension UUID (harmless if absent).
|
|
871
|
+
...(process.env['SHOPIFY_UPSELLS_ID']
|
|
872
|
+
? { SHOPIFY_UPSELLS_ID: process.env['SHOPIFY_UPSELLS_ID'] }
|
|
873
|
+
: {}),
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
console.log('[runOfflineFull] prisma migrate deploy…');
|
|
877
|
+
// Strip the MSW loader from NODE_OPTIONS for prisma's invocation.
|
|
878
|
+
// MSW shouldn't intercept Postgres connections (it's HTTP-only),
|
|
879
|
+
// but loading it from inside `npx prisma` triggers a silent
|
|
880
|
+
// failure inside the container (the loader's undici interception
|
|
881
|
+
// setup conflicts with prisma's CLI lifecycle in ways that don't
|
|
882
|
+
// reproduce on the host's Node 24.0). prisma's job is `migrate
|
|
883
|
+
// deploy` — pure DB work, no Shopify API calls — so dropping the
|
|
884
|
+
// MSW loader here is safe and not even semantically different.
|
|
885
|
+
const prismaEnv = { ...backendEnv };
|
|
886
|
+
delete prismaEnv['NODE_OPTIONS'];
|
|
887
|
+
execSync('npx prisma migrate deploy', { stdio: 'inherit', env: prismaEnv });
|
|
888
|
+
|
|
889
|
+
// Optional app-provided extra schema. prisma migrate only creates the
|
|
890
|
+
// prisma-managed tables; an app may also read a second database with a
|
|
891
|
+
// different ORM (e.g. drizzle for analytics) whose tables prisma doesn't
|
|
892
|
+
// know about. If the consuming app ships
|
|
893
|
+
// tests/test-offline/offline-extra-schema.sql, apply it to the SAME offline
|
|
894
|
+
// Postgres (idempotent CREATE TABLE IF NOT EXISTS), via psql with the
|
|
895
|
+
// MSW-loader-stripped env, exactly like migrate. Skipped if absent, so it's
|
|
896
|
+
// a no-op for apps that don't need it.
|
|
897
|
+
const extraSchema = resolve(process.cwd(), 'tests/test-offline/offline-extra-schema.sql');
|
|
898
|
+
if (existsSync(extraSchema)) {
|
|
899
|
+
console.log('[runOfflineFull] applying extra schema (tests/test-offline/offline-extra-schema.sql)…');
|
|
900
|
+
execSync(
|
|
901
|
+
`psql -h ${PG_HOST} -p ${PG_PORT} -d ${dbName} -v ON_ERROR_STOP=1 -f ${JSON.stringify(extraSchema)}`,
|
|
902
|
+
{ stdio: 'inherit', env: prismaEnv },
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
console.log('[runOfflineFull] seeding session row…');
|
|
907
|
+
try {
|
|
908
|
+
seedSession(prismaEnv as Record<string, string>, DEFAULT_SHOP_DOMAIN);
|
|
909
|
+
} catch (err) {
|
|
910
|
+
console.warn(
|
|
911
|
+
`[runOfflineFull] session seed failed (continuing): ${(err as Error).message}`,
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ── Backend (Vite) ────────────────────────────────────────
|
|
916
|
+
let dev: ChildProcess | null = null;
|
|
917
|
+
|
|
918
|
+
/**
|
|
919
|
+
* Kill the backend child + drop the per-run DB. Used by:
|
|
920
|
+
* - the normal `finally` block at end of `main()`
|
|
921
|
+
* - the SIGINT/SIGTERM handlers below (Ctrl+C, parent kill)
|
|
922
|
+
*
|
|
923
|
+
* Idempotent: safe to call from both paths during teardown.
|
|
924
|
+
* Synchronous so it works inside signal handlers (Node only
|
|
925
|
+
* allows sync work there before the process exits).
|
|
926
|
+
*
|
|
927
|
+
* `preserveDb` (TEST_PRESERVE_DB=true) keeps the DB for
|
|
928
|
+
* post-mortem inspection — useful when chasing test-only data
|
|
929
|
+
* drift after a failure.
|
|
930
|
+
*/
|
|
931
|
+
const cleanup = (): void => {
|
|
932
|
+
if (dev && !dev.killed) {
|
|
933
|
+
try {
|
|
934
|
+
dev.kill('SIGTERM');
|
|
935
|
+
} catch {
|
|
936
|
+
// ignore — child may have already exited
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
// Stop the Neon WS proxy. Fire-and-forget: cleanup runs in sync
|
|
940
|
+
// signal-handler context, and the process is exiting anyway.
|
|
941
|
+
try {
|
|
942
|
+
void neonProxy.close();
|
|
943
|
+
} catch {
|
|
944
|
+
// ignore
|
|
945
|
+
}
|
|
946
|
+
if (!preserveDb) {
|
|
947
|
+
try {
|
|
948
|
+
// `--force` terminates straggler connections (the backend
|
|
949
|
+
// process may not have closed Prisma's pool yet). Falls
|
|
950
|
+
// back to plain dropdb on older psql installs.
|
|
951
|
+
execSync(
|
|
952
|
+
`dropdb --force -h ${PG_HOST} -p ${PG_PORT} ${dbName} 2>/dev/null || dropdb -h ${PG_HOST} -p ${PG_PORT} ${dbName}`,
|
|
953
|
+
{ stdio: 'ignore' },
|
|
954
|
+
);
|
|
955
|
+
} catch {
|
|
956
|
+
// Best-effort. Orphan cleanup at next run's start will get
|
|
957
|
+
// anything we leak here.
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Signal handler: ensure the DB drops before we exit. Node would
|
|
964
|
+
* otherwise skip the `finally` block in `main()` for SIGINT/
|
|
965
|
+
* SIGTERM, leaking the DB. We run cleanup, then re-raise with
|
|
966
|
+
* the conventional exit code (`128 + signal`).
|
|
967
|
+
*/
|
|
968
|
+
const onSignal = (sig: 'SIGINT' | 'SIGTERM'): void => {
|
|
969
|
+
console.log(`\n[runOfflineFull] received ${sig}, tearing down…`);
|
|
970
|
+
cleanup();
|
|
971
|
+
// 128 + signal number is the POSIX convention.
|
|
972
|
+
process.exit(sig === 'SIGINT' ? 130 : 143);
|
|
973
|
+
};
|
|
974
|
+
process.on('SIGINT', () => onSignal('SIGINT'));
|
|
975
|
+
process.on('SIGTERM', () => onSignal('SIGTERM'));
|
|
976
|
+
|
|
977
|
+
let exitCode = 1;
|
|
978
|
+
try {
|
|
979
|
+
// Production build → remix-serve (no Vite dev, no HMR). Vite dev
|
|
980
|
+
// mode injects HMR <link>/<script> tags client-side that aren't
|
|
981
|
+
// in the SSR HTML, breaking React 18's hydration check. The
|
|
982
|
+
// production build is a static SSR + client bundle pair with no
|
|
983
|
+
// such tag injection.
|
|
984
|
+
//
|
|
985
|
+
// Build-cache strategy: we hash the consuming app's source tree
|
|
986
|
+
// + package files + relevant configs. If that hash matches what
|
|
987
|
+
// we cached after the last successful build, skip the build.
|
|
988
|
+
// This makes CI-safe what `TEST_OFFLINE_SKIP_BUILD=true` does
|
|
989
|
+
// unsafely: the cache invalidates on ANY source change.
|
|
990
|
+
//
|
|
991
|
+
// Cache file lives in `build/.test-build-cache` (gitignored by
|
|
992
|
+
// virtue of being under the build/ output dir). Force a fresh
|
|
993
|
+
// build with `TEST_OFFLINE_FORCE_BUILD=true` (handy when
|
|
994
|
+
// suspecting the cache is stale, e.g. after a dependency
|
|
995
|
+
// update that didn't touch package.json hashes).
|
|
996
|
+
const buildCachePath = resolve(process.cwd(), 'build/.test-build-cache');
|
|
997
|
+
const currentBuildHash = await computeBuildHash(process.cwd());
|
|
998
|
+
const forceBuild = process.env['TEST_OFFLINE_FORCE_BUILD'] === 'true';
|
|
999
|
+
const cachedHash =
|
|
1000
|
+
!forceBuild && existsSync(buildCachePath) && existsSync('build/server/index.js')
|
|
1001
|
+
? readFileSync(buildCachePath, 'utf8').trim()
|
|
1002
|
+
: null;
|
|
1003
|
+
const skipBuildBecauseExplicit = skipBuild;
|
|
1004
|
+
const skipBuildBecauseCache = cachedHash === currentBuildHash;
|
|
1005
|
+
|
|
1006
|
+
if (skipBuildBecauseExplicit) {
|
|
1007
|
+
console.log('[runOfflineFull] skipping build (TEST_OFFLINE_SKIP_BUILD=true)');
|
|
1008
|
+
} else if (skipBuildBecauseCache) {
|
|
1009
|
+
console.log(
|
|
1010
|
+
`[runOfflineFull] build cache HIT (${currentBuildHash.slice(0, 12)}) — skipping npm run build. ` +
|
|
1011
|
+
`Force with TEST_OFFLINE_FORCE_BUILD=true.`,
|
|
1012
|
+
);
|
|
1013
|
+
} else {
|
|
1014
|
+
console.log(
|
|
1015
|
+
`[runOfflineFull] build cache MISS — running npm run build. ` +
|
|
1016
|
+
`(${cachedHash ? `was ${cachedHash.slice(0, 12)}, now ` : 'no cache, '}${currentBuildHash.slice(0, 12)})`,
|
|
1017
|
+
);
|
|
1018
|
+
execSync('npm run build', {
|
|
1019
|
+
stdio: 'inherit',
|
|
1020
|
+
env: { ...backendEnv, NODE_ENV: 'production' },
|
|
1021
|
+
});
|
|
1022
|
+
// Only write the cache file AFTER the build succeeds — a
|
|
1023
|
+
// crashed build leaves stale `build/` we shouldn't endorse.
|
|
1024
|
+
writeFileSync(buildCachePath, currentBuildHash);
|
|
1025
|
+
}
|
|
1026
|
+
console.log('[runOfflineFull] booting Remix prod server (remix-serve)…');
|
|
1027
|
+
// Resolve remix-serve's CLI path directly — `npx remix-serve`
|
|
1028
|
+
// on some systems alters NODE_OPTIONS (it spawns a child via
|
|
1029
|
+
// npm-cli with PATH manipulation), which silently drops our
|
|
1030
|
+
// --import preload. `node ./node_modules/.bin/remix-serve` is
|
|
1031
|
+
// a hard pointer.
|
|
1032
|
+
const remixServeBin = resolve(process.cwd(), 'node_modules/.bin/remix-serve');
|
|
1033
|
+
const backendRuntimeEnv: NodeJS.ProcessEnv = {
|
|
1034
|
+
...backendEnv,
|
|
1035
|
+
PORT: backendPort,
|
|
1036
|
+
// NODE_ENV stays 'test' at RUNTIME (we set 'production' only
|
|
1037
|
+
// for the build step above). app/lib/prisma.server.ts swaps
|
|
1038
|
+
// in the Neon serverless WebSocket adapter on
|
|
1039
|
+
// NODE_ENV=production — that's incompatible with our local
|
|
1040
|
+
// Postgres setup.
|
|
1041
|
+
NODE_ENV: 'test',
|
|
1042
|
+
};
|
|
1043
|
+
// remix-serve binds to process.env.HOST as its LISTEN address
|
|
1044
|
+
// (`app.listen(port, HOST)`). Apps commonly set HOST to their public
|
|
1045
|
+
// URL in .env (harmless on Vercel/prod, which never runs remix-serve)
|
|
1046
|
+
// — but a URL value makes app.listen throw `getaddrinfo ENOTFOUND
|
|
1047
|
+
// https://…` so the backend never boots. Drop it here so the server
|
|
1048
|
+
// binds all interfaces (loopback-reachable, like apps that don't set
|
|
1049
|
+
// HOST at all). Zero-app-touch: the runner owns the remix-serve
|
|
1050
|
+
// process, so it owns the bind address. (prismaEnv + the build step
|
|
1051
|
+
// keep HOST — only the server-listen step is sensitive to it.)
|
|
1052
|
+
delete backendRuntimeEnv.HOST;
|
|
1053
|
+
dev = spawn(
|
|
1054
|
+
remixServeBin,
|
|
1055
|
+
['./build/server/index.js'],
|
|
1056
|
+
{
|
|
1057
|
+
// Route the backend's stderr to OUR stdout (fd 1). The VM runner
|
|
1058
|
+
// only captures stdout, so plain 'inherit' loses backend stderr —
|
|
1059
|
+
// including unhandled action throws and the MSW preload logs,
|
|
1060
|
+
// which makes offline failures undebuggable.
|
|
1061
|
+
stdio: ['inherit', 'inherit', 1],
|
|
1062
|
+
env: backendRuntimeEnv,
|
|
1063
|
+
},
|
|
1064
|
+
);
|
|
1065
|
+
console.log(`[runOfflineFull] waiting for backend at ${backendUrl}…`);
|
|
1066
|
+
await waitForReady(backendUrl, READY_TIMEOUT_MS);
|
|
1067
|
+
console.log('[runOfflineFull] backend ready.');
|
|
1068
|
+
|
|
1069
|
+
// ── Pre-warm Remix routes ──────────────────────────────────
|
|
1070
|
+
// remix-serve lazy-compiles each route's loader/action module on
|
|
1071
|
+
// first authenticated request. The first /app navigation in a
|
|
1072
|
+
// test can take 5-15s on a cold backend (JIT for the route module,
|
|
1073
|
+
// Prisma client init, GraphQL schema parse, mock MSW interception
|
|
1074
|
+
// setup). For tests whose entire scope is "publish funnel +
|
|
1075
|
+
// customer checkout", that one-time hit dominates the per-test
|
|
1076
|
+
// budget — they spend 60-80s of a 90s timeout waiting for pages
|
|
1077
|
+
// to render the first time.
|
|
1078
|
+
//
|
|
1079
|
+
// The warm-up REQUEST must pass shopify-app-remix's authenticate
|
|
1080
|
+
// gate, otherwise we get a fast 302 to /auth/login and the route
|
|
1081
|
+
// module never loads (we saw a 14ms total warm-up that touched
|
|
1082
|
+
// nothing). Mint a real mock id_token signed with the same secret
|
|
1083
|
+
// the backend is configured with.
|
|
1084
|
+
//
|
|
1085
|
+
// Failures are non-fatal — the next real test request will
|
|
1086
|
+
// surface a clearer error than "warm-up failed".
|
|
1087
|
+
console.log('[runOfflineFull] pre-warming hot Remix routes…');
|
|
1088
|
+
const warmupToken = await mintIdToken({
|
|
1089
|
+
shop: DEFAULT_SHOP_DOMAIN,
|
|
1090
|
+
clientId: MOCK_CLIENT_ID,
|
|
1091
|
+
secret: DEFAULT_MOCK_JWT_SECRET,
|
|
1092
|
+
});
|
|
1093
|
+
const warmupHost = Buffer.from(`${DEFAULT_SHOP_DOMAIN}/admin`).toString(
|
|
1094
|
+
'base64',
|
|
1095
|
+
);
|
|
1096
|
+
const warmupQuery =
|
|
1097
|
+
`host=${encodeURIComponent(warmupHost)}` +
|
|
1098
|
+
`&shop=${DEFAULT_SHOP_DOMAIN}` +
|
|
1099
|
+
`&embedded=1` +
|
|
1100
|
+
`&id_token=${warmupToken}`;
|
|
1101
|
+
const warmupRoutes = [
|
|
1102
|
+
// The embedded-app entry — every test's first iframe goto hits
|
|
1103
|
+
// this. Loads `routes/app` (auth + layout) + `routes/app._index`
|
|
1104
|
+
// (dashboard loader + Polaris components). Two big modules.
|
|
1105
|
+
`/app?${warmupQuery}`,
|
|
1106
|
+
`/app?${warmupQuery}&_data=routes%2Fapp`,
|
|
1107
|
+
`/app?${warmupQuery}&_data=routes%2Fapp._index`,
|
|
1108
|
+
// Funnel-creation form — every "publish via admin UI" flow.
|
|
1109
|
+
`/app/funnels/new?${warmupQuery}`,
|
|
1110
|
+
`/app/funnels/new?${warmupQuery}&_data=routes%2Fapp.funnels.new`,
|
|
1111
|
+
];
|
|
1112
|
+
const t0 = Date.now();
|
|
1113
|
+
const warmupResults = await Promise.all(
|
|
1114
|
+
warmupRoutes.map(async (path) => {
|
|
1115
|
+
const tStart = Date.now();
|
|
1116
|
+
try {
|
|
1117
|
+
const resp = await fetch(`${backendUrl}${path}`, {
|
|
1118
|
+
headers: {
|
|
1119
|
+
authorization: `Bearer ${warmupToken}`,
|
|
1120
|
+
// shopify-app-remix's `isbot` check returns 410 for
|
|
1121
|
+
// Node's default User-Agent. Match the same UA the
|
|
1122
|
+
// browser context uses in tests (see offlineFullStack
|
|
1123
|
+
// fixture) so the request reaches the loader.
|
|
1124
|
+
'user-agent':
|
|
1125
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
|
|
1126
|
+
'(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
1127
|
+
},
|
|
1128
|
+
redirect: 'manual',
|
|
1129
|
+
});
|
|
1130
|
+
await resp.text().catch(() => '');
|
|
1131
|
+
return {
|
|
1132
|
+
path,
|
|
1133
|
+
status: resp.status,
|
|
1134
|
+
ms: Date.now() - tStart,
|
|
1135
|
+
location: resp.headers.get('location') ?? null,
|
|
1136
|
+
};
|
|
1137
|
+
} catch (err) {
|
|
1138
|
+
return { path, error: (err as Error).message, ms: Date.now() - tStart };
|
|
1139
|
+
}
|
|
1140
|
+
}),
|
|
1141
|
+
);
|
|
1142
|
+
console.log(
|
|
1143
|
+
`[runOfflineFull] route pre-warm done (${Date.now() - t0}ms):`,
|
|
1144
|
+
);
|
|
1145
|
+
for (const r of warmupResults) {
|
|
1146
|
+
const status =
|
|
1147
|
+
'error' in r ? `ERR ${r.error}` : `${r.status}${r.location ? ` → ${r.location}` : ''}`;
|
|
1148
|
+
console.log(` ${r.ms}ms ${status} ${r.path.split('?')[0]}`);
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// ── Explore mode (no Playwright; manual click-around) ──
|
|
1152
|
+
// When `TEST_OFFLINE_EXPLORE=true` is set, skip Playwright
|
|
1153
|
+
// entirely. Instead, launch a single non-headless Chrome on the
|
|
1154
|
+
// container's Xvfb display, pointed at the admin app's entry
|
|
1155
|
+
// URL. The container is started with `--publish 5900:5900` +
|
|
1156
|
+
// `TEST_ONLINE_VNC=1`, so the developer can connect from macOS via
|
|
1157
|
+
// open vnc://localhost:5900 (password: `test`)
|
|
1158
|
+
// and click through the admin and storefront against the online
|
|
1159
|
+
// mock stack. The process holds open until SIGINT/SIGTERM.
|
|
1160
|
+
//
|
|
1161
|
+
// This is the "interactive Shopify dev store" mode — useful
|
|
1162
|
+
// for visually inspecting funnel rendering, debugging admin
|
|
1163
|
+
// UI flows, mutating state via Prisma Studio in another tab,
|
|
1164
|
+
// etc.
|
|
1165
|
+
if (process.env['TEST_OFFLINE_EXPLORE'] === 'true') {
|
|
1166
|
+
const adminUrl =
|
|
1167
|
+
process.env['TEST_OFFLINE_EXPLORE_URL'] ??
|
|
1168
|
+
`https://admin.shopify.com/store/test/apps/${DEFAULT_APP_HANDLE}/app`;
|
|
1169
|
+
console.log('[runOfflineFull] explore mode — launching Chrome at:');
|
|
1170
|
+
console.log(` ${adminUrl}`);
|
|
1171
|
+
console.log('');
|
|
1172
|
+
console.log(' Useful URLs you can navigate to (all routed via the edge proxy):');
|
|
1173
|
+
console.log(` Admin app: ${adminUrl}`);
|
|
1174
|
+
console.log(` Storefront: https://${DEFAULT_SHOP_DOMAIN}/`);
|
|
1175
|
+
console.log(` PDP example: https://${DEFAULT_SHOP_DOMAIN}/products/placeholder`);
|
|
1176
|
+
console.log(` Cart: https://${DEFAULT_SHOP_DOMAIN}/cart`);
|
|
1177
|
+
console.log('');
|
|
1178
|
+
console.log(' Connect: open vnc://localhost:5900 (password: test)');
|
|
1179
|
+
console.log(' Stop: Ctrl-C');
|
|
1180
|
+
|
|
1181
|
+
// Drive Playwright's bundled chromium directly via its own
|
|
1182
|
+
// CLI, instead of the system google-chrome.
|
|
1183
|
+
//
|
|
1184
|
+
// Why: real google-chrome (v148 as of writing) crashes under
|
|
1185
|
+
// Apple `container`'s Rosetta amd64-on-arm64 translation with
|
|
1186
|
+
// `assertion failed [arm::is_brk_instruction(instruction)]:
|
|
1187
|
+
// SIGTRAP from kernel was not from a BRK`
|
|
1188
|
+
// after a few renderer signals. The same image runs Playwright
|
|
1189
|
+
// chromium (~v131) for tests with zero crashes, so the bug is
|
|
1190
|
+
// Chrome v148-specific, not a container/image bug. By using
|
|
1191
|
+
// Playwright's chromium for explore mode too, we get:
|
|
1192
|
+
// 1. No Rosetta crash (tests prove the binary is stable)
|
|
1193
|
+
// 2. Same browser tests use → identical behavior (a click
|
|
1194
|
+
// that works in a test works here)
|
|
1195
|
+
// 3. No need for system google-chrome in the image at all
|
|
1196
|
+
// (could be removed in a future image trim)
|
|
1197
|
+
//
|
|
1198
|
+
// Profile is wiped on every launch — persistent profiles
|
|
1199
|
+
// accumulate stale state (cookies pointing at old session
|
|
1200
|
+
// tokens, HSTS upgrades, cached error pages) that produced
|
|
1201
|
+
// mysterious "first page loads but click does nothing" bugs
|
|
1202
|
+
// across cert/proxy iterations. Fresh profile = same starting
|
|
1203
|
+
// point as a test.
|
|
1204
|
+
try {
|
|
1205
|
+
rmSync('/tmp/explore-profile', { recursive: true, force: true });
|
|
1206
|
+
} catch {
|
|
1207
|
+
/* ignore */
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
// Drive Playwright via its programmatic API directly — same
|
|
1211
|
+
// entrypoint the test fixture uses (`chromium.launchPersistentContext`).
|
|
1212
|
+
// The `playwright open` CLI is close but doesn't accept arbitrary
|
|
1213
|
+
// chromium flags (we need `--allow-running-insecure-content` and
|
|
1214
|
+
// the local-network-access disable-feature gang).
|
|
1215
|
+
// @playwright/test re-exports `chromium` (and friends) from
|
|
1216
|
+
// the underlying `playwright` package; using it avoids adding
|
|
1217
|
+
// a second top-level dep.
|
|
1218
|
+
const playwrightModule = await import('@playwright/test');
|
|
1219
|
+
const chromiumPkg = playwrightModule.chromium;
|
|
1220
|
+
assertInVm('launch a browser');
|
|
1221
|
+
const exploreCtx = await chromiumPkg.launchPersistentContext(
|
|
1222
|
+
'/tmp/explore-profile',
|
|
1223
|
+
{
|
|
1224
|
+
headless: false,
|
|
1225
|
+
ignoreHTTPSErrors: true,
|
|
1226
|
+
// Slightly smaller than the Xvfb screen (1600x1000) so the
|
|
1227
|
+
// browser window plus its DevTools panel both fit without
|
|
1228
|
+
// clipping off the right edge of the virtual display.
|
|
1229
|
+
viewport: { width: 1560, height: 960 },
|
|
1230
|
+
// Match the test fixture's UA so isbot heuristics + any
|
|
1231
|
+
// UA-keyed app logic behave identically.
|
|
1232
|
+
userAgent:
|
|
1233
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' +
|
|
1234
|
+
'(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
1235
|
+
args: [
|
|
1236
|
+
'--no-sandbox',
|
|
1237
|
+
'--no-first-run',
|
|
1238
|
+
'--no-default-browser-check',
|
|
1239
|
+
'--ip-address-space-overrides=127.0.0.1:0=public,[::1]:0=public',
|
|
1240
|
+
'--disable-features=LocalNetworkAccessChecks,LocalNetworkAccessChecksWebSockets,LocalNetworkAccessChecksWebTransport,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessRespectPreflightResults,PrivateNetworkAccessSendPreflights',
|
|
1241
|
+
'--local-network-access-permissions-policy-default-enabled',
|
|
1242
|
+
'--allow-running-insecure-content',
|
|
1243
|
+
'--disable-features=BlockInsecurePrivateNetworkRequestsFromPrivate,BlockInsecurePrivateNetworkRequestsFromUnknown',
|
|
1244
|
+
// Don't use --start-maximized — under Xvfb it can ask
|
|
1245
|
+
// for more pixels than the virtual display has and the
|
|
1246
|
+
// right edge gets clipped. Playwright's viewport setting
|
|
1247
|
+
// sizes the inner window precisely instead.
|
|
1248
|
+
'--window-position=0,0',
|
|
1249
|
+
// Auto-open DevTools so the developer can inspect
|
|
1250
|
+
// Network/Console while clicking through. The mock
|
|
1251
|
+
// stack has many moving parts (edge cert, /etc/hosts,
|
|
1252
|
+
// App Bridge mock, JWT bounce); when something silently
|
|
1253
|
+
// fails, having Network + Console one click away cuts
|
|
1254
|
+
// diagnosis from hours to seconds. Pass
|
|
1255
|
+
// TEST_OFFLINE_EXPLORE_NO_DEVTOOLS=1 to disable.
|
|
1256
|
+
...(process.env['TEST_OFFLINE_EXPLORE_NO_DEVTOOLS'] === '1'
|
|
1257
|
+
? []
|
|
1258
|
+
: ['--auto-open-devtools-for-tabs']),
|
|
1259
|
+
],
|
|
1260
|
+
},
|
|
1261
|
+
);
|
|
1262
|
+
const explorePage =
|
|
1263
|
+
exploreCtx.pages()[0] ?? (await exploreCtx.newPage());
|
|
1264
|
+
await explorePage.goto(adminUrl, { waitUntil: 'domcontentloaded' });
|
|
1265
|
+
|
|
1266
|
+
// Wait indefinitely. Exit triggers:
|
|
1267
|
+
// - Developer closes the browser window → context emits `close`.
|
|
1268
|
+
// - Container receives SIGINT/SIGTERM → we close the context
|
|
1269
|
+
// and resolve so the outer `finally` can tear down mocks.
|
|
1270
|
+
await new Promise<void>((resolveExit) => {
|
|
1271
|
+
const onSignal = () => {
|
|
1272
|
+
exploreCtx.close().catch(() => {});
|
|
1273
|
+
resolveExit();
|
|
1274
|
+
};
|
|
1275
|
+
exploreCtx.on('close', () => resolveExit());
|
|
1276
|
+
process.on('SIGINT', onSignal);
|
|
1277
|
+
process.on('SIGTERM', onSignal);
|
|
1278
|
+
});
|
|
1279
|
+
exitCode = 0;
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
// ── Playwright ─────────────────────────────────────────
|
|
1284
|
+
console.log('[runOfflineFull] running Playwright…');
|
|
1285
|
+
const playwrightEnv: NodeJS.ProcessEnv = {
|
|
1286
|
+
...process.env,
|
|
1287
|
+
// Expose the per-run test DB to specs so they can seed/inspect the
|
|
1288
|
+
// app's OWN Postgres directly (e.g. seed Article rows for
|
|
1289
|
+
// list/detail flows). The mock ShopState covers Shopify resources,
|
|
1290
|
+
// but the app DB has no control plane — and apps whose create flow
|
|
1291
|
+
// is gated behind external services (AI, queues) can't be seeded
|
|
1292
|
+
// via their action routes offline. The Playwright process runs in
|
|
1293
|
+
// the same VM as Postgres, so a plain `pg` client on this URL
|
|
1294
|
+
// reaches it directly (no Neon proxy needed).
|
|
1295
|
+
DATABASE_URL: dbConn,
|
|
1296
|
+
DIRECT_URL: dbConn,
|
|
1297
|
+
TEST_OFFLINE_ORCHESTRATED: 'true',
|
|
1298
|
+
TEST_OFFLINE_MOCK_ADMIN_SHELL_URL: adminShell.baseUrl,
|
|
1299
|
+
TEST_OFFLINE_MOCK_ADMIN_API_URL: adminApi.baseUrl,
|
|
1300
|
+
TEST_OFFLINE_MOCK_STOREFRONT_API_URL: storefrontApi.baseUrl,
|
|
1301
|
+
TEST_OFFLINE_MOCK_STOREFRONT_URL: storefrontMock.baseUrl,
|
|
1302
|
+
TEST_OFFLINE_MOCK_BACKEND_URL: backendUrl,
|
|
1303
|
+
TEST_OFFLINE_MOCK_PARTNER_API_URL: partnerApi.baseUrl,
|
|
1304
|
+
TEST_OFFLINE_MOCK_SHOP_DOMAIN: DEFAULT_SHOP_DOMAIN,
|
|
1305
|
+
TEST_OFFLINE_MOCK_APP_HANDLE: DEFAULT_APP_HANDLE,
|
|
1306
|
+
TEST_OFFLINE_MOCK_CLIENT_ID: MOCK_CLIENT_ID,
|
|
1307
|
+
TEST_OFFLINE_MOCK_EDGE_URL: edge.baseUrl,
|
|
1308
|
+
TEST_OFFLINE_MOCK_EDGE_PORT: String(edge.port),
|
|
1309
|
+
};
|
|
1310
|
+
// Use async spawn (NOT execSync) — execSync blocks the
|
|
1311
|
+
// orchestrator's event loop, so the in-process mock servers
|
|
1312
|
+
// can't accept connections from the Playwright child while
|
|
1313
|
+
// tests run. Subtle and ruins everything.
|
|
1314
|
+
// Build Playwright arg list. When this orchestrator is a child
|
|
1315
|
+
// shard, `--shard k/N` partitions the test files across all
|
|
1316
|
+
// shards (Playwright auto-distributes based on file count).
|
|
1317
|
+
// Per-shard HTML reports go to distinct dirs so they don't
|
|
1318
|
+
// collide on a shared workspace.
|
|
1319
|
+
const playwrightArgs: string[] = [
|
|
1320
|
+
'playwright',
|
|
1321
|
+
'test',
|
|
1322
|
+
'--config',
|
|
1323
|
+
playwrightConfig,
|
|
1324
|
+
];
|
|
1325
|
+
if (isChildShard && requestedWorkers > 1) {
|
|
1326
|
+
playwrightArgs.push('--shard', `${shardIndex}/${requestedWorkers}`);
|
|
1327
|
+
// Per-shard output dirs (defaults would clobber each other).
|
|
1328
|
+
playwrightArgs.push('--output', `test-results-shard-${shardIndex}`);
|
|
1329
|
+
playwrightEnv['PLAYWRIGHT_HTML_REPORT'] = `playwright-report-shard-${shardIndex}`;
|
|
1330
|
+
}
|
|
1331
|
+
// Forward user-supplied argv to Playwright — `--grep "name"`,
|
|
1332
|
+
// a file path, `--repeat-each`, etc. The docker runner already
|
|
1333
|
+
// forwards user args verbatim into this script's argv; we just
|
|
1334
|
+
// pass them through to playwright so single-test iteration is
|
|
1335
|
+
// possible (`npm run test:offline-full -- --grep "PDP form"`).
|
|
1336
|
+
// Skip our own known flags (currently none after the launcher
|
|
1337
|
+
// strips them — process.argv[0] is `node`, argv[1] is this
|
|
1338
|
+
// script).
|
|
1339
|
+
const userArgs = process.argv.slice(2).filter((a) => a.length > 0);
|
|
1340
|
+
if (userArgs.length > 0) {
|
|
1341
|
+
playwrightArgs.push(...userArgs);
|
|
1342
|
+
}
|
|
1343
|
+
const playwrightExitCode = await new Promise<number>((resolveCode, rejectCode) => {
|
|
1344
|
+
const proc = spawn('npx', playwrightArgs, { stdio: 'inherit', env: playwrightEnv });
|
|
1345
|
+
proc.on('exit', (code) => resolveCode(code ?? 1));
|
|
1346
|
+
proc.on('error', rejectCode);
|
|
1347
|
+
});
|
|
1348
|
+
if (playwrightExitCode !== 0) {
|
|
1349
|
+
throw new Error(`Playwright exited with code ${playwrightExitCode}`);
|
|
1350
|
+
}
|
|
1351
|
+
exitCode = 0;
|
|
1352
|
+
} catch (err) {
|
|
1353
|
+
console.error(`[runOfflineFull] failed: ${(err as Error).message}`);
|
|
1354
|
+
} finally {
|
|
1355
|
+
console.log('[runOfflineFull] tearing down…');
|
|
1356
|
+
// `cleanup()` kills the backend AND drops the DB (with
|
|
1357
|
+
// preserveDb honored). It's the same function the signal
|
|
1358
|
+
// handlers call, so the teardown path is identical whether
|
|
1359
|
+
// we exit gracefully or via Ctrl+C.
|
|
1360
|
+
cleanup();
|
|
1361
|
+
await sleep(1_000);
|
|
1362
|
+
|
|
1363
|
+
// Close mocks. Errors here shouldn't mask test exit code.
|
|
1364
|
+
await Promise.allSettled([
|
|
1365
|
+
edge.close(),
|
|
1366
|
+
adminShell.close(),
|
|
1367
|
+
adminApi.close(),
|
|
1368
|
+
storefrontApi.close(),
|
|
1369
|
+
storefrontMock.close(),
|
|
1370
|
+
partnerApi.close(),
|
|
1371
|
+
]);
|
|
1372
|
+
|
|
1373
|
+
if (preserveDb) {
|
|
1374
|
+
console.log(`[runOfflineFull] DB preserved: ${dbName}`);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
process.exit(exitCode);
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// computeBuildHash now lives in @essential-apps/shopify-test-core
|
|
1382
|
+
// (shared with runOffline's host build, so the host + in-VM build caches
|
|
1383
|
+
// agree). Imported at the top of this file.
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Parse `TEST_OFFLINE_EXTENSIONS_JSON` into an array of ExtensionConfig.
|
|
1387
|
+
* Schema (JSON array):
|
|
1388
|
+
* [{ name: string, rootDir: string, enabled?: boolean }, ...]
|
|
1389
|
+
*
|
|
1390
|
+
* `rootDir` may be absolute or relative to `process.cwd()` (the
|
|
1391
|
+
* consuming app's checkout). Validates that each rootDir exists and
|
|
1392
|
+
* fails fast otherwise — silently dropping mis-spelled paths leads
|
|
1393
|
+
* to "extension didn't render" mysteries.
|
|
1394
|
+
*/
|
|
1395
|
+
function loadExtensions(): ExtensionConfig[] {
|
|
1396
|
+
const raw = process.env['TEST_OFFLINE_EXTENSIONS_JSON'];
|
|
1397
|
+
if (!raw) return [];
|
|
1398
|
+
let parsed: unknown;
|
|
1399
|
+
try {
|
|
1400
|
+
parsed = JSON.parse(raw);
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
throw new Error(
|
|
1403
|
+
`[runOfflineFull] TEST_OFFLINE_EXTENSIONS_JSON is not valid JSON: ${(err as Error).message}`,
|
|
1404
|
+
);
|
|
1405
|
+
}
|
|
1406
|
+
if (!Array.isArray(parsed)) {
|
|
1407
|
+
throw new Error(
|
|
1408
|
+
`[runOfflineFull] TEST_OFFLINE_EXTENSIONS_JSON must be a JSON array; got ${typeof parsed}`,
|
|
1409
|
+
);
|
|
1410
|
+
}
|
|
1411
|
+
const out: ExtensionConfig[] = [];
|
|
1412
|
+
for (const entry of parsed) {
|
|
1413
|
+
if (typeof entry !== 'object' || entry === null) {
|
|
1414
|
+
throw new Error(`[runOfflineFull] extension entry must be an object: ${JSON.stringify(entry)}`);
|
|
1415
|
+
}
|
|
1416
|
+
const e = entry as { name?: unknown; rootDir?: unknown; enabled?: unknown };
|
|
1417
|
+
if (typeof e.name !== 'string' || typeof e.rootDir !== 'string') {
|
|
1418
|
+
throw new Error(
|
|
1419
|
+
`[runOfflineFull] each extension needs { name: string, rootDir: string }; got ${JSON.stringify(entry)}`,
|
|
1420
|
+
);
|
|
1421
|
+
}
|
|
1422
|
+
const rootDir = resolve(process.cwd(), e.rootDir);
|
|
1423
|
+
if (!existsSync(rootDir)) {
|
|
1424
|
+
throw new Error(`[runOfflineFull] extension rootDir does not exist: ${rootDir}`);
|
|
1425
|
+
}
|
|
1426
|
+
out.push({
|
|
1427
|
+
name: e.name,
|
|
1428
|
+
rootDir,
|
|
1429
|
+
enabled: e.enabled !== false,
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
if (out.length > 0) {
|
|
1433
|
+
console.log(
|
|
1434
|
+
`[runOfflineFull] loaded ${out.length} extension(s): ${out.map((e) => `${e.name}=${e.rootDir}`).join(', ')}`,
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
return out;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* Supervisor mode — fans out N child orchestrator processes.
|
|
1442
|
+
* Each child runs the same script with `TEST_OFFLINE_SHARD_INDEX=k`
|
|
1443
|
+
* + `TEST_OFFLINE_WORKERS=N` so it picks its slot via the
|
|
1444
|
+
* conditional at the top of `main()`.
|
|
1445
|
+
*
|
|
1446
|
+
* We do the build ONCE (before fanning out) and pass
|
|
1447
|
+
* `TEST_OFFLINE_SKIP_BUILD=true` to children to avoid N parallel
|
|
1448
|
+
* builds racing on the same `build/` directory. Orphan DB cleanup
|
|
1449
|
+
* also runs once here.
|
|
1450
|
+
*
|
|
1451
|
+
* Output streams are inherited (children print directly to our
|
|
1452
|
+
* stdout/stderr). Lines aren't prefixed by shard — Playwright's
|
|
1453
|
+
* own output already includes test paths which disambiguate.
|
|
1454
|
+
*
|
|
1455
|
+
* Returns the worst child exit code so the supervisor's exit
|
|
1456
|
+
* accurately reflects failure.
|
|
1457
|
+
*/
|
|
1458
|
+
|
|
1459
|
+
/**
|
|
1460
|
+
* Seed a small product catalog + featured collection for explore
|
|
1461
|
+
* mode. Just enough variety so Dawn's homepage sections render
|
|
1462
|
+
* something realistic — different price points, a "compare at"
|
|
1463
|
+
* (for on-sale styling), a fake image URL each so card layout
|
|
1464
|
+
* exercises both with-image and without-image paths.
|
|
1465
|
+
*
|
|
1466
|
+
* Not used by tests (they seed what they assert on).
|
|
1467
|
+
*/
|
|
1468
|
+
function seedExploreProducts(state: ShopState): void {
|
|
1469
|
+
// Image URLs point at the bundled Shopify-asset mirror — these are
|
|
1470
|
+
// the actual product photos from theme-dawn-demo.myshopify.com,
|
|
1471
|
+
// captured by the `mirror` probe. Browser requests are routed by
|
|
1472
|
+
// the edge proxy + `/__shopify-mirror/*` handler so they 200 with
|
|
1473
|
+
// real byte content (no broken-image placeholders during explore).
|
|
1474
|
+
//
|
|
1475
|
+
// Real Shopify normalizes uploaded image URLs to its CDN; we
|
|
1476
|
+
// emit `https://test-shop.myshopify.com/cdn/shop/products/<file>`
|
|
1477
|
+
// (per-shop CDN proxy shape) which our mirror handler resolves
|
|
1478
|
+
// host-agnostically by matching the `/cdn/shop/products/<file>`
|
|
1479
|
+
// suffix.
|
|
1480
|
+
const cdn = (file: string) =>
|
|
1481
|
+
`https://test-shop.myshopify.com/cdn/shop/products/${file}`;
|
|
1482
|
+
// Seed product titles match the actual photos mirrored from the
|
|
1483
|
+
// Dawn-demo store (it's a handbag store; the images are bags).
|
|
1484
|
+
// Mismatching titles + handbag photos looked broken — the user
|
|
1485
|
+
// saw "Snowboard" text with a purse photo. Aligning them keeps
|
|
1486
|
+
// explore mode visually coherent. Image filenames reproduce the
|
|
1487
|
+
// exact mirrored basenames (Shopify hashes are preserved).
|
|
1488
|
+
const fixtures: Array<{
|
|
1489
|
+
title: string;
|
|
1490
|
+
handle: string;
|
|
1491
|
+
price: number;
|
|
1492
|
+
compareAt?: number;
|
|
1493
|
+
description: string;
|
|
1494
|
+
image: string;
|
|
1495
|
+
}> = [
|
|
1496
|
+
{ title: 'Art Deco Bag', handle: 'art-deco-bag', price: 39900, description: 'Statement crossbody in cyclamen pink.', image: 'mlouye-art-deco-cyclamen-1_ec8e69b6-92ea-4c48-b8b6-34601cf3c070.jpg' },
|
|
1497
|
+
{ title: 'Bo Ivy Bag', handle: 'bo-ivy-bag', price: 28900, compareAt: 34900, description: 'Sculpted shoulder bag in emerald green.', image: 'mlouye-bo-ivy-emerald-1_73c3987e-5ec7-4e72-879a-2ba2e560648f.jpg' },
|
|
1498
|
+
{ title: 'Soft Strap Bag', handle: 'soft-strap-bag', price: 35900, description: 'Soft brown leather, structured strap.', image: 'mlouye-bo-soft-strap-brown-1.jpg' },
|
|
1499
|
+
{ title: 'Brick Mini Bag', handle: 'brick-mini-bag', price: 22900, description: 'Oil-yellow rectangular mini.', image: 'mlouye-brick-oil-yellow-1.jpg' },
|
|
1500
|
+
{ title: 'Business Bag', handle: 'business-bag', price: 49900, description: 'Black-and-grey carry-all.', image: 'mlouye-business-bag-black_grey-1.jpg' },
|
|
1501
|
+
{ title: 'Helix Multi Bag', handle: 'helix-multi-bag', price: 41900, description: 'Statement multicolor helix shape.', image: 'mlouye-helix-multicolor-2_1800x1800_10c62242-6743-4d46-a251-defa246dd195.jpg' },
|
|
1502
|
+
];
|
|
1503
|
+
for (const f of fixtures) {
|
|
1504
|
+
const src = cdn(f.image);
|
|
1505
|
+
const img = { src, alt: f.title, width: 800, height: 800 };
|
|
1506
|
+
state.addProduct({
|
|
1507
|
+
title: f.title,
|
|
1508
|
+
handle: f.handle,
|
|
1509
|
+
description: f.description,
|
|
1510
|
+
description_text: f.description,
|
|
1511
|
+
price: f.price,
|
|
1512
|
+
compare_at_price: f.compareAt ?? null,
|
|
1513
|
+
vendor: 'Snowboard Vendor',
|
|
1514
|
+
type: 'Snowboards',
|
|
1515
|
+
tags: ['Featured', 'New'],
|
|
1516
|
+
// Collection membership is wired by addCollection below.
|
|
1517
|
+
featured_image: img,
|
|
1518
|
+
// Dawn reads `card_product.featured_media`, NOT `featured_image`,
|
|
1519
|
+
// for product-card rendering — it's a Shopify "Media" object
|
|
1520
|
+
// with extra `.aspect_ratio` / `.preview_image` props. Without
|
|
1521
|
+
// this Dawn skips the image block on every product card.
|
|
1522
|
+
featured_media: {
|
|
1523
|
+
...img,
|
|
1524
|
+
aspect_ratio: 1,
|
|
1525
|
+
media_type: 'image',
|
|
1526
|
+
preview_image: img,
|
|
1527
|
+
},
|
|
1528
|
+
// Dawn's PDP main-product section reads `product.media` (an
|
|
1529
|
+
// ARRAY of Media objects) to build the gallery. Without this
|
|
1530
|
+
// the product page shows no image. `position` is the 1-indexed
|
|
1531
|
+
// slot in the gallery; we only have one.
|
|
1532
|
+
media: [
|
|
1533
|
+
{
|
|
1534
|
+
id: img.width * 100 + 1,
|
|
1535
|
+
position: 1,
|
|
1536
|
+
media_type: 'image',
|
|
1537
|
+
aspect_ratio: 1,
|
|
1538
|
+
preview_image: img,
|
|
1539
|
+
...img,
|
|
1540
|
+
},
|
|
1541
|
+
],
|
|
1542
|
+
images: [img],
|
|
1543
|
+
});
|
|
1544
|
+
}
|
|
1545
|
+
// Seed BOTH collections Dawn's default templates reference:
|
|
1546
|
+
// - `all` — referenced by `templates/index.json`'s
|
|
1547
|
+
// featured-collection section by default
|
|
1548
|
+
// - `frontpage` — referenced by various sections that look
|
|
1549
|
+
// for the merchant's main landing collection
|
|
1550
|
+
// Add the products to both so each section renders its own
|
|
1551
|
+
// grid even before the merchant edits the template.
|
|
1552
|
+
const handles = fixtures.map((f) => f.handle);
|
|
1553
|
+
state.addCollection({ handle: 'all', title: 'All products', productHandles: handles });
|
|
1554
|
+
state.addCollection({ handle: 'frontpage', title: 'Home page', productHandles: handles });
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
async function runSupervisor(workers: number): Promise<number> {
|
|
1558
|
+
console.log(`[runOfflineFull] supervisor: spawning ${workers} shard(s)…`);
|
|
1559
|
+
|
|
1560
|
+
// Pre-build: each child would otherwise duplicate the build (or
|
|
1561
|
+
// race on the cache). Build here once, then pass SKIP_BUILD=true
|
|
1562
|
+
// to children. The build cache check still runs (cheap), so this
|
|
1563
|
+
// is a no-op on warm caches.
|
|
1564
|
+
const appRoot = process.cwd();
|
|
1565
|
+
const buildCachePath = resolve(appRoot, 'build/.test-build-cache');
|
|
1566
|
+
const currentBuildHash = await computeBuildHash(appRoot);
|
|
1567
|
+
const cachedHash =
|
|
1568
|
+
existsSync(buildCachePath) && existsSync('build/server/index.js')
|
|
1569
|
+
? readFileSync(buildCachePath, 'utf8').trim()
|
|
1570
|
+
: null;
|
|
1571
|
+
if (cachedHash === currentBuildHash) {
|
|
1572
|
+
console.log(`[runOfflineFull] supervisor: build cache HIT (${currentBuildHash.slice(0, 12)})`);
|
|
1573
|
+
} else {
|
|
1574
|
+
console.log(`[runOfflineFull] supervisor: build cache MISS — running npm run build…`);
|
|
1575
|
+
execSync('npm run build', {
|
|
1576
|
+
stdio: 'inherit',
|
|
1577
|
+
// `TEST_OFFLINE=true` switches the consuming app's
|
|
1578
|
+
// vite.config.ts off of any production deployment preset
|
|
1579
|
+
// (e.g. Vercel's `vercelPreset()`) and back to the plain
|
|
1580
|
+
// Remix build that produces `build/server/index.js`. Without
|
|
1581
|
+
// this, presets that shard the output by runtime (e.g.
|
|
1582
|
+
// `build/server/nodejs-…/`) leave the expected `index.js`
|
|
1583
|
+
// path empty and the child shard's `remix-serve` errors with
|
|
1584
|
+
// ENOENT. The non-supervisor path (line ~911) already passes
|
|
1585
|
+
// this through `backendEnv`; we mirror it here so both build
|
|
1586
|
+
// paths produce the same artifact layout.
|
|
1587
|
+
env: { ...process.env, NODE_ENV: 'production', TEST_OFFLINE: 'true' },
|
|
1588
|
+
});
|
|
1589
|
+
writeFileSync(buildCachePath, currentBuildHash);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
// Orphan-DB cleanup once for the whole supervisor run.
|
|
1593
|
+
const appName = readAppName();
|
|
1594
|
+
const dbPrefix = `${appName}_offline`.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
|
1595
|
+
dropOrphanDatabases(dbPrefix);
|
|
1596
|
+
|
|
1597
|
+
// Spawn N children in parallel.
|
|
1598
|
+
const childScript = process.argv[1] ?? '';
|
|
1599
|
+
const children: Array<Promise<number>> = [];
|
|
1600
|
+
for (let k = 1; k <= workers; k++) {
|
|
1601
|
+
const childEnv: NodeJS.ProcessEnv = {
|
|
1602
|
+
...process.env,
|
|
1603
|
+
TEST_OFFLINE_SHARD_INDEX: String(k),
|
|
1604
|
+
TEST_OFFLINE_WORKERS: String(workers),
|
|
1605
|
+
TEST_OFFLINE_SKIP_BUILD: 'true',
|
|
1606
|
+
};
|
|
1607
|
+
children.push(
|
|
1608
|
+
new Promise<number>((resolveChild, rejectChild) => {
|
|
1609
|
+
// Re-exec ourselves through `node --import tsx` so TS still
|
|
1610
|
+
// parses. argv[1] points at the user's `runOfflineFullTests.ts`
|
|
1611
|
+
// by the time we're invoked from the consuming app's
|
|
1612
|
+
// npm script.
|
|
1613
|
+
const proc = spawn(
|
|
1614
|
+
process.execPath,
|
|
1615
|
+
['--import', 'tsx', childScript],
|
|
1616
|
+
{ stdio: 'inherit', env: childEnv },
|
|
1617
|
+
);
|
|
1618
|
+
proc.on('exit', (code) => resolveChild(code ?? 1));
|
|
1619
|
+
proc.on('error', rejectChild);
|
|
1620
|
+
}),
|
|
1621
|
+
);
|
|
1622
|
+
}
|
|
1623
|
+
const exitCodes = await Promise.all(children);
|
|
1624
|
+
const worst = exitCodes.reduce((a, b) => Math.max(a, b), 0);
|
|
1625
|
+
console.log(
|
|
1626
|
+
`[runOfflineFull] supervisor: shard exit codes [${exitCodes.join(', ')}]; overall=${worst}`,
|
|
1627
|
+
);
|
|
1628
|
+
return worst;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
main().catch((err) => {
|
|
1632
|
+
console.error(err);
|
|
1633
|
+
process.exit(1);
|
|
1634
|
+
});
|