@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.
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/bin/verax.js +452 -0
- package/package.json +57 -0
- package/src/verax/detect/comparison.js +69 -0
- package/src/verax/detect/confidence-engine.js +498 -0
- package/src/verax/detect/evidence-validator.js +33 -0
- package/src/verax/detect/expectation-model.js +204 -0
- package/src/verax/detect/findings-writer.js +31 -0
- package/src/verax/detect/index.js +397 -0
- package/src/verax/detect/skip-classifier.js +202 -0
- package/src/verax/flow/flow-engine.js +265 -0
- package/src/verax/flow/flow-spec.js +145 -0
- package/src/verax/flow/redaction.js +74 -0
- package/src/verax/index.js +97 -0
- package/src/verax/learn/action-contract-extractor.js +281 -0
- package/src/verax/learn/ast-contract-extractor.js +255 -0
- package/src/verax/learn/index.js +18 -0
- package/src/verax/learn/manifest-writer.js +97 -0
- package/src/verax/learn/project-detector.js +87 -0
- package/src/verax/learn/react-router-extractor.js +73 -0
- package/src/verax/learn/route-extractor.js +122 -0
- package/src/verax/learn/route-validator.js +215 -0
- package/src/verax/learn/source-instrumenter.js +214 -0
- package/src/verax/learn/static-extractor.js +222 -0
- package/src/verax/learn/truth-assessor.js +96 -0
- package/src/verax/learn/ts-contract-resolver.js +395 -0
- package/src/verax/observe/browser.js +22 -0
- package/src/verax/observe/console-sensor.js +166 -0
- package/src/verax/observe/dom-signature.js +23 -0
- package/src/verax/observe/domain-boundary.js +38 -0
- package/src/verax/observe/evidence-capture.js +5 -0
- package/src/verax/observe/human-driver.js +376 -0
- package/src/verax/observe/index.js +67 -0
- package/src/verax/observe/interaction-discovery.js +269 -0
- package/src/verax/observe/interaction-runner.js +410 -0
- package/src/verax/observe/network-sensor.js +173 -0
- package/src/verax/observe/selector-generator.js +74 -0
- package/src/verax/observe/settle.js +155 -0
- package/src/verax/observe/state-ui-sensor.js +200 -0
- package/src/verax/observe/traces-writer.js +82 -0
- package/src/verax/observe/ui-signal-sensor.js +197 -0
- package/src/verax/resolve-workspace-root.js +173 -0
- package/src/verax/scan-summary-writer.js +41 -0
- package/src/verax/shared/artifact-manager.js +139 -0
- package/src/verax/shared/caching.js +104 -0
- package/src/verax/shared/expectation-proof.js +4 -0
- package/src/verax/shared/redaction.js +227 -0
- package/src/verax/shared/retry-policy.js +89 -0
- 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
|
+
|