@veraxhq/verax 0.1.0

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 (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +237 -0
  3. package/bin/verax.js +452 -0
  4. package/package.json +57 -0
  5. package/src/verax/detect/comparison.js +69 -0
  6. package/src/verax/detect/confidence-engine.js +498 -0
  7. package/src/verax/detect/evidence-validator.js +33 -0
  8. package/src/verax/detect/expectation-model.js +204 -0
  9. package/src/verax/detect/findings-writer.js +31 -0
  10. package/src/verax/detect/index.js +397 -0
  11. package/src/verax/detect/skip-classifier.js +202 -0
  12. package/src/verax/flow/flow-engine.js +265 -0
  13. package/src/verax/flow/flow-spec.js +145 -0
  14. package/src/verax/flow/redaction.js +74 -0
  15. package/src/verax/index.js +97 -0
  16. package/src/verax/learn/action-contract-extractor.js +281 -0
  17. package/src/verax/learn/ast-contract-extractor.js +255 -0
  18. package/src/verax/learn/index.js +18 -0
  19. package/src/verax/learn/manifest-writer.js +97 -0
  20. package/src/verax/learn/project-detector.js +87 -0
  21. package/src/verax/learn/react-router-extractor.js +73 -0
  22. package/src/verax/learn/route-extractor.js +122 -0
  23. package/src/verax/learn/route-validator.js +215 -0
  24. package/src/verax/learn/source-instrumenter.js +214 -0
  25. package/src/verax/learn/static-extractor.js +222 -0
  26. package/src/verax/learn/truth-assessor.js +96 -0
  27. package/src/verax/learn/ts-contract-resolver.js +395 -0
  28. package/src/verax/observe/browser.js +22 -0
  29. package/src/verax/observe/console-sensor.js +166 -0
  30. package/src/verax/observe/dom-signature.js +23 -0
  31. package/src/verax/observe/domain-boundary.js +38 -0
  32. package/src/verax/observe/evidence-capture.js +5 -0
  33. package/src/verax/observe/human-driver.js +376 -0
  34. package/src/verax/observe/index.js +67 -0
  35. package/src/verax/observe/interaction-discovery.js +269 -0
  36. package/src/verax/observe/interaction-runner.js +410 -0
  37. package/src/verax/observe/network-sensor.js +173 -0
  38. package/src/verax/observe/selector-generator.js +74 -0
  39. package/src/verax/observe/settle.js +155 -0
  40. package/src/verax/observe/state-ui-sensor.js +200 -0
  41. package/src/verax/observe/traces-writer.js +82 -0
  42. package/src/verax/observe/ui-signal-sensor.js +197 -0
  43. package/src/verax/resolve-workspace-root.js +173 -0
  44. package/src/verax/scan-summary-writer.js +41 -0
  45. package/src/verax/shared/artifact-manager.js +139 -0
  46. package/src/verax/shared/caching.js +104 -0
  47. package/src/verax/shared/expectation-proof.js +4 -0
  48. package/src/verax/shared/redaction.js +227 -0
  49. package/src/verax/shared/retry-policy.js +89 -0
  50. package/src/verax/shared/timing-metrics.js +44 -0
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Wave 6 & 8 — TypeScript-based Contract Resolver
3
+ *
4
+ * Resolves PROVEN network action contracts (Wave 6) and state action contracts (Wave 8)
5
+ * across files using TypeScript Program.
6
+ * Follows handler identifiers across modules and call chains (depth <= 3).
7
+ * Zero heuristics. Only static literal URLs and resolvable state mutations produce PROVEN contracts.
8
+ */
9
+
10
+ import ts from 'typescript';
11
+ import { resolve, relative, dirname, sep, join } from 'path';
12
+ import { readFileSync, existsSync, statSync } from 'fs';
13
+ import { glob } from 'glob';
14
+
15
+ const MAX_DEPTH = 3;
16
+ const SUPPORTED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx'];
17
+
18
+ /**
19
+ * Resolve action contracts across files using TypeScript compiler API.
20
+ *
21
+ * @param {string} rootDir - Directory containing the source (fixture/project)
22
+ * @param {string} workspaceRoot - Workspace root for relative paths
23
+ * @returns {Promise<Array<Object>>} - PROVEN contracts (both NETWORK_ACTION and STATE_ACTION)
24
+ */
25
+ export async function resolveActionContracts(rootDir, workspaceRoot) {
26
+ const files = await glob('**/*.{ts,tsx,js,jsx}', {
27
+ cwd: rootDir,
28
+ absolute: true,
29
+ ignore: ['node_modules/**', 'dist/**', 'build/**']
30
+ });
31
+
32
+ const normalizedRoot = resolve(rootDir);
33
+
34
+ const program = ts.createProgram(files, {
35
+ allowJs: true,
36
+ checkJs: true,
37
+ jsx: ts.JsxEmit.ReactJSX,
38
+ target: ts.ScriptTarget.ES2020,
39
+ module: ts.ModuleKind.CommonJS,
40
+ moduleResolution: ts.ModuleResolutionKind.NodeJs,
41
+ skipLibCheck: true,
42
+ noResolve: false
43
+ });
44
+
45
+ const checker = program.getTypeChecker();
46
+ const contracts = [];
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) {
56
+ if (ts.isJsxAttribute(node)) {
57
+ const name = node.name.getText();
58
+ if (name !== 'onClick' && name !== 'onSubmit') return;
59
+
60
+ if (!node.initializer || !ts.isJsxExpression(node.initializer)) return;
61
+ const expr = node.initializer.expression;
62
+ if (!expr) return;
63
+
64
+ // We only handle identifier handlers (cross-file capable)
65
+ if (!ts.isIdentifier(expr)) return;
66
+
67
+ const handlerName = expr.text;
68
+ const handlerRef = deriveHandlerRef(expr, importMap, sourceFile, workspaceRoot);
69
+ if (!handlerRef) return;
70
+
71
+ const symbol = checker.getSymbolAtLocation(expr);
72
+ if (!symbol) return;
73
+ const targetSymbol = ts.SymbolFlags.Alias & symbol.flags ? checker.getAliasedSymbol(symbol) : symbol;
74
+
75
+ // Check for network action contracts (Wave 6)
76
+ const networkContract = followHandler(targetSymbol, checker, 0, 'network');
77
+ if (networkContract) {
78
+ const sourceRef = formatSourceRef(sourceFile.fileName, workspaceRoot, node.getStart(), sourceFile);
79
+ const openingElement = node.parent && node.parent.parent && ts.isJsxOpeningElement(node.parent.parent)
80
+ ? node.parent.parent
81
+ : null;
82
+ const elementType = getElementName(openingElement);
83
+
84
+ contracts.push({
85
+ kind: 'NETWORK_ACTION',
86
+ method: networkContract.method,
87
+ urlPath: networkContract.url,
88
+ source: sourceRef,
89
+ handlerRef,
90
+ sourceChain: networkContract.chain,
91
+ elementType
92
+ });
93
+ }
94
+
95
+ // Check for state action contracts (Wave 8)
96
+ const stateContract = followHandler(targetSymbol, checker, 0, 'state');
97
+ if (stateContract) {
98
+ const sourceRef = formatSourceRef(sourceFile.fileName, workspaceRoot, node.getStart(), sourceFile);
99
+ const openingElement = node.parent && node.parent.parent && ts.isJsxOpeningElement(node.parent.parent)
100
+ ? node.parent.parent
101
+ : null;
102
+ const elementType = getElementName(openingElement);
103
+
104
+ contracts.push({
105
+ kind: 'STATE_ACTION',
106
+ stateKind: stateContract.stateKind,
107
+ source: sourceRef,
108
+ handlerRef,
109
+ sourceChain: stateContract.chain,
110
+ elementType
111
+ });
112
+ }
113
+ }
114
+ ts.forEachChild(node, visit);
115
+ }
116
+
117
+ visit(sourceFile);
118
+ }
119
+
120
+ return dedupeContracts(contracts);
121
+ }
122
+
123
+ function dedupeContracts(list) {
124
+ const seen = new Set();
125
+ const out = [];
126
+ for (const c of list) {
127
+ const key = `${c.handlerRef}|${c.urlPath}|${c.method}|${c.source}`;
128
+ if (seen.has(key)) continue;
129
+ seen.add(key);
130
+ out.push(c);
131
+ }
132
+ return out;
133
+ }
134
+
135
+ function buildImportMap(sourceFile, rootDir, workspaceRoot) {
136
+ const map = new Map(); // localName -> { modulePath, exportName }
137
+ const fileDir = dirname(sourceFile.fileName);
138
+
139
+ sourceFile.forEachChild((node) => {
140
+ if (!ts.isImportDeclaration(node) || !node.importClause || !node.moduleSpecifier) return;
141
+ const moduleText = node.moduleSpecifier.getText().slice(1, -1);
142
+ const resolved = resolveModulePath(moduleText, fileDir, rootDir, workspaceRoot);
143
+ if (!resolved) return;
144
+
145
+ const { importClause } = node;
146
+ if (importClause.name) {
147
+ // default import: import foo from './x'
148
+ map.set(importClause.name.text, { modulePath: resolved, exportName: 'default' });
149
+ }
150
+ if (importClause.namedBindings && ts.isNamedImports(importClause.namedBindings)) {
151
+ for (const spec of importClause.namedBindings.elements) {
152
+ const importedName = spec.propertyName ? spec.propertyName.text : spec.name.text;
153
+ map.set(spec.name.text, { modulePath: resolved, exportName: importedName });
154
+ }
155
+ }
156
+ });
157
+
158
+ return map;
159
+ }
160
+
161
+ function resolveModulePath(specifier, fromDir, rootDir, workspaceRoot) {
162
+ if (specifier.startsWith('.') || specifier.startsWith('/')) {
163
+ const base = specifier.startsWith('.') ? resolve(fromDir, specifier) : resolve(rootDir, specifier);
164
+ const candidateFiles = [base, ...SUPPORTED_EXTENSIONS.map(ext => base + ext), ...SUPPORTED_EXTENSIONS.map(ext => join(base, 'index' + ext))];
165
+ for (const file of candidateFiles) {
166
+ if (existsSync(file) && statSync(file).isFile()) return normalizePath(file, workspaceRoot);
167
+ }
168
+ return null;
169
+ }
170
+ // External module -> treat as unresolved (UNKNOWN)
171
+ return null;
172
+ }
173
+
174
+ function deriveHandlerRef(identifier, importMap, sourceFile, workspaceRoot) {
175
+ const localName = identifier.text;
176
+ const entry = importMap.get(localName);
177
+ if (entry) {
178
+ return `${entry.modulePath}#${entry.exportName}`;
179
+ }
180
+ // Local declaration in same file
181
+ const modulePath = normalizePath(sourceFile.fileName, workspaceRoot);
182
+ return `${modulePath}#${localName}`;
183
+ }
184
+
185
+ function followHandler(symbol, checker, depth, contractType = 'network') {
186
+ if (!symbol || depth > MAX_DEPTH) return null;
187
+
188
+ const declarations = symbol.getDeclarations() || [];
189
+ for (const decl of declarations) {
190
+ if (ts.isFunctionDeclaration(decl) && decl.body) {
191
+ if (contractType === 'network') {
192
+ const res = findNetworkCallInBody(decl.body, checker, depth);
193
+ if (res) return res;
194
+ } else {
195
+ const res = findStateCallInBody(decl.body, checker, depth);
196
+ if (res) return res;
197
+ }
198
+ }
199
+ if (ts.isVariableDeclaration(decl) && decl.initializer) {
200
+ if (ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) {
201
+ if (contractType === 'network') {
202
+ const res = findNetworkCallInBody(decl.initializer.body, checker, depth);
203
+ if (res) return res;
204
+ } else {
205
+ const res = findStateCallInBody(decl.initializer.body, checker, depth);
206
+ if (res) return res;
207
+ }
208
+ }
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function findNetworkCallInBody(body, checker, depth) {
215
+ let found = null;
216
+
217
+ function walk(node) {
218
+ if (found) return;
219
+
220
+ // Direct network calls
221
+ if (ts.isCallExpression(node)) {
222
+ const call = analyzeCall(node);
223
+ if (call) {
224
+ found = { method: call.method, url: call.url, chain: call.chain }; return;
225
+ }
226
+
227
+ // Follow inner call chain: handler -> wrapper -> fetch
228
+ if (depth < MAX_DEPTH) {
229
+ const callee = node.expression;
230
+ if (ts.isIdentifier(callee)) {
231
+ const sym = checker.getSymbolAtLocation(callee);
232
+ const target = sym && (ts.SymbolFlags.Alias & sym.flags ? checker.getAliasedSymbol(sym) : sym);
233
+ const inner = followHandler(target, checker, depth + 1, 'network');
234
+ if (inner) {
235
+ const name = callee.text;
236
+ const chain = [{ name }].concat(inner.chain || []);
237
+ found = { method: inner.method, url: inner.url, chain };
238
+ return;
239
+ }
240
+ }
241
+ }
242
+ }
243
+
244
+ ts.forEachChild(node, walk);
245
+ }
246
+
247
+ walk(body);
248
+ return found;
249
+ }
250
+
251
+ /**
252
+ * Find state mutations (useState setter, Redux dispatch, Zustand set) in function body.
253
+ * Returns { stateKind, chain } if found, null otherwise.
254
+ */
255
+ function findStateCallInBody(body, checker, depth) {
256
+ let found = null;
257
+
258
+ function walk(node) {
259
+ if (found) return;
260
+
261
+ // Direct state setter calls: setX(...), dispatch(...), set(...)
262
+ if (ts.isCallExpression(node)) {
263
+ const stateCall = analyzeStateCall(node);
264
+ if (stateCall) {
265
+ found = { stateKind: stateCall.stateKind, chain: stateCall.chain };
266
+ return;
267
+ }
268
+
269
+ // Follow inner call chain for state mutations
270
+ if (depth < MAX_DEPTH) {
271
+ const callee = node.expression;
272
+ if (ts.isIdentifier(callee)) {
273
+ const sym = checker.getSymbolAtLocation(callee);
274
+ const target = sym && (ts.SymbolFlags.Alias & sym.flags ? checker.getAliasedSymbol(sym) : sym);
275
+ const inner = followHandler(target, checker, depth + 1, 'state');
276
+ if (inner) {
277
+ const name = callee.text;
278
+ const chain = [{ name }].concat(inner.chain || []);
279
+ found = { stateKind: inner.stateKind, chain };
280
+ return;
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ ts.forEachChild(node, walk);
287
+ }
288
+
289
+ walk(body);
290
+ return found;
291
+ }
292
+
293
+ /**
294
+ * Analyze a call expression to determine if it's a state mutation.
295
+ * Returns { stateKind, chain } if it's a state call, null otherwise.
296
+ */
297
+ function analyzeStateCall(node) {
298
+ if (!ts.isCallExpression(node)) return null;
299
+
300
+ const callee = node.expression;
301
+
302
+ // React setter: setX(...) — any identifier starting with 'set' followed by capital letter
303
+ if (ts.isIdentifier(callee)) {
304
+ const name = callee.text;
305
+ if (name.startsWith('set') && name.length > 3 && /^set[A-Z]/.test(name)) {
306
+ return { stateKind: 'react_setter', chain: [] };
307
+ }
308
+ // dispatch(...) — Redux or similar
309
+ if (name === 'dispatch') {
310
+ return { stateKind: 'redux_dispatch', chain: [] };
311
+ }
312
+ }
313
+
314
+ // Zustand: store.set(...) or setState(...)
315
+ if (ts.isPropertyAccessExpression(callee)) {
316
+ const obj = callee.expression;
317
+ const prop = callee.name;
318
+ // Common pattern: storeObj.set(...)
319
+ if (prop.text === 'set') {
320
+ return { stateKind: 'zustand_set', chain: [] };
321
+ }
322
+ // setState(...) called on object
323
+ if (prop.text === 'setState') {
324
+ return { stateKind: 'react_setter', chain: [] };
325
+ }
326
+ }
327
+
328
+ return null;
329
+ }
330
+
331
+ function analyzeCall(node) {
332
+ // fetch("/api")
333
+ if (ts.isIdentifier(node.expression) && node.expression.text === 'fetch') {
334
+ const urlArg = node.arguments[0];
335
+ if (urlArg && ts.isStringLiteral(urlArg)) {
336
+ let method = 'GET';
337
+ const options = node.arguments[1];
338
+ if (options && ts.isObjectLiteralExpression(options)) {
339
+ const methodProp = options.properties.find(p => ts.isPropertyAssignment(p) && p.name && p.name.getText() === 'method');
340
+ if (methodProp && ts.isPropertyAssignment(methodProp) && ts.isStringLiteral(methodProp.initializer)) {
341
+ method = methodProp.initializer.text.toUpperCase();
342
+ }
343
+ }
344
+ return { method, url: urlArg.text, chain: [] };
345
+ }
346
+ }
347
+
348
+ // axios.post("/api")
349
+ if (ts.isPropertyAccessExpression(node.expression)) {
350
+ const obj = node.expression.expression;
351
+ const prop = node.expression.name;
352
+ if (ts.isIdentifier(obj) && obj.text === 'axios') {
353
+ const method = prop.text.toUpperCase();
354
+ const urlArg = node.arguments[0];
355
+ if (urlArg && ts.isStringLiteral(urlArg)) {
356
+ return { method, url: urlArg.text, chain: [] };
357
+ }
358
+ }
359
+ }
360
+
361
+ // new XMLHttpRequest().open("METHOD", "URL")
362
+ if (ts.isCallExpression(node.expression) && ts.isPropertyAccessExpression(node.expression)) {
363
+ const inner = node.expression;
364
+ if (inner.name.text === 'open') {
365
+ const obj = inner.expression;
366
+ if (ts.isNewExpression(obj) && ts.isIdentifier(obj.expression) && obj.expression.text === 'XMLHttpRequest') {
367
+ const methodArg = node.arguments[0];
368
+ const urlArg = node.arguments[1];
369
+ if (methodArg && ts.isStringLiteral(methodArg) && urlArg && ts.isStringLiteral(urlArg)) {
370
+ return { method: methodArg.text.toUpperCase(), url: urlArg.text, chain: [] };
371
+ }
372
+ }
373
+ }
374
+ }
375
+
376
+ return null;
377
+ }
378
+
379
+ function getElementName(openingElement) {
380
+ if (!openingElement || !openingElement.name) return 'element';
381
+ const nameNode = openingElement.name;
382
+ if (ts.isIdentifier(nameNode)) return nameNode.text;
383
+ return 'element';
384
+ }
385
+
386
+ function formatSourceRef(filePath, workspaceRoot, pos, sourceFile) {
387
+ const rel = normalizePath(filePath, workspaceRoot);
388
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(pos);
389
+ return `${rel}:${line + 1}:${character}`; // keep column 0-based for consistency with Babel loc.column
390
+ }
391
+
392
+ function normalizePath(filePath, workspaceRoot) {
393
+ const rel = relative(workspaceRoot, filePath);
394
+ return rel.split(sep).join('/');
395
+ }
@@ -0,0 +1,22 @@
1
+ import { chromium } from 'playwright';
2
+
3
+ const STABLE_WAIT_MS = 2000;
4
+
5
+ export async function createBrowser() {
6
+ const browser = await chromium.launch({ headless: true });
7
+ const context = await browser.newContext({
8
+ viewport: { width: 1280, height: 720 }
9
+ });
10
+ const page = await context.newPage();
11
+ return { browser, page };
12
+ }
13
+
14
+ export async function navigateToUrl(page, url) {
15
+ await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 });
16
+ await page.waitForTimeout(STABLE_WAIT_MS);
17
+ }
18
+
19
+ export async function closeBrowser(browser) {
20
+ await browser.close();
21
+ }
22
+
@@ -0,0 +1,166 @@
1
+ /**
2
+ * WAVE 3: Console Truth Sensor
3
+ * Captures console errors, warnings, page errors, unhandled rejections
4
+ */
5
+
6
+ export class ConsoleSensor {
7
+ constructor(options = {}) {
8
+ this.maxErrorsToKeep = options.maxErrorsToKeep || 10;
9
+ this.windows = new Map(); // windowId -> window state
10
+ this.nextWindowId = 0;
11
+ }
12
+
13
+ /**
14
+ * Start monitoring console and page errors, return window ID.
15
+ */
16
+ startWindow(page) {
17
+ const windowId = this.nextWindowId++;
18
+
19
+ const state = {
20
+ id: windowId,
21
+ startTime: Date.now(),
22
+ consoleErrors: [],
23
+ consoleWarnings: [],
24
+ pageErrors: [],
25
+ unhandledRejections: []
26
+ };
27
+
28
+ // Capture console.error and console.warn
29
+ const onConsoleMessage = (msg) => {
30
+ const type = msg.type();
31
+ const text = msg.text().slice(0, 200); // Limit message length
32
+
33
+ if (type === 'error') {
34
+ state.consoleErrors.push(text);
35
+ if (state.consoleErrors.length > this.maxErrorsToKeep) {
36
+ state.consoleErrors.shift();
37
+ }
38
+ } else if (type === 'warning') {
39
+ state.consoleWarnings.push(text);
40
+ if (state.consoleWarnings.length > this.maxErrorsToKeep) {
41
+ state.consoleWarnings.shift();
42
+ }
43
+ }
44
+ };
45
+
46
+ // Capture uncaught page errors
47
+ const onPageError = (error) => {
48
+ const message = error?.toString().slice(0, 200) || 'Unknown error';
49
+ state.pageErrors.push({
50
+ message: message,
51
+ name: error?.name || 'Error'
52
+ });
53
+
54
+ if (state.pageErrors.length > this.maxErrorsToKeep) {
55
+ state.pageErrors.shift();
56
+ }
57
+ };
58
+
59
+ // Capture unhandled promise rejections
60
+ const onUnhandledRejection = (promise, reason) => {
61
+ const message = (reason?.toString?.() || String(reason)).slice(0, 200);
62
+ state.unhandledRejections.push({
63
+ message: message,
64
+ type: typeof reason
65
+ });
66
+
67
+ if (state.unhandledRejections.length > this.maxErrorsToKeep) {
68
+ state.unhandledRejections.shift();
69
+ }
70
+ };
71
+
72
+ page.on('console', onConsoleMessage);
73
+ page.on('pageerror', onPageError);
74
+
75
+ // Inject listener for unhandledrejection (Playwright doesn't have direct event)
76
+ try {
77
+ page.evaluate(() => {
78
+ if (typeof window !== 'undefined') {
79
+ window.__unhandledRejections = [];
80
+ window.addEventListener('unhandledrejection', (event) => {
81
+ const reason = event.reason || {};
82
+ window.__unhandledRejections.push({
83
+ message: (reason?.toString?.() || String(reason)).slice(0, 200),
84
+ type: typeof reason
85
+ });
86
+ });
87
+ }
88
+ }).catch(() => {
89
+ // Page may not support this
90
+ });
91
+ } catch {
92
+ // Ignore
93
+ }
94
+
95
+ state.cleanup = () => {
96
+ page.removeListener('console', onConsoleMessage);
97
+ page.removeListener('pageerror', onPageError);
98
+ };
99
+
100
+ this.windows.set(windowId, state);
101
+ return windowId;
102
+ }
103
+
104
+ /**
105
+ * Stop monitoring and return a summary for the window.
106
+ */
107
+ async stopWindow(windowId, page) {
108
+ const state = this.windows.get(windowId);
109
+ if (!state) {
110
+ return this.getEmptySummary();
111
+ }
112
+
113
+ state.cleanup();
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
+ const summary = {
132
+ windowId,
133
+ errorCount: state.consoleErrors.length + state.pageErrors.length,
134
+ consoleErrorCount: state.consoleErrors.length,
135
+ pageErrorCount: state.pageErrors.length,
136
+ unhandledRejectionCount: state.unhandledRejections.length,
137
+ lastErrors: [
138
+ ...state.consoleErrors.map((msg) => ({ type: 'console.error', message: msg })),
139
+ ...state.pageErrors.map((err) => ({ type: 'pageerror', message: err.message })),
140
+ ...state.unhandledRejections.map((rej) => ({
141
+ type: 'unhandledrejection',
142
+ message: rej.message
143
+ }))
144
+ ].slice(0, this.maxErrorsToKeep),
145
+ hasErrors:
146
+ state.consoleErrors.length > 0 ||
147
+ state.pageErrors.length > 0 ||
148
+ state.unhandledRejections.length > 0
149
+ };
150
+
151
+ this.windows.delete(windowId);
152
+ return summary;
153
+ }
154
+
155
+ getEmptySummary() {
156
+ return {
157
+ windowId: -1,
158
+ errorCount: 0,
159
+ consoleErrorCount: 0,
160
+ pageErrorCount: 0,
161
+ unhandledRejectionCount: 0,
162
+ lastErrors: [],
163
+ hasErrors: false
164
+ };
165
+ }
166
+ }
@@ -0,0 +1,23 @@
1
+ import { createHash } from 'crypto';
2
+
3
+ const MAX_TEXT_LENGTH = 5000;
4
+
5
+ export async function captureDomSignature(page) {
6
+ try {
7
+ const text = await page.evaluate(() => {
8
+ return document.body?.innerText?.trim() || '';
9
+ });
10
+
11
+ const normalized = text
12
+ .replace(/\s+/g, ' ')
13
+ .trim()
14
+ .substring(0, MAX_TEXT_LENGTH);
15
+
16
+ const hash = createHash('sha256').update(normalized).digest('hex');
17
+
18
+ return hash;
19
+ } catch (error) {
20
+ return null;
21
+ }
22
+ }
23
+
@@ -0,0 +1,38 @@
1
+ export function getBaseOrigin(url) {
2
+ try {
3
+ const urlObj = new URL(url);
4
+ return `${urlObj.protocol}//${urlObj.host}${urlObj.port ? `:${urlObj.port}` : ''}`;
5
+ } catch (error) {
6
+ return null;
7
+ }
8
+ }
9
+
10
+ export function isExternalUrl(url, baseOrigin) {
11
+ if (!baseOrigin) return false;
12
+
13
+ try {
14
+ const urlObj = new URL(url);
15
+ const urlOrigin = urlObj.origin;
16
+ return urlOrigin !== baseOrigin;
17
+ } catch (error) {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export function isExternalHref(href, baseOrigin, currentUrl) {
23
+ if (!href || href.startsWith('#') || href.startsWith('javascript:')) {
24
+ return false;
25
+ }
26
+
27
+ if (href.startsWith('http://') || href.startsWith('https://')) {
28
+ return isExternalUrl(href, baseOrigin);
29
+ }
30
+
31
+ try {
32
+ const resolvedUrl = new URL(href, currentUrl);
33
+ return isExternalUrl(resolvedUrl.href, baseOrigin);
34
+ } catch (error) {
35
+ return false;
36
+ }
37
+ }
38
+
@@ -0,0 +1,5 @@
1
+ export async function captureScreenshot(page, filepath) {
2
+ await page.screenshot({ path: filepath, fullPage: false });
3
+ return filepath;
4
+ }
5
+