@veraxhq/verax 0.1.0 → 0.2.1

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 (135) hide show
  1. package/README.md +123 -88
  2. package/bin/verax.js +11 -452
  3. package/package.json +24 -36
  4. package/src/cli/commands/default.js +681 -0
  5. package/src/cli/commands/doctor.js +197 -0
  6. package/src/cli/commands/inspect.js +109 -0
  7. package/src/cli/commands/run.js +586 -0
  8. package/src/cli/entry.js +196 -0
  9. package/src/cli/util/atomic-write.js +37 -0
  10. package/src/cli/util/detection-engine.js +297 -0
  11. package/src/cli/util/env-url.js +33 -0
  12. package/src/cli/util/errors.js +44 -0
  13. package/src/cli/util/events.js +110 -0
  14. package/src/cli/util/expectation-extractor.js +388 -0
  15. package/src/cli/util/findings-writer.js +32 -0
  16. package/src/cli/util/idgen.js +87 -0
  17. package/src/cli/util/learn-writer.js +39 -0
  18. package/src/cli/util/observation-engine.js +412 -0
  19. package/src/cli/util/observe-writer.js +25 -0
  20. package/src/cli/util/paths.js +30 -0
  21. package/src/cli/util/project-discovery.js +297 -0
  22. package/src/cli/util/project-writer.js +26 -0
  23. package/src/cli/util/redact.js +128 -0
  24. package/src/cli/util/run-id.js +30 -0
  25. package/src/cli/util/runtime-budget.js +147 -0
  26. package/src/cli/util/summary-writer.js +43 -0
  27. package/src/types/global.d.ts +28 -0
  28. package/src/types/ts-ast.d.ts +24 -0
  29. package/src/verax/cli/ci-summary.js +35 -0
  30. package/src/verax/cli/context-explanation.js +89 -0
  31. package/src/verax/cli/doctor.js +277 -0
  32. package/src/verax/cli/error-normalizer.js +154 -0
  33. package/src/verax/cli/explain-output.js +105 -0
  34. package/src/verax/cli/finding-explainer.js +130 -0
  35. package/src/verax/cli/init.js +237 -0
  36. package/src/verax/cli/run-overview.js +163 -0
  37. package/src/verax/cli/url-safety.js +111 -0
  38. package/src/verax/cli/wizard.js +109 -0
  39. package/src/verax/cli/zero-findings-explainer.js +57 -0
  40. package/src/verax/cli/zero-interaction-explainer.js +127 -0
  41. package/src/verax/core/action-classifier.js +86 -0
  42. package/src/verax/core/budget-engine.js +218 -0
  43. package/src/verax/core/canonical-outcomes.js +157 -0
  44. package/src/verax/core/decision-snapshot.js +335 -0
  45. package/src/verax/core/determinism-model.js +432 -0
  46. package/src/verax/core/incremental-store.js +245 -0
  47. package/src/verax/core/invariants.js +356 -0
  48. package/src/verax/core/promise-model.js +230 -0
  49. package/src/verax/core/replay-validator.js +350 -0
  50. package/src/verax/core/replay.js +222 -0
  51. package/src/verax/core/run-id.js +175 -0
  52. package/src/verax/core/run-manifest.js +99 -0
  53. package/src/verax/core/silence-impact.js +369 -0
  54. package/src/verax/core/silence-model.js +523 -0
  55. package/src/verax/detect/comparison.js +7 -34
  56. package/src/verax/detect/confidence-engine.js +764 -329
  57. package/src/verax/detect/detection-engine.js +293 -0
  58. package/src/verax/detect/evidence-index.js +127 -0
  59. package/src/verax/detect/expectation-model.js +241 -168
  60. package/src/verax/detect/explanation-helpers.js +187 -0
  61. package/src/verax/detect/finding-detector.js +450 -0
  62. package/src/verax/detect/findings-writer.js +41 -12
  63. package/src/verax/detect/flow-detector.js +366 -0
  64. package/src/verax/detect/index.js +200 -288
  65. package/src/verax/detect/interactive-findings.js +612 -0
  66. package/src/verax/detect/signal-mapper.js +308 -0
  67. package/src/verax/detect/skip-classifier.js +4 -4
  68. package/src/verax/detect/verdict-engine.js +561 -0
  69. package/src/verax/evidence-index-writer.js +61 -0
  70. package/src/verax/flow/flow-engine.js +3 -2
  71. package/src/verax/flow/flow-spec.js +1 -2
  72. package/src/verax/index.js +103 -15
  73. package/src/verax/intel/effect-detector.js +368 -0
  74. package/src/verax/intel/handler-mapper.js +249 -0
  75. package/src/verax/intel/index.js +281 -0
  76. package/src/verax/intel/route-extractor.js +280 -0
  77. package/src/verax/intel/ts-program.js +256 -0
  78. package/src/verax/intel/vue-navigation-extractor.js +642 -0
  79. package/src/verax/intel/vue-router-extractor.js +325 -0
  80. package/src/verax/learn/action-contract-extractor.js +338 -104
  81. package/src/verax/learn/ast-contract-extractor.js +148 -6
  82. package/src/verax/learn/flow-extractor.js +172 -0
  83. package/src/verax/learn/index.js +36 -2
  84. package/src/verax/learn/manifest-writer.js +122 -58
  85. package/src/verax/learn/project-detector.js +40 -0
  86. package/src/verax/learn/route-extractor.js +28 -97
  87. package/src/verax/learn/route-validator.js +8 -7
  88. package/src/verax/learn/state-extractor.js +212 -0
  89. package/src/verax/learn/static-extractor-navigation.js +114 -0
  90. package/src/verax/learn/static-extractor-validation.js +88 -0
  91. package/src/verax/learn/static-extractor.js +119 -10
  92. package/src/verax/learn/truth-assessor.js +24 -21
  93. package/src/verax/learn/ts-contract-resolver.js +14 -12
  94. package/src/verax/observe/aria-sensor.js +211 -0
  95. package/src/verax/observe/browser.js +30 -6
  96. package/src/verax/observe/console-sensor.js +2 -18
  97. package/src/verax/observe/domain-boundary.js +10 -1
  98. package/src/verax/observe/expectation-executor.js +513 -0
  99. package/src/verax/observe/flow-matcher.js +143 -0
  100. package/src/verax/observe/focus-sensor.js +196 -0
  101. package/src/verax/observe/human-driver.js +660 -273
  102. package/src/verax/observe/index.js +910 -26
  103. package/src/verax/observe/interaction-discovery.js +378 -15
  104. package/src/verax/observe/interaction-runner.js +562 -197
  105. package/src/verax/observe/loading-sensor.js +145 -0
  106. package/src/verax/observe/navigation-sensor.js +255 -0
  107. package/src/verax/observe/network-sensor.js +55 -7
  108. package/src/verax/observe/observed-expectation-deriver.js +186 -0
  109. package/src/verax/observe/observed-expectation.js +305 -0
  110. package/src/verax/observe/page-frontier.js +234 -0
  111. package/src/verax/observe/settle.js +38 -17
  112. package/src/verax/observe/state-sensor.js +393 -0
  113. package/src/verax/observe/state-ui-sensor.js +7 -1
  114. package/src/verax/observe/timing-sensor.js +228 -0
  115. package/src/verax/observe/traces-writer.js +73 -21
  116. package/src/verax/observe/ui-signal-sensor.js +143 -17
  117. package/src/verax/scan-summary-writer.js +80 -15
  118. package/src/verax/shared/artifact-manager.js +111 -9
  119. package/src/verax/shared/budget-profiles.js +136 -0
  120. package/src/verax/shared/caching.js +1 -1
  121. package/src/verax/shared/ci-detection.js +39 -0
  122. package/src/verax/shared/config-loader.js +169 -0
  123. package/src/verax/shared/dynamic-route-utils.js +224 -0
  124. package/src/verax/shared/expectation-coverage.js +44 -0
  125. package/src/verax/shared/expectation-prover.js +81 -0
  126. package/src/verax/shared/expectation-tracker.js +201 -0
  127. package/src/verax/shared/expectations-writer.js +60 -0
  128. package/src/verax/shared/first-run.js +44 -0
  129. package/src/verax/shared/progress-reporter.js +171 -0
  130. package/src/verax/shared/retry-policy.js +9 -1
  131. package/src/verax/shared/root-artifacts.js +49 -0
  132. package/src/verax/shared/scan-budget.js +86 -0
  133. package/src/verax/shared/url-normalizer.js +162 -0
  134. package/src/verax/shared/zip-artifacts.js +66 -0
  135. package/src/verax/validate/context-validator.js +244 -0
@@ -1,14 +1,13 @@
1
1
  import { glob } from 'glob';
2
- import { resolve, dirname, join, relative } from 'path';
2
+ import { resolve, dirname, relative } from 'path';
3
3
  import { readFileSync, existsSync } from 'fs';
4
4
  import { parse } from 'node-html-parser';
5
- import { ExpectationProof } from '../shared/expectation-proof.js';
6
5
 
7
6
  const MAX_HTML_FILES = 200;
8
7
 
9
8
  function htmlFileToRoute(file) {
10
- let path = file.replace(/[\\\/]/g, '/');
11
- path = path.replace(/\/index\.html$/, '');
9
+ let path = file.replace(/[\\/]/g, '/');
10
+ path = path.replace(/\/index.html$/, '');
12
11
  path = path.replace(/\.html$/, '');
13
12
  path = path.replace(/^index$/, '');
14
13
 
@@ -121,7 +120,6 @@ function extractButtonNavigationExpectations(root, fromPath, file, routeMap, pro
121
120
  fromPath: fromPath,
122
121
  type: 'navigation',
123
122
  targetPath: targetPath,
124
- proof: ExpectationProof.PROVEN_EXPECTATION,
125
123
  evidence: {
126
124
  source: file,
127
125
  selectorHint: selectorHint
@@ -151,7 +149,6 @@ function extractFormSubmissionExpectations(root, fromPath, file, routeMap, proje
151
149
  fromPath: fromPath,
152
150
  type: 'form_submission',
153
151
  targetPath: targetPath,
154
- proof: ExpectationProof.PROVEN_EXPECTATION,
155
152
  evidence: {
156
153
  source: file,
157
154
  selectorHint: selectorHint
@@ -162,6 +159,105 @@ function extractFormSubmissionExpectations(root, fromPath, file, routeMap, proje
162
159
  return expectations;
163
160
  }
164
161
 
162
+ function extractNetworkExpectations(root, fromPath, file, _projectDir) {
163
+ const expectations = [];
164
+
165
+ // Extract from inline scripts
166
+ const scripts = root.querySelectorAll('script');
167
+ for (const script of scripts) {
168
+ const scriptContent = script.textContent || '';
169
+ if (!scriptContent) continue;
170
+
171
+ // Find fetch() calls with string literals
172
+ const fetchMatches = scriptContent.matchAll(/fetch\s*\(\s*['"]([^'"]+)['"]/g);
173
+ for (const match of fetchMatches) {
174
+ const endpoint = match[1];
175
+ if (!endpoint) continue;
176
+
177
+ // Only extract if it's an API endpoint (starts with /api/)
178
+ if (!endpoint.startsWith('/api/')) continue;
179
+
180
+ // Find the button/function that triggers this
181
+ // Look for onclick handlers or function definitions
182
+ const buttonId = scriptContent.match(/getElementById\s*\(\s*['"]([^'"]+)['"]/)?.[1];
183
+ const functionName = scriptContent.match(/function\s+(\w+)\s*\(/)?.[1];
184
+
185
+ let selectorHint = null;
186
+ if (buttonId) {
187
+ selectorHint = `#${buttonId}`;
188
+ } else if (functionName) {
189
+ // Try to find button with onclick that calls this function
190
+ const buttons = root.querySelectorAll(`button[onclick*="${functionName}"]`);
191
+ if (buttons.length > 0) {
192
+ const btn = buttons[0];
193
+ selectorHint = btn.id ? `#${btn.id}` : `button[onclick*="${functionName}"]`;
194
+ }
195
+ }
196
+
197
+ if (selectorHint) {
198
+ // Determine method from fetch options if present
199
+ let method = 'GET';
200
+ const methodMatch = scriptContent.match(/method\s*:\s*['"]([^'"]+)['"]/i);
201
+ if (methodMatch) {
202
+ method = methodMatch[1].toUpperCase();
203
+ }
204
+
205
+ expectations.push({
206
+ fromPath: fromPath,
207
+ type: 'network_action',
208
+ expectedTarget: endpoint,
209
+ urlPath: endpoint,
210
+ method: method,
211
+ proof: 'PROVEN_EXPECTATION',
212
+ sourceRef: `${file}:${scriptContent.indexOf(match[0])}`,
213
+ selectorHint: selectorHint,
214
+ evidence: {
215
+ source: file,
216
+ selectorHint: selectorHint
217
+ }
218
+ });
219
+ }
220
+ }
221
+ }
222
+
223
+ // Also check onclick attributes directly
224
+ const buttons = root.querySelectorAll('button[onclick]');
225
+ for (const button of buttons) {
226
+ const onclick = button.getAttribute('onclick') || '';
227
+ const fetchMatch = onclick.match(/fetch\s*\(\s*['"]([^'"]+)['"]/);
228
+ if (fetchMatch) {
229
+ const endpoint = fetchMatch[1];
230
+ if (endpoint.startsWith('/api/')) {
231
+ const buttonId = button.getAttribute('id');
232
+ const selectorHint = buttonId ? `#${buttonId}` : `button[onclick*="${endpoint}"]`;
233
+
234
+ let method = 'GET';
235
+ const methodMatch = onclick.match(/method\s*:\s*['"]([^'"]+)['"]/i);
236
+ if (methodMatch) {
237
+ method = methodMatch[1].toUpperCase();
238
+ }
239
+
240
+ expectations.push({
241
+ fromPath: fromPath,
242
+ type: 'network_action',
243
+ expectedTarget: endpoint,
244
+ urlPath: endpoint,
245
+ method: method,
246
+ proof: 'PROVEN_EXPECTATION',
247
+ sourceRef: `${file}:onclick`,
248
+ selectorHint: selectorHint,
249
+ evidence: {
250
+ source: file,
251
+ selectorHint: selectorHint
252
+ }
253
+ });
254
+ }
255
+ }
256
+ }
257
+
258
+ return expectations;
259
+ }
260
+
165
261
  export async function extractStaticExpectations(projectDir, routes) {
166
262
  const expectations = [];
167
263
  const routeMap = new Map(routes.map(r => [r.path, r]));
@@ -190,16 +286,16 @@ export async function extractStaticExpectations(projectDir, routes) {
190
286
  const targetPath = resolveLinkPath(href, file, projectDir);
191
287
  if (!targetPath) continue;
192
288
 
193
- if (!routeMap.has(targetPath)) continue;
194
-
195
- const linkText = link.textContent?.trim() || '';
289
+ // Extract expectation even if target route doesn't exist yet
290
+ // This allows detection of broken links or prevented navigation
291
+ // (The route may not exist, but the link promises navigation)
292
+ const _linkText = link.textContent?.trim() || '';
196
293
  const selectorHint = link.id ? `#${link.id}` : `a[href="${href}"]`;
197
294
 
198
295
  expectations.push({
199
296
  fromPath: fromPath,
200
297
  type: 'navigation',
201
298
  targetPath: targetPath,
202
- proof: ExpectationProof.PROVEN_EXPECTATION,
203
299
  evidence: {
204
300
  source: file,
205
301
  selectorHint: selectorHint
@@ -212,6 +308,19 @@ export async function extractStaticExpectations(projectDir, routes) {
212
308
 
213
309
  const formExpectations = extractFormSubmissionExpectations(root, fromPath, file, routeMap, projectDir);
214
310
  expectations.push(...formExpectations);
311
+
312
+ const networkExpectations = extractNetworkExpectations(root, fromPath, file, projectDir);
313
+ expectations.push(...networkExpectations);
314
+
315
+ // NAVIGATION INTELLIGENCE v2: Extract navigation expectations from inline scripts
316
+ const { extractNavigationExpectations } = await import('./static-extractor-navigation.js');
317
+ const navigationExpectations = extractNavigationExpectations(root, fromPath, file, projectDir);
318
+ expectations.push(...navigationExpectations);
319
+
320
+ // VALIDATION INTELLIGENCE v1: Extract validation_block expectations from inline scripts
321
+ const { extractValidationExpectations } = await import('./static-extractor-validation.js');
322
+ const validationExpectations = extractValidationExpectations(root, fromPath, file, projectDir);
323
+ expectations.push(...validationExpectations);
215
324
  } catch (error) {
216
325
  continue;
217
326
  }
@@ -3,7 +3,7 @@ import { hasReactRouterDom } from './project-detector.js';
3
3
 
4
4
  const MAX_HTML_FILES = 200;
5
5
 
6
- export async function assessLearnTruth(projectDir, projectType, routes, staticExpectations, spaExpectations = null) {
6
+ export async function assessLearnTruth(projectDir, projectType, routes, staticExpectations) {
7
7
  const truth = {
8
8
  routesDiscovered: routes.length,
9
9
  routesSource: 'none',
@@ -18,14 +18,24 @@ export async function assessLearnTruth(projectDir, projectType, routes, staticEx
18
18
  if (projectType === 'nextjs_app_router' || projectType === 'nextjs_pages_router') {
19
19
  truth.routesSource = 'nextjs_fs';
20
20
  truth.routesConfidence = 'HIGH';
21
+ } else if (projectType === 'vue_router') {
22
+ truth.routesSource = 'vue_router_ast';
23
+ truth.routesConfidence = 'HIGH';
21
24
 
22
- // Wave 1 - CODE TRUTH ENGINE: Count AST-derived expectations
23
- if (spaExpectations && spaExpectations.length > 0) {
24
- truth.expectationsDiscovered = spaExpectations.length;
25
- truth.expectationsStrong = spaExpectations.length;
25
+ if (staticExpectations && staticExpectations.length > 0) {
26
+ truth.expectationsDiscovered = staticExpectations.length;
27
+ truth.expectationsStrong = staticExpectations.filter(e =>
28
+ e.type === 'spa_navigation'
29
+ ).length;
26
30
  truth.expectationsWeak = 0;
27
- truth.expectationsSource = 'ast_contracts';
28
31
  }
32
+ } else if (projectType === 'vue_spa') {
33
+ truth.routesSource = 'vue_no_router';
34
+ truth.routesConfidence = 'LOW';
35
+ truth.limitations.push({
36
+ code: 'VUE_ROUTER_NOT_INSTALLED',
37
+ message: 'Vue detected but vue-router not installed. Routes cannot be extracted from router configuration.'
38
+ });
29
39
  } else if (projectType === 'static') {
30
40
  truth.routesSource = 'static_html';
31
41
 
@@ -67,21 +77,14 @@ export async function assessLearnTruth(projectDir, projectType, routes, staticEx
67
77
  });
68
78
  }
69
79
 
70
- // Wave 1 - CODE TRUTH ENGINE: Report AST-based expectations instead of routes
71
- if (spaExpectations && spaExpectations.length > 0) {
72
- truth.expectationsDiscovered = spaExpectations.length;
73
- truth.expectationsStrong = spaExpectations.length;
74
- truth.expectationsWeak = 0;
75
- truth.expectationsSource = 'ast_contracts';
76
- } else {
77
- truth.expectationsDiscovered = 0;
78
- truth.expectationsStrong = 0;
79
- truth.expectationsWeak = 0;
80
- truth.warnings.push({
81
- code: 'NO_AST_CONTRACTS_FOUND',
82
- message: 'No JSX Link/NavLink elements with static href/to found. No PROVEN expectations available.'
83
- });
84
- }
80
+ truth.warnings.push({
81
+ code: 'REACT_ROUTE_EXTRACTION_FRAGILE',
82
+ message: 'Route extraction uses regex parsing of source files. Dynamic routes, nested routers, and code-split route definitions may be missed.'
83
+ });
84
+
85
+ truth.expectationsDiscovered = routes.length;
86
+ truth.expectationsStrong = routes.length;
87
+ truth.expectationsWeak = 0;
85
88
  } else if (projectType === 'unknown') {
86
89
  truth.routesSource = 'none';
87
90
  truth.routesConfidence = 'LOW';
@@ -9,7 +9,7 @@
9
9
 
10
10
  import ts from 'typescript';
11
11
  import { resolve, relative, dirname, sep, join } from 'path';
12
- import { readFileSync, existsSync, statSync } from 'fs';
12
+ import { existsSync, statSync } from 'fs';
13
13
  import { glob } from 'glob';
14
14
 
15
15
  const MAX_DEPTH = 3;
@@ -45,14 +45,8 @@ export async function resolveActionContracts(rootDir, workspaceRoot) {
45
45
  const checker = program.getTypeChecker();
46
46
  const contracts = [];
47
47
 
48
- for (const sourceFile of program.getSourceFiles()) {
49
- const sourcePath = resolve(sourceFile.fileName);
50
- if (!sourcePath.toLowerCase().startsWith(normalizedRoot.toLowerCase())) continue;
51
- if (sourceFile.isDeclarationFile) continue;
52
-
53
- const importMap = buildImportMap(sourceFile, rootDir, workspaceRoot);
54
-
55
- function visit(node) {
48
+ function createVisitFunction(importMap, sourceFile) {
49
+ return function visit(node) {
56
50
  if (ts.isJsxAttribute(node)) {
57
51
  const name = node.name.getText();
58
52
  if (name !== 'onClick' && name !== 'onSubmit') return;
@@ -64,7 +58,7 @@ export async function resolveActionContracts(rootDir, workspaceRoot) {
64
58
  // We only handle identifier handlers (cross-file capable)
65
59
  if (!ts.isIdentifier(expr)) return;
66
60
 
67
- const handlerName = expr.text;
61
+ const _handlerName = expr.text;
68
62
  const handlerRef = deriveHandlerRef(expr, importMap, sourceFile, workspaceRoot);
69
63
  if (!handlerRef) return;
70
64
 
@@ -112,8 +106,16 @@ export async function resolveActionContracts(rootDir, workspaceRoot) {
112
106
  }
113
107
  }
114
108
  ts.forEachChild(node, visit);
115
- }
109
+ };
110
+ }
116
111
 
112
+ for (const sourceFile of program.getSourceFiles()) {
113
+ const sourcePath = resolve(sourceFile.fileName);
114
+ if (!sourcePath.toLowerCase().startsWith(normalizedRoot.toLowerCase())) continue;
115
+ if (sourceFile.isDeclarationFile) continue;
116
+
117
+ const importMap = buildImportMap(sourceFile, rootDir, workspaceRoot);
118
+ const visit = createVisitFunction(importMap, sourceFile);
117
119
  visit(sourceFile);
118
120
  }
119
121
 
@@ -313,7 +315,7 @@ function analyzeStateCall(node) {
313
315
 
314
316
  // Zustand: store.set(...) or setState(...)
315
317
  if (ts.isPropertyAccessExpression(callee)) {
316
- const obj = callee.expression;
318
+ const _obj = callee.expression;
317
319
  const prop = callee.name;
318
320
  // Common pattern: storeObj.set(...)
319
321
  if (prop.text === 'set') {
@@ -0,0 +1,211 @@
1
+ /**
2
+ * ARIA Announcement Sensor
3
+ * Tracks ARIA live regions, status/alert roles, and announcements
4
+ */
5
+
6
+ export class AriaSensor {
7
+ constructor() {
8
+ this.ariaStateBefore = null;
9
+ this.ariaStateAfter = null;
10
+ }
11
+
12
+ /**
13
+ * Capture ARIA state before interaction
14
+ */
15
+ async captureBefore(page) {
16
+ const ariaData = await page.evaluate(() => {
17
+ const result = {
18
+ liveRegions: [],
19
+ statusRoles: [],
20
+ alerts: [],
21
+ ariaBusyElements: [],
22
+ ariaLive: [],
23
+ announcements: []
24
+ };
25
+
26
+ // Find all live regions
27
+ const liveRegions = document.querySelectorAll('[aria-live]');
28
+ liveRegions.forEach(el => {
29
+ result.liveRegions.push({
30
+ selector: generateSelector(el),
31
+ ariaLive: el.getAttribute('aria-live'),
32
+ text: el.textContent?.slice(0, 100) || '',
33
+ ariaAtomic: el.getAttribute('aria-atomic'),
34
+ ariaRelevant: el.getAttribute('aria-relevant')
35
+ });
36
+ });
37
+
38
+ // Find status and alert roles
39
+ const statusAlerts = document.querySelectorAll('[role="status"], [role="alert"]');
40
+ statusAlerts.forEach(el => {
41
+ result.statusRoles.push({
42
+ selector: generateSelector(el),
43
+ role: el.getAttribute('role'),
44
+ text: el.textContent?.slice(0, 100) || '',
45
+ ariaLive: el.getAttribute('aria-live')
46
+ });
47
+ });
48
+
49
+ // Find aria-busy elements
50
+ const busyElements = document.querySelectorAll('[aria-busy="true"]');
51
+ busyElements.forEach(el => {
52
+ result.ariaBusyElements.push({
53
+ selector: generateSelector(el),
54
+ ariaBusy: el.getAttribute('aria-busy')
55
+ });
56
+ });
57
+
58
+ return result;
59
+
60
+ function generateSelector(el) {
61
+ if (el.id) return `#${el.id}`;
62
+ if (el.className) {
63
+ const classes = Array.from(el.classList || []).slice(0, 2).join('.');
64
+ return el.tagName.toLowerCase() + (classes ? `.${classes}` : '');
65
+ }
66
+ return el.tagName.toLowerCase();
67
+ }
68
+ });
69
+
70
+ this.ariaStateBefore = ariaData;
71
+ return ariaData;
72
+ }
73
+
74
+ /**
75
+ * Capture ARIA state after interaction
76
+ */
77
+ async captureAfter(page) {
78
+ const ariaData = await page.evaluate(() => {
79
+ const result = {
80
+ liveRegions: [],
81
+ statusRoles: [],
82
+ alerts: [],
83
+ ariaBusyElements: [],
84
+ ariaLive: [],
85
+ announcements: []
86
+ };
87
+
88
+ // Find all live regions
89
+ const liveRegions = document.querySelectorAll('[aria-live]');
90
+ liveRegions.forEach(el => {
91
+ result.liveRegions.push({
92
+ selector: generateSelector(el),
93
+ ariaLive: el.getAttribute('aria-live'),
94
+ text: el.textContent?.slice(0, 100) || '',
95
+ ariaAtomic: el.getAttribute('aria-atomic'),
96
+ ariaRelevant: el.getAttribute('aria-relevant')
97
+ });
98
+ });
99
+
100
+ // Find status and alert roles
101
+ const statusAlerts = document.querySelectorAll('[role="status"], [role="alert"]');
102
+ statusAlerts.forEach(el => {
103
+ result.statusRoles.push({
104
+ selector: generateSelector(el),
105
+ role: el.getAttribute('role'),
106
+ text: el.textContent?.slice(0, 100) || '',
107
+ ariaLive: el.getAttribute('aria-live')
108
+ });
109
+ });
110
+
111
+ // Find aria-busy elements
112
+ const busyElements = document.querySelectorAll('[aria-busy="true"]');
113
+ busyElements.forEach(el => {
114
+ result.ariaBusyElements.push({
115
+ selector: generateSelector(el),
116
+ ariaBusy: el.getAttribute('aria-busy')
117
+ });
118
+ });
119
+
120
+ return result;
121
+
122
+ function generateSelector(el) {
123
+ if (el.id) return `#${el.id}`;
124
+ if (el.className) {
125
+ const classes = Array.from(el.classList || []).slice(0, 2).join('.');
126
+ return el.tagName.toLowerCase() + (classes ? `.${classes}` : '');
127
+ }
128
+ return el.tagName.toLowerCase();
129
+ }
130
+ });
131
+
132
+ this.ariaStateAfter = ariaData;
133
+ return ariaData;
134
+ }
135
+
136
+ /**
137
+ * Detect if ARIA state changed (live region updates, role changes, etc)
138
+ */
139
+ detectAriaChange() {
140
+ if (!this.ariaStateBefore || !this.ariaStateAfter) {
141
+ return false;
142
+ }
143
+
144
+ // Check if live region text changed
145
+ const beforeLiveText = this.ariaStateBefore.liveRegions.map(r => r.text).join('|');
146
+ const afterLiveText = this.ariaStateAfter.liveRegions.map(r => r.text).join('|');
147
+
148
+ if (beforeLiveText !== afterLiveText) {
149
+ return true;
150
+ }
151
+
152
+ // Check if status/alert role text changed
153
+ const beforeStatusText = this.ariaStateBefore.statusRoles.map(r => r.text).join('|');
154
+ const afterStatusText = this.ariaStateAfter.statusRoles.map(r => r.text).join('|');
155
+
156
+ if (beforeStatusText !== afterStatusText) {
157
+ return true;
158
+ }
159
+
160
+ // Check if aria-busy state changed
161
+ const beforeBusy = this.ariaStateBefore.ariaBusyElements.length;
162
+ const afterBusy = this.ariaStateAfter.ariaBusyElements.length;
163
+
164
+ if (beforeBusy !== afterBusy) {
165
+ return true;
166
+ }
167
+
168
+ return false;
169
+ }
170
+
171
+ /**
172
+ * Check if ARIA announcement should have occurred but didn't
173
+ */
174
+ detectMissingAnnouncement(eventType) {
175
+ // eventType: 'submit', 'network_success', 'network_error', 'validation_error', etc
176
+ // These meaningful events should typically trigger ARIA announcements
177
+
178
+ if (!this.ariaStateBefore || !this.ariaStateAfter) {
179
+ return false;
180
+ }
181
+
182
+ // Check if any live region exists (at least one should for accessibility)
183
+ const hasLiveRegion = this.ariaStateAfter.liveRegions.length > 0;
184
+ const hasStatus = this.ariaStateAfter.statusRoles.length > 0;
185
+
186
+ if (!hasLiveRegion && !hasStatus) {
187
+ return true; // No ARIA announcement mechanism present
188
+ }
189
+
190
+ // Check if announcement actually changed
191
+ const ariaChanged = this.detectAriaChange();
192
+
193
+ // For meaningful events, ARIA should have changed
194
+ if (!ariaChanged && (eventType === 'submit' || eventType === 'network_success' || eventType === 'network_error')) {
195
+ return true;
196
+ }
197
+
198
+ return false;
199
+ }
200
+
201
+ /**
202
+ * Get ARIA diff for evidence
203
+ */
204
+ getAriaDiff() {
205
+ return {
206
+ before: this.ariaStateBefore,
207
+ after: this.ariaStateAfter,
208
+ changed: this.detectAriaChange()
209
+ };
210
+ }
211
+ }
@@ -1,6 +1,5 @@
1
1
  import { chromium } from 'playwright';
2
-
3
- const STABLE_WAIT_MS = 2000;
2
+ import { DEFAULT_SCAN_BUDGET } from '../shared/scan-budget.js';
4
3
 
5
4
  export async function createBrowser() {
6
5
  const browser = await chromium.launch({ headless: true });
@@ -11,12 +10,37 @@ export async function createBrowser() {
11
10
  return { browser, page };
12
11
  }
13
12
 
14
- export async function navigateToUrl(page, url) {
15
- await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
16
- await page.waitForTimeout(STABLE_WAIT_MS);
13
+ export async function navigateToUrl(page, url, scanBudget = DEFAULT_SCAN_BUDGET) {
14
+ let stableWait = scanBudget.navigationStableWaitMs;
15
+ try {
16
+ if (url.startsWith('file:') || url.includes('localhost:') || url.includes('127.0.0.1')) {
17
+ stableWait = 200; // Short wait for local fixtures
18
+ }
19
+ } catch {
20
+ // Ignore config errors
21
+ }
22
+ // Use domcontentloaded first for faster timeout, then wait for networkidle separately
23
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: scanBudget.initialNavigationTimeoutMs });
24
+ await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {
25
+ // Network idle timeout is acceptable, continue
26
+ });
27
+ await page.waitForTimeout(stableWait);
17
28
  }
18
29
 
19
30
  export async function closeBrowser(browser) {
20
- await browser.close();
31
+ try {
32
+ // Close all contexts first
33
+ const contexts = browser.contexts();
34
+ for (const context of contexts) {
35
+ try {
36
+ await context.close({ timeout: 5000 }).catch(() => {});
37
+ } catch (e) {
38
+ // Ignore context close errors
39
+ }
40
+ }
41
+ await browser.close({ timeout: 5000 }).catch(() => {});
42
+ } catch (e) {
43
+ // Ignore browser close errors - best effort cleanup
44
+ }
21
45
  }
22
46
 
@@ -57,7 +57,7 @@ export class ConsoleSensor {
57
57
  };
58
58
 
59
59
  // Capture unhandled promise rejections
60
- const onUnhandledRejection = (promise, reason) => {
60
+ const _onUnhandledRejection = (promise, reason) => {
61
61
  const message = (reason?.toString?.() || String(reason)).slice(0, 200);
62
62
  state.unhandledRejections.push({
63
63
  message: message,
@@ -104,7 +104,7 @@ export class ConsoleSensor {
104
104
  /**
105
105
  * Stop monitoring and return a summary for the window.
106
106
  */
107
- async stopWindow(windowId, page) {
107
+ stopWindow(windowId, _page) {
108
108
  const state = this.windows.get(windowId);
109
109
  if (!state) {
110
110
  return this.getEmptySummary();
@@ -112,22 +112,6 @@ export class ConsoleSensor {
112
112
 
113
113
  state.cleanup();
114
114
 
115
- // Collect any unhandled rejections that were captured
116
- let capturedRejections = [];
117
- try {
118
- capturedRejections = await page.evaluate(() => {
119
- return window.__unhandledRejections || [];
120
- });
121
- } catch {
122
- // Page may not have this
123
- }
124
-
125
- // Merge captured rejections with ones we heard about
126
- state.unhandledRejections = [
127
- ...state.unhandledRejections,
128
- ...capturedRejections
129
- ].slice(0, this.maxErrorsToKeep);
130
-
131
115
  const summary = {
132
116
  windowId,
133
117
  errorCount: state.consoleErrors.length + state.pageErrors.length,
@@ -1,7 +1,10 @@
1
1
  export function getBaseOrigin(url) {
2
2
  try {
3
3
  const urlObj = new URL(url);
4
- return `${urlObj.protocol}//${urlObj.host}${urlObj.port ? `:${urlObj.port}` : ''}`;
4
+ if (urlObj.protocol === 'file:') {
5
+ return 'file://';
6
+ }
7
+ return urlObj.origin;
5
8
  } catch (error) {
6
9
  return null;
7
10
  }
@@ -12,6 +15,12 @@ export function isExternalUrl(url, baseOrigin) {
12
15
 
13
16
  try {
14
17
  const urlObj = new URL(url);
18
+ // Special-case file protocol: treat all file:// URLs as same-origin
19
+ const isFileProtocol = urlObj.protocol === 'file:';
20
+ const baseIsFile = baseOrigin.startsWith('file:');
21
+ if (isFileProtocol && baseIsFile) {
22
+ return false;
23
+ }
15
24
  const urlOrigin = urlObj.origin;
16
25
  return urlOrigin !== baseOrigin;
17
26
  } catch (error) {