@agentskit/doc-bridge 1.6.2 → 1.6.4

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.
@@ -42,3 +42,17 @@ Set `intelligence.registry.enabled: true` and `runnerModule` in `doc-bridge.conf
42
42
  Rerunning an unchanged command reuses valid stage artifacts. A changed source revision, configuration hash, or tool version creates a stale/superseding run. CI can use `ak-docs check --json`; non-zero rule findings fail the command while unsupported analysis is reported as explicit coverage.
43
43
 
44
44
  The first implementation analyzes JavaScript/TypeScript and Markdown. Other languages should add analyzers that emit the same canonical entity, relation, evidence, coverage, and hash contracts.
45
+
46
+ ## Relation coverage policy
47
+
48
+ Missing declarations are configurable because not every implementation import is useful documentation. In the root configuration, `reconciliation.requiredRelationKinds` selects the observed relation kinds that must be declared in Markdown:
49
+
50
+ ```json
51
+ {
52
+ "reconciliation": {
53
+ "requiredRelationKinds": []
54
+ }
55
+ }
56
+ ```
57
+
58
+ Omit the option to preserve the original all-relation behavior. Use an empty list for low-friction adoption when package/app coverage and explicitly declared claims matter more than documenting every module, test, or external-library import. Existing declarations are still checked for stale, conflicting, and unresolved references.
@@ -0,0 +1,36 @@
1
+ ---
2
+ title: Verification harness
3
+ description: Fail-closed, evidence-backed verification for humans and agents.
4
+ ---
5
+
6
+ # Verification harness
7
+
8
+ `ak-verify` is the executable completion gate for work that must be proven, not merely compiled.
9
+
10
+ ```bash
11
+ ak-verify run --config .codex/verification.json --json
12
+ ak-verify status --config .codex/verification.json --json
13
+ ak-verify approve <run-id> approved --by human --config .codex/verification.json
14
+ ak-verify authorize <run-id> approved --by human --config .codex/verification.json
15
+ ak-verify clean --periodic --config .codex/verification.json
16
+ ```
17
+
18
+ The contract is JSON so it works without adding a YAML runtime. It declares the artifact surfaces that apply to the run, executable checks, explicit non-applicable reasons, the verification profile, and tracking policy.
19
+
20
+ ## States
21
+
22
+ `PLANNED` → `VERIFYING` → `AWAITING_HUMAN_APPROVAL` → `AWAITING_AUTHORIZATION` → `COMPLETE`.
23
+
24
+ Any failed required check or unavailable required surface produces `BLOCKED`. The harness never promotes a run from `BLOCKED`, `AWAITING_HUMAN_APPROVAL`, or `AWAITING_AUTHORIZATION` to `COMPLETE` without the corresponding evidence and intent.
25
+
26
+ `strict` is the default profile. `poc` and `custom` require explicit exemptions, which are included in the run evidence and cannot be silently hidden.
27
+
28
+ ## Evidence and recovery
29
+
30
+ Runs live under `.codex/verification/runs/<run-id>/run.json`. The latest pointer is `.codex/verification/latest.json`. Commands are captured with exit code, duration, stdout, stderr, source revision, configuration hash, and input hash. Re-running an unchanged pending or completed run is idempotent; changed source or contract creates a new run.
31
+
32
+ Checks may emit one final JSON line with `status` set to `passed`, `failed`, or `pending-human-review`. Structured `failed` evidence blocks the run even when the process exits with code 0; structured pending evidence remains explicitly awaiting human approval. This prevents a visual checker from being mistaken for a successful verification merely because it launched.
33
+
34
+ Visual checks must use a real browser or an explicitly configured equivalent. A passing build is not visual approval. Endpoint, database, CLI, and MCP checks must execute their real artifact when the contract marks that surface as required.
35
+
36
+ The harness only removes paths listed as task-owned and contained by configured cleanup roots. It never performs broad workspace deletion.
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "doc-bridge",
4
4
  "display_name": "Doc Bridge",
5
- "version": "1.6.2",
5
+ "version": "1.6.4",
6
6
  "description": "Deterministic repository handoffs for coding agents, running locally without an LLM or API key.",
7
7
  "long_description": "Doc Bridge turns a repository's own documentation and ownership metadata into deterministic handoffs: where an agent should start, which paths it may edit, which checks it must run, and when a human must take over. The local connector exposes the same read-only contract available through Doc Bridge CLI and CI.",
8
8
  "author": {
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@agentskit/doc-bridge",
3
- "version": "1.6.2",
3
+ "version": "1.6.4",
4
4
  "mcpName": "io.github.AgentsKit-io/doc-bridge",
5
5
  "description": "Human↔agent documentation bridge — deterministic handoffs, doc-site links, memory→docs, optional AgentsKit RAG/chat.",
6
6
  "type": "module",
7
7
  "bin": {
8
- "ak-docs": "bin/ak-docs.js"
8
+ "ak-docs": "bin/ak-docs.js",
9
+ "ak-verify": "bin/ak-verify.js"
9
10
  },
10
11
  "main": "./dist/index.js",
11
12
  "types": "./dist/index.d.ts",
@@ -24,6 +25,8 @@
24
25
  "bin",
25
26
  "action.yml",
26
27
  "scripts/prepare.mjs",
28
+ "scripts/verification-harness.mjs",
29
+ "scripts/report-visual-check.mjs",
27
30
  "scripts/check-ecosystem-upstream.mjs",
28
31
  "ecosystem.json",
29
32
  "ecosystem-claims.json",
@@ -43,6 +46,51 @@
43
46
  "src",
44
47
  "skills"
45
48
  ],
49
+ "scripts": {
50
+ "prebuild": "node scripts/sync-version.mjs",
51
+ "build": "tsup",
52
+ "test": "vitest run && pnpm test:cursor-plugin && pnpm test:claude-plugin && pnpm test:copilot-plugin && pnpm test:portable-skill",
53
+ "test:watch": "vitest",
54
+ "coverage": "vitest run --coverage",
55
+ "check:ecosystem-upstream": "node scripts/check-ecosystem-upstream.mjs",
56
+ "check:no-legacy-chat-imports": "node scripts/check-no-legacy-chat-imports.mjs",
57
+ "typecheck": "tsc --noEmit",
58
+ "smoke:packaged": "node scripts/smoke-packaged.mjs",
59
+ "smoke:docsites": "node scripts/smoke-docsites.mjs",
60
+ "smoke:real-docsites": "node scripts/smoke-real-docsites.mjs",
61
+ "smoke:ollama": "node scripts/smoke-ollama.mjs",
62
+ "mcpb:stage": "npm run build && node scripts/build-mcpb.mjs stage",
63
+ "mcpb:validate": "node scripts/build-mcpb.mjs validate",
64
+ "mcpb:smoke": "node scripts/smoke-mcpb.mjs",
65
+ "mcpb:pack": "npm run mcpb:stage && npm run mcpb:smoke && node scripts/build-mcpb.mjs pack",
66
+ "test:mcpb": "node --test scripts/mcpb-contract.test.mjs",
67
+ "test:cursor-plugin": "node --test scripts/cursor-plugin-contract.test.mjs",
68
+ "test:claude-plugin": "node --test scripts/claude-plugin-contract.test.mjs",
69
+ "test:copilot-plugin": "node --test scripts/copilot-plugin-contract.test.mjs",
70
+ "test:portable-skill": "node --test scripts/portable-skill-contract.test.mjs",
71
+ "coverage:badge": "node scripts/update-coverage-badge.mjs",
72
+ "changeset": "changeset",
73
+ "version-packages": "changeset version && node scripts/sync-version.mjs",
74
+ "release": "npm run build && npm test && changeset publish",
75
+ "version": "node scripts/sync-version.mjs && git add package.json src/version.ts action.yml",
76
+ "prepublishOnly": "npm run build && npm test",
77
+ "prepare": "node scripts/prepare.mjs",
78
+ "prepack": "npm run build",
79
+ "docs:dev": "cd apps/docs && next dev",
80
+ "docs:artifacts": "node scripts/build-docs-artifacts.mjs",
81
+ "docs:build": "pnpm build && node bin/ak-docs.js index && pnpm docs:artifacts && cd apps/docs && next build",
82
+ "docs:typecheck": "cd apps/docs && fumadocs-mdx && tsc --noEmit",
83
+ "docs:e2e": "playwright test",
84
+ "report:visual": "node scripts/report-visual-check.mjs",
85
+ "test:verification-harness": "node --test scripts/verification-harness.test.mjs",
86
+ "docs:lighthouse": "lhci autorun",
87
+ "test:readme-standard": "node --test scripts/readme-standard.test.mjs",
88
+ "test:marketplace": "node --test scripts/marketplace-contract.test.mjs",
89
+ "check:marketplace": "node scripts/check-marketplace-contract.mjs",
90
+ "test:docs-artifacts": "node --test scripts/docs-artifacts-contract.test.mjs",
91
+ "check:readme-standard": "node scripts/check-readme-standard.mjs",
92
+ "check:docs": "pnpm check:readme-standard && node bin/ak-docs.js gate run && node bin/ak-docs.js conformance run documentation-standard-v1 --text && pnpm docs:typecheck && pnpm docs:build && pnpm test:docs-artifacts"
93
+ },
46
94
  "keywords": [
47
95
  "agentskit",
48
96
  "documentation",
@@ -140,45 +188,5 @@
140
188
  "bugs": {
141
189
  "url": "https://github.com/AgentsKit-io/doc-bridge/issues"
142
190
  },
143
- "homepage": "https://doc-bridge.agentskit.io/",
144
- "scripts": {
145
- "prebuild": "node scripts/sync-version.mjs",
146
- "build": "tsup",
147
- "test": "vitest run && pnpm test:cursor-plugin && pnpm test:claude-plugin && pnpm test:copilot-plugin && pnpm test:portable-skill",
148
- "test:watch": "vitest",
149
- "coverage": "vitest run --coverage",
150
- "check:ecosystem-upstream": "node scripts/check-ecosystem-upstream.mjs",
151
- "check:no-legacy-chat-imports": "node scripts/check-no-legacy-chat-imports.mjs",
152
- "typecheck": "tsc --noEmit",
153
- "smoke:packaged": "node scripts/smoke-packaged.mjs",
154
- "smoke:docsites": "node scripts/smoke-docsites.mjs",
155
- "smoke:real-docsites": "node scripts/smoke-real-docsites.mjs",
156
- "smoke:ollama": "node scripts/smoke-ollama.mjs",
157
- "mcpb:stage": "npm run build && node scripts/build-mcpb.mjs stage",
158
- "mcpb:validate": "node scripts/build-mcpb.mjs validate",
159
- "mcpb:smoke": "node scripts/smoke-mcpb.mjs",
160
- "mcpb:pack": "npm run mcpb:stage && npm run mcpb:smoke && node scripts/build-mcpb.mjs pack",
161
- "test:mcpb": "node --test scripts/mcpb-contract.test.mjs",
162
- "test:cursor-plugin": "node --test scripts/cursor-plugin-contract.test.mjs",
163
- "test:claude-plugin": "node --test scripts/claude-plugin-contract.test.mjs",
164
- "test:copilot-plugin": "node --test scripts/copilot-plugin-contract.test.mjs",
165
- "test:portable-skill": "node --test scripts/portable-skill-contract.test.mjs",
166
- "coverage:badge": "node scripts/update-coverage-badge.mjs",
167
- "changeset": "changeset",
168
- "version-packages": "changeset version && node scripts/sync-version.mjs",
169
- "release": "npm run build && npm test && changeset publish",
170
- "version": "node scripts/sync-version.mjs && git add package.json src/version.ts action.yml",
171
- "docs:dev": "cd apps/docs && next dev",
172
- "docs:artifacts": "node scripts/build-docs-artifacts.mjs",
173
- "docs:build": "pnpm build && node bin/ak-docs.js index && pnpm docs:artifacts && cd apps/docs && next build",
174
- "docs:typecheck": "cd apps/docs && fumadocs-mdx && tsc --noEmit",
175
- "docs:e2e": "playwright test",
176
- "docs:lighthouse": "lhci autorun",
177
- "test:readme-standard": "node --test scripts/readme-standard.test.mjs",
178
- "test:marketplace": "node --test scripts/marketplace-contract.test.mjs",
179
- "check:marketplace": "node scripts/check-marketplace-contract.mjs",
180
- "test:docs-artifacts": "node --test scripts/docs-artifacts-contract.test.mjs",
181
- "check:readme-standard": "node scripts/check-readme-standard.mjs",
182
- "check:docs": "pnpm check:readme-standard && node bin/ak-docs.js gate run && node bin/ak-docs.js conformance run documentation-standard-v1 --text && pnpm docs:typecheck && pnpm docs:build && pnpm test:docs-artifacts"
183
- }
184
- }
191
+ "homepage": "https://doc-bridge.agentskit.io/"
192
+ }
@@ -0,0 +1,250 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ import { chromium } from '@playwright/test'
6
+
7
+ const args = process.argv.slice(2)
8
+ const reportPath = resolve(args.find((arg) => !arg.startsWith('--')) ?? '.doc-bridge/report.html')
9
+ const outputFlag = args.indexOf('--output')
10
+ const outputDir = resolve(outputFlag >= 0 ? args[outputFlag + 1] ?? '.doc-bridge/visual-acceptance' : '.doc-bridge/visual-acceptance')
11
+ const humanApproved = args.includes('--human-approved')
12
+ const interactionTimeoutMs = 2500
13
+ const viewports = [[390, 844], [768, 1024], [1440, 900], [1600, 1000], [1920, 1080]]
14
+ const themes = ['light', 'dark']
15
+ const failures = []
16
+ const checks = []
17
+ const networkRequests = []
18
+
19
+ mkdirSync(outputDir, { recursive: true })
20
+
21
+ const record = (viewport, theme, result) => {
22
+ const entry = { viewport: `${viewport[0]}x${viewport[1]}`, theme, ...result }
23
+ checks.push(entry)
24
+ for (const failure of result.failures ?? []) failures.push(`${entry.viewport}/${theme}: ${failure}`)
25
+ }
26
+
27
+ const inspect = async (frame, width, height) => frame.evaluate(({ width: viewportWidth, height: viewportHeight }) => {
28
+ const controls = [...document.querySelectorAll('#search,#status,#severity,#reset,[data-lens],[data-level]')]
29
+ const clippedControls = controls.filter((element) => {
30
+ const rect = element.getBoundingClientRect()
31
+ return rect.width > 0 && rect.height > 0 && (rect.left < 0 || rect.right > viewportWidth || rect.top < 0 || rect.bottom > viewportHeight)
32
+ }).map((element) => element.id || element.dataset.lens || element.dataset.level || element.tagName)
33
+ const workspace = document.querySelector('.workspace')?.getBoundingClientRect()
34
+ const graph = document.querySelector('#graph')?.getBoundingClientRect()
35
+ const failures = []
36
+ if (document.documentElement.scrollWidth - document.documentElement.clientWidth > 1) failures.push('horizontal overflow')
37
+ if (clippedControls.length) failures.push(`clipped controls: ${clippedControls.join(', ')}`)
38
+ if (!workspace || workspace.top > viewportHeight * 0.7) failures.push('architecture map starts too far below the first screen')
39
+ if (!document.querySelector('#graph')) failures.push('architecture graph is missing')
40
+ if (document.querySelectorAll('[data-lens]').length < 4 || document.querySelectorAll('[data-level]').length < 4) failures.push('required lens or level controls are missing')
41
+ if (document.querySelectorAll('.finding').length > 40) failures.push('initial findings render grows beyond one bounded page')
42
+ if (graph && graph.width < Math.min(480, viewportWidth * 0.7)) failures.push('architecture graph is too narrow to inspect')
43
+
44
+ const unnamedButtons = [...document.querySelectorAll('button')].filter((button) => {
45
+ const rect = button.getBoundingClientRect()
46
+ return rect.width > 0 && rect.height > 0 && !button.textContent.trim() && !button.getAttribute('aria-label') && !button.getAttribute('aria-labelledby')
47
+ })
48
+ if (unnamedButtons.length) failures.push(`unnamed visible buttons: ${unnamedButtons.length}`)
49
+
50
+ const visibleOverflow = [...document.querySelectorAll('body *')].filter((element) => {
51
+ const rect = element.getBoundingClientRect()
52
+ if (!rect.width || !rect.height || element.closest('.map-wrap')) return false
53
+ const horizontalOverflow = element.scrollWidth > element.clientWidth + 2
54
+ const containerOverflow = element.children.length > 0 && element.scrollHeight > element.clientHeight + 2
55
+ return horizontalOverflow || containerOverflow
56
+ })
57
+ if (visibleOverflow.length) failures.push(`text/content overflow: ${visibleOverflow.slice(0, 3).map((element) => element.id || element.className || element.tagName).join(', ')}`)
58
+
59
+ const parseColor = (value) => {
60
+ const text = String(value).trim()
61
+ const hex = text.match(/^#([0-9a-f]{3,8})$/i)?.[1]
62
+ if (hex) {
63
+ const expanded = hex.length <= 4 ? [...hex].map((channel) => channel + channel).join('') : hex
64
+ return [expanded.slice(0, 2), expanded.slice(2, 4), expanded.slice(4, 6)].map((channel) => Number.parseInt(channel, 16) / 255)
65
+ }
66
+ const rgb = text.match(/rgba?\(([^)]+)\)/i)?.[1]
67
+ return rgb ? rgb.split(',').slice(0, 3).map((channel) => Number.parseFloat(channel.trim()) / 255) : null
68
+ }
69
+ const luminance = (rgb) => rgb.map((channel) => channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4).reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0)
70
+ const contrast = (foreground, background) => { const a = luminance(foreground); const b = luminance(background); return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) }
71
+ const rootStyle = getComputedStyle(document.documentElement)
72
+ const ink = parseColor(rootStyle.getPropertyValue('--ink'))
73
+ const muted = parseColor(rootStyle.getPropertyValue('--muted'))
74
+ const paper = parseColor(rootStyle.getPropertyValue('--paper'))
75
+ const panel = parseColor(rootStyle.getPropertyValue('--panel'))
76
+ const contrastFailures = []
77
+ for (const [name, foreground] of [['ink', ink], ['muted', muted]]) {
78
+ for (const [backgroundName, background] of [['paper', paper], ['panel', panel]]) {
79
+ if (foreground && background && contrast(foreground, background) < 4.5) contrastFailures.push(`${name}/${backgroundName}`)
80
+ }
81
+ }
82
+ if (contrastFailures.length) failures.push(`insufficient text contrast: ${contrastFailures.join(', ')}`)
83
+
84
+ return {
85
+ failures,
86
+ overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
87
+ clippedControls,
88
+ workspaceTop: workspace?.top ?? null,
89
+ graphWidth: graph?.width ?? null,
90
+ initialFindingCount: document.querySelectorAll('.finding').length,
91
+ findingGroupCount: document.querySelectorAll('.finding-group').length,
92
+ diagnosticCount: typeof globalThis.__DOC_BRIDGE_DATA__?.diagnosticCount === 'number'
93
+ ? globalThis.__DOC_BRIDGE_DATA__.diagnosticCount
94
+ : Number.parseInt(document.querySelector('#finding-count')?.textContent ?? '', 10) || document.querySelectorAll('.finding').length,
95
+ unnamedButtonCount: unnamedButtons.length,
96
+ visibleOverflowCount: visibleOverflow.length,
97
+ contrastFailures,
98
+ }
99
+ }, { width, height })
100
+
101
+ const poll = async (read, predicate, label, timeoutMs = interactionTimeoutMs) => {
102
+ const started = Date.now()
103
+ while (Date.now() - started < timeoutMs) {
104
+ if (predicate(await read())) return Date.now() - started
105
+ await new Promise((resolve) => setTimeout(resolve, 50))
106
+ }
107
+ throw new Error(`${label} did not reach the expected state within ${timeoutMs}ms`)
108
+ }
109
+
110
+ const exercise = async (frame, result) => {
111
+ const run = async (label, action, verify) => {
112
+ const started = Date.now()
113
+ try {
114
+ await Promise.race([
115
+ (async () => { await action(); if (verify) await verify() })(),
116
+ new Promise((_, reject) => setTimeout(() => reject(new Error('interaction timeout')), interactionTimeoutMs)),
117
+ ])
118
+ const durationMs = Date.now() - started
119
+ if (durationMs > 2000) result.failures.push(`${label} took ${durationMs}ms`)
120
+ } catch (error) {
121
+ result.failures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`)
122
+ }
123
+ }
124
+
125
+ for (const [lens, view] of [['architecture', 'architecture'], ['drift', 'insights'], ['risks', 'findings'], ['evidence', 'coverage']]) {
126
+ await run(`lens ${lens}`, () => frame.locator(`[data-lens="${lens}"]`).click(), async () => {
127
+ await poll(() => frame.locator('body').getAttribute('data-report-view'), (actual) => actual === view, `lens ${lens}`)
128
+ if (await frame.locator(`[data-lens="${lens}"]`).getAttribute('aria-selected') !== 'true') throw new Error('aria-selected did not follow the active lens')
129
+ })
130
+ }
131
+
132
+ await run('finding groups load', async () => {
133
+ const load = frame.locator('#load-findings')
134
+ if (await load.count()) await load.click()
135
+ else if (result.diagnosticCount > 0 && await frame.locator('.finding-group').count() === 0) throw new Error('finding loader is missing while findings are present')
136
+ }, async () => {
137
+ if (result.diagnosticCount > 0) {
138
+ await poll(() => frame.locator('.finding-group').count(), (count) => count > 0, 'finding groups')
139
+ } else if (await frame.locator('.finding-group').count() !== 0) {
140
+ throw new Error('finding groups rendered despite zero findings')
141
+ }
142
+ })
143
+
144
+ await run('map reset', () => frame.locator('[data-lens="architecture"]').click(), async () => {
145
+ await poll(() => frame.locator('body').getAttribute('data-report-view'), (actual) => actual === 'architecture', 'map reset')
146
+ })
147
+
148
+ if (!(await frame.locator('[data-node]').count())) result.failures.push('architecture graph has no interactive nodes')
149
+ else {
150
+ const drillSelectors = [
151
+ ['package', ['[data-node][aria-label*=" app"]', '[data-node][aria-label*=" domain"]', '[data-node][aria-label*=" group"]', '[data-node]']],
152
+ ['module', ['[data-node][aria-label*=" package"]', '[data-node][aria-label*=" shared"]']],
153
+ ['file', ['[data-node][aria-label*=" module"]', '[data-node][aria-label*=" file"]']],
154
+ ]
155
+ for (const [expectedLevel, selectors] of drillSelectors) {
156
+ let selector
157
+ for (const candidate of selectors) {
158
+ if (await frame.locator(candidate).count()) {
159
+ selector = candidate
160
+ break
161
+ }
162
+ }
163
+ if (!selector) continue
164
+ await run(`double-click drill-down to ${expectedLevel}`, () => frame.locator(selector).first().dblclick(), async () => {
165
+ await poll(() => frame.locator('[data-level][aria-pressed="true"]').getAttribute('data-level'), (actual) => actual === expectedLevel, `drill-down ${expectedLevel}`)
166
+ const details = await frame.locator('#details').innerText()
167
+ if (/Select a node in the map/i.test(details)) throw new Error('details panel did not update')
168
+ })
169
+ }
170
+ if (await frame.locator('#breadcrumbs [data-breadcrumb-level]').count() < 1) result.failures.push('drill-down did not create usable breadcrumbs')
171
+ await run('breadcrumb back to repository', () => frame.locator('#breadcrumbs [data-breadcrumb-level="overview"]').click(), async () => {
172
+ await poll(() => frame.locator('[data-level][aria-pressed="true"]').getAttribute('data-level'), (actual) => actual === 'overview', 'breadcrumb repository')
173
+ })
174
+ }
175
+
176
+ const map = frame.locator('.map-wrap')
177
+ const beforeTransform = await frame.locator('#graph').getAttribute('style')
178
+ await run('map zoom', () => map.dispatchEvent('wheel', { deltaY: -120, deltaX: 0 }), async () => {
179
+ const afterTransform = await frame.locator('#graph').getAttribute('style')
180
+ if (!afterTransform || afterTransform === beforeTransform || !afterTransform.includes('scale(')) throw new Error('zoom did not change the graph transform')
181
+ })
182
+ await run('map reset keyboard', () => frame.locator('body').dispatchEvent('keydown', { key: '0' }), async () => {
183
+ const transform = await frame.locator('#graph').getAttribute('style')
184
+ if (!transform?.includes('scale(1)')) throw new Error('keyboard reset did not restore scale 1')
185
+ })
186
+ await run('clear selection', () => frame.locator('#clear-selection').click())
187
+ }
188
+
189
+ let browser
190
+ try {
191
+ browser = await chromium.launch()
192
+ for (const theme of themes) {
193
+ for (const viewport of viewports) {
194
+ const page = await browser.newPage()
195
+ const pageErrors = []
196
+ const consoleErrors = []
197
+ const failedRequests = []
198
+ page.on('pageerror', (error) => pageErrors.push(error.message))
199
+ page.on('console', (message) => { if (message.type() === 'error') consoleErrors.push(message.text()) })
200
+ page.on('requestfailed', (request) => failedRequests.push(`${request.url()} · ${request.failure()?.errorText ?? 'failed'}`))
201
+ page.on('request', (request) => networkRequests.push(request.url()))
202
+ let result = { failures: [] }
203
+ try {
204
+ await page.setViewportSize({ width: viewport[0], height: viewport[1] })
205
+ await page.emulateMedia({ colorScheme: theme })
206
+ await page.goto(pathToFileURL(reportPath).href, { waitUntil: 'load' })
207
+ const frame = page.frames().find((candidate) => candidate !== page.mainFrame() && candidate.url().endsWith('/report/index.html')) ?? page.mainFrame()
208
+ await frame.locator('#graph').waitFor({ state: 'visible', timeout: interactionTimeoutMs })
209
+ result = await inspect(frame, viewport[0], viewport[1])
210
+ await exercise(frame, result)
211
+ result.pageErrors = pageErrors
212
+ result.consoleErrors = consoleErrors
213
+ result.failedRequests = failedRequests
214
+ if (pageErrors.length) result.failures.push(`page errors: ${pageErrors.join(' | ')}`)
215
+ if (consoleErrors.length) result.failures.push(`console errors: ${consoleErrors.join(' | ')}`)
216
+ if (failedRequests.length) result.failures.push(`failed requests: ${failedRequests.join(' | ')}`)
217
+ await page.screenshot({ path: join(outputDir, `${viewport[0]}x${viewport[1]}-${theme}.png`), fullPage: false })
218
+ } catch (error) {
219
+ result.failures.push(error instanceof Error ? error.message : String(error))
220
+ }
221
+ record(viewport, theme, result)
222
+ await page.close()
223
+ }
224
+ }
225
+ } catch (error) {
226
+ failures.push(error instanceof Error ? error.message : String(error))
227
+ } finally {
228
+ await browser?.close()
229
+ }
230
+
231
+ const status = failures.length || checks.some((check) => check.failures.length) ? 'failed' : humanApproved ? 'passed' : 'pending-human-review'
232
+ const result = {
233
+ status,
234
+ reportPath,
235
+ outputDir,
236
+ viewports,
237
+ themes,
238
+ networkRequests,
239
+ checks,
240
+ failures,
241
+ note: status === 'failed'
242
+ ? 'Automated visual or interaction evidence failed; human approval is not available for this run.'
243
+ : humanApproved
244
+ ? 'Automated checks passed and human approval was explicitly supplied.'
245
+ : 'Automated checks passed; screenshots require human visual review. Re-run with --human-approved only after review.',
246
+ }
247
+ writeFileSync(join(outputDir, 'result.json'), `${JSON.stringify(result, null, 2)}\n`, 'utf8')
248
+ console.log(JSON.stringify(result, null, 2))
249
+ console.log(JSON.stringify({ status }))
250
+ if (status === 'failed') process.exitCode = 1