@essential-apps/shopify-test-runner 1.0.12 → 1.0.14
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/edge/edgeProxy.d.ts.map +1 -1
- package/dist/edge/edgeProxy.js +18 -12
- package/dist/edge/edgeProxy.js.map +1 -1
- package/dist/lib/guestVnc.d.ts +31 -0
- package/dist/lib/guestVnc.d.ts.map +1 -0
- package/dist/lib/guestVnc.js +111 -0
- package/dist/lib/guestVnc.js.map +1 -0
- package/dist/probes/runProbe.js +0 -0
- package/dist/scripts/addStore.js +0 -0
- package/dist/scripts/buildImage.d.ts +3 -0
- package/dist/scripts/buildImage.d.ts.map +1 -0
- package/dist/scripts/{buildDockerImage.js → buildImage.js} +12 -10
- package/dist/scripts/buildImage.js.map +1 -0
- package/dist/scripts/captureAuth.js +0 -0
- package/dist/scripts/captureContracts.js +0 -0
- package/dist/scripts/captureRestContracts.js +0 -0
- package/dist/scripts/captureSharedContracts.d.ts +3 -0
- package/dist/scripts/captureSharedContracts.d.ts.map +1 -0
- package/dist/scripts/captureSharedContracts.js +209 -0
- package/dist/scripts/captureSharedContracts.js.map +1 -0
- package/dist/scripts/checkOperationCoverage.js +0 -0
- package/dist/scripts/cleanupStores.js +0 -0
- package/dist/scripts/createStores.js +0 -0
- package/dist/scripts/deployAppVersion.js +0 -0
- package/dist/scripts/devOnlineBackend.js +0 -0
- package/dist/scripts/installApp.js +0 -0
- package/dist/scripts/listStores.js +0 -0
- package/dist/scripts/runOffline.js +78 -1
- package/dist/scripts/runOffline.js.map +1 -1
- package/dist/scripts/runOfflineFullTests.js +49 -21
- package/dist/scripts/runOfflineFullTests.js.map +1 -1
- package/dist/scripts/runTests.js +0 -0
- package/dist/scripts/runVm.js +0 -0
- package/dist/scripts/runVmAuth.js +0 -0
- package/dist/scripts/setupTestDb.js +0 -0
- package/dist/scripts/verifyContracts.js +20 -29
- package/dist/scripts/verifyContracts.js.map +1 -1
- package/dist/scripts/verifyRestContracts.js +17 -30
- package/dist/scripts/verifyRestContracts.js.map +1 -1
- package/package.json +11 -9
- package/src/edge/edgeProxy.ts +19 -12
- package/src/lib/guestVnc.ts +147 -0
- package/src/scripts/{buildDockerImage.ts → buildImage.ts} +11 -9
- package/src/scripts/captureSharedContracts.ts +228 -0
- package/src/scripts/runOffline.ts +82 -1
- package/src/scripts/runOfflineFullTests.ts +56 -21
- package/src/scripts/verifyContracts.ts +22 -38
- package/src/scripts/verifyRestContracts.ts +23 -42
- package/dist/edge/nodeShim.d.ts +0 -2
- package/dist/edge/nodeShim.d.ts.map +0 -1
- package/dist/edge/nodeShim.js +0 -217
- package/dist/edge/nodeShim.js.map +0 -1
- package/dist/scripts/_probeSourceUrl.d.ts +0 -3
- package/dist/scripts/_probeSourceUrl.d.ts.map +0 -1
- package/dist/scripts/_probeSourceUrl.js +0 -119
- package/dist/scripts/_probeSourceUrl.js.map +0 -1
- package/dist/scripts/buildDockerImage.d.ts +0 -3
- package/dist/scripts/buildDockerImage.d.ts.map +0 -1
- package/dist/scripts/buildDockerImage.js.map +0 -1
- package/dist/scripts/devE2eBackend.d.ts +0 -3
- package/dist/scripts/devE2eBackend.d.ts.map +0 -1
- package/dist/scripts/devE2eBackend.js +0 -117
- package/dist/scripts/devE2eBackend.js.map +0 -1
- package/dist/scripts/runDocker.d.ts +0 -3
- package/dist/scripts/runDocker.d.ts.map +0 -1
- package/dist/scripts/runDocker.js +0 -88
- package/dist/scripts/runDocker.js.map +0 -1
- package/dist/scripts/runDockerAuth.d.ts +0 -3
- package/dist/scripts/runDockerAuth.d.ts.map +0 -1
- package/dist/scripts/runDockerAuth.js +0 -108
- package/dist/scripts/runDockerAuth.js.map +0 -1
- package/dist/scripts/runDockerOffline.d.ts +0 -3
- package/dist/scripts/runDockerOffline.d.ts.map +0 -1
- package/dist/scripts/runDockerOffline.js +0 -129
- package/dist/scripts/runDockerOffline.js.map +0 -1
- package/dist/scripts/runDockerOfflineExplore.d.ts +0 -3
- package/dist/scripts/runDockerOfflineExplore.d.ts.map +0 -1
- package/dist/scripts/runDockerOfflineExplore.js +0 -116
- package/dist/scripts/runDockerOfflineExplore.js.map +0 -1
- package/dist/scripts/runIsolatedDockerOffline.d.ts +0 -3
- package/dist/scripts/runIsolatedDockerOffline.d.ts.map +0 -1
- package/dist/scripts/runIsolatedDockerOffline.js +0 -351
- package/dist/scripts/runIsolatedDockerOffline.js.map +0 -1
- package/dist/scripts/runOfflineE2e.d.ts +0 -3
- package/dist/scripts/runOfflineE2e.d.ts.map +0 -1
- package/dist/scripts/runOfflineE2e.js +0 -408
- package/dist/scripts/runOfflineE2e.js.map +0 -1
- package/dist/scripts/runSupermachine.d.ts +0 -3
- package/dist/scripts/runSupermachine.d.ts.map +0 -1
- package/dist/scripts/runSupermachine.js +0 -474
- package/dist/scripts/runSupermachine.js.map +0 -1
- package/dist/scripts/runSupermachineAuth.d.ts +0 -3
- package/dist/scripts/runSupermachineAuth.d.ts.map +0 -1
- package/dist/scripts/runSupermachineAuth.js +0 -454
- package/dist/scripts/runSupermachineAuth.js.map +0 -1
- package/dist/vite/offlineConfig.d.ts +0 -34
- package/dist/vite/offlineConfig.d.ts.map +0 -1
- package/dist/vite/offlineConfig.js +0 -61
- package/dist/vite/offlineConfig.js.map +0 -1
- package/src/scripts/runDockerAuth.ts +0 -120
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Regenerate the SHARED contract goldens in
|
|
4
|
+
* `@essential-apps/shopify-test-contracts/fixtures` from the in-process
|
|
5
|
+
* offline mock — the "replayable-variable capture" generator.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists (the design the centralization landed on):
|
|
8
|
+
* A verify golden must be *mock-replayable* — `verify-contracts`
|
|
9
|
+
* replays its `source` + `variables` against the offline mock and
|
|
10
|
+
* asserts the response matches. A conformance capture (scrubbed LIVE
|
|
11
|
+
* variables like `ownerId: <NUMERIC_ID>`, a richer field selection)
|
|
12
|
+
* is NOT replayable: the mock can't resolve the placeholders. So the
|
|
13
|
+
* two are different artifacts.
|
|
14
|
+
*
|
|
15
|
+
* The clean split:
|
|
16
|
+
* - The verify golden is captured FROM THE MOCK with deterministic
|
|
17
|
+
* seed variables — replayable by construction. That's this script.
|
|
18
|
+
* - `@essential-apps/shopify-test-conformance` separately CERTIFIES
|
|
19
|
+
* the mock matches live Shopify (schema SDL + shape diffs). A
|
|
20
|
+
* golden that's mock-replayable AND mock-certified-vs-live is
|
|
21
|
+
* trustworthy end to end.
|
|
22
|
+
*
|
|
23
|
+
* This script keeps each golden's `operationName` / `source` /
|
|
24
|
+
* `variables` (so the *request* contract is stable) and refreshes only
|
|
25
|
+
* the `response` to the current mock output. Idempotent: unchanged
|
|
26
|
+
* responses aren't rewritten.
|
|
27
|
+
*
|
|
28
|
+
* node --import tsx packages/runner/src/scripts/captureSharedContracts.ts
|
|
29
|
+
* # or the bin: shopify-test-capture-shared-contracts
|
|
30
|
+
*/
|
|
31
|
+
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
32
|
+
import { resolve } from 'node:path';
|
|
33
|
+
import {
|
|
34
|
+
createAdminApi,
|
|
35
|
+
createStorefrontApi,
|
|
36
|
+
} from '@essential-apps/shopify-test-shopify-api';
|
|
37
|
+
import { ShopState } from '@essential-apps/shopify-test-storefront';
|
|
38
|
+
import {
|
|
39
|
+
fixturesDir,
|
|
40
|
+
type OperationContract,
|
|
41
|
+
type RestContract,
|
|
42
|
+
} from '@essential-apps/shopify-test-contracts/operation-contract';
|
|
43
|
+
import { normaliseResponse } from '../contracts/normalize.js';
|
|
44
|
+
|
|
45
|
+
const ADMIN_API_VERSION = '2025-07';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Structural equality under the same normalisation `verify` applies
|
|
49
|
+
* (gids → `<ID>`, timestamps → `<DATETIME>`, …). We rewrite a golden
|
|
50
|
+
* only on genuine structural drift — not when a volatile-but-equivalent
|
|
51
|
+
* value (a fresh timestamp/id) changed — so the generator is idempotent.
|
|
52
|
+
*/
|
|
53
|
+
function sameShape(a: unknown, b: unknown): boolean {
|
|
54
|
+
return (
|
|
55
|
+
JSON.stringify(normaliseResponse(a)) === JSON.stringify(normaliseResponse(b))
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Deterministic GraphQL seed. MUST stay in lockstep with
|
|
61
|
+
* `verifyContracts.ts` / `captureContracts.ts` `buildSeededState` — the
|
|
62
|
+
* goldens are replayed against this exact state at verify time.
|
|
63
|
+
*/
|
|
64
|
+
function buildGraphqlState(): ShopState {
|
|
65
|
+
const state = new ShopState({
|
|
66
|
+
shop: {
|
|
67
|
+
domain: 'test-shop.myshopify.com',
|
|
68
|
+
permanent_domain: 'test-shop.myshopify.com',
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
state.addProduct({
|
|
72
|
+
id: 900_000_001,
|
|
73
|
+
handle: 'sample-product',
|
|
74
|
+
title: 'Sample Product',
|
|
75
|
+
description: 'Used by contract capture as a deterministic fixture.',
|
|
76
|
+
price: 1000,
|
|
77
|
+
vendor: 'Sample Vendor',
|
|
78
|
+
type: 'Sample',
|
|
79
|
+
variants: [
|
|
80
|
+
{
|
|
81
|
+
id: 900_010_001,
|
|
82
|
+
title: 'Default Title',
|
|
83
|
+
price: 1000,
|
|
84
|
+
available: true,
|
|
85
|
+
sku: 'SAMPLE-1',
|
|
86
|
+
inventory_quantity: 100,
|
|
87
|
+
selected_options: ['Default Title'],
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
tags: [],
|
|
91
|
+
});
|
|
92
|
+
state.addCollection({
|
|
93
|
+
id: 900_020_001,
|
|
94
|
+
handle: 'sample-collection',
|
|
95
|
+
title: 'Sample Collection',
|
|
96
|
+
productHandles: ['sample-product'],
|
|
97
|
+
});
|
|
98
|
+
return state;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** REST seed — matches `verifyRestContracts.ts` `buildSeededState`. */
|
|
102
|
+
function buildRestState(): ShopState {
|
|
103
|
+
return new ShopState({
|
|
104
|
+
shop: {
|
|
105
|
+
domain: 'test-shop.myshopify.com',
|
|
106
|
+
permanent_domain: 'test-shop.myshopify.com',
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function fillPath(template: string, params: Record<string, string>): string {
|
|
112
|
+
return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
|
|
113
|
+
if (!(key in params)) {
|
|
114
|
+
throw new Error(`path template references {${key}} but no value provided`);
|
|
115
|
+
}
|
|
116
|
+
return encodeURIComponent(params[key] ?? '');
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
function appendQuery(path: string, query?: Record<string, string>): string {
|
|
120
|
+
if (!query || Object.keys(query).length === 0) return path;
|
|
121
|
+
const params = new URLSearchParams();
|
|
122
|
+
for (const [k, v] of Object.entries(query)) params.set(k, v);
|
|
123
|
+
return `${path}${path.includes('?') ? '&' : '?'}${params.toString()}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Flat `<op>.json` goldens only (skip manifests + store-keyed captures). */
|
|
127
|
+
function flatGoldens(dir: string): string[] {
|
|
128
|
+
let entries: string[];
|
|
129
|
+
try {
|
|
130
|
+
entries = readdirSync(dir);
|
|
131
|
+
} catch {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
return entries.filter(
|
|
135
|
+
(f) =>
|
|
136
|
+
f.endsWith('.json') &&
|
|
137
|
+
!f.endsWith('.manifest.json') &&
|
|
138
|
+
!f.slice(0, -'.json'.length).includes('.'),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function regenGraphql(api: 'admin' | 'storefront'): Promise<number> {
|
|
143
|
+
const dir = resolve(fixturesDir(), api);
|
|
144
|
+
const state = buildGraphqlState();
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hono types are loose
|
|
146
|
+
const app: any =
|
|
147
|
+
api === 'admin' ? createAdminApi({ state }) : createStorefrontApi({ state });
|
|
148
|
+
const endpoint =
|
|
149
|
+
api === 'admin'
|
|
150
|
+
? '/admin/api/2025-07/graphql.json'
|
|
151
|
+
: '/api/2025-07/graphql.json';
|
|
152
|
+
let changed = 0;
|
|
153
|
+
for (const f of flatGoldens(dir)) {
|
|
154
|
+
const p = resolve(dir, f);
|
|
155
|
+
const g = JSON.parse(readFileSync(p, 'utf8')) as OperationContract;
|
|
156
|
+
if (g.warning) continue;
|
|
157
|
+
const resp = await app.request(endpoint, {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: {
|
|
160
|
+
'Content-Type': 'application/json',
|
|
161
|
+
'X-Shopify-Access-Token': 'mock-access-token',
|
|
162
|
+
},
|
|
163
|
+
body: JSON.stringify({ query: g.source, variables: g.variables }),
|
|
164
|
+
});
|
|
165
|
+
const actual = await resp.json();
|
|
166
|
+
if (!sameShape(g.response, actual)) {
|
|
167
|
+
g.response = actual;
|
|
168
|
+
writeFileSync(p, JSON.stringify(g, null, 2) + '\n');
|
|
169
|
+
console.log(` updated ${api}/${f}`);
|
|
170
|
+
changed++;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return changed;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function regenRest(): Promise<number> {
|
|
177
|
+
const dir = resolve(fixturesDir(), 'admin-rest');
|
|
178
|
+
const state = buildRestState();
|
|
179
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Hono types are loose
|
|
180
|
+
const app: any = createAdminApi({ state });
|
|
181
|
+
let changed = 0;
|
|
182
|
+
for (const f of flatGoldens(dir)) {
|
|
183
|
+
const p = resolve(dir, f);
|
|
184
|
+
const c = JSON.parse(readFileSync(p, 'utf8')) as RestContract;
|
|
185
|
+
if (c.warning) continue;
|
|
186
|
+
const finalPath = appendQuery(
|
|
187
|
+
fillPath(c.path, { version: ADMIN_API_VERSION, ...(c.pathParams ?? {}) }),
|
|
188
|
+
c.query,
|
|
189
|
+
);
|
|
190
|
+
const init: RequestInit = {
|
|
191
|
+
method: c.method,
|
|
192
|
+
headers: {
|
|
193
|
+
'Content-Type': 'application/json',
|
|
194
|
+
'X-Shopify-Access-Token': 'mock-access-token',
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
if (c.body !== undefined) init.body = JSON.stringify(c.body);
|
|
198
|
+
const resp: Response = await app.request(finalPath, init);
|
|
199
|
+
let body: unknown = null;
|
|
200
|
+
try {
|
|
201
|
+
body = await resp.json();
|
|
202
|
+
} catch {
|
|
203
|
+
/* empty / non-JSON */
|
|
204
|
+
}
|
|
205
|
+
const next = { status: resp.status, body };
|
|
206
|
+
if (c.response.status !== next.status || !sameShape(c.response.body, next.body)) {
|
|
207
|
+
c.response = next;
|
|
208
|
+
writeFileSync(p, JSON.stringify(c, null, 2) + '\n');
|
|
209
|
+
console.log(` updated admin-rest/${f}`);
|
|
210
|
+
changed++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return changed;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function main(): Promise<void> {
|
|
217
|
+
console.log('[capture-shared-contracts] regenerating shared goldens from the offline mock…');
|
|
218
|
+
let total = 0;
|
|
219
|
+
total += await regenGraphql('admin');
|
|
220
|
+
total += await regenGraphql('storefront');
|
|
221
|
+
total += await regenRest();
|
|
222
|
+
console.log(`[capture-shared-contracts] done — ${total} golden(s) refreshed.`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
main().catch((err) => {
|
|
226
|
+
console.error(err);
|
|
227
|
+
process.exit(1);
|
|
228
|
+
});
|
|
@@ -56,6 +56,7 @@ import { dirname, resolve } from 'node:path';
|
|
|
56
56
|
import { fileURLToPath } from 'node:url';
|
|
57
57
|
import { pickApp, prepareOciArchive, envFileArgs, computeBuildHash } from '@essential-apps/shopify-test-core';
|
|
58
58
|
import { buildExtensions } from '../lib/buildExtensions.js';
|
|
59
|
+
import { exposeGuestVnc } from '../lib/guestVnc.js';
|
|
59
60
|
|
|
60
61
|
const repoRoot = process.cwd();
|
|
61
62
|
|
|
@@ -338,6 +339,27 @@ async function main(): Promise<void> {
|
|
|
338
339
|
}
|
|
339
340
|
})();
|
|
340
341
|
const runtimeTag = `sm${runtimeVersion.replace(/[^0-9a-z]+/gi, '')}`;
|
|
342
|
+
|
|
343
|
+
// Fold THIS runner package's own version into the snapshot key. The
|
|
344
|
+
// warm snapshot bakes the in-VM `npm install` (which installs the
|
|
345
|
+
// orchestrator + fixtures this same shopify-test version ships). Those
|
|
346
|
+
// run INSIDE the VM, so a version bump that changes orchestrator/
|
|
347
|
+
// fixture behaviour (e.g. screen-video support) would otherwise
|
|
348
|
+
// warm-restore a STALE in-VM install — the consumer updates the
|
|
349
|
+
// package but keeps running the old in-VM code until they manually
|
|
350
|
+
// `rm -rf ~/.cache/<app>-test`. Keying on the runner version makes a
|
|
351
|
+
// bump auto-rebake (cold install of the new version) — same
|
|
352
|
+
// no-manual-bust philosophy as manifestHash + runtimeTag.
|
|
353
|
+
const runnerVersion = ((): string => {
|
|
354
|
+
try {
|
|
355
|
+
return (
|
|
356
|
+
createRequire(import.meta.url)('../../package.json') as { version: string }
|
|
357
|
+
).version;
|
|
358
|
+
} catch {
|
|
359
|
+
return 'unknown';
|
|
360
|
+
}
|
|
361
|
+
})();
|
|
362
|
+
const runnerTag = `r${runnerVersion.replace(/[^0-9a-z]+/gi, '')}`;
|
|
341
363
|
// Scope the snapshot to the consuming app. supermachine keys WARM
|
|
342
364
|
// snapshots by (image + warmupTag), but the image is shared across
|
|
343
365
|
// apps and the rest of the tag is identical for any two apps vendoring
|
|
@@ -347,7 +369,7 @@ async function main(): Promise<void> {
|
|
|
347
369
|
// identity (observed: essential-upsell booting with essential-seo's
|
|
348
370
|
// DB). The cache paths are already appName-namespaced; this aligns the
|
|
349
371
|
// snapshot key with them.
|
|
350
|
-
const warmupTag = `offline-v13-${appName}-${runtimeTag}-${manifestHash}`;
|
|
372
|
+
const warmupTag = `offline-v13-${appName}-${runtimeTag}-${runnerTag}-${manifestHash}`;
|
|
351
373
|
|
|
352
374
|
// Dynamic import — @supermachine/core is heavy and only needed
|
|
353
375
|
// when actually running tests, not for `--help` etc.
|
|
@@ -513,6 +535,15 @@ async function main(): Promise<void> {
|
|
|
513
535
|
// orchestrator's Xvfb startup + the offlineFullStack screen-video
|
|
514
536
|
// fixture).
|
|
515
537
|
'TEST_OFFLINE_VIDEO',
|
|
538
|
+
// Offline runs headed-under-Xvfb by default; opt out to headless.
|
|
539
|
+
'TEST_OFFLINE_HEADLESS',
|
|
540
|
+
// Interactive explore mode: orchestrator skips Playwright and
|
|
541
|
+
// instead opens a headed Chromium (admin + storefront tabs) on
|
|
542
|
+
// the guest Xvfb display, which we surface over VNC (see the
|
|
543
|
+
// explore branch below).
|
|
544
|
+
'TEST_OFFLINE_EXPLORE',
|
|
545
|
+
'TEST_OFFLINE_EXPLORE_URL',
|
|
546
|
+
'TEST_OFFLINE_EXPLORE_NO_DEVTOOLS',
|
|
516
547
|
'CI',
|
|
517
548
|
'DEBUG',
|
|
518
549
|
]) {
|
|
@@ -585,6 +616,56 @@ async function main(): Promise<void> {
|
|
|
585
616
|
forwardEnv['PRISMA_QUERY_ENGINE_LIBRARY'] =
|
|
586
617
|
'/workspace/node_modules/.prisma/client/libquery_engine-linux-arm64-openssl-3.0.x.so.node';
|
|
587
618
|
|
|
619
|
+
// ── Explore mode (interactive, VNC) ───────────────────────────
|
|
620
|
+
// `TEST_OFFLINE_EXPLORE=true` boots the full offline mock stack
|
|
621
|
+
// and hands the developer a real browser inside the VM instead of
|
|
622
|
+
// running Playwright. We surface the guest's Xvfb display over VNC
|
|
623
|
+
// (supermachine `vm.exposeTcp`, NOT docker --publish) so they can
|
|
624
|
+
// click through the admin app + storefront against the mock stack.
|
|
625
|
+
// The orchestrator opens both tabs (see runOfflineFullTests explore
|
|
626
|
+
// branch) and holds until the browser window is closed / Ctrl-C.
|
|
627
|
+
if (process.env['TEST_OFFLINE_EXPLORE'] === 'true') {
|
|
628
|
+
let forwarder: { stop?: () => Promise<void> | void } | undefined;
|
|
629
|
+
try {
|
|
630
|
+
forwarder = (await exposeGuestVnc(vm, {
|
|
631
|
+
logPrefix: '[runOffline]',
|
|
632
|
+
})) as { stop?: () => Promise<void> | void };
|
|
633
|
+
|
|
634
|
+
console.error('[runOffline] launching explore session…');
|
|
635
|
+
const exploreProc = await vm.spawn({
|
|
636
|
+
argv: ['sh', '-c', `
|
|
637
|
+
cd /workspace
|
|
638
|
+
export PATH=/workspace/node_modules/.bin:$PATH
|
|
639
|
+
export DISPLAY=:99
|
|
640
|
+
node ${envFileArgs(repoRoot)} node_modules/@essential-apps/shopify-test-runner/dist/scripts/runOfflineFullTests.js
|
|
641
|
+
`],
|
|
642
|
+
env: forwardEnv,
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
// Stream guest output verbatim with NO stall/hard timeout — an
|
|
646
|
+
// explore session legitimately sits idle for as long as the
|
|
647
|
+
// developer is poking around. It ends when they close the
|
|
648
|
+
// browser (orchestrator exits) or Ctrl-C here.
|
|
649
|
+
const pump = (async () => {
|
|
650
|
+
while (true) {
|
|
651
|
+
const out = await exploreProc.readStdout(64 * 1024);
|
|
652
|
+
if (out.length === 0) {
|
|
653
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
process.stdout.write(out);
|
|
657
|
+
}
|
|
658
|
+
})();
|
|
659
|
+
pump.catch(() => {});
|
|
660
|
+
|
|
661
|
+
const wait = await exploreProc.wait();
|
|
662
|
+
process.exitCode = wait.exitCode ?? 0;
|
|
663
|
+
return;
|
|
664
|
+
} finally {
|
|
665
|
+
if (forwarder?.stop) await Promise.resolve(forwarder.stop()).catch(() => {});
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
588
669
|
// Optional --grep forwarding for fast iteration on a single
|
|
589
670
|
// scenario or spec without writing a one-off bench script.
|
|
590
671
|
const grep = process.env['PLAYWRIGHT_GREP'];
|
|
@@ -1229,13 +1229,15 @@ async function main(): Promise<void> {
|
|
|
1229
1229
|
|
|
1230
1230
|
// ── Explore mode (no Playwright; manual click-around) ──
|
|
1231
1231
|
// When `TEST_OFFLINE_EXPLORE=true` is set, skip Playwright
|
|
1232
|
-
// entirely. Instead, launch a
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
// `
|
|
1232
|
+
// entirely. Instead, launch a non-headless Chrome on the guest's
|
|
1233
|
+
// Xvfb :99 display with TWO tabs — the admin app and the storefront
|
|
1234
|
+
// — both pointed at the in-VM mock stack. The host-side runner
|
|
1235
|
+
// (`runOffline.ts`) surfaces :99 over VNC via supermachine's
|
|
1236
|
+
// `vm.exposeTcp` (NOT docker --publish — we don't use docker for
|
|
1237
|
+
// anything), so the developer connects from macOS via
|
|
1236
1238
|
// open vnc://localhost:5900 (password: `test`)
|
|
1237
|
-
// and
|
|
1238
|
-
//
|
|
1239
|
+
// and clicks through admin + storefront against the mock stack.
|
|
1240
|
+
// The process holds open until the browser is closed / SIGINT.
|
|
1239
1241
|
//
|
|
1240
1242
|
// This is the "interactive Shopify dev store" mode — useful
|
|
1241
1243
|
// for visually inspecting funnel rendering, debugging admin
|
|
@@ -1342,6 +1344,26 @@ async function main(): Promise<void> {
|
|
|
1342
1344
|
exploreCtx.pages()[0] ?? (await exploreCtx.newPage());
|
|
1343
1345
|
await explorePage.goto(adminUrl, { waitUntil: 'domcontentloaded' });
|
|
1344
1346
|
|
|
1347
|
+
// Second tab: the storefront (app embed + widget render against
|
|
1348
|
+
// the seeded catalog). Opening both up-front means the developer
|
|
1349
|
+
// lands on a ready admin + storefront without typing URLs. Errors
|
|
1350
|
+
// are swallowed — a slow/failed storefront load shouldn't abort
|
|
1351
|
+
// the whole explore session (the admin tab is still useful).
|
|
1352
|
+
const storefrontUrl =
|
|
1353
|
+
process.env['TEST_OFFLINE_EXPLORE_STOREFRONT_URL'] ??
|
|
1354
|
+
`https://${DEFAULT_SHOP_DOMAIN}/`;
|
|
1355
|
+
try {
|
|
1356
|
+
const sfPage = await exploreCtx.newPage();
|
|
1357
|
+
await sfPage.goto(storefrontUrl, { waitUntil: 'domcontentloaded' });
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
console.error(
|
|
1360
|
+
`[runOfflineFull] explore: storefront tab failed to load (${(err as Error).message}) — continuing`,
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
// Leave the admin tab focused as the active one.
|
|
1364
|
+
await explorePage.bringToFront().catch(() => {});
|
|
1365
|
+
console.log(` Storefront tab opened: ${storefrontUrl}`);
|
|
1366
|
+
|
|
1345
1367
|
// Wait indefinitely. Exit triggers:
|
|
1346
1368
|
// - Developer closes the browser window → context emits `close`.
|
|
1347
1369
|
// - Container receives SIGINT/SIGTERM → we close the context
|
|
@@ -1361,16 +1383,20 @@ async function main(): Promise<void> {
|
|
|
1361
1383
|
|
|
1362
1384
|
// ── Playwright ─────────────────────────────────────────
|
|
1363
1385
|
console.log('[runOfflineFull] running Playwright…');
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
1367
|
-
//
|
|
1386
|
+
// Headed-under-Xvfb is the DEFAULT for offline runs (production-
|
|
1387
|
+
// faithful, ~4% slower than headless). Opt OUT with
|
|
1388
|
+
// TEST_OFFLINE_HEADLESS=true. Full-screen recording (TEST_OFFLINE_VIDEO)
|
|
1389
|
+
// and TEST_VISIBLE always force headed. Xvfb ships in the Playwright
|
|
1390
|
+
// base image; ffmpeg (for recording) is baked in too. We start Xvfb
|
|
1391
|
+
// :99 whenever headed, and only require ffmpeg when recording.
|
|
1368
1392
|
let videoOn = process.env['TEST_OFFLINE_VIDEO'] === 'true';
|
|
1393
|
+
const headlessOn = process.env['TEST_OFFLINE_HEADLESS'] === 'true';
|
|
1394
|
+
let headedOn = videoOn || process.env['TEST_VISIBLE'] === 'true' || !headlessOn;
|
|
1395
|
+
let headedReady = false;
|
|
1369
1396
|
if (videoOn) {
|
|
1370
|
-
|
|
1371
|
-
//
|
|
1372
|
-
//
|
|
1373
|
-
// clear rebuild hint rather than failing every test's recording.
|
|
1397
|
+
// Recording needs ffmpeg (with x11grab). An image built before
|
|
1398
|
+
// video support won't have it — degrade to headed-no-record with
|
|
1399
|
+
// a clear rebuild hint rather than failing every test's recording.
|
|
1374
1400
|
let hasFfmpeg = false;
|
|
1375
1401
|
try {
|
|
1376
1402
|
const { execSync: execSyncCheck } = await import('node:child_process');
|
|
@@ -1384,19 +1410,19 @@ async function main(): Promise<void> {
|
|
|
1384
1410
|
'[runOfflineFull] TEST_OFFLINE_VIDEO set but `ffmpeg` is missing from the ' +
|
|
1385
1411
|
'VM image — rebuild it (it predates video support): ' +
|
|
1386
1412
|
'`container image rm essential-apps/shopify-test-vm:latest` then re-run. ' +
|
|
1387
|
-
'Continuing WITHOUT
|
|
1413
|
+
'Continuing headed WITHOUT recording.',
|
|
1388
1414
|
);
|
|
1389
1415
|
videoOn = false;
|
|
1390
1416
|
process.env['TEST_OFFLINE_VIDEO'] = 'false';
|
|
1391
1417
|
}
|
|
1392
1418
|
}
|
|
1393
|
-
if (
|
|
1419
|
+
if (headedOn) {
|
|
1394
1420
|
const { existsSync } = await import('node:fs');
|
|
1395
1421
|
if (!existsSync('/tmp/.X11-unix/X99')) {
|
|
1396
1422
|
const { spawn: spawnXvfb } = await import('node:child_process');
|
|
1397
1423
|
const xv = spawnXvfb(
|
|
1398
1424
|
'Xvfb',
|
|
1399
|
-
[':99', '-screen', '0', '
|
|
1425
|
+
[':99', '-screen', '0', '1400x900x24', '-nolisten', 'tcp', '-ac'],
|
|
1400
1426
|
{ detached: true, stdio: 'ignore' },
|
|
1401
1427
|
);
|
|
1402
1428
|
xv.unref();
|
|
@@ -1405,17 +1431,26 @@ async function main(): Promise<void> {
|
|
|
1405
1431
|
await new Promise((r) => setTimeout(r, 100));
|
|
1406
1432
|
}
|
|
1407
1433
|
}
|
|
1408
|
-
|
|
1409
|
-
|
|
1434
|
+
headedReady = existsSync('/tmp/.X11-unix/X99');
|
|
1435
|
+
if (!headedReady) {
|
|
1436
|
+
console.error(
|
|
1437
|
+
'[runOfflineFull] WARNING: Xvfb :99 did not start — falling back to headless',
|
|
1438
|
+
);
|
|
1439
|
+
videoOn = false;
|
|
1440
|
+
headedOn = false;
|
|
1441
|
+
process.env['TEST_OFFLINE_VIDEO'] = 'false';
|
|
1442
|
+
process.env['TEST_OFFLINE_HEADED'] = 'false';
|
|
1410
1443
|
} else {
|
|
1411
|
-
console.error(
|
|
1444
|
+
console.error(
|
|
1445
|
+
`[runOfflineFull] headed under Xvfb :99 (${videoOn ? 'recording' : 'no recording'})`,
|
|
1446
|
+
);
|
|
1412
1447
|
}
|
|
1413
1448
|
}
|
|
1414
1449
|
|
|
1415
1450
|
const playwrightEnv: NodeJS.ProcessEnv = {
|
|
1416
1451
|
...process.env,
|
|
1417
1452
|
// Point headed Chromium + the screen-video fixture at the Xvfb display.
|
|
1418
|
-
...(
|
|
1453
|
+
...(headedReady ? { DISPLAY: ':99' } : {}),
|
|
1419
1454
|
// Expose the per-run test DB to specs so they can seed/inspect the
|
|
1420
1455
|
// app's OWN Postgres directly (e.g. seed Article rows for
|
|
1421
1456
|
// list/detail flows). The mock ShopState covers Shopify resources,
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
*
|
|
30
30
|
* Run: `npm run test:online:verify-contracts`.
|
|
31
31
|
*/
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
32
|
+
import { resolve } from 'node:path';
|
|
33
|
+
import { resolveContracts } from '@essential-apps/shopify-test-contracts/operation-contract';
|
|
34
34
|
import {
|
|
35
35
|
createAdminApi,
|
|
36
36
|
createStorefrontApi,
|
|
@@ -112,16 +112,6 @@ function buildOfflineExecutor(api: ApiType, state: ShopState): Executor {
|
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
interface Contract {
|
|
116
|
-
operationName: string;
|
|
117
|
-
source: string;
|
|
118
|
-
variables: Record<string, unknown>;
|
|
119
|
-
response: unknown;
|
|
120
|
-
capturedFrom: 'offline' | 'live';
|
|
121
|
-
capturedAt: string;
|
|
122
|
-
warning?: string;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
115
|
/**
|
|
126
116
|
* Same deterministic ShopState seed `captureContracts` uses. The
|
|
127
117
|
* contracts were captured against this state; verification must
|
|
@@ -214,21 +204,24 @@ function diff(
|
|
|
214
204
|
async function main(): Promise<void> {
|
|
215
205
|
const args = parseArgs();
|
|
216
206
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
207
|
+
// Merged view: shared package goldens (the centralized generic ops)
|
|
208
|
+
// overlaid by the app's local contracts dir, which overrides by
|
|
209
|
+
// operation name. An app vendors a local file only when its shape
|
|
210
|
+
// genuinely diverges from the shared golden.
|
|
211
|
+
const resolved = resolveContracts({ api: args.api, appDir: args.contractsDir });
|
|
212
|
+
if (resolved.length === 0) {
|
|
221
213
|
console.error(
|
|
222
|
-
`[verify-contracts] no contracts
|
|
223
|
-
`
|
|
214
|
+
`[verify-contracts] no contracts for api=${args.api} ` +
|
|
215
|
+
`(shared package + ${args.contractsDir}).`,
|
|
224
216
|
);
|
|
225
217
|
process.exit(2);
|
|
226
218
|
}
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
219
|
+
const sharedCount = resolved.filter((r) => r.origin === 'shared').length;
|
|
220
|
+
const appCount = resolved.length - sharedCount;
|
|
221
|
+
console.log(
|
|
222
|
+
`[verify-contracts] api=${args.api}: ${resolved.length} operations ` +
|
|
223
|
+
`(${sharedCount} shared, ${appCount} app-local override)`,
|
|
224
|
+
);
|
|
232
225
|
|
|
233
226
|
const state = buildSeededState();
|
|
234
227
|
const executor: Executor =
|
|
@@ -239,16 +232,9 @@ async function main(): Promise<void> {
|
|
|
239
232
|
let skipped = 0;
|
|
240
233
|
const failures: { contract: string; diff: string }[] = [];
|
|
241
234
|
|
|
242
|
-
for (const
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
try {
|
|
246
|
-
st = statSync(path);
|
|
247
|
-
} catch {
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
if (!st.isFile()) continue;
|
|
251
|
-
const contract = JSON.parse(readFileSync(path, 'utf8')) as Contract;
|
|
235
|
+
for (const entry of resolved) {
|
|
236
|
+
const contract = entry.contract;
|
|
237
|
+
const label = `${entry.operationName} [${entry.origin}]`;
|
|
252
238
|
// Contracts captured with a warning never executed cleanly;
|
|
253
239
|
// skip them in verify (they need fixtures.json before they can
|
|
254
240
|
// be verified).
|
|
@@ -262,7 +248,7 @@ async function main(): Promise<void> {
|
|
|
262
248
|
} catch (err) {
|
|
263
249
|
drift++;
|
|
264
250
|
failures.push({
|
|
265
|
-
contract:
|
|
251
|
+
contract: label,
|
|
266
252
|
diff: `executor threw: ${(err as Error).message}`,
|
|
267
253
|
});
|
|
268
254
|
continue;
|
|
@@ -280,7 +266,7 @@ async function main(): Promise<void> {
|
|
|
280
266
|
pass++;
|
|
281
267
|
} else {
|
|
282
268
|
drift++;
|
|
283
|
-
failures.push({ contract:
|
|
269
|
+
failures.push({ contract: label, diff: d });
|
|
284
270
|
}
|
|
285
271
|
}
|
|
286
272
|
|
|
@@ -291,9 +277,7 @@ async function main(): Promise<void> {
|
|
|
291
277
|
console.log('');
|
|
292
278
|
console.log('[verify-contracts] drift:');
|
|
293
279
|
for (const f of failures) {
|
|
294
|
-
console.log(
|
|
295
|
-
` ${relative(args.cwd, resolve(args.contractsDir, f.contract))}`,
|
|
296
|
-
);
|
|
280
|
+
console.log(` ${f.contract}`);
|
|
297
281
|
console.log(` ${f.diff}`);
|
|
298
282
|
}
|
|
299
283
|
console.log('');
|