@saluzi/saluzi-edu 0.4.7 → 0.4.8

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.
Files changed (82) hide show
  1. package/dist/cli.js +51 -60
  2. package/dist/guide/guide-data.json +1 -1
  3. package/dist/vendor/archify/LICENSE +22 -0
  4. package/dist/vendor/archify/SKILL.md +137 -0
  5. package/dist/vendor/archify/THIRD_PARTY_NOTICES.md +69 -0
  6. package/dist/vendor/archify/assets/JetBrainsMono-OFL.txt +93 -0
  7. package/dist/vendor/archify/assets/template.html +14934 -0
  8. package/dist/vendor/archify/bin/archify.mjs +2687 -0
  9. package/dist/vendor/archify/bin/open-artifact.mjs +95 -0
  10. package/dist/vendor/archify/bin/preview.mjs +764 -0
  11. package/dist/vendor/archify/bin/visual-check.mjs +1012 -0
  12. package/dist/vendor/archify/brand-marks/README.md +31 -0
  13. package/dist/vendor/archify/brand-marks/catalog.json +131 -0
  14. package/dist/vendor/archify/delta/architecture-delta.mjs +1221 -0
  15. package/dist/vendor/archify/examples/agent-run.lifecycle.json +60 -0
  16. package/dist/vendor/archify/examples/agent-tool-call.workflow.json +94 -0
  17. package/dist/vendor/archify/examples/async-job-roundtrip.sequence.json +61 -0
  18. package/dist/vendor/archify/examples/brand-aware-delivery.architecture.json +47 -0
  19. package/dist/vendor/archify/examples/cache-miss-request.sequence.json +82 -0
  20. package/dist/vendor/archify/examples/checkout-platform.base.architecture.json +31 -0
  21. package/dist/vendor/archify/examples/checkout-platform.head.architecture.json +31 -0
  22. package/dist/vendor/archify/examples/dataflow-product-analytics.html +15045 -0
  23. package/dist/vendor/archify/examples/deployment-release.lifecycle.json +49 -0
  24. package/dist/vendor/archify/examples/event-stream.dataflow.json +57 -0
  25. package/dist/vendor/archify/examples/incident-response.workflow.json +64 -0
  26. package/dist/vendor/archify/examples/lifecycle-agent-run.html +14980 -0
  27. package/dist/vendor/archify/examples/product-analytics.dataflow.json +76 -0
  28. package/dist/vendor/archify/examples/production-deployment.architecture.json +71 -0
  29. package/dist/vendor/archify/examples/release-delivery.workflow.json +62 -0
  30. package/dist/vendor/archify/examples/sequence-cache-miss-request.html +15060 -0
  31. package/dist/vendor/archify/examples/web-app-rendered.html +15009 -0
  32. package/dist/vendor/archify/examples/web-app.architecture.json +46 -0
  33. package/dist/vendor/archify/examples/workflow-agent-tool-call-rendered.html +15051 -0
  34. package/dist/vendor/archify/migrations/workflow-v2.mjs +279 -0
  35. package/dist/vendor/archify/package.json +17 -0
  36. package/dist/vendor/archify/recipes/scenarios.mjs +391 -0
  37. package/dist/vendor/archify/references/authoring-contract.md +243 -0
  38. package/dist/vendor/archify/references/brand-marks.md +65 -0
  39. package/dist/vendor/archify/references/delivery-contract.md +120 -0
  40. package/dist/vendor/archify/references/viewer-runtime.md +45 -0
  41. package/dist/vendor/archify/renderers/architecture/grid.mjs +62 -0
  42. package/dist/vendor/archify/renderers/architecture/render-architecture.mjs +1078 -0
  43. package/dist/vendor/archify/renderers/dataflow/README.md +104 -0
  44. package/dist/vendor/archify/renderers/dataflow/render-dataflow.mjs +483 -0
  45. package/dist/vendor/archify/renderers/lifecycle/README.md +115 -0
  46. package/dist/vendor/archify/renderers/lifecycle/render-lifecycle.mjs +561 -0
  47. package/dist/vendor/archify/renderers/sequence/README.md +114 -0
  48. package/dist/vendor/archify/renderers/sequence/render-sequence.mjs +464 -0
  49. package/dist/vendor/archify/renderers/shared/brand-marks.mjs +563 -0
  50. package/dist/vendor/archify/renderers/shared/cli.mjs +218 -0
  51. package/dist/vendor/archify/renderers/shared/desktop-readability.mjs +26 -0
  52. package/dist/vendor/archify/renderers/shared/diagnostics.mjs +127 -0
  53. package/dist/vendor/archify/renderers/shared/engineering-profiles.mjs +157 -0
  54. package/dist/vendor/archify/renderers/shared/generated-brand-marks.mjs +2003 -0
  55. package/dist/vendor/archify/renderers/shared/generated-validators.mjs +13 -0
  56. package/dist/vendor/archify/renderers/shared/geometry.mjs +1423 -0
  57. package/dist/vendor/archify/renderers/shared/i18n.mjs +595 -0
  58. package/dist/vendor/archify/renderers/shared/layout-report.mjs +40 -0
  59. package/dist/vendor/archify/renderers/shared/legend.mjs +217 -0
  60. package/dist/vendor/archify/renderers/shared/output-path.mjs +340 -0
  61. package/dist/vendor/archify/renderers/shared/repository-evidence.mjs +238 -0
  62. package/dist/vendor/archify/renderers/shared/repository-location.mjs +58 -0
  63. package/dist/vendor/archify/renderers/shared/text-fit.mjs +49 -0
  64. package/dist/vendor/archify/renderers/shared/utils.mjs +232 -0
  65. package/dist/vendor/archify/renderers/shared/validator.mjs +86 -0
  66. package/dist/vendor/archify/renderers/workflow/README.md +223 -0
  67. package/dist/vendor/archify/renderers/workflow/render-workflow.mjs +35 -0
  68. package/dist/vendor/archify/renderers/workflow/workflow-compiler.mjs +4400 -0
  69. package/dist/vendor/archify/renderers/workflow/workflow-migration-geometry.mjs +144 -0
  70. package/dist/vendor/archify/schemas/README.md +211 -0
  71. package/dist/vendor/archify/schemas/architecture.schema.json +178 -0
  72. package/dist/vendor/archify/schemas/common.schema.json +115 -0
  73. package/dist/vendor/archify/schemas/dataflow.schema.json +243 -0
  74. package/dist/vendor/archify/schemas/lifecycle.schema.json +266 -0
  75. package/dist/vendor/archify/schemas/sequence.schema.json +223 -0
  76. package/dist/vendor/archify/schemas/workflow.schema.json +428 -0
  77. package/dist/vendor/archify/scripts/check-render-output.mjs +836 -0
  78. package/dist/vendor/archify/scripts/check-update.mjs +1667 -0
  79. package/dist/vendor/archify/scripts/render-examples.mjs +26 -0
  80. package/dist/vendor/archify/scripts/update-contract.mjs +182 -0
  81. package/dist/vendor/archify/skill-release.json +10 -0
  82. package/package.json +1 -1
@@ -0,0 +1,1012 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+ import {
8
+ DESKTOP_READABILITY_VIEWPORT,
9
+ MIN_PROJECTED_NODE_TEXT_PX,
10
+ } from '../renderers/shared/desktop-readability.mjs'
11
+
12
+ export const VISUAL_CHECK_VIEWPORTS = Object.freeze([
13
+ DESKTOP_READABILITY_VIEWPORT,
14
+ Object.freeze({ width: 1600, height: 1000 }),
15
+ Object.freeze({ width: 1920, height: 1080 }),
16
+ Object.freeze({ width: 2048, height: 1320 }),
17
+ ])
18
+
19
+ const CAPTURE_VIEWPORTS = Object.freeze([
20
+ VISUAL_CHECK_VIEWPORTS[0],
21
+ VISUAL_CHECK_VIEWPORTS[VISUAL_CHECK_VIEWPORTS.length - 1],
22
+ ])
23
+ const THEMES = Object.freeze(['light', 'dark'])
24
+ const EXIT = Object.freeze({ pass: 0, fail: 1, skipped: 2 })
25
+ export const CHROME_NO_SANDBOX_ENV = 'ARCHIFY_CHROME_NO_SANDBOX'
26
+
27
+ function sha256(buffer) {
28
+ return createHash('sha256').update(buffer).digest('hex')
29
+ }
30
+
31
+ function htmlEscape(value) {
32
+ return String(value).replace(
33
+ /[&<>"']/g,
34
+ char =>
35
+ ({
36
+ '&': '&amp;',
37
+ '<': '&lt;',
38
+ '>': '&gt;',
39
+ '"': '&quot;',
40
+ "'": '&#39;',
41
+ })[char],
42
+ )
43
+ }
44
+
45
+ function safeUnlink(file) {
46
+ try {
47
+ fs.rmSync(file, { force: true })
48
+ } catch {
49
+ // A stale optional sidecar must never make the delivered HTML mutable.
50
+ }
51
+ }
52
+
53
+ function writeAtomic(file, contents) {
54
+ const temporary = `${file}.tmp-${process.pid}`
55
+ try {
56
+ fs.writeFileSync(temporary, contents, { flag: 'w' })
57
+ fs.renameSync(temporary, file)
58
+ } finally {
59
+ safeUnlink(temporary)
60
+ }
61
+ }
62
+
63
+ function screenshotKey(width, height, theme) {
64
+ return `${width}x${height}:${theme}`
65
+ }
66
+
67
+ export function sidecarPaths(artifactPath) {
68
+ const artifact = path.resolve(artifactPath)
69
+ const stem = artifact.replace(/\.html?$/i, '')
70
+ const base = `${stem}.visual-check`
71
+ const screenshots = CAPTURE_VIEWPORTS.flatMap(({ width, height }) =>
72
+ THEMES.map(theme => ({
73
+ width,
74
+ height,
75
+ theme,
76
+ path: `${base}.${width}x${height}.${theme}.png`,
77
+ })),
78
+ )
79
+ return {
80
+ base,
81
+ receipt: `${base}.json`,
82
+ contactSheet: `${base}.html`,
83
+ screenshots,
84
+ }
85
+ }
86
+
87
+ function cleanupCaptureSidecars(paths) {
88
+ safeUnlink(paths.contactSheet)
89
+ for (const screenshot of paths.screenshots) safeUnlink(screenshot.path)
90
+ }
91
+
92
+ function executable(file, platform = process.platform) {
93
+ if (!file) return null
94
+ try {
95
+ fs.accessSync(
96
+ file,
97
+ platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK,
98
+ )
99
+ return path.resolve(file)
100
+ } catch {
101
+ return null
102
+ }
103
+ }
104
+
105
+ function findOnPath(command, env, platform) {
106
+ const directories = String(env.PATH || '')
107
+ .split(path.delimiter)
108
+ .filter(Boolean)
109
+ const extensions =
110
+ platform === 'win32'
111
+ ? String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
112
+ .split(';')
113
+ .filter(Boolean)
114
+ : ['']
115
+ for (const directory of directories) {
116
+ for (const extension of extensions) {
117
+ const candidate = path.join(directory, `${command}${extension}`)
118
+ const resolved = executable(candidate, platform)
119
+ if (resolved) return resolved
120
+ }
121
+ }
122
+ return null
123
+ }
124
+
125
+ export function findChrome({
126
+ env = process.env,
127
+ platform = process.platform,
128
+ } = {}) {
129
+ if (Object.hasOwn(env, 'ARCHIFY_CHROME')) {
130
+ return executable(env.ARCHIFY_CHROME, platform)
131
+ }
132
+
133
+ const fixed = []
134
+ const commands = []
135
+ if (platform === 'darwin') {
136
+ fixed.push(
137
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
138
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
139
+ )
140
+ } else if (platform === 'win32') {
141
+ for (const root of [
142
+ env.PROGRAMFILES,
143
+ env['PROGRAMFILES(X86)'],
144
+ env.LOCALAPPDATA,
145
+ ].filter(Boolean)) {
146
+ fixed.push(
147
+ path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'),
148
+ path.join(root, 'Chromium', 'Application', 'chrome.exe'),
149
+ )
150
+ }
151
+ } else {
152
+ commands.push(
153
+ 'google-chrome',
154
+ 'google-chrome-stable',
155
+ 'chromium',
156
+ 'chromium-browser',
157
+ )
158
+ }
159
+
160
+ for (const candidate of fixed) {
161
+ const resolved = executable(candidate, platform)
162
+ if (resolved) return resolved
163
+ }
164
+ for (const command of commands) {
165
+ const resolved = findOnPath(command, env, platform)
166
+ if (resolved) return resolved
167
+ }
168
+ return null
169
+ }
170
+
171
+ class PipeCdp {
172
+ constructor(child, { failureDetails = () => '' } = {}) {
173
+ this.child = child
174
+ this.failureDetails = failureDetails
175
+ this.nextId = 1
176
+ this.buffer = ''
177
+ this.pending = new Map()
178
+ this.waiters = []
179
+ this.writePipe = child.stdio[3]
180
+ this.readPipe = child.stdio[4]
181
+ this.readPipe.setEncoding('utf8')
182
+ this.readPipe.on('data', chunk => this.consume(chunk))
183
+ this.writePipe.on('error', error =>
184
+ this.failAll(this.failure('write pipe', error)),
185
+ )
186
+ this.readPipe.on('error', error =>
187
+ this.failAll(this.failure('read pipe', error)),
188
+ )
189
+ child.once('error', error =>
190
+ this.failAll(this.failure('process launch', error)),
191
+ )
192
+ child.once('close', (code, signal) => {
193
+ const ending = signal ? `signal ${signal}` : `exit code ${code}`
194
+ this.failAll(
195
+ this.failure('process exit', new Error(`Chrome closed with ${ending}`)),
196
+ )
197
+ })
198
+ }
199
+
200
+ failure(stage, error) {
201
+ const code = error?.code ? ` [${error.code}]` : ''
202
+ const details = this.failureDetails()
203
+ return new Error(
204
+ [
205
+ `Chrome DevTools ${stage} failed: ${error?.message || String(error)}${code}`,
206
+ details,
207
+ ]
208
+ .filter(Boolean)
209
+ .join('\n'),
210
+ )
211
+ }
212
+
213
+ consume(chunk) {
214
+ this.buffer += chunk
215
+ let boundary
216
+ while ((boundary = this.buffer.indexOf('\0')) >= 0) {
217
+ const raw = this.buffer.slice(0, boundary)
218
+ this.buffer = this.buffer.slice(boundary + 1)
219
+ if (!raw) continue
220
+ let message
221
+ try {
222
+ message = JSON.parse(raw)
223
+ } catch (error) {
224
+ this.failAll(
225
+ new Error(`Chrome DevTools returned invalid JSON: ${error.message}`),
226
+ )
227
+ continue
228
+ }
229
+ if (message.id) {
230
+ const pending = this.pending.get(message.id)
231
+ if (!pending) continue
232
+ clearTimeout(pending.timer)
233
+ this.pending.delete(message.id)
234
+ if (message.error)
235
+ pending.reject(
236
+ new Error(`${pending.method}: ${message.error.message}`),
237
+ )
238
+ else pending.resolve(message.result || {})
239
+ continue
240
+ }
241
+ for (const waiter of [...this.waiters]) {
242
+ if (waiter.method !== message.method) continue
243
+ if (waiter.sessionId && waiter.sessionId !== message.sessionId) continue
244
+ clearTimeout(waiter.timer)
245
+ this.waiters.splice(this.waiters.indexOf(waiter), 1)
246
+ waiter.resolve(message.params || {})
247
+ }
248
+ }
249
+ }
250
+
251
+ send(method, params = {}, sessionId = undefined, timeoutMs = 15000) {
252
+ const id = this.nextId++
253
+ const message = { id, method, params }
254
+ if (sessionId) message.sessionId = sessionId
255
+ return new Promise((resolve, reject) => {
256
+ const timer = setTimeout(() => {
257
+ this.pending.delete(id)
258
+ reject(new Error(`${method}: timed out after ${timeoutMs}ms`))
259
+ }, timeoutMs)
260
+ this.pending.set(id, { method, resolve, reject, timer })
261
+ try {
262
+ this.writePipe.write(`${JSON.stringify(message)}\0`, error => {
263
+ if (error) this.failAll(this.failure('write pipe', error))
264
+ })
265
+ } catch (error) {
266
+ this.failAll(this.failure('write pipe', error))
267
+ }
268
+ })
269
+ }
270
+
271
+ waitFor(method, sessionId, timeoutMs = 15000) {
272
+ return new Promise((resolve, reject) => {
273
+ const waiter = { method, sessionId, resolve, reject, timer: null }
274
+ waiter.timer = setTimeout(() => {
275
+ this.waiters.splice(this.waiters.indexOf(waiter), 1)
276
+ reject(new Error(`${method}: event timed out after ${timeoutMs}ms`))
277
+ }, timeoutMs)
278
+ this.waiters.push(waiter)
279
+ })
280
+ }
281
+
282
+ failAll(error) {
283
+ for (const pending of this.pending.values()) {
284
+ clearTimeout(pending.timer)
285
+ pending.reject(error)
286
+ }
287
+ for (const waiter of this.waiters) {
288
+ clearTimeout(waiter.timer)
289
+ waiter.reject(error)
290
+ }
291
+ this.pending.clear()
292
+ this.waiters = []
293
+ }
294
+ }
295
+
296
+ export function chromeVisualBrowserArgs(
297
+ profileRoot,
298
+ {
299
+ env = process.env,
300
+ getuid = typeof process.getuid === 'function'
301
+ ? () => process.getuid()
302
+ : null,
303
+ } = {},
304
+ ) {
305
+ const args = [
306
+ '--headless=new',
307
+ '--remote-debugging-pipe',
308
+ '--disable-gpu',
309
+ '--hide-scrollbars',
310
+ '--disable-background-networking',
311
+ '--disable-component-update',
312
+ '--disable-default-apps',
313
+ '--disable-sync',
314
+ '--metrics-recording-only',
315
+ '--no-first-run',
316
+ '--no-default-browser-check',
317
+ '--disable-background-timer-throttling',
318
+ '--disable-backgrounding-occluded-windows',
319
+ '--disable-renderer-backgrounding',
320
+ '--force-device-scale-factor=1',
321
+ `--user-data-dir=${profileRoot}`,
322
+ 'about:blank',
323
+ ]
324
+ const rootUser = typeof getuid === 'function' && getuid() === 0
325
+ const sandboxOptOut = env?.[CHROME_NO_SANDBOX_ENV] === '1'
326
+ if (rootUser || sandboxOptOut) args.unshift('--no-sandbox')
327
+ return args
328
+ }
329
+
330
+ async function evaluate(cdp, sessionId, expression, awaitPromise = false) {
331
+ const response = await cdp.send(
332
+ 'Runtime.evaluate',
333
+ {
334
+ expression,
335
+ awaitPromise,
336
+ returnByValue: true,
337
+ },
338
+ sessionId,
339
+ )
340
+ if (response.exceptionDetails) {
341
+ throw new Error(
342
+ response.exceptionDetails.exception?.description ||
343
+ response.exceptionDetails.text ||
344
+ 'Runtime.evaluate failed',
345
+ )
346
+ }
347
+ return response.result?.value
348
+ }
349
+
350
+ export class ChromeVisualBrowser {
351
+ constructor(
352
+ chromePath,
353
+ {
354
+ env = process.env,
355
+ getuid = typeof process.getuid === 'function'
356
+ ? () => process.getuid()
357
+ : null,
358
+ spawnImpl = spawn,
359
+ } = {},
360
+ ) {
361
+ this.profileRoot = fs.mkdtempSync(
362
+ path.join(os.tmpdir(), 'archify-visual-check-profile-'),
363
+ )
364
+ this.stderr = ''
365
+ const args = chromeVisualBrowserArgs(this.profileRoot, { env, getuid })
366
+ this.child = spawnImpl(chromePath, args, {
367
+ stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
368
+ })
369
+ this.child.stderr.setEncoding('utf8')
370
+ this.child.stderr.on('data', chunk => {
371
+ this.stderr = `${this.stderr}${chunk}`.slice(-8000)
372
+ })
373
+ this.child.stderr.on('error', error => {
374
+ this.stderr =
375
+ `${this.stderr}\nChrome stderr stream failed: ${error.message}`
376
+ .trim()
377
+ .slice(-8000)
378
+ })
379
+ this.cdp = new PipeCdp(this.child, {
380
+ failureDetails: () => {
381
+ const exit = this.child.signalCode
382
+ ? `signal ${this.child.signalCode}`
383
+ : this.child.exitCode == null
384
+ ? 'still running'
385
+ : `exit code ${this.child.exitCode}`
386
+ const stderr = this.stderr.trim()
387
+ return [
388
+ `Chrome process: ${exit}.`,
389
+ stderr ? `Chrome stderr:\n${stderr}` : '',
390
+ ]
391
+ .filter(Boolean)
392
+ .join('\n')
393
+ },
394
+ })
395
+ this.sessionPromise = this.attach()
396
+ }
397
+
398
+ async attach() {
399
+ const targets = await this.cdp.send('Target.getTargets')
400
+ let target = targets.targetInfos?.find(item => item.type === 'page')
401
+ if (!target) {
402
+ const created = await this.cdp.send('Target.createTarget', {
403
+ url: 'about:blank',
404
+ })
405
+ target = { targetId: created.targetId }
406
+ }
407
+ const attached = await this.cdp.send('Target.attachToTarget', {
408
+ targetId: target.targetId,
409
+ flatten: true,
410
+ })
411
+ await this.cdp.send('Page.enable', {}, attached.sessionId)
412
+ await this.cdp.send('Runtime.enable', {}, attached.sessionId)
413
+ return attached.sessionId
414
+ }
415
+
416
+ async inspect({ artifactPath, width, height, theme, screenshotPath }) {
417
+ const sessionId = await this.sessionPromise
418
+ await this.cdp.send(
419
+ 'Emulation.setDeviceMetricsOverride',
420
+ {
421
+ width,
422
+ height,
423
+ deviceScaleFactor: 1,
424
+ mobile: false,
425
+ },
426
+ sessionId,
427
+ )
428
+
429
+ const url = new URL(pathToFileURL(artifactPath).href)
430
+ url.searchParams.set('theme', theme)
431
+ const loaded = this.cdp.waitFor('Page.loadEventFired', sessionId)
432
+ const navigation = await this.cdp.send(
433
+ 'Page.navigate',
434
+ { url: url.href },
435
+ sessionId,
436
+ )
437
+ if (navigation.errorText)
438
+ throw new Error(`Chrome navigation failed: ${navigation.errorText}`)
439
+ await loaded
440
+ await evaluate(
441
+ this.cdp,
442
+ sessionId,
443
+ `(function () {
444
+ document.documentElement.setAttribute('data-motion', 'still');
445
+ var panel = document.querySelector('.diagram-container');
446
+ if (panel) panel.setAttribute('data-detail-level', 'read');
447
+ var fontsReady = document.fonts && document.fonts.ready
448
+ ? document.fonts.ready.catch(function () {})
449
+ : Promise.resolve();
450
+ return fontsReady.then(function () {
451
+ if (window.Archify && Archify.readerLayout && typeof Archify.readerLayout.whenStable === 'function') {
452
+ return Archify.readerLayout.whenStable();
453
+ }
454
+ }).then(function () {
455
+ if (window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.whenStable === 'function') {
456
+ return Archify.viewerChromeLayout.whenStable();
457
+ }
458
+ }).then(function () {
459
+ if (window.Archify && Archify.readerLayout && typeof Archify.readerLayout.whenStable === 'function') {
460
+ return Archify.readerLayout.whenStable();
461
+ }
462
+ }).then(function () {
463
+ if (window.Archify && Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.whenStable === 'function') {
464
+ return Archify.viewerChromeLayout.whenStable();
465
+ }
466
+ return new Promise(function (resolve) {
467
+ requestAnimationFrame(function () { requestAnimationFrame(resolve); });
468
+ });
469
+ });
470
+ })()`,
471
+ true,
472
+ )
473
+
474
+ const metrics = await evaluate(
475
+ this.cdp,
476
+ sessionId,
477
+ `(function () {
478
+ var reader = document.querySelector('.container');
479
+ var diagram = document.querySelector('.diagram-container');
480
+ var svg = diagram && (
481
+ diagram.querySelector(':scope > svg') ||
482
+ diagram.querySelector(':scope > .diagram-stage > svg')
483
+ );
484
+ var stage = diagram && (diagram.querySelector(':scope > .diagram-stage') || svg);
485
+ var legend = svg && svg.querySelector('[data-legend]');
486
+ var navigationDock = diagram && diagram.querySelector('.diagram-nav');
487
+ var viewBox = svg && svg.viewBox && svg.viewBox.baseVal;
488
+ var diagramWidth = svg ? svg.getBoundingClientRect().width : 0;
489
+ var viewBoxWidth = viewBox ? viewBox.width : 0;
490
+ var scale = viewBoxWidth > 0 ? Math.min(1, diagramWidth / viewBoxWidth) : 0;
491
+ var minimum = null;
492
+ if (svg && scale > 0) {
493
+ Array.from(svg.querySelectorAll('text[data-node-label], text[data-boundary-label], text[data-detail="context"]')).forEach(function (text) {
494
+ var detail = text.hasAttribute('data-node-label')
495
+ ? 'primary'
496
+ : text.hasAttribute('data-boundary-label') ? 'boundary' : 'context';
497
+ if (detail === 'context' && !text.closest('[data-node-id]')) return;
498
+ var sourceFontPx = parseFloat(text.getAttribute('font-size') || '');
499
+ if (!Number.isFinite(sourceFontPx)) return;
500
+ var projectedFontPx = sourceFontPx * scale;
501
+ if (!minimum || projectedFontPx < minimum.projectedFontPx) {
502
+ minimum = {
503
+ text: (text.textContent || '').trim(),
504
+ detail: detail,
505
+ sourceFontPx: sourceFontPx,
506
+ projectedFontPx: projectedFontPx
507
+ };
508
+ }
509
+ });
510
+ }
511
+ function intersectionArea(a, b) {
512
+ if (!a || !b || !a.width || !a.height || !b.width || !b.height) return 0;
513
+ var width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
514
+ var height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
515
+ return width * height;
516
+ }
517
+ var legendRect = legend ? legend.getBoundingClientRect() : null;
518
+ var stageRect = window.Archify && Archify.viewerChromeLayout
519
+ && typeof Archify.viewerChromeLayout.stageRect === 'function'
520
+ ? Archify.viewerChromeLayout.stageRect()
521
+ : (stage ? stage.getBoundingClientRect() : null);
522
+ var navigationDockRect = navigationDock ? navigationDock.getBoundingClientRect() : null;
523
+ var stageDockIntersectionArea = intersectionArea(stageRect, navigationDockRect);
524
+ var viewerChromeReceipt = window.Archify && Archify.viewerChromeLayout
525
+ && typeof Archify.viewerChromeLayout.receipt === 'function'
526
+ ? Archify.viewerChromeLayout.receipt()
527
+ : null;
528
+ return {
529
+ innerWidth: window.innerWidth,
530
+ innerHeight: window.innerHeight,
531
+ scrollWidth: Math.ceil(document.documentElement.scrollWidth),
532
+ scrollHeight: Math.ceil(document.documentElement.scrollHeight),
533
+ resolvedTheme: document.documentElement.getAttribute('data-theme') || '',
534
+ readerWidth: reader ? reader.getBoundingClientRect().width : 0,
535
+ diagramWidth: diagramWidth,
536
+ viewBoxWidth: viewBoxWidth,
537
+ minimumProjectedNodeTextPx: minimum ? minimum.projectedFontPx : null,
538
+ minimumProjectedNodeText: minimum ? minimum.text : null,
539
+ minimumProjectedNodeTextDetail: minimum ? minimum.detail : null,
540
+ hasLegend: Boolean(legendRect && legendRect.width && legendRect.height),
541
+ hasNavigationDock: Boolean(navigationDockRect && navigationDockRect.width && navigationDockRect.height),
542
+ legendDockIntersectionArea: stageDockIntersectionArea > 0
543
+ ? intersectionArea(legendRect, navigationDockRect)
544
+ : 0,
545
+ dockStageIntersectionArea: stageDockIntersectionArea,
546
+ dockStageGap: stageRect && navigationDockRect ? navigationDockRect.top - stageRect.bottom : null,
547
+ viewerChromeRequiredGap: viewerChromeReceipt ? viewerChromeReceipt.gap : null,
548
+ viewerChromeReserve: viewerChromeReceipt ? viewerChromeReceipt.reserve : 0,
549
+ viewerChromeActive: viewerChromeReceipt ? viewerChromeReceipt.active : false
550
+ };
551
+ })()`,
552
+ )
553
+ if (
554
+ !metrics ||
555
+ !Number.isFinite(metrics.scrollWidth) ||
556
+ !Number.isFinite(metrics.scrollHeight)
557
+ ) {
558
+ throw new Error('Chrome returned incomplete containment metrics.')
559
+ }
560
+
561
+ if (screenshotPath) {
562
+ const capture = await this.cdp.send(
563
+ 'Page.captureScreenshot',
564
+ {
565
+ format: 'png',
566
+ fromSurface: true,
567
+ captureBeyondViewport: false,
568
+ },
569
+ sessionId,
570
+ 20000,
571
+ )
572
+ if (!capture.data) throw new Error('Chrome returned an empty screenshot.')
573
+ fs.writeFileSync(screenshotPath, Buffer.from(capture.data, 'base64'))
574
+ }
575
+ return metrics
576
+ }
577
+
578
+ async close() {
579
+ this.cdp.failAll(new Error('visual-check finished'))
580
+ if (this.child.exitCode === null && this.child.signalCode === null) {
581
+ this.child.kill('SIGTERM')
582
+ await new Promise(resolve => {
583
+ const timer = setTimeout(() => {
584
+ if (this.child.exitCode === null && this.child.signalCode === null)
585
+ this.child.kill('SIGKILL')
586
+ resolve()
587
+ }, 1500)
588
+ this.child.once('exit', () => {
589
+ clearTimeout(timer)
590
+ resolve()
591
+ })
592
+ })
593
+ }
594
+ try {
595
+ fs.rmSync(this.profileRoot, { recursive: true, force: true })
596
+ } catch {
597
+ // Chrome may briefly retain profile files on Windows; evidence is done.
598
+ }
599
+ }
600
+ }
601
+
602
+ function observation({ width, height, theme, metrics }) {
603
+ const innerWidth = Number(metrics.innerWidth)
604
+ const innerHeight = Number(metrics.innerHeight)
605
+ const scrollWidth = Number(metrics.scrollWidth)
606
+ const scrollHeight = Number(metrics.scrollHeight)
607
+ const overflowX = scrollWidth > innerWidth
608
+ const overflowY = scrollHeight > innerHeight
609
+ const minimumProjectedNodeTextPx =
610
+ metrics.minimumProjectedNodeTextPx == null
611
+ ? null
612
+ : Number(metrics.minimumProjectedNodeTextPx)
613
+ const readabilityOk =
614
+ minimumProjectedNodeTextPx == null ||
615
+ minimumProjectedNodeTextPx >= MIN_PROJECTED_NODE_TEXT_PX
616
+ const legendDockIntersectionArea =
617
+ Number(metrics.legendDockIntersectionArea) || 0
618
+ const dockStageIntersectionArea =
619
+ Number(metrics.dockStageIntersectionArea) || 0
620
+ const dockStageGap =
621
+ metrics.dockStageGap == null ? null : Number(metrics.dockStageGap)
622
+ const receiptDockStageGap =
623
+ metrics.viewerChromeRequiredGap == null
624
+ ? null
625
+ : Number(metrics.viewerChromeRequiredGap)
626
+ const requiredDockStageGap = Number.isFinite(receiptDockStageGap)
627
+ ? receiptDockStageGap
628
+ : 0
629
+ const viewerChromeStageOk =
630
+ !metrics.hasNavigationDock ||
631
+ (Number.isFinite(dockStageGap) &&
632
+ dockStageIntersectionArea <= 0.5 &&
633
+ dockStageGap >= requiredDockStageGap - 1)
634
+ const viewerChromeOk =
635
+ legendDockIntersectionArea <= 0.5 && viewerChromeStageOk
636
+ return {
637
+ width,
638
+ height,
639
+ theme,
640
+ innerWidth,
641
+ innerHeight,
642
+ scrollWidth,
643
+ scrollHeight,
644
+ overflowX,
645
+ overflowY,
646
+ ok: !overflowX && !overflowY,
647
+ readerWidth: Number(metrics.readerWidth) || null,
648
+ diagramWidth: Number(metrics.diagramWidth) || null,
649
+ viewBoxWidth: Number(metrics.viewBoxWidth) || null,
650
+ minimumProjectedNodeTextPx,
651
+ minimumProjectedNodeText: metrics.minimumProjectedNodeText || null,
652
+ minimumProjectedNodeTextDetail:
653
+ metrics.minimumProjectedNodeTextDetail || null,
654
+ minimumRequiredNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX,
655
+ readabilityOk,
656
+ hasLegend: Boolean(metrics.hasLegend),
657
+ hasNavigationDock: Boolean(metrics.hasNavigationDock),
658
+ legendDockIntersectionArea,
659
+ dockStageIntersectionArea,
660
+ dockStageGap,
661
+ requiredDockStageGap,
662
+ viewerChromeStageOk,
663
+ viewerChromeReserve: Number(metrics.viewerChromeReserve) || 0,
664
+ viewerChromeActive: Boolean(metrics.viewerChromeActive),
665
+ viewerChromeOk,
666
+ resolvedTheme: metrics.resolvedTheme || theme,
667
+ }
668
+ }
669
+
670
+ function contactSheetHtml({ artifactPath, receipt, screenshots }) {
671
+ const cards = screenshots
672
+ .map(
673
+ entry => `
674
+ <figure>
675
+ <img src="${htmlEscape(entry.file)}" alt="${htmlEscape(`${entry.theme} ${entry.width} by ${entry.height}`)}">
676
+ <figcaption><strong>${htmlEscape(entry.theme.toUpperCase())}</strong> · ${entry.width}×${entry.height} · containment ${entry.ok ? 'pass' : 'fail'}</figcaption>
677
+ </figure>`,
678
+ )
679
+ .join('')
680
+ return `<!doctype html>
681
+ <html lang="en">
682
+ <head>
683
+ <meta charset="utf-8">
684
+ <meta name="viewport" content="width=device-width,initial-scale=1">
685
+ <title>Archify automated browser evidence · ${htmlEscape(path.basename(artifactPath))}</title>
686
+ <style>
687
+ *{box-sizing:border-box}body{margin:0;padding:24px;background:#e9eef5;color:#172033;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}header{max-width:1500px;margin:0 auto 18px}h1{margin:0 0 6px;font-size:20px}p{margin:0;color:#526176}.grid{max-width:1500px;margin:auto;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}figure{margin:0;padding:10px;background:white;border:1px solid #c9d4e3;border-radius:12px;box-shadow:0 10px 30px rgba(15,23,42,.08)}img{display:block;width:100%;height:auto;border:1px solid #e2e8f0}figcaption{padding:9px 4px 2px;color:#526176}@media(max-width:900px){.grid{grid-template-columns:1fr}}
688
+ </style>
689
+ </head>
690
+ <body>
691
+ <header><h1>Automated browser evidence</h1><p>${htmlEscape(path.basename(artifactPath))} · visual-check containment ${htmlEscape(receipt.containment.status)} · perceptual visual review pending</p></header>
692
+ <main class="grid">${cards}
693
+ </main>
694
+ </body>
695
+ </html>
696
+ `
697
+ }
698
+
699
+ function viewportSubject(artifact, entry) {
700
+ return {
701
+ artifact,
702
+ viewport: { width: entry.width, height: entry.height, theme: entry.theme },
703
+ }
704
+ }
705
+
706
+ function failureDiagnostic({
707
+ code,
708
+ message,
709
+ subject,
710
+ evidence,
711
+ supportedFixes,
712
+ severity = 'error',
713
+ }) {
714
+ return { code, severity, message, subject, evidence, supportedFixes }
715
+ }
716
+
717
+ function observationDiagnostics({
718
+ artifact,
719
+ allObservations,
720
+ readabilityObservations,
721
+ }) {
722
+ const diagnostics = []
723
+ for (const entry of allObservations) {
724
+ if (!entry.ok) {
725
+ diagnostics.push(
726
+ failureDiagnostic({
727
+ code: 'viewer/viewport-overflow',
728
+ message: `The rendered artifact overflows the ${entry.width}x${entry.height} ${entry.theme} viewport.`,
729
+ subject: viewportSubject(artifact, entry),
730
+ evidence: {
731
+ innerWidth: entry.innerWidth,
732
+ innerHeight: entry.innerHeight,
733
+ scrollWidth: entry.scrollWidth,
734
+ scrollHeight: entry.scrollHeight,
735
+ overflowX: entry.overflowX,
736
+ overflowY: entry.overflowY,
737
+ },
738
+ supportedFixes: [
739
+ `contain the rendered layout within ${entry.width}x${entry.height}, then rerun visual-check`,
740
+ ],
741
+ }),
742
+ )
743
+ }
744
+ if (entry.legendDockIntersectionArea > 0.5) {
745
+ diagnostics.push(
746
+ failureDiagnostic({
747
+ code: 'viewer/chrome-legend-clearance',
748
+ message: `The navigation Dock obscures the SVG Legend at ${entry.width}x${entry.height} (${entry.theme}).`,
749
+ subject: viewportSubject(artifact, entry),
750
+ evidence: {
751
+ legendDockIntersectionArea: entry.legendDockIntersectionArea,
752
+ },
753
+ supportedFixes: [
754
+ 'move the SVG Legend or Viewer Dock until legendDockIntersectionArea is 0, then rerun visual-check',
755
+ ],
756
+ }),
757
+ )
758
+ }
759
+ if (!entry.viewerChromeStageOk) {
760
+ const stageOverlapsDock = entry.dockStageIntersectionArea > 0.5
761
+ diagnostics.push(
762
+ failureDiagnostic({
763
+ code: 'viewer/chrome-stage-clearance',
764
+ message: stageOverlapsDock
765
+ ? `Navigation Dock enters the protected SVG stage at ${entry.width}x${entry.height} (${entry.theme}).`
766
+ : `Navigation Dock clearance from the protected SVG stage is below the required gap at ${entry.width}x${entry.height} (${entry.theme}).`,
767
+ subject: viewportSubject(artifact, entry),
768
+ evidence: {
769
+ dockStageIntersectionArea: entry.dockStageIntersectionArea,
770
+ dockStageGap: entry.dockStageGap,
771
+ requiredDockStageGap: entry.requiredDockStageGap,
772
+ },
773
+ supportedFixes: [
774
+ `adjust Viewer stage reservation or clipping until dockStageGap is at least ${entry.requiredDockStageGap} and dockStageIntersectionArea is 0, then rerun visual-check`,
775
+ ],
776
+ }),
777
+ )
778
+ }
779
+ }
780
+ for (const entry of readabilityObservations) {
781
+ if (entry.readabilityOk) continue
782
+ diagnostics.push(
783
+ failureDiagnostic({
784
+ code: 'viewer/projected-text-readability',
785
+ message: `Projected ${entry.minimumProjectedNodeTextDetail || 'node'} text is below the readability floor at ${entry.width}x${entry.height}.`,
786
+ subject: viewportSubject(artifact, entry),
787
+ evidence: {
788
+ text: entry.minimumProjectedNodeText,
789
+ detail: entry.minimumProjectedNodeTextDetail,
790
+ minimumProjectedNodeTextPx: entry.minimumProjectedNodeTextPx,
791
+ minimumRequiredNodeTextPx: entry.minimumRequiredNodeTextPx,
792
+ },
793
+ supportedFixes: [
794
+ `increase projected node text to at least ${entry.minimumRequiredNodeTextPx}px at ${entry.width}x${entry.height}, then rerun visual-check`,
795
+ ],
796
+ }),
797
+ )
798
+ }
799
+ return diagnostics
800
+ }
801
+
802
+ function baseReceipt({ artifactPath, artifact, outputs, chrome }) {
803
+ return {
804
+ schemaVersion: 1,
805
+ ok: false,
806
+ command: 'visual-check',
807
+ evidenceKind: 'automated-browser',
808
+ status: 'fail',
809
+ visualReview: 'pending',
810
+ artifact: {
811
+ path: artifactPath,
812
+ sha256: sha256(artifact),
813
+ bytes: artifact.byteLength,
814
+ },
815
+ state: { detail: 'read', motion: 'still' },
816
+ chrome,
817
+ diagnostics: [],
818
+ containment: { status: 'fail', viewports: [] },
819
+ readability: {
820
+ status: 'fail',
821
+ minimumProjectedNodeTextPx: MIN_PROJECTED_NODE_TEXT_PX,
822
+ viewports: [],
823
+ },
824
+ viewerChrome: { status: 'fail', viewports: [] },
825
+ captures: { status: 'fail', screenshots: [], contactSheet: null },
826
+ sidecars: {
827
+ receipt: path.basename(outputs.receipt),
828
+ contactSheet: path.basename(outputs.contactSheet),
829
+ },
830
+ }
831
+ }
832
+
833
+ function persistReceipt(outputs, receipt) {
834
+ writeAtomic(outputs.receipt, `${JSON.stringify(receipt, null, 2)}\n`)
835
+ }
836
+
837
+ export async function runVisualCheck({
838
+ artifactPath,
839
+ chromePath,
840
+ resolveChrome = findChrome,
841
+ browserFactory = async resolvedChrome =>
842
+ new ChromeVisualBrowser(resolvedChrome),
843
+ } = {}) {
844
+ if (!artifactPath)
845
+ throw new Error('visual-check requires one delivered HTML artifact.')
846
+ const artifact = path.resolve(artifactPath)
847
+ if (!/\.html?$/i.test(artifact))
848
+ throw new Error('visual-check requires an .html artifact.')
849
+ const artifactBytes = fs.readFileSync(artifact)
850
+ const outputs = sidecarPaths(artifact)
851
+ cleanupCaptureSidecars(outputs)
852
+ safeUnlink(outputs.receipt)
853
+
854
+ const resolvedChrome = chromePath || resolveChrome()
855
+ const receipt = baseReceipt({
856
+ artifactPath: artifact,
857
+ artifact: artifactBytes,
858
+ outputs,
859
+ chrome: resolvedChrome
860
+ ? { status: 'available', executable: resolvedChrome }
861
+ : { status: 'unavailable', executable: null },
862
+ })
863
+
864
+ if (!resolvedChrome) {
865
+ receipt.status = 'skipped'
866
+ receipt.containment.status = 'skipped'
867
+ receipt.readability.status = 'skipped'
868
+ receipt.viewerChrome.status = 'skipped'
869
+ receipt.captures.status = 'skipped'
870
+ receipt.error =
871
+ 'Chrome or Chromium is unavailable. Set ARCHIFY_CHROME to its executable path.'
872
+ receipt.diagnostics = [
873
+ failureDiagnostic({
874
+ code: 'viewer/chrome-unavailable',
875
+ severity: 'warning',
876
+ message: receipt.error,
877
+ subject: { artifact },
878
+ evidence: { executable: null },
879
+ supportedFixes: [
880
+ 'set ARCHIFY_CHROME to a Chrome or Chromium executable and rerun visual-check',
881
+ ],
882
+ }),
883
+ ]
884
+ persistReceipt(outputs, receipt)
885
+ return { exitCode: EXIT.skipped, receipt }
886
+ }
887
+
888
+ let browser
889
+ try {
890
+ browser = await browserFactory(resolvedChrome)
891
+ const observations = new Map()
892
+ const screenshotsByKey = new Map(
893
+ outputs.screenshots.map(entry => [
894
+ screenshotKey(entry.width, entry.height, entry.theme),
895
+ entry,
896
+ ]),
897
+ )
898
+
899
+ for (const viewport of VISUAL_CHECK_VIEWPORTS) {
900
+ const key = screenshotKey(viewport.width, viewport.height, 'light')
901
+ const screenshot = screenshotsByKey.get(key)
902
+ const metrics = await browser.inspect({
903
+ artifactPath: artifact,
904
+ ...viewport,
905
+ theme: 'light',
906
+ ...(screenshot ? { screenshotPath: screenshot.path } : {}),
907
+ })
908
+ observations.set(
909
+ key,
910
+ observation({ ...viewport, theme: 'light', metrics }),
911
+ )
912
+ }
913
+ for (const viewport of CAPTURE_VIEWPORTS) {
914
+ const key = screenshotKey(viewport.width, viewport.height, 'dark')
915
+ const screenshot = screenshotsByKey.get(key)
916
+ const metrics = await browser.inspect({
917
+ artifactPath: artifact,
918
+ ...viewport,
919
+ theme: 'dark',
920
+ screenshotPath: screenshot.path,
921
+ })
922
+ observations.set(
923
+ key,
924
+ observation({ ...viewport, theme: 'dark', metrics }),
925
+ )
926
+ }
927
+
928
+ const afterBytes = fs.readFileSync(artifact)
929
+ if (
930
+ sha256(afterBytes) !== receipt.artifact.sha256 ||
931
+ afterBytes.byteLength !== receipt.artifact.bytes
932
+ ) {
933
+ throw new Error(
934
+ 'The delivered artifact changed while visual-check was running.',
935
+ )
936
+ }
937
+
938
+ receipt.containment.viewports = VISUAL_CHECK_VIEWPORTS.map(
939
+ ({ width, height }) =>
940
+ observations.get(screenshotKey(width, height, 'light')),
941
+ )
942
+ receipt.readability.viewports = receipt.containment.viewports.map(
943
+ entry => ({ ...entry }),
944
+ )
945
+ receipt.viewerChrome.viewports = receipt.containment.viewports.map(
946
+ entry => ({ ...entry }),
947
+ )
948
+ receipt.captures.screenshots = outputs.screenshots.map(entry => ({
949
+ ...observations.get(
950
+ screenshotKey(entry.width, entry.height, entry.theme),
951
+ ),
952
+ file: path.basename(entry.path),
953
+ }))
954
+ const allObservations = [...observations.values()]
955
+ const containmentPass = allObservations.every(entry => entry.ok)
956
+ const readabilityPass = receipt.readability.viewports.every(
957
+ entry => entry.readabilityOk,
958
+ )
959
+ const viewerChromePass = allObservations.every(
960
+ entry => entry.viewerChromeOk,
961
+ )
962
+ receipt.diagnostics = observationDiagnostics({
963
+ artifact,
964
+ allObservations,
965
+ readabilityObservations: receipt.readability.viewports,
966
+ })
967
+ receipt.containment.status = containmentPass ? 'pass' : 'fail'
968
+ receipt.readability.status = readabilityPass ? 'pass' : 'fail'
969
+ receipt.viewerChrome.status = viewerChromePass ? 'pass' : 'fail'
970
+ receipt.captures.status = 'pass'
971
+ receipt.captures.contactSheet = path.basename(outputs.contactSheet)
972
+ receipt.status =
973
+ containmentPass && readabilityPass && viewerChromePass ? 'pass' : 'fail'
974
+ receipt.ok = containmentPass && readabilityPass && viewerChromePass
975
+ writeAtomic(
976
+ outputs.contactSheet,
977
+ contactSheetHtml({
978
+ artifactPath: artifact,
979
+ receipt,
980
+ screenshots: receipt.captures.screenshots,
981
+ }),
982
+ )
983
+ persistReceipt(outputs, receipt)
984
+ return { exitCode: receipt.ok ? EXIT.pass : EXIT.fail, receipt }
985
+ } catch (error) {
986
+ cleanupCaptureSidecars(outputs)
987
+ receipt.status = 'fail'
988
+ receipt.ok = false
989
+ receipt.error = error.message
990
+ receipt.containment.status = 'fail'
991
+ receipt.readability.status = 'fail'
992
+ receipt.viewerChrome.status = 'fail'
993
+ receipt.captures.status = 'fail'
994
+ receipt.captures.screenshots = []
995
+ receipt.captures.contactSheet = null
996
+ receipt.diagnostics = [
997
+ failureDiagnostic({
998
+ code: 'viewer/visual-check-runtime',
999
+ message: 'visual-check could not complete its Chrome inspection.',
1000
+ subject: { artifact },
1001
+ evidence: { reason: error.message },
1002
+ supportedFixes: [
1003
+ 'resolve the reported Chrome inspection error, then rerun visual-check',
1004
+ ],
1005
+ }),
1006
+ ]
1007
+ persistReceipt(outputs, receipt)
1008
+ return { exitCode: EXIT.fail, receipt }
1009
+ } finally {
1010
+ if (browser?.close) await browser.close()
1011
+ }
1012
+ }