@reshotdev/screenshot 0.0.1-beta.2 → 0.0.1-beta.20
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/LICENSE +1 -1
- package/README.md +138 -47
- package/package.json +27 -16
- package/src/commands/auth.js +159 -30
- package/src/commands/capture-dom.js +50 -0
- package/src/commands/certify.js +62 -0
- package/src/commands/compose.js +220 -0
- package/src/commands/doctor-release.js +74 -0
- package/src/commands/doctor-target.js +108 -0
- package/src/commands/drifts.js +16 -69
- package/src/commands/import-tests.js +13 -13
- package/src/commands/init.js +16 -277
- package/src/commands/publish.js +484 -257
- package/src/commands/pull.js +302 -35
- package/src/commands/refresh.js +166 -0
- package/src/commands/run.js +292 -12
- package/src/commands/setup-wizard.js +348 -496
- package/src/commands/status.js +334 -126
- package/src/commands/sync.js +28 -236
- package/src/commands/ui.js +1 -1
- package/src/commands/variation.js +194 -0
- package/src/commands/verify-publish.js +46 -0
- package/src/index.js +383 -118
- package/src/lib/api-client.js +172 -60
- package/src/lib/auto-update/refresh.js +598 -0
- package/src/lib/auto-update/scene-runtime.compose.tsx +73 -0
- package/src/lib/auto-update/spec.js +89 -0
- package/src/lib/capture-engine.js +179 -9
- package/src/lib/capture-script-runner.js +639 -214
- package/src/lib/certification.js +887 -0
- package/src/lib/compose-context.js +156 -0
- package/src/lib/compose-pack.js +42 -0
- package/src/lib/compose-runtime.js +34 -0
- package/src/lib/compose-upload.js +142 -0
- package/src/lib/config.js +186 -81
- package/src/lib/dom-capture.js +64 -0
- package/src/lib/ensure-browser.js +147 -0
- package/src/lib/output-path-template.js +3 -3
- package/src/lib/record-cdp.js +288 -16
- package/src/lib/record-clip.js +83 -3
- package/src/lib/record-config.js +1 -5
- package/src/lib/release-doctor.js +321 -0
- package/src/lib/resolve-targets.js +60 -0
- package/src/lib/run-manifest.js +148 -0
- package/src/lib/standalone-mode.js +1 -1
- package/src/lib/storage-providers.js +5 -5
- package/src/lib/style-engine.js +5 -5
- package/src/lib/target-contract.js +292 -0
- package/src/lib/ui-api-helpers.js +118 -0
- package/src/lib/ui-api.js +31 -824
- package/src/lib/ui-asset-cleanup.js +62 -0
- package/src/lib/ui-output-versions.js +165 -0
- package/src/lib/ui-recorder-routes.js +341 -0
- package/src/lib/ui-scenario-metadata.js +161 -0
- package/vendor/compose/dist/auto-update.cjs +5544 -0
- package/vendor/compose/dist/auto-update.mjs +5518 -0
- package/vendor/compose/dist/capture.cjs +1450 -0
- package/vendor/compose/dist/capture.mjs +1416 -0
- package/vendor/compose/dist/eligibility.cjs +5331 -0
- package/vendor/compose/dist/eligibility.mjs +5313 -0
- package/vendor/compose/dist/index.cjs +2046 -0
- package/vendor/compose/dist/index.mjs +1997 -0
- package/vendor/compose/dist/jsx-dev-runtime.cjs +55 -0
- package/vendor/compose/dist/jsx-dev-runtime.mjs +27 -0
- package/vendor/compose/dist/jsx-runtime.cjs +58 -0
- package/vendor/compose/dist/jsx-runtime.mjs +31 -0
- package/vendor/compose/dist/render.cjs +558 -0
- package/vendor/compose/dist/render.mjs +515 -0
- package/vendor/compose/dist/verify-cli.cjs +3806 -0
- package/vendor/compose/dist/verify-cli.mjs +3812 -0
- package/vendor/compose/dist/verify.cjs +3880 -0
- package/vendor/compose/dist/verify.mjs +3858 -0
- package/web/manager/dist/assets/index-D0S2otug.js +507 -0
- package/web/manager/dist/index.html +1 -1
- package/src/commands/ci-run.js +0 -123
- package/src/commands/ci-setup.js +0 -288
- package/src/commands/ingest.js +0 -458
- package/src/commands/setup.js +0 -137
- package/src/commands/validate-docs.js +0 -529
- package/src/lib/playwright-runner.js +0 -252
- package/web/manager/dist/assets/index--ZgioErz.js +0 -507
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs-extra");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const { spawn } = require("child_process");
|
|
6
|
+
const { chromium } = require("playwright");
|
|
7
|
+
const config = require("./config");
|
|
8
|
+
const apiClient = require("./api-client");
|
|
9
|
+
const { getDefaultSessionPath } = require("./record-cdp");
|
|
10
|
+
const { substituteUrlVariables } = require("./capture-script-runner");
|
|
11
|
+
|
|
12
|
+
const REPORT_DIR = path.join(process.cwd(), ".reshot", "reports");
|
|
13
|
+
const CERTIFICATION_REPORT_PATH = path.join(REPORT_DIR, "certification.json");
|
|
14
|
+
|
|
15
|
+
function createIssue(severity, code, message, details = null) {
|
|
16
|
+
return {
|
|
17
|
+
severity,
|
|
18
|
+
code,
|
|
19
|
+
message,
|
|
20
|
+
...(details ? { details } : {}),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildSummary(blockingIssues = [], advisories = [], info = []) {
|
|
25
|
+
return {
|
|
26
|
+
overallSeverity:
|
|
27
|
+
blockingIssues.length > 0
|
|
28
|
+
? "blocking"
|
|
29
|
+
: advisories.length > 0
|
|
30
|
+
? "warning"
|
|
31
|
+
: "info",
|
|
32
|
+
blockingIssues,
|
|
33
|
+
advisories,
|
|
34
|
+
info,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function toCamelCase(str) {
|
|
39
|
+
return String(str || "")
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[^a-zA-Z0-9]+(.)/g, (_, chr) => chr.toUpperCase())
|
|
42
|
+
.replace(/^[A-Z]/, (chr) => chr.toLowerCase());
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function ensureReportDir() {
|
|
46
|
+
fs.ensureDirSync(REPORT_DIR);
|
|
47
|
+
return REPORT_DIR;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getSelectedScenarios(docSyncConfig, scenarioKeys = null) {
|
|
51
|
+
const selectedKeys = config.getCertifiedScenarioKeys(docSyncConfig, scenarioKeys);
|
|
52
|
+
return (docSyncConfig.scenarios || []).filter((scenario) =>
|
|
53
|
+
selectedKeys.includes(scenario.key),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function hasDeterministicReadyContract(scenario) {
|
|
58
|
+
return Boolean(
|
|
59
|
+
scenario.ready?.selector ||
|
|
60
|
+
scenario.ready?.expression ||
|
|
61
|
+
(scenario.steps || []).some((step) => step.action === "waitForSelector"),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function usesSleepOnlyReadiness(scenario) {
|
|
66
|
+
const steps = scenario.steps || [];
|
|
67
|
+
if (steps.length === 0) return true;
|
|
68
|
+
const hasWait = steps.some((step) => step.action === "wait");
|
|
69
|
+
const hasReadySignal = hasDeterministicReadyContract(scenario);
|
|
70
|
+
return hasWait && !hasReadySignal;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function joinUrl(baseUrl, route) {
|
|
74
|
+
if (!route) return baseUrl;
|
|
75
|
+
if (route.startsWith("http://") || route.startsWith("https://")) {
|
|
76
|
+
return route;
|
|
77
|
+
}
|
|
78
|
+
return new URL(route, `${baseUrl.replace(/\/+$/, "")}/`).toString();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveContractUrl(baseUrl, route) {
|
|
82
|
+
return joinUrl(baseUrl, substituteUrlVariables(route));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function classifyDiagnostic(event) {
|
|
86
|
+
const kind = event.kind || "unknown";
|
|
87
|
+
if (kind === "pageerror" || kind === "requestfailed" || kind === "response_error") {
|
|
88
|
+
return { blocking: true, reason: kind };
|
|
89
|
+
}
|
|
90
|
+
if (kind === "console" && event.severity === "error") {
|
|
91
|
+
return { blocking: true, reason: event.cspViolation ? "csp_violation" : "console_error" };
|
|
92
|
+
}
|
|
93
|
+
return { blocking: false, reason: kind };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasBlockingDiagnostics(events) {
|
|
97
|
+
return (events || []).some((event) => classifyDiagnostic(event).blocking);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function withTimeout(task, timeoutMs, label) {
|
|
101
|
+
let timeoutId = null;
|
|
102
|
+
try {
|
|
103
|
+
return await Promise.race([
|
|
104
|
+
Promise.resolve().then(task),
|
|
105
|
+
new Promise((_, reject) => {
|
|
106
|
+
timeoutId = setTimeout(() => {
|
|
107
|
+
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
108
|
+
}, timeoutMs);
|
|
109
|
+
}),
|
|
110
|
+
]);
|
|
111
|
+
} finally {
|
|
112
|
+
if (timeoutId) {
|
|
113
|
+
clearTimeout(timeoutId);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function fetchRoute(routeUrl) {
|
|
119
|
+
try {
|
|
120
|
+
const response = await fetch(routeUrl, { redirect: "manual" });
|
|
121
|
+
return {
|
|
122
|
+
ok: response.status < 400,
|
|
123
|
+
status: response.status,
|
|
124
|
+
contentType: response.headers.get("content-type") || "",
|
|
125
|
+
url: routeUrl,
|
|
126
|
+
location: response.headers.get("location") || null,
|
|
127
|
+
};
|
|
128
|
+
} catch (error) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
status: 0,
|
|
132
|
+
contentType: "",
|
|
133
|
+
url: routeUrl,
|
|
134
|
+
error: error.message,
|
|
135
|
+
location: null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function runShellCommand(command, cwd) {
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
const child = spawn(command, {
|
|
143
|
+
cwd,
|
|
144
|
+
shell: true,
|
|
145
|
+
stdio: "pipe",
|
|
146
|
+
env: process.env,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
let stdout = "";
|
|
150
|
+
let stderr = "";
|
|
151
|
+
child.stdout.on("data", (chunk) => {
|
|
152
|
+
stdout += chunk.toString();
|
|
153
|
+
});
|
|
154
|
+
child.stderr.on("data", (chunk) => {
|
|
155
|
+
stderr += chunk.toString();
|
|
156
|
+
});
|
|
157
|
+
child.on("close", (code) => {
|
|
158
|
+
resolve({
|
|
159
|
+
ok: code === 0,
|
|
160
|
+
code,
|
|
161
|
+
stdout,
|
|
162
|
+
stderr,
|
|
163
|
+
command,
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function ensureFixturePrepared(target, scenarios) {
|
|
170
|
+
const needsFixture = scenarios.some((scenario) => scenario.captureClass === "fixture-auth");
|
|
171
|
+
if (!needsFixture || !target.fixture?.command) {
|
|
172
|
+
return {
|
|
173
|
+
ok: !needsFixture,
|
|
174
|
+
skipped: !needsFixture,
|
|
175
|
+
command: target.fixture?.command || null,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = await runShellCommand(target.fixture.command, process.cwd());
|
|
180
|
+
return {
|
|
181
|
+
...result,
|
|
182
|
+
skipped: false,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function assertCaptureSafeRuntime(target) {
|
|
187
|
+
if (!target.captureSafe) {
|
|
188
|
+
return { ok: true, skipped: true };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const response = await fetch(target.baseUrl, { redirect: "manual" });
|
|
192
|
+
const html = await response.text();
|
|
193
|
+
return {
|
|
194
|
+
ok: response.ok && html.includes('data-capture-safe-runtime="true"'),
|
|
195
|
+
skipped: false,
|
|
196
|
+
status: response.status,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function auditScenarioPage(target, scenario) {
|
|
201
|
+
const sessionPath = getDefaultSessionPath();
|
|
202
|
+
const storageState =
|
|
203
|
+
scenario.captureClass !== "public" && fs.existsSync(sessionPath)
|
|
204
|
+
? sessionPath
|
|
205
|
+
: undefined;
|
|
206
|
+
|
|
207
|
+
const browser = await chromium.launch({ headless: true });
|
|
208
|
+
const context = await browser.newContext(
|
|
209
|
+
storageState ? { storageState } : {},
|
|
210
|
+
);
|
|
211
|
+
const page = await context.newPage();
|
|
212
|
+
const diagnostics = [];
|
|
213
|
+
|
|
214
|
+
page.on("pageerror", (error) => {
|
|
215
|
+
diagnostics.push({
|
|
216
|
+
kind: "pageerror",
|
|
217
|
+
severity: "error",
|
|
218
|
+
message: error.message,
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
page.on("console", (message) => {
|
|
222
|
+
const severity =
|
|
223
|
+
message.type() === "error"
|
|
224
|
+
? "error"
|
|
225
|
+
: message.type() === "warning"
|
|
226
|
+
? "warning"
|
|
227
|
+
: "info";
|
|
228
|
+
if (severity !== "info") {
|
|
229
|
+
diagnostics.push({
|
|
230
|
+
kind: "console",
|
|
231
|
+
severity,
|
|
232
|
+
message: message.text(),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
page.on("requestfailed", (request) => {
|
|
237
|
+
diagnostics.push({
|
|
238
|
+
kind: "requestfailed",
|
|
239
|
+
severity: "error",
|
|
240
|
+
url: request.url(),
|
|
241
|
+
message: request.failure()?.errorText || "Request failed",
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
const pageUrl = resolveContractUrl(target.baseUrl, scenario.url);
|
|
247
|
+
const response = await page.goto(pageUrl, {
|
|
248
|
+
waitUntil: "domcontentloaded",
|
|
249
|
+
timeout: 30_000,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const missingSelectors = [];
|
|
253
|
+
for (const selector of scenario.requiredSelectors || []) {
|
|
254
|
+
const count = await page.locator(selector).count();
|
|
255
|
+
if (count === 0) {
|
|
256
|
+
missingSelectors.push(selector);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let ready = true;
|
|
261
|
+
let readyFailure = null;
|
|
262
|
+
if (scenario.ready?.selector) {
|
|
263
|
+
try {
|
|
264
|
+
await page.locator(scenario.ready.selector).first().waitFor({
|
|
265
|
+
state: "attached",
|
|
266
|
+
timeout: scenario.ready.timeout || 10_000,
|
|
267
|
+
});
|
|
268
|
+
} catch {
|
|
269
|
+
ready = false;
|
|
270
|
+
readyFailure = `Missing ready selector ${scenario.ready.selector}`;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
if (ready && scenario.ready?.expression) {
|
|
274
|
+
try {
|
|
275
|
+
await page.waitForFunction(scenario.ready.expression, {
|
|
276
|
+
timeout: scenario.ready.timeout || 10_000,
|
|
277
|
+
});
|
|
278
|
+
} catch {
|
|
279
|
+
ready = false;
|
|
280
|
+
readyFailure = `Ready expression did not resolve for ${scenario.key}`;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
ok:
|
|
286
|
+
Boolean(response?.status() && response.status() < 400) &&
|
|
287
|
+
missingSelectors.length === 0 &&
|
|
288
|
+
ready &&
|
|
289
|
+
!hasBlockingDiagnostics(diagnostics),
|
|
290
|
+
scenario: scenario.key,
|
|
291
|
+
url: pageUrl,
|
|
292
|
+
status: response?.status() || 0,
|
|
293
|
+
missingSelectors,
|
|
294
|
+
ready,
|
|
295
|
+
readyFailure,
|
|
296
|
+
diagnostics,
|
|
297
|
+
};
|
|
298
|
+
} finally {
|
|
299
|
+
await context.close();
|
|
300
|
+
await browser.close();
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function flattenAssetEntry(entry) {
|
|
305
|
+
if (!entry) return [];
|
|
306
|
+
if (Array.isArray(entry.steps)) {
|
|
307
|
+
return entry.steps.map((step) => ({
|
|
308
|
+
url: step.src,
|
|
309
|
+
poster: step.poster || null,
|
|
310
|
+
step: step.step,
|
|
311
|
+
}));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return [
|
|
315
|
+
{
|
|
316
|
+
url: entry.src,
|
|
317
|
+
poster: entry.poster || null,
|
|
318
|
+
step: null,
|
|
319
|
+
},
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function getExpectedAssetChecks(assets, scenarios) {
|
|
324
|
+
const checks = [];
|
|
325
|
+
|
|
326
|
+
for (const scenario of scenarios) {
|
|
327
|
+
const groupKey = toCamelCase(scenario.key);
|
|
328
|
+
const visualGroup = assets?.[groupKey] || {};
|
|
329
|
+
const artifacts = scenario.expectedArtifacts || [];
|
|
330
|
+
for (const assetKey of artifacts) {
|
|
331
|
+
const visualKey = toCamelCase(assetKey);
|
|
332
|
+
const entry =
|
|
333
|
+
visualGroup?.[visualKey]?.default ||
|
|
334
|
+
visualGroup?.[visualKey]?.base ||
|
|
335
|
+
Object.values(visualGroup?.[visualKey] || {})[0];
|
|
336
|
+
checks.push({
|
|
337
|
+
scenario: scenario.key,
|
|
338
|
+
assetKey,
|
|
339
|
+
urls: flattenAssetEntry(entry),
|
|
340
|
+
found: Boolean(entry),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return checks;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function verifyHostedUrl(url) {
|
|
349
|
+
if (!url) {
|
|
350
|
+
return {
|
|
351
|
+
ok: false,
|
|
352
|
+
status: 0,
|
|
353
|
+
reason: "missing_url",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const normalized = String(url);
|
|
358
|
+
const response = await fetch(normalized, { redirect: "manual" });
|
|
359
|
+
const contentType = response.headers.get("content-type") || "";
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
ok: response.status === 307,
|
|
363
|
+
status: response.status,
|
|
364
|
+
contentType,
|
|
365
|
+
location: response.headers.get("location") || null,
|
|
366
|
+
htmlFallback: contentType.includes("text/html"),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async function runDoctorTarget(options = {}) {
|
|
371
|
+
ensureReportDir();
|
|
372
|
+
const docSyncConfig = config.readConfig();
|
|
373
|
+
const target = docSyncConfig.target;
|
|
374
|
+
const scenarios = getSelectedScenarios(docSyncConfig, options.scenarioKeys);
|
|
375
|
+
const timeoutMs = options.timeoutMs || 15_000;
|
|
376
|
+
// Overall budget so the command fails fast instead of grinding through every
|
|
377
|
+
// scenario at the full per-step timeout (which read as an indefinite hang).
|
|
378
|
+
const overallTimeoutMs = options.overallTimeoutMs || Math.max(timeoutMs * 4, 60_000);
|
|
379
|
+
const startedAt = Date.now();
|
|
380
|
+
const overBudget = () => Date.now() - startedAt > overallTimeoutMs;
|
|
381
|
+
const onProgress =
|
|
382
|
+
typeof options.onProgress === "function" ? options.onProgress : null;
|
|
383
|
+
|
|
384
|
+
onProgress?.(`Loaded ${scenarios.length} certified scenario(s) for ${target.displayName}.`);
|
|
385
|
+
|
|
386
|
+
const requiredEnv = (target.requiredEnv || []).map((key) => ({
|
|
387
|
+
key,
|
|
388
|
+
present: Boolean(process.env[key]),
|
|
389
|
+
}));
|
|
390
|
+
|
|
391
|
+
onProgress?.("Preparing fixture state (if required)...");
|
|
392
|
+
const fixture = await withTimeout(
|
|
393
|
+
() => ensureFixturePrepared(target, scenarios),
|
|
394
|
+
timeoutMs,
|
|
395
|
+
"Fixture preparation",
|
|
396
|
+
);
|
|
397
|
+
onProgress?.("Checking capture-safe runtime marker...");
|
|
398
|
+
const captureSafe = await withTimeout(
|
|
399
|
+
() => assertCaptureSafeRuntime(target),
|
|
400
|
+
timeoutMs,
|
|
401
|
+
"Capture-safe runtime check",
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
const routeAudits = [];
|
|
405
|
+
const readinessAudits = [];
|
|
406
|
+
const blockingIssues = [];
|
|
407
|
+
const advisories = [];
|
|
408
|
+
const info = [];
|
|
409
|
+
|
|
410
|
+
let budgetExceeded = false;
|
|
411
|
+
for (const scenario of scenarios) {
|
|
412
|
+
if (overBudget()) {
|
|
413
|
+
budgetExceeded = true;
|
|
414
|
+
onProgress?.(
|
|
415
|
+
`Overall doctor budget (${overallTimeoutMs}ms) exceeded — stopping before "${scenario.key}".`,
|
|
416
|
+
);
|
|
417
|
+
blockingIssues.push(
|
|
418
|
+
createIssue(
|
|
419
|
+
"blocking",
|
|
420
|
+
"doctor_timeout",
|
|
421
|
+
`Target doctor exceeded its overall time budget of ${overallTimeoutMs}ms. Remaining scenarios were not audited.`,
|
|
422
|
+
{ auditedScenarios: readinessAudits.length, totalScenarios: scenarios.length },
|
|
423
|
+
),
|
|
424
|
+
);
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
onProgress?.(`Auditing routes for ${scenario.key}...`);
|
|
428
|
+
const routeResults = [];
|
|
429
|
+
for (const route of scenario.requiredRoutes || []) {
|
|
430
|
+
try {
|
|
431
|
+
const resolvedUrl = resolveContractUrl(target.baseUrl, route);
|
|
432
|
+
const routeResult = await withTimeout(
|
|
433
|
+
() => fetchRoute(resolvedUrl),
|
|
434
|
+
Math.min(timeoutMs, 10_000),
|
|
435
|
+
`Route audit for ${scenario.key}`,
|
|
436
|
+
);
|
|
437
|
+
routeResult.resolvedUrl = resolvedUrl;
|
|
438
|
+
routeResults.push(routeResult);
|
|
439
|
+
} catch (error) {
|
|
440
|
+
routeResults.push({
|
|
441
|
+
ok: false,
|
|
442
|
+
status: 0,
|
|
443
|
+
contentType: "",
|
|
444
|
+
url: route,
|
|
445
|
+
resolvedUrl: null,
|
|
446
|
+
location: null,
|
|
447
|
+
error: error.message,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
routeAudits.push({
|
|
452
|
+
scenario: scenario.key,
|
|
453
|
+
routes: routeResults,
|
|
454
|
+
ok: routeResults.every((result) => result.ok),
|
|
455
|
+
});
|
|
456
|
+
if (!routeResults.every((result) => result.ok)) {
|
|
457
|
+
blockingIssues.push(
|
|
458
|
+
createIssue(
|
|
459
|
+
"blocking",
|
|
460
|
+
"route_mismatch",
|
|
461
|
+
`Scenario "${scenario.key}" failed route audit.`,
|
|
462
|
+
{ scenario: scenario.key, routes: routeResults },
|
|
463
|
+
),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let readyContractOk = hasDeterministicReadyContract(scenario);
|
|
468
|
+
let contractFailure = null;
|
|
469
|
+
if (target.tier === "certified" && !readyContractOk) {
|
|
470
|
+
contractFailure = "Scenario is missing an app-owned readiness contract.";
|
|
471
|
+
} else if (target.tier === "certified" && usesSleepOnlyReadiness(scenario)) {
|
|
472
|
+
readyContractOk = false;
|
|
473
|
+
contractFailure = "Scenario relies on waits without a deterministic ready contract.";
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
onProgress?.(`Auditing page readiness for ${scenario.key}...`);
|
|
477
|
+
let pageAudit;
|
|
478
|
+
try {
|
|
479
|
+
pageAudit = await withTimeout(
|
|
480
|
+
() => auditScenarioPage(target, scenario),
|
|
481
|
+
timeoutMs,
|
|
482
|
+
`Page readiness audit for ${scenario.key}`,
|
|
483
|
+
);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
pageAudit = {
|
|
486
|
+
ok: false,
|
|
487
|
+
scenario: scenario.key,
|
|
488
|
+
url: resolveContractUrl(target.baseUrl, scenario.url),
|
|
489
|
+
status: 0,
|
|
490
|
+
missingSelectors: [],
|
|
491
|
+
ready: false,
|
|
492
|
+
readyFailure: error.message,
|
|
493
|
+
diagnostics: [
|
|
494
|
+
{
|
|
495
|
+
kind: "timeout",
|
|
496
|
+
severity: "error",
|
|
497
|
+
message: error.message,
|
|
498
|
+
},
|
|
499
|
+
],
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
readinessAudits.push({
|
|
503
|
+
scenario: scenario.key,
|
|
504
|
+
ok: readyContractOk && pageAudit.ok,
|
|
505
|
+
contractOk: readyContractOk,
|
|
506
|
+
contractFailure,
|
|
507
|
+
pageAudit,
|
|
508
|
+
});
|
|
509
|
+
if (contractFailure) {
|
|
510
|
+
blockingIssues.push(
|
|
511
|
+
createIssue("blocking", "target_readiness", contractFailure, {
|
|
512
|
+
scenario: scenario.key,
|
|
513
|
+
}),
|
|
514
|
+
);
|
|
515
|
+
} else if (!pageAudit.ok) {
|
|
516
|
+
blockingIssues.push(
|
|
517
|
+
createIssue(
|
|
518
|
+
"blocking",
|
|
519
|
+
"capture_runtime",
|
|
520
|
+
`Scenario "${scenario.key}" failed page readiness audit.`,
|
|
521
|
+
{ scenario: scenario.key, diagnostics: pageAudit.diagnostics },
|
|
522
|
+
),
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
for (const item of requiredEnv.filter((entry) => !entry.present)) {
|
|
528
|
+
blockingIssues.push(
|
|
529
|
+
createIssue(
|
|
530
|
+
"blocking",
|
|
531
|
+
"auth_setup",
|
|
532
|
+
`Required environment variable "${item.key}" is missing.`,
|
|
533
|
+
),
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!fixture.skipped && !fixture.ok) {
|
|
538
|
+
blockingIssues.push(
|
|
539
|
+
createIssue(
|
|
540
|
+
"blocking",
|
|
541
|
+
"auth_setup",
|
|
542
|
+
"Fixture preparation failed.",
|
|
543
|
+
{ command: fixture.command, code: fixture.code },
|
|
544
|
+
),
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if (!captureSafe.ok) {
|
|
549
|
+
advisories.push(
|
|
550
|
+
createIssue(
|
|
551
|
+
"warning",
|
|
552
|
+
"capture_runtime",
|
|
553
|
+
"Capture-safe runtime marker was not detected.",
|
|
554
|
+
{ status: captureSafe.status ?? null },
|
|
555
|
+
),
|
|
556
|
+
);
|
|
557
|
+
} else if (!captureSafe.skipped) {
|
|
558
|
+
info.push(
|
|
559
|
+
createIssue(
|
|
560
|
+
"info",
|
|
561
|
+
"capture_runtime",
|
|
562
|
+
"Capture-safe runtime verified successfully.",
|
|
563
|
+
),
|
|
564
|
+
);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const ok =
|
|
568
|
+
!budgetExceeded &&
|
|
569
|
+
requiredEnv.every((item) => item.present) &&
|
|
570
|
+
(fixture.skipped || fixture.ok) &&
|
|
571
|
+
captureSafe.ok &&
|
|
572
|
+
routeAudits.every((audit) => audit.ok) &&
|
|
573
|
+
readinessAudits.every((audit) => audit.ok);
|
|
574
|
+
|
|
575
|
+
const report = {
|
|
576
|
+
type: "TargetCertificationReport",
|
|
577
|
+
stage: "doctor",
|
|
578
|
+
generatedAt: new Date().toISOString(),
|
|
579
|
+
target,
|
|
580
|
+
requiredEnv,
|
|
581
|
+
fixture,
|
|
582
|
+
captureSafe,
|
|
583
|
+
routeAudits,
|
|
584
|
+
readinessAudits,
|
|
585
|
+
summary: buildSummary(blockingIssues, advisories, info),
|
|
586
|
+
ok,
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
await fs.writeJson(CERTIFICATION_REPORT_PATH, report, { spaces: 2 });
|
|
590
|
+
onProgress?.(`Doctor target completed with status: ${ok ? "ok" : "failed"}.`);
|
|
591
|
+
return report;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function runVerifyPublish(options = {}) {
|
|
595
|
+
ensureReportDir();
|
|
596
|
+
const publishCommand = require("../commands/publish");
|
|
597
|
+
const pullCommand = require("../commands/pull");
|
|
598
|
+
const docSyncConfig = config.readConfig();
|
|
599
|
+
const target = docSyncConfig.target;
|
|
600
|
+
const scenarios = getSelectedScenarios(docSyncConfig, options.scenarioKeys);
|
|
601
|
+
const projectId = docSyncConfig.projectId || docSyncConfig._metadata?.projectId;
|
|
602
|
+
|
|
603
|
+
const publishResult = await publishCommand({
|
|
604
|
+
tag: options.tag,
|
|
605
|
+
message: options.message,
|
|
606
|
+
force: true,
|
|
607
|
+
outputJson: true,
|
|
608
|
+
skipReleaseDoctor: true,
|
|
609
|
+
noExit: true,
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
const pullOutputPath = path.join(REPORT_DIR, "certification-pull.json");
|
|
613
|
+
const pullResult = await pullCommand({
|
|
614
|
+
format: "json",
|
|
615
|
+
output: pullOutputPath,
|
|
616
|
+
status: "all",
|
|
617
|
+
noExit: true,
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
let exportResult = null;
|
|
621
|
+
try {
|
|
622
|
+
exportResult = await apiClient.exportVisuals(projectId, {
|
|
623
|
+
format: "json",
|
|
624
|
+
status: "all",
|
|
625
|
+
});
|
|
626
|
+
} catch (error) {
|
|
627
|
+
exportResult = { error: error.message, assets: null };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const exportChecks = getExpectedAssetChecks(exportResult.assets, scenarios);
|
|
631
|
+
const deliveryChecks = [];
|
|
632
|
+
const blockingIssues = [];
|
|
633
|
+
const advisories = [];
|
|
634
|
+
const info = [];
|
|
635
|
+
for (const check of exportChecks) {
|
|
636
|
+
if (!check.found) {
|
|
637
|
+
deliveryChecks.push({
|
|
638
|
+
scenario: check.scenario,
|
|
639
|
+
assetKey: check.assetKey,
|
|
640
|
+
ok: false,
|
|
641
|
+
reason: "missing_export_asset",
|
|
642
|
+
});
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
for (const url of check.urls) {
|
|
647
|
+
const hosted = await verifyHostedUrl(url.url);
|
|
648
|
+
deliveryChecks.push({
|
|
649
|
+
scenario: check.scenario,
|
|
650
|
+
assetKey: check.assetKey,
|
|
651
|
+
url: url.url,
|
|
652
|
+
step: url.step,
|
|
653
|
+
ok: hosted.ok,
|
|
654
|
+
reason: hosted.ok
|
|
655
|
+
? null
|
|
656
|
+
: hosted.htmlFallback
|
|
657
|
+
? "html_catch_all"
|
|
658
|
+
: `status_${hosted.status}`,
|
|
659
|
+
response: hosted,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const ok =
|
|
665
|
+
Boolean(publishResult?.success) &&
|
|
666
|
+
Boolean(pullResult?.success) &&
|
|
667
|
+
(pullResult?.normalizationRepairs || 0) === 0 &&
|
|
668
|
+
deliveryChecks.length > 0 &&
|
|
669
|
+
deliveryChecks.every((check) => check.ok);
|
|
670
|
+
|
|
671
|
+
if (!publishResult?.success) {
|
|
672
|
+
blockingIssues.push(
|
|
673
|
+
createIssue("blocking", "publish", "Publish did not complete successfully."),
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
if (!pullResult?.success) {
|
|
678
|
+
blockingIssues.push(
|
|
679
|
+
createIssue("blocking", "publish", "Pull/export did not complete successfully."),
|
|
680
|
+
);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
if ((pullResult?.normalizationRepairs || 0) > 0) {
|
|
684
|
+
blockingIssues.push(
|
|
685
|
+
createIssue(
|
|
686
|
+
"blocking",
|
|
687
|
+
"publish",
|
|
688
|
+
"Pull normalized newly generated URLs. Launch output must already be canonical.",
|
|
689
|
+
{ normalizationRepairs: pullResult.normalizationRepairs },
|
|
690
|
+
),
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if (exportResult?.error) {
|
|
695
|
+
blockingIssues.push(
|
|
696
|
+
createIssue("blocking", "hosted_delivery", "Visual export failed.", {
|
|
697
|
+
error: exportResult.error,
|
|
698
|
+
}),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
for (const check of deliveryChecks) {
|
|
703
|
+
if (!check.ok) {
|
|
704
|
+
const code =
|
|
705
|
+
check.reason === "html_catch_all" ? "route_mismatch" : "hosted_delivery";
|
|
706
|
+
blockingIssues.push(
|
|
707
|
+
createIssue(
|
|
708
|
+
"blocking",
|
|
709
|
+
code,
|
|
710
|
+
`Hosted delivery failed for ${check.assetKey}.`,
|
|
711
|
+
check,
|
|
712
|
+
),
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (deliveryChecks.every((check) => check.ok) && deliveryChecks.length > 0) {
|
|
718
|
+
info.push(
|
|
719
|
+
createIssue(
|
|
720
|
+
"info",
|
|
721
|
+
"hosted_delivery",
|
|
722
|
+
"Hosted delivery verified for all expected assets.",
|
|
723
|
+
),
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
const report = {
|
|
728
|
+
type: "DeliveryVerificationResult",
|
|
729
|
+
stage: "verify-publish",
|
|
730
|
+
generatedAt: new Date().toISOString(),
|
|
731
|
+
target,
|
|
732
|
+
publishResult,
|
|
733
|
+
pullResult,
|
|
734
|
+
exportError: exportResult?.error || null,
|
|
735
|
+
deliveryChecks,
|
|
736
|
+
summary: buildSummary(blockingIssues, advisories, info),
|
|
737
|
+
ok,
|
|
738
|
+
};
|
|
739
|
+
|
|
740
|
+
await fs.writeJson(CERTIFICATION_REPORT_PATH, report, { spaces: 2 });
|
|
741
|
+
return report;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
async function runCertification(options = {}) {
|
|
745
|
+
ensureReportDir();
|
|
746
|
+
const runCommand = require("../commands/run");
|
|
747
|
+
const { runReleaseDoctor } = require("./release-doctor");
|
|
748
|
+
const docSyncConfig = config.readConfig();
|
|
749
|
+
const target = docSyncConfig.target;
|
|
750
|
+
const scenarios = getSelectedScenarios(docSyncConfig, options.scenarioKeys);
|
|
751
|
+
const selectedScenarioKeys = scenarios.map((scenario) => scenario.key);
|
|
752
|
+
|
|
753
|
+
const releaseDoctor = options.skipReleaseDoctor
|
|
754
|
+
? {
|
|
755
|
+
skipped: true,
|
|
756
|
+
ok: true,
|
|
757
|
+
summary: {
|
|
758
|
+
blockingIssues: [],
|
|
759
|
+
advisories: [],
|
|
760
|
+
info: [
|
|
761
|
+
createIssue(
|
|
762
|
+
"info",
|
|
763
|
+
"release_doctor",
|
|
764
|
+
"Release doctor skipped by operator request.",
|
|
765
|
+
),
|
|
766
|
+
],
|
|
767
|
+
},
|
|
768
|
+
}
|
|
769
|
+
: await runReleaseDoctor({ scenarioKeys: selectedScenarioKeys });
|
|
770
|
+
|
|
771
|
+
if (!releaseDoctor.ok) {
|
|
772
|
+
const report = {
|
|
773
|
+
type: "TargetCertificationReport",
|
|
774
|
+
stage: "certify",
|
|
775
|
+
generatedAt: new Date().toISOString(),
|
|
776
|
+
target,
|
|
777
|
+
scenarios: selectedScenarioKeys,
|
|
778
|
+
releaseDoctor,
|
|
779
|
+
doctor: {
|
|
780
|
+
skipped: true,
|
|
781
|
+
ok: false,
|
|
782
|
+
},
|
|
783
|
+
capture: {
|
|
784
|
+
skipped: true,
|
|
785
|
+
success: false,
|
|
786
|
+
results: [],
|
|
787
|
+
diagnostics: [],
|
|
788
|
+
},
|
|
789
|
+
publishVerification: {
|
|
790
|
+
skipped: true,
|
|
791
|
+
ok: false,
|
|
792
|
+
},
|
|
793
|
+
summary: buildSummary(
|
|
794
|
+
[
|
|
795
|
+
...(releaseDoctor.summary?.blockingIssues || []),
|
|
796
|
+
],
|
|
797
|
+
[...(releaseDoctor.summary?.advisories || [])],
|
|
798
|
+
[],
|
|
799
|
+
),
|
|
800
|
+
finalStatus: "failed",
|
|
801
|
+
ok: false,
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
await fs.writeJson(CERTIFICATION_REPORT_PATH, report, { spaces: 2 });
|
|
805
|
+
return report;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const doctor = await runDoctorTarget({ scenarioKeys: selectedScenarioKeys });
|
|
809
|
+
const capture = await runCommand({
|
|
810
|
+
scenarioKeys: selectedScenarioKeys,
|
|
811
|
+
headless: true,
|
|
812
|
+
noExit: true,
|
|
813
|
+
});
|
|
814
|
+
const verify = await runVerifyPublish({
|
|
815
|
+
scenarioKeys: selectedScenarioKeys,
|
|
816
|
+
tag: options.tag,
|
|
817
|
+
message: options.message || `${target.displayName} certified target verification`,
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
const captureDiagnostics = (capture.results || []).map((result) => ({
|
|
821
|
+
scenario: result.scenario,
|
|
822
|
+
diagnostics: result.diagnostics || [],
|
|
823
|
+
ok: !hasBlockingDiagnostics(result.diagnostics || []) && result.success,
|
|
824
|
+
}));
|
|
825
|
+
|
|
826
|
+
const ok =
|
|
827
|
+
doctor.ok &&
|
|
828
|
+
capture.success &&
|
|
829
|
+
captureDiagnostics.every((result) => result.ok) &&
|
|
830
|
+
verify.ok;
|
|
831
|
+
|
|
832
|
+
const blockingIssues = [
|
|
833
|
+
...(releaseDoctor.summary?.blockingIssues || []),
|
|
834
|
+
...(doctor.summary?.blockingIssues || []),
|
|
835
|
+
...(verify.summary?.blockingIssues || []),
|
|
836
|
+
];
|
|
837
|
+
const advisories = [
|
|
838
|
+
...(releaseDoctor.summary?.advisories || []),
|
|
839
|
+
...(doctor.summary?.advisories || []),
|
|
840
|
+
...(verify.summary?.advisories || []),
|
|
841
|
+
];
|
|
842
|
+
const info = [
|
|
843
|
+
...(releaseDoctor.summary?.info || []),
|
|
844
|
+
...(doctor.summary?.info || []),
|
|
845
|
+
...(verify.summary?.info || []),
|
|
846
|
+
];
|
|
847
|
+
|
|
848
|
+
for (const result of captureDiagnostics.filter((item) => !item.ok)) {
|
|
849
|
+
blockingIssues.push(
|
|
850
|
+
createIssue(
|
|
851
|
+
"blocking",
|
|
852
|
+
"capture_runtime",
|
|
853
|
+
`Capture diagnostics failed for scenario "${result.scenario}".`,
|
|
854
|
+
result,
|
|
855
|
+
),
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const report = {
|
|
860
|
+
type: "TargetCertificationReport",
|
|
861
|
+
stage: "certify",
|
|
862
|
+
generatedAt: new Date().toISOString(),
|
|
863
|
+
target,
|
|
864
|
+
scenarios: selectedScenarioKeys,
|
|
865
|
+
releaseDoctor,
|
|
866
|
+
doctor,
|
|
867
|
+
capture: {
|
|
868
|
+
success: capture.success,
|
|
869
|
+
results: capture.results,
|
|
870
|
+
diagnostics: captureDiagnostics,
|
|
871
|
+
},
|
|
872
|
+
publishVerification: verify,
|
|
873
|
+
summary: buildSummary(blockingIssues, advisories, info),
|
|
874
|
+
finalStatus: ok ? "certified" : "failed",
|
|
875
|
+
ok,
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
await fs.writeJson(CERTIFICATION_REPORT_PATH, report, { spaces: 2 });
|
|
879
|
+
return report;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
module.exports = {
|
|
883
|
+
CERTIFICATION_REPORT_PATH,
|
|
884
|
+
runDoctorTarget,
|
|
885
|
+
runVerifyPublish,
|
|
886
|
+
runCertification,
|
|
887
|
+
};
|