@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,173 @@
1
+ /**
2
+ * WAVE 3: Network Truth Sensor
3
+ * Monitors network requests, responses, failures via Playwright page events
4
+ */
5
+
6
+ export class NetworkSensor {
7
+ constructor(options = {}) {
8
+ this.slowThresholdMs = options.slowThresholdMs || 2000;
9
+ this.windows = new Map(); // windowId -> window state
10
+ this.nextWindowId = 0;
11
+ }
12
+
13
+ /**
14
+ * Start monitoring network activity and return a window ID.
15
+ * Call stopWindow(windowId) to get the summary.
16
+ */
17
+ startWindow(page) {
18
+ const windowId = this.nextWindowId++;
19
+
20
+ const state = {
21
+ id: windowId,
22
+ startTime: Date.now(),
23
+ requests: new Map(), // url -> { startTime, endTime, status, failed, duration }
24
+ failedRequests: [],
25
+ failedByStatus: {}, // status code -> count
26
+ unfinishedRequests: new Set(), // urls still pending
27
+ lastErrors: []
28
+ };
29
+
30
+ // Track all requests
31
+ const onRequest = (request) => {
32
+ const url = this.redactUrl(request.url());
33
+ state.unfinishedRequests.add(url);
34
+
35
+ if (!state.requests.has(url)) {
36
+ state.requests.set(url, {
37
+ url: url,
38
+ startTime: Date.now(),
39
+ endTime: null,
40
+ status: null,
41
+ failed: false,
42
+ duration: 0,
43
+ count: 0
44
+ });
45
+ }
46
+
47
+ const reqData = state.requests.get(url);
48
+ reqData.count = (reqData.count || 0) + 1;
49
+ };
50
+
51
+ // Track responses and failures
52
+ const onResponse = (response) => {
53
+ const url = this.redactUrl(response.url());
54
+ const status = response.status();
55
+
56
+ if (state.requests.has(url)) {
57
+ const reqData = state.requests.get(url);
58
+ reqData.endTime = Date.now();
59
+ reqData.status = status;
60
+ reqData.duration = reqData.endTime - reqData.startTime;
61
+
62
+ if (status >= 400) {
63
+ reqData.failed = true;
64
+ state.failedRequests.push({ url, status, duration: reqData.duration });
65
+ state.failedByStatus[status] = (state.failedByStatus[status] || 0) + 1;
66
+ }
67
+ }
68
+
69
+ state.unfinishedRequests.delete(url);
70
+ };
71
+
72
+ const onRequestFailed = (request) => {
73
+ const url = this.redactUrl(request.url());
74
+
75
+ if (state.requests.has(url)) {
76
+ const reqData = state.requests.get(url);
77
+ reqData.endTime = Date.now();
78
+ reqData.duration = reqData.endTime - reqData.startTime;
79
+ reqData.failed = true;
80
+ }
81
+
82
+ state.failedRequests.push({ url, status: 'FAILED', duration: 0 });
83
+ state.unfinishedRequests.delete(url);
84
+ };
85
+
86
+ page.on('request', onRequest);
87
+ page.on('response', onResponse);
88
+ page.on('requestfailed', onRequestFailed);
89
+
90
+ state.cleanup = () => {
91
+ page.removeListener('request', onRequest);
92
+ page.removeListener('response', onResponse);
93
+ page.removeListener('requestfailed', onRequestFailed);
94
+ };
95
+
96
+ this.windows.set(windowId, state);
97
+ return windowId;
98
+ }
99
+
100
+ /**
101
+ * Stop monitoring and return a summary for the window.
102
+ */
103
+ stopWindow(windowId) {
104
+ const state = this.windows.get(windowId);
105
+ if (!state) {
106
+ return this.getEmptySummary();
107
+ }
108
+
109
+ state.cleanup();
110
+
111
+ const endTime = Date.now();
112
+ const duration = endTime - state.startTime;
113
+
114
+ // Find slow requests
115
+ const slowRequests = Array.from(state.requests.values())
116
+ .filter((r) => r.duration > this.slowThresholdMs)
117
+ .sort((a, b) => b.duration - a.duration)
118
+ .slice(0, 5);
119
+
120
+ // Get top failed URLs (limit to 5)
121
+ const topFailedUrls = state.failedRequests
122
+ .slice(0, 5)
123
+ .map((f) => ({ url: f.url, status: f.status, duration: f.duration }));
124
+
125
+ const summary = {
126
+ windowId,
127
+ totalRequests: state.requests.size,
128
+ failedRequests: state.failedRequests.length,
129
+ failedByStatus: state.failedByStatus,
130
+ hasNetworkActivity: state.requests.size > 0,
131
+ slowRequestsCount: slowRequests.length,
132
+ slowRequests: slowRequests.map((r) => ({
133
+ url: r.url,
134
+ duration: r.duration
135
+ })),
136
+ topFailedUrls: topFailedUrls,
137
+ duration: duration,
138
+ unfinishedCount: state.unfinishedRequests.size
139
+ };
140
+
141
+ this.windows.delete(windowId);
142
+ return summary;
143
+ }
144
+
145
+ /**
146
+ * Redact query strings from URLs to reduce noise.
147
+ */
148
+ redactUrl(url) {
149
+ try {
150
+ const parsed = new URL(url);
151
+ // Keep only scheme + host + pathname, drop query
152
+ return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
153
+ } catch {
154
+ // If URL parsing fails, return as-is with first 100 chars
155
+ return url.slice(0, 100);
156
+ }
157
+ }
158
+
159
+ getEmptySummary() {
160
+ return {
161
+ windowId: -1,
162
+ totalRequests: 0,
163
+ failedRequests: 0,
164
+ failedByStatus: {},
165
+ hasNetworkActivity: false,
166
+ slowRequestsCount: 0,
167
+ slowRequests: [],
168
+ topFailedUrls: [],
169
+ duration: 0,
170
+ unfinishedCount: 0
171
+ };
172
+ }
173
+ }
@@ -0,0 +1,74 @@
1
+ export async function generateSelector(locator) {
2
+ try {
3
+ const element = await locator.elementHandle();
4
+ if (!element) return 'unknown';
5
+
6
+ const id = await element.getAttribute('id');
7
+ if (id && id.trim()) {
8
+ return `#${id.trim()}`;
9
+ }
10
+
11
+ const dataTestId = await element.getAttribute('data-testid');
12
+ if (dataTestId && dataTestId.trim()) {
13
+ return `[data-testid="${dataTestId.trim()}"]`;
14
+ }
15
+
16
+ const name = await element.getAttribute('name');
17
+ if (name && name.trim()) {
18
+ const tag = await element.evaluate(el => el.tagName.toLowerCase());
19
+ return `${tag}[name="${name.trim()}"]`;
20
+ }
21
+
22
+ const ariaLabel = await element.getAttribute('aria-label');
23
+ if (ariaLabel && ariaLabel.trim()) {
24
+ const tag = await element.evaluate(el => el.tagName.toLowerCase());
25
+ return `${tag}[aria-label="${ariaLabel.trim()}"]`;
26
+ }
27
+
28
+ const tag = await element.evaluate(el => el.tagName.toLowerCase());
29
+ const parent = await element.evaluateHandle(el => el.parentElement);
30
+
31
+ if (parent && !parent.isDisposed()) {
32
+ const parentTag = await parent.evaluate(el => el.tagName.toLowerCase());
33
+ const parentId = await parent.getAttribute('id');
34
+
35
+ if (parentId && parentId.trim()) {
36
+ const siblings = await element.evaluate((el, t) => {
37
+ const parent = el.parentElement;
38
+ if (!parent) return 0;
39
+ const children = Array.from(parent.children);
40
+ return children.filter(c => c.tagName.toLowerCase() === t).indexOf(el);
41
+ }, tag);
42
+
43
+ if (siblings > 0) {
44
+ return `#${parentId.trim()} > ${tag}:nth-of-type(${siblings + 1})`;
45
+ }
46
+ return `#${parentId.trim()} > ${tag}`;
47
+ }
48
+
49
+ if (parentTag) {
50
+ const siblings = await element.evaluate((el, t) => {
51
+ const parent = el.parentElement;
52
+ if (!parent) return 0;
53
+ const children = Array.from(parent.children);
54
+ return children.filter(c => c.tagName.toLowerCase() === t).indexOf(el);
55
+ }, tag);
56
+
57
+ if (siblings > 0) {
58
+ return `${parentTag} > ${tag}:nth-of-type(${siblings + 1})`;
59
+ }
60
+ }
61
+ }
62
+
63
+ const classes = await element.getAttribute('class');
64
+ if (classes && classes.trim()) {
65
+ const classList = classes.trim().split(/\s+/).filter(c => c).slice(0, 3).join('.');
66
+ return `${tag}.${classList}`;
67
+ }
68
+
69
+ return tag;
70
+ } catch (error) {
71
+ return 'unknown';
72
+ }
73
+ }
74
+
@@ -0,0 +1,155 @@
1
+ /**
2
+ * WAVE 2: Deterministic DOM settle logic
3
+ * Waits for page to stabilize after navigation or interaction
4
+ */
5
+
6
+ /**
7
+ * Wait for page to settle after navigation or interaction.
8
+ * Combines multiple signals: load event, network idle, DOM mutation stabilization.
9
+ *
10
+ * @param {Page} page - Playwright page object
11
+ * @param {Object} options
12
+ * @param {number} options.timeoutMs - Overall timeout (default 30000)
13
+ * @param {number} options.idleMs - Network idle threshold (default 1500)
14
+ * @param {number} options.domStableMs - DOM stability window (default 2000)
15
+ * @returns {Promise<void>}
16
+ */
17
+ export async function waitForSettle(page, options = {}) {
18
+ const {
19
+ timeoutMs = 30000,
20
+ idleMs = 1500,
21
+ domStableMs = 2000
22
+ } = options;
23
+
24
+ const startTime = Date.now();
25
+
26
+ try {
27
+ // Signal 1: Wait for load event (DOMContentLoaded or load)
28
+ await Promise.race([
29
+ page.waitForLoadState('networkidle', { timeout: timeoutMs }).catch(() => {}),
30
+ page.waitForLoadState('domcontentloaded', { timeout: timeoutMs }).catch(() => {})
31
+ ]).catch(() => {});
32
+
33
+ // Signal 2: Network idle detection using Playwright Request/Response events
34
+ await waitForNetworkIdle(page, idleMs, timeoutMs - (Date.now() - startTime));
35
+
36
+ // Signal 3: DOM mutation stabilization
37
+ await waitForDomStability(page, domStableMs, timeoutMs - (Date.now() - startTime));
38
+ } catch (err) {
39
+ // Timeout is acceptable - page may have settled despite timeout
40
+ if (!err.message?.includes('Timeout')) {
41
+ throw err;
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Wait for network to become idle (no inflight requests for idleMs).
48
+ * Uses Playwright's Request/Response event listening.
49
+ */
50
+ async function waitForNetworkIdle(page, idleMs, timeoutMs) {
51
+ if (timeoutMs <= 0) return;
52
+
53
+ return new Promise((resolve) => {
54
+ let lastNetworkActivityTime = Date.now();
55
+ let hasFinished = false;
56
+
57
+ const onRequest = () => {
58
+ lastNetworkActivityTime = Date.now();
59
+ };
60
+
61
+ const onResponse = () => {
62
+ lastNetworkActivityTime = Date.now();
63
+ };
64
+
65
+ const checkIdle = () => {
66
+ if (Date.now() - lastNetworkActivityTime >= idleMs) {
67
+ cleanup();
68
+ resolve();
69
+ } else {
70
+ idleCheckTimer = setTimeout(checkIdle, Math.min(100, idleMs / 2));
71
+ }
72
+ };
73
+
74
+ const cleanup = () => {
75
+ if (hasFinished) return;
76
+ hasFinished = true;
77
+ page.removeListener('request', onRequest);
78
+ page.removeListener('response', onResponse);
79
+ if (idleCheckTimer) clearTimeout(idleCheckTimer);
80
+ if (timeoutTimer) clearTimeout(timeoutTimer);
81
+ };
82
+
83
+ let idleCheckTimer;
84
+ const timeoutTimer = setTimeout(() => {
85
+ cleanup();
86
+ resolve(); // Timeout resolved, not rejected
87
+ }, timeoutMs);
88
+
89
+ page.on('request', onRequest);
90
+ page.on('response', onResponse);
91
+
92
+ // Start checking after a brief initial wait
93
+ idleCheckTimer = setTimeout(checkIdle, 100);
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Wait for DOM mutations to stabilize (no mutations for domStableMs).
99
+ * Uses MutationObserver to track DOM changes.
100
+ */
101
+ async function waitForDomStability(page, domStableMs, timeoutMs) {
102
+ if (timeoutMs <= 0) return;
103
+
104
+ await page.evaluate(
105
+ async (domStableMs, timeoutMs) => {
106
+ return new Promise((resolve) => {
107
+ let lastMutationTime = Date.now();
108
+ let hasFinished = false;
109
+
110
+ const observer = new MutationObserver(() => {
111
+ lastMutationTime = Date.now();
112
+ });
113
+
114
+ observer.observe(document.documentElement, {
115
+ childList: true,
116
+ subtree: true,
117
+ attributes: true,
118
+ attributeFilter: ['class', 'style', 'data-', 'disabled', 'hidden'],
119
+ characterData: false, // Ignore text changes to reduce noise
120
+ characterDataOldValue: false
121
+ });
122
+
123
+ const checkStability = () => {
124
+ if (Date.now() - lastMutationTime >= domStableMs) {
125
+ cleanup();
126
+ resolve();
127
+ } else {
128
+ stabilityCheckTimer = setTimeout(checkStability, Math.min(100, domStableMs / 2));
129
+ }
130
+ };
131
+
132
+ const cleanup = () => {
133
+ if (hasFinished) return;
134
+ hasFinished = true;
135
+ observer.disconnect();
136
+ if (stabilityCheckTimer) clearTimeout(stabilityCheckTimer);
137
+ if (timeoutTimer) clearTimeout(timeoutTimer);
138
+ };
139
+
140
+ let stabilityCheckTimer;
141
+ const timeoutTimer = setTimeout(() => {
142
+ cleanup();
143
+ resolve(); // Timeout resolved
144
+ }, timeoutMs);
145
+
146
+ // Start checking after a brief initial wait
147
+ stabilityCheckTimer = setTimeout(checkStability, 100);
148
+ });
149
+ },
150
+ domStableMs,
151
+ timeoutMs
152
+ ).catch(() => {
153
+ // Page may have navigated, ignore
154
+ });
155
+ }
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Wave 8 — State UI Sensor
3
+ *
4
+ * Detects meaningful UI state changes related to state mutations.
5
+ * Conservative, accessibility-first signals:
6
+ * - Dialog/modal visibility (role=dialog, aria-modal)
7
+ * - Expansion state (aria-expanded)
8
+ * - Tab selection (role=tab/tabpanel, aria-selected)
9
+ * - Checkbox/toggle state (aria-checked)
10
+ * - Alert/status content changes (role=alert/status)
11
+ * - DOM mutation count (meaningful node additions, not just style/class changes)
12
+ *
13
+ * Zero heuristics. Reports exactly what changed.
14
+ */
15
+
16
+ export class StateUISensor {
17
+ /**
18
+ * Take a snapshot of UI signals related to state mutations.
19
+ * @param {Object} page - Playwright page object
20
+ * @param {string|Object} contextSelector - Optional: CSS selector or element handle to focus on
21
+ * @returns {Promise<Object>} - { signals: {...}, rawSnapshot: {...} }
22
+ */
23
+ async snapshot(page, contextSelector = null) {
24
+ try {
25
+ const snapshot = await page.evaluate(({ selector }) => {
26
+ const signals = {};
27
+ const rawSnapshot = {};
28
+
29
+ // 1. Dialog/Modal signals
30
+ signals.dialogs = [];
31
+ const dialogs = document.querySelectorAll('[role="dialog"]');
32
+ for (const dialog of dialogs) {
33
+ const isVisible = dialog.offsetParent !== null || dialog.hasAttribute('open');
34
+ const ariaModal = dialog.getAttribute('aria-modal');
35
+ signals.dialogs.push({
36
+ visible: isVisible,
37
+ ariaModal: ariaModal === 'true',
38
+ hasOpen: dialog.hasAttribute('open')
39
+ });
40
+ if (isVisible || ariaModal === 'true') {
41
+ signals.hasDialog = true;
42
+ }
43
+ }
44
+ rawSnapshot.dialogCount = dialogs.length;
45
+
46
+ // 2. Expansion state (aria-expanded)
47
+ signals.expandedElements = [];
48
+ const expandables = document.querySelectorAll('[aria-expanded]');
49
+ for (const el of expandables) {
50
+ const expanded = el.getAttribute('aria-expanded') === 'true';
51
+ signals.expandedElements.push({
52
+ id: el.id || el.className,
53
+ expanded
54
+ });
55
+ }
56
+ rawSnapshot.expandableCount = expandables.length;
57
+
58
+ // 3. Tab selection (role=tab with aria-selected)
59
+ signals.selectedTabs = [];
60
+ const tabs = document.querySelectorAll('[role="tab"]');
61
+ for (const tab of tabs) {
62
+ const selected = tab.getAttribute('aria-selected') === 'true';
63
+ signals.selectedTabs.push({
64
+ id: tab.id || tab.textContent?.substring(0, 20),
65
+ selected
66
+ });
67
+ }
68
+ rawSnapshot.tabCount = tabs.length;
69
+
70
+ // 4. Checkbox/toggle state (aria-checked)
71
+ signals.checkedElements = [];
72
+ const checkables = document.querySelectorAll('[aria-checked]');
73
+ for (const el of checkables) {
74
+ const checked = el.getAttribute('aria-checked') === 'true';
75
+ signals.checkedElements.push({
76
+ id: el.id || el.className,
77
+ checked
78
+ });
79
+ }
80
+ rawSnapshot.checkableCount = checkables.length;
81
+
82
+ // 5. Alert/Status changes (role=alert, role=status)
83
+ signals.alerts = [];
84
+ const alerts = document.querySelectorAll('[role="alert"], [role="status"]');
85
+ for (const alert of alerts) {
86
+ const text = alert.textContent?.trim() || '';
87
+ signals.alerts.push({
88
+ role: alert.getAttribute('role'),
89
+ text: text.substring(0, 100), // First 100 chars
90
+ visible: alert.offsetParent !== null
91
+ });
92
+ }
93
+ rawSnapshot.alertCount = alerts.length;
94
+
95
+ // 6. DOM mutation count (meaningful changes)
96
+ // Count visible nodes and text nodes (excluding style/comment nodes)
97
+ const countMeaningfulNodes = () => {
98
+ let count = 0;
99
+ for (const node of document.querySelectorAll('*')) {
100
+ if (node.offsetParent !== null) { // Visible
101
+ count++;
102
+ }
103
+ }
104
+ return count;
105
+ };
106
+ rawSnapshot.meaningfulNodeCount = countMeaningfulNodes();
107
+
108
+ return { signals, rawSnapshot };
109
+ }, { selector: contextSelector });
110
+
111
+ return snapshot;
112
+ } catch (e) {
113
+ // If page evaluation fails, return minimal snapshot
114
+ return {
115
+ signals: {
116
+ dialogs: [],
117
+ expandedElements: [],
118
+ selectedTabs: [],
119
+ checkedElements: [],
120
+ alerts: []
121
+ },
122
+ rawSnapshot: {
123
+ error: e.message
124
+ }
125
+ };
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Compare two snapshots and report changes.
131
+ * @param {Object} before - Snapshot from before state mutation
132
+ * @param {Object} after - Snapshot from after state mutation
133
+ * @returns {Object} - { changed: boolean, reasons: string[] }
134
+ */
135
+ diff(before, after) {
136
+ const reasons = [];
137
+
138
+ if (!before || !after) {
139
+ return { changed: false, reasons: ['No snapshot data'] };
140
+ }
141
+
142
+ const beforeSignals = before.signals || {};
143
+ const afterSignals = after.signals || {};
144
+
145
+ // Check dialog visibility change
146
+ const beforeHasDialog = beforeSignals.hasDialog === true;
147
+ const afterHasDialog = afterSignals.hasDialog === true;
148
+ if (beforeHasDialog !== afterHasDialog) {
149
+ reasons.push(afterHasDialog ? 'Dialog opened' : 'Dialog closed');
150
+ }
151
+
152
+ // Check expansion changes
153
+ const beforeExpanded = this._extractState(beforeSignals.expandedElements || [], 'expanded');
154
+ const afterExpanded = this._extractState(afterSignals.expandedElements || [], 'expanded');
155
+ if (beforeExpanded !== afterExpanded) {
156
+ reasons.push(`Expansion state changed: ${beforeExpanded} → ${afterExpanded}`);
157
+ }
158
+
159
+ // Check tab selection changes
160
+ const beforeSelected = this._extractState(beforeSignals.selectedTabs || [], 'selected');
161
+ const afterSelected = this._extractState(afterSignals.selectedTabs || [], 'selected');
162
+ if (beforeSelected !== afterSelected) {
163
+ reasons.push(`Tab selection changed: ${beforeSelected} → ${afterSelected}`);
164
+ }
165
+
166
+ // Check checkbox state changes
167
+ const beforeChecked = this._extractState(beforeSignals.checkedElements || [], 'checked');
168
+ const afterChecked = this._extractState(afterSignals.checkedElements || [], 'checked');
169
+ if (beforeChecked !== afterChecked) {
170
+ reasons.push(`Checked state changed: ${beforeChecked} → ${afterChecked}`);
171
+ }
172
+
173
+ // Check alert changes
174
+ const beforeAlerts = (beforeSignals.alerts || []).map(a => a.text).join('|');
175
+ const afterAlerts = (afterSignals.alerts || []).map(a => a.text).join('|');
176
+ if (beforeAlerts !== afterAlerts) {
177
+ reasons.push('Alert content changed');
178
+ }
179
+
180
+ // Check DOM mutation (meaningful node count)
181
+ const beforeNodeCount = before.rawSnapshot?.meaningfulNodeCount || 0;
182
+ const afterNodeCount = after.rawSnapshot?.meaningfulNodeCount || 0;
183
+ const nodeDelta = afterNodeCount - beforeNodeCount;
184
+ if (nodeDelta > 2 || nodeDelta < -2) { // Allow small variance
185
+ reasons.push(`DOM mutation: ${beforeNodeCount} → ${afterNodeCount} visible nodes`);
186
+ }
187
+
188
+ const changed = reasons.length > 0;
189
+ return { changed, reasons };
190
+ }
191
+
192
+ /**
193
+ * Extract state summary from array of elements.
194
+ * @private
195
+ */
196
+ _extractState(elements, key) {
197
+ if (!elements || elements.length === 0) return '(none)';
198
+ return elements.map(e => e[key] ? 'T' : 'F').join('');
199
+ }
200
+ }
@@ -0,0 +1,82 @@
1
+ import { resolve } from 'path';
2
+ import { writeFileSync, mkdirSync } from 'fs';
3
+ import { appendTrace } from '../shared/artifact-manager.js';
4
+
5
+ export function writeTraces(projectDir, url, traces, coverage = null, warnings = [], artifactPaths = null) {
6
+ const observation = {
7
+ version: 1,
8
+ observedAt: new Date().toISOString(),
9
+ url: url,
10
+ traces: traces
11
+ };
12
+
13
+ if (coverage) {
14
+ observation.coverage = coverage;
15
+ }
16
+ if (warnings && warnings.length > 0) {
17
+ observation.warnings = warnings;
18
+ }
19
+
20
+ let tracesPath;
21
+ if (artifactPaths) {
22
+ // Use new artifact structure
23
+ // Write JSONL format (one trace per line) for artifact structure
24
+ traces.forEach(trace => appendTrace(artifactPaths, trace));
25
+
26
+ // Also write full observation JSON for detect() compatibility
27
+ const jsonPath = resolve(artifactPaths.evidence, 'observation-traces.json');
28
+ writeFileSync(jsonPath, JSON.stringify(observation, null, 2) + '\n');
29
+ tracesPath = jsonPath;
30
+ } else {
31
+ // Legacy structure
32
+ const observeDir = resolve(projectDir, '.veraxverax', 'observe');
33
+ mkdirSync(observeDir, { recursive: true });
34
+ tracesPath = resolve(observeDir, 'observation-traces.json');
35
+ writeFileSync(tracesPath, JSON.stringify(observation, null, 2) + '\n');
36
+ }
37
+
38
+ let externalNavigationBlockedCount = 0;
39
+ let timeoutsCount = 0;
40
+ let settleChangedCount = 0;
41
+
42
+ for (const trace of traces) {
43
+ if (trace.policy) {
44
+ if (trace.policy.externalNavigationBlocked) {
45
+ externalNavigationBlockedCount++;
46
+ }
47
+ if (trace.policy.timeout) {
48
+ timeoutsCount++;
49
+ }
50
+ }
51
+
52
+ if (trace.dom && trace.dom.settle && trace.dom.settle.domChangedDuringSettle) {
53
+ settleChangedCount++;
54
+ }
55
+ }
56
+
57
+ const observeTruth = {
58
+ interactionsObserved: traces.length,
59
+ externalNavigationBlockedCount: externalNavigationBlockedCount,
60
+ timeoutsCount: timeoutsCount,
61
+ settleChangedCount: settleChangedCount
62
+ };
63
+
64
+ if (coverage) {
65
+ observeTruth.coverage = coverage;
66
+ if (coverage.capped) {
67
+ if (!warnings || warnings.length === 0) {
68
+ warnings = [{ code: 'INTERACTIONS_CAPPED', message: 'Interaction discovery reached the cap (30). Scan coverage is incomplete.' }];
69
+ }
70
+ }
71
+ }
72
+ if (warnings && warnings.length > 0) {
73
+ observeTruth.warnings = warnings;
74
+ }
75
+
76
+ return {
77
+ ...observation,
78
+ tracesPath: tracesPath,
79
+ observeTruth: observeTruth
80
+ };
81
+ }
82
+