@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.
- package/README.md +123 -88
- package/bin/verax.js +11 -452
- package/package.json +24 -36
- package/src/cli/commands/default.js +681 -0
- package/src/cli/commands/doctor.js +197 -0
- package/src/cli/commands/inspect.js +109 -0
- package/src/cli/commands/run.js +586 -0
- package/src/cli/entry.js +196 -0
- package/src/cli/util/atomic-write.js +37 -0
- package/src/cli/util/detection-engine.js +297 -0
- package/src/cli/util/env-url.js +33 -0
- package/src/cli/util/errors.js +44 -0
- package/src/cli/util/events.js +110 -0
- package/src/cli/util/expectation-extractor.js +388 -0
- package/src/cli/util/findings-writer.js +32 -0
- package/src/cli/util/idgen.js +87 -0
- package/src/cli/util/learn-writer.js +39 -0
- package/src/cli/util/observation-engine.js +412 -0
- package/src/cli/util/observe-writer.js +25 -0
- package/src/cli/util/paths.js +30 -0
- package/src/cli/util/project-discovery.js +297 -0
- package/src/cli/util/project-writer.js +26 -0
- package/src/cli/util/redact.js +128 -0
- package/src/cli/util/run-id.js +30 -0
- package/src/cli/util/runtime-budget.js +147 -0
- package/src/cli/util/summary-writer.js +43 -0
- package/src/types/global.d.ts +28 -0
- package/src/types/ts-ast.d.ts +24 -0
- package/src/verax/cli/ci-summary.js +35 -0
- package/src/verax/cli/context-explanation.js +89 -0
- package/src/verax/cli/doctor.js +277 -0
- package/src/verax/cli/error-normalizer.js +154 -0
- package/src/verax/cli/explain-output.js +105 -0
- package/src/verax/cli/finding-explainer.js +130 -0
- package/src/verax/cli/init.js +237 -0
- package/src/verax/cli/run-overview.js +163 -0
- package/src/verax/cli/url-safety.js +111 -0
- package/src/verax/cli/wizard.js +109 -0
- package/src/verax/cli/zero-findings-explainer.js +57 -0
- package/src/verax/cli/zero-interaction-explainer.js +127 -0
- package/src/verax/core/action-classifier.js +86 -0
- package/src/verax/core/budget-engine.js +218 -0
- package/src/verax/core/canonical-outcomes.js +157 -0
- package/src/verax/core/decision-snapshot.js +335 -0
- package/src/verax/core/determinism-model.js +432 -0
- package/src/verax/core/incremental-store.js +245 -0
- package/src/verax/core/invariants.js +356 -0
- package/src/verax/core/promise-model.js +230 -0
- package/src/verax/core/replay-validator.js +350 -0
- package/src/verax/core/replay.js +222 -0
- package/src/verax/core/run-id.js +175 -0
- package/src/verax/core/run-manifest.js +99 -0
- package/src/verax/core/silence-impact.js +369 -0
- package/src/verax/core/silence-model.js +523 -0
- package/src/verax/detect/comparison.js +7 -34
- package/src/verax/detect/confidence-engine.js +764 -329
- package/src/verax/detect/detection-engine.js +293 -0
- package/src/verax/detect/evidence-index.js +127 -0
- package/src/verax/detect/expectation-model.js +241 -168
- package/src/verax/detect/explanation-helpers.js +187 -0
- package/src/verax/detect/finding-detector.js +450 -0
- package/src/verax/detect/findings-writer.js +41 -12
- package/src/verax/detect/flow-detector.js +366 -0
- package/src/verax/detect/index.js +200 -288
- package/src/verax/detect/interactive-findings.js +612 -0
- package/src/verax/detect/signal-mapper.js +308 -0
- package/src/verax/detect/skip-classifier.js +4 -4
- package/src/verax/detect/verdict-engine.js +561 -0
- package/src/verax/evidence-index-writer.js +61 -0
- package/src/verax/flow/flow-engine.js +3 -2
- package/src/verax/flow/flow-spec.js +1 -2
- package/src/verax/index.js +103 -15
- package/src/verax/intel/effect-detector.js +368 -0
- package/src/verax/intel/handler-mapper.js +249 -0
- package/src/verax/intel/index.js +281 -0
- package/src/verax/intel/route-extractor.js +280 -0
- package/src/verax/intel/ts-program.js +256 -0
- package/src/verax/intel/vue-navigation-extractor.js +642 -0
- package/src/verax/intel/vue-router-extractor.js +325 -0
- package/src/verax/learn/action-contract-extractor.js +338 -104
- package/src/verax/learn/ast-contract-extractor.js +148 -6
- package/src/verax/learn/flow-extractor.js +172 -0
- package/src/verax/learn/index.js +36 -2
- package/src/verax/learn/manifest-writer.js +122 -58
- package/src/verax/learn/project-detector.js +40 -0
- package/src/verax/learn/route-extractor.js +28 -97
- package/src/verax/learn/route-validator.js +8 -7
- package/src/verax/learn/state-extractor.js +212 -0
- package/src/verax/learn/static-extractor-navigation.js +114 -0
- package/src/verax/learn/static-extractor-validation.js +88 -0
- package/src/verax/learn/static-extractor.js +119 -10
- package/src/verax/learn/truth-assessor.js +24 -21
- package/src/verax/learn/ts-contract-resolver.js +14 -12
- package/src/verax/observe/aria-sensor.js +211 -0
- package/src/verax/observe/browser.js +30 -6
- package/src/verax/observe/console-sensor.js +2 -18
- package/src/verax/observe/domain-boundary.js +10 -1
- package/src/verax/observe/expectation-executor.js +513 -0
- package/src/verax/observe/flow-matcher.js +143 -0
- package/src/verax/observe/focus-sensor.js +196 -0
- package/src/verax/observe/human-driver.js +660 -273
- package/src/verax/observe/index.js +910 -26
- package/src/verax/observe/interaction-discovery.js +378 -15
- package/src/verax/observe/interaction-runner.js +562 -197
- package/src/verax/observe/loading-sensor.js +145 -0
- package/src/verax/observe/navigation-sensor.js +255 -0
- package/src/verax/observe/network-sensor.js +55 -7
- package/src/verax/observe/observed-expectation-deriver.js +186 -0
- package/src/verax/observe/observed-expectation.js +305 -0
- package/src/verax/observe/page-frontier.js +234 -0
- package/src/verax/observe/settle.js +38 -17
- package/src/verax/observe/state-sensor.js +393 -0
- package/src/verax/observe/state-ui-sensor.js +7 -1
- package/src/verax/observe/timing-sensor.js +228 -0
- package/src/verax/observe/traces-writer.js +73 -21
- package/src/verax/observe/ui-signal-sensor.js +143 -17
- package/src/verax/scan-summary-writer.js +80 -15
- package/src/verax/shared/artifact-manager.js +111 -9
- package/src/verax/shared/budget-profiles.js +136 -0
- package/src/verax/shared/caching.js +1 -1
- package/src/verax/shared/ci-detection.js +39 -0
- package/src/verax/shared/config-loader.js +169 -0
- package/src/verax/shared/dynamic-route-utils.js +224 -0
- package/src/verax/shared/expectation-coverage.js +44 -0
- package/src/verax/shared/expectation-prover.js +81 -0
- package/src/verax/shared/expectation-tracker.js +201 -0
- package/src/verax/shared/expectations-writer.js +60 -0
- package/src/verax/shared/first-run.js +44 -0
- package/src/verax/shared/progress-reporter.js +171 -0
- package/src/verax/shared/retry-policy.js +9 -1
- package/src/verax/shared/root-artifacts.js +49 -0
- package/src/verax/shared/scan-budget.js +86 -0
- package/src/verax/shared/url-normalizer.js +162 -0
- package/src/verax/shared/zip-artifacts.js +66 -0
- package/src/verax/validate/context-validator.js +244 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* State Sensor v1
|
|
3
|
+
* Safe, opt-in state change detection for Redux and Zustand.
|
|
4
|
+
*
|
|
5
|
+
* SAFETY:
|
|
6
|
+
* - Keys only, no values
|
|
7
|
+
* - Opt-in via store detection
|
|
8
|
+
* - Cleanup after interaction
|
|
9
|
+
* - Non-destructive
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MAX_DIFF_KEYS = 10;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Computes a shallow diff between two state objects.
|
|
16
|
+
* Returns array of changed keys (no values for privacy).
|
|
17
|
+
*/
|
|
18
|
+
function computeStateDiff(before, after) {
|
|
19
|
+
const changed = [];
|
|
20
|
+
const allKeys = new Set([
|
|
21
|
+
...Object.keys(before || {}),
|
|
22
|
+
...Object.keys(after || {})
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
for (const key of allKeys) {
|
|
26
|
+
if (before[key] !== after[key]) {
|
|
27
|
+
changed.push(key);
|
|
28
|
+
if (changed.length >= MAX_DIFF_KEYS) break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return changed;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Redux store sensor.
|
|
37
|
+
* Subscribes to store changes and captures state snapshots.
|
|
38
|
+
*/
|
|
39
|
+
class ReduxSensor {
|
|
40
|
+
constructor() {
|
|
41
|
+
this.store = null;
|
|
42
|
+
this.unsubscribe = null;
|
|
43
|
+
this.beforeState = null;
|
|
44
|
+
this.afterState = null;
|
|
45
|
+
this.active = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Attempts to detect and hook into Redux store.
|
|
50
|
+
* Returns true if store found and hooked.
|
|
51
|
+
*/
|
|
52
|
+
async detect(page) {
|
|
53
|
+
try {
|
|
54
|
+
// First, wait for the store to be initialized
|
|
55
|
+
await page.evaluate(() => {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
if (window.__REDUX_STORE__) {
|
|
58
|
+
resolve();
|
|
59
|
+
} else {
|
|
60
|
+
// Wait up to 5 seconds for store initialization
|
|
61
|
+
const timeout = setTimeout(() => {
|
|
62
|
+
clearInterval(check);
|
|
63
|
+
resolve();
|
|
64
|
+
}, 5000);
|
|
65
|
+
const check = setInterval(() => {
|
|
66
|
+
if (window.__REDUX_STORE__) {
|
|
67
|
+
clearInterval(check);
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
resolve();
|
|
70
|
+
}
|
|
71
|
+
}, 100);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const hasRedux = await page.evaluate(() => {
|
|
77
|
+
// Try to find Redux store via common patterns
|
|
78
|
+
if (window.__REDUX_STORE__) return true;
|
|
79
|
+
if (window.store && typeof window.store.getState === 'function') return true;
|
|
80
|
+
|
|
81
|
+
// Check React context provider (common pattern)
|
|
82
|
+
const reduxProvider = document.querySelector('[data-redux-provider]');
|
|
83
|
+
if (reduxProvider) return true;
|
|
84
|
+
|
|
85
|
+
// Check for Redux DevTools extension
|
|
86
|
+
if (window.__REDUX_DEVTOOLS_EXTENSION__) return true;
|
|
87
|
+
|
|
88
|
+
// Try to find store in React component tree (best-effort)
|
|
89
|
+
// This is a heuristic but safe - we only read, never modify
|
|
90
|
+
try {
|
|
91
|
+
const reactRoot = document.querySelector('#root, [data-reactroot], [id^="root"]');
|
|
92
|
+
if (reactRoot && window.__REACT_DEVTOOLS_GLOBAL_HOOK__) {
|
|
93
|
+
return true; // Likely Redux if React DevTools present
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
// Ignore
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return false;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
if (!hasRedux) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Install sensor in page context
|
|
107
|
+
await page.evaluate(() => {
|
|
108
|
+
if (window.__VERAX_STATE_SENSOR__) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
window.__VERAX_STATE_SENSOR__ = {
|
|
113
|
+
type: 'redux',
|
|
114
|
+
snapshots: [],
|
|
115
|
+
store: null,
|
|
116
|
+
unsubscribe: null,
|
|
117
|
+
captureSnapshot() {
|
|
118
|
+
let state = null;
|
|
119
|
+
if (window.__REDUX_STORE__) {
|
|
120
|
+
state = window.__REDUX_STORE__.getState();
|
|
121
|
+
this.store = window.__REDUX_STORE__;
|
|
122
|
+
} else if (window.store && typeof window.store.getState === 'function') {
|
|
123
|
+
state = window.store.getState();
|
|
124
|
+
this.store = window.store;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (state && typeof state === 'object') {
|
|
128
|
+
// Shallow copy of top-level keys only (privacy: no values)
|
|
129
|
+
const snapshot = {};
|
|
130
|
+
for (const key in state) {
|
|
131
|
+
if (Object.prototype.hasOwnProperty.call(state, key)) {
|
|
132
|
+
snapshot[key] = '[REDACTED]'; // Never store values, only keys
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
this.snapshots.push({ timestamp: Date.now(), state: snapshot });
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
getSnapshots() {
|
|
139
|
+
return this.snapshots;
|
|
140
|
+
},
|
|
141
|
+
reset() {
|
|
142
|
+
this.snapshots = [];
|
|
143
|
+
if (this.unsubscribe) {
|
|
144
|
+
this.unsubscribe();
|
|
145
|
+
this.unsubscribe = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
this.active = true;
|
|
152
|
+
return true;
|
|
153
|
+
} catch (error) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async captureBefore(page) {
|
|
159
|
+
if (!this.active) return;
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
await page.evaluate(() => {
|
|
163
|
+
window.__VERAX_STATE_SENSOR__?.captureSnapshot();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
this.beforeState = await page.evaluate(() => {
|
|
167
|
+
const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
|
|
168
|
+
return snapshots[snapshots.length - 1]?.state || null;
|
|
169
|
+
});
|
|
170
|
+
} catch (error) {
|
|
171
|
+
this.beforeState = null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async captureAfter(page) {
|
|
176
|
+
if (!this.active) return;
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
await page.evaluate(() => {
|
|
180
|
+
window.__VERAX_STATE_SENSOR__?.captureSnapshot();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
this.afterState = await page.evaluate(() => {
|
|
184
|
+
const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
|
|
185
|
+
return snapshots[snapshots.length - 1]?.state || null;
|
|
186
|
+
});
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.afterState = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getDiff() {
|
|
193
|
+
if (!this.beforeState || !this.afterState) {
|
|
194
|
+
return { changed: [], available: false };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const changed = computeStateDiff(this.beforeState, this.afterState);
|
|
198
|
+
return { changed, available: true };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
cleanup() {
|
|
202
|
+
this.beforeState = null;
|
|
203
|
+
this.afterState = null;
|
|
204
|
+
this.active = false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Zustand store sensor.
|
|
210
|
+
* Wraps set() calls to capture state changes.
|
|
211
|
+
*/
|
|
212
|
+
class ZustandSensor {
|
|
213
|
+
constructor() {
|
|
214
|
+
this.beforeState = null;
|
|
215
|
+
this.afterState = null;
|
|
216
|
+
this.active = false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async detect(page) {
|
|
220
|
+
try {
|
|
221
|
+
const hasZustand = await page.evaluate(() => {
|
|
222
|
+
// Check for Zustand store markers
|
|
223
|
+
if (window.__ZUSTAND_STORE__) return true;
|
|
224
|
+
|
|
225
|
+
// Look for common Zustand patterns in window object
|
|
226
|
+
for (const key in window) {
|
|
227
|
+
if (key.startsWith('use') && typeof window[key] === 'function') {
|
|
228
|
+
// Dynamic property access on window for Zustand store detection (runtime property)
|
|
229
|
+
const store = /** @type {any} */ (window[key]);
|
|
230
|
+
if (store && typeof store === 'object' && 'getState' in store && typeof store.getState === 'function') {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return false;
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!hasZustand) return false;
|
|
240
|
+
|
|
241
|
+
// Install sensor
|
|
242
|
+
await page.evaluate(() => {
|
|
243
|
+
if (window.__VERAX_STATE_SENSOR__) return;
|
|
244
|
+
|
|
245
|
+
window.__VERAX_STATE_SENSOR__ = {
|
|
246
|
+
type: 'zustand',
|
|
247
|
+
snapshots: [],
|
|
248
|
+
captureSnapshot() {
|
|
249
|
+
// Try to find and capture Zustand store state
|
|
250
|
+
if (window.__ZUSTAND_STORE__) {
|
|
251
|
+
const state = window.__ZUSTAND_STORE__.getState();
|
|
252
|
+
if (state && typeof state === 'object') {
|
|
253
|
+
const snapshot = {};
|
|
254
|
+
for (const key in state) {
|
|
255
|
+
if (typeof state[key] !== 'function') {
|
|
256
|
+
snapshot[key] = '[REDACTED]'; // Never store values, only keys
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
this.snapshots.push({ timestamp: Date.now(), state: snapshot });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
},
|
|
263
|
+
getSnapshots() {
|
|
264
|
+
return this.snapshots;
|
|
265
|
+
},
|
|
266
|
+
reset() {
|
|
267
|
+
this.snapshots = [];
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
this.active = true;
|
|
273
|
+
return true;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async captureBefore(page) {
|
|
280
|
+
if (!this.active) return;
|
|
281
|
+
|
|
282
|
+
try {
|
|
283
|
+
await page.evaluate(() => {
|
|
284
|
+
window.__VERAX_STATE_SENSOR__?.captureSnapshot();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
this.beforeState = await page.evaluate(() => {
|
|
288
|
+
const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
|
|
289
|
+
return snapshots[snapshots.length - 1]?.state || null;
|
|
290
|
+
});
|
|
291
|
+
} catch (error) {
|
|
292
|
+
this.beforeState = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async captureAfter(page) {
|
|
297
|
+
if (!this.active) return;
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
await page.evaluate(() => {
|
|
301
|
+
window.__VERAX_STATE_SENSOR__?.captureSnapshot();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
this.afterState = await page.evaluate(() => {
|
|
305
|
+
const snapshots = window.__VERAX_STATE_SENSOR__?.getSnapshots() || [];
|
|
306
|
+
return snapshots[snapshots.length - 1]?.state || null;
|
|
307
|
+
});
|
|
308
|
+
} catch (error) {
|
|
309
|
+
this.afterState = null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
getDiff() {
|
|
314
|
+
if (!this.beforeState || !this.afterState) {
|
|
315
|
+
return { changed: [], available: false };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const changed = computeStateDiff(this.beforeState, this.afterState);
|
|
319
|
+
return { changed, available: true };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
cleanup() {
|
|
323
|
+
this.beforeState = null;
|
|
324
|
+
this.afterState = null;
|
|
325
|
+
this.active = false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* State Sensor orchestrator.
|
|
331
|
+
* Detects store type and delegates to appropriate sensor.
|
|
332
|
+
*/
|
|
333
|
+
export class StateSensor {
|
|
334
|
+
constructor() {
|
|
335
|
+
this.reduxSensor = new ReduxSensor();
|
|
336
|
+
this.zustandSensor = new ZustandSensor();
|
|
337
|
+
this.activeType = null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Detects state stores and activates appropriate sensor.
|
|
342
|
+
* Returns { detected: bool, type: 'redux' | 'zustand' | null }
|
|
343
|
+
*/
|
|
344
|
+
async detect(page) {
|
|
345
|
+
// Try Redux first
|
|
346
|
+
const reduxDetected = await this.reduxSensor.detect(page);
|
|
347
|
+
if (reduxDetected) {
|
|
348
|
+
this.activeType = 'redux';
|
|
349
|
+
return { detected: true, type: 'redux' };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Try Zustand
|
|
353
|
+
const zustandDetected = await this.zustandSensor.detect(page);
|
|
354
|
+
if (zustandDetected) {
|
|
355
|
+
this.activeType = 'zustand';
|
|
356
|
+
return { detected: true, type: 'zustand' };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return { detected: false, type: null };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async captureBefore(page) {
|
|
363
|
+
if (this.activeType === 'redux') {
|
|
364
|
+
await this.reduxSensor.captureBefore(page);
|
|
365
|
+
} else if (this.activeType === 'zustand') {
|
|
366
|
+
await this.zustandSensor.captureBefore(page);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async captureAfter(page) {
|
|
371
|
+
if (this.activeType === 'redux') {
|
|
372
|
+
await this.reduxSensor.captureAfter(page);
|
|
373
|
+
} else if (this.activeType === 'zustand') {
|
|
374
|
+
await this.zustandSensor.captureAfter(page);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
getDiff() {
|
|
379
|
+
if (this.activeType === 'redux') {
|
|
380
|
+
return this.reduxSensor.getDiff();
|
|
381
|
+
} else if (this.activeType === 'zustand') {
|
|
382
|
+
return this.zustandSensor.getDiff();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return { changed: [], available: false };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
cleanup() {
|
|
389
|
+
this.reduxSensor.cleanup();
|
|
390
|
+
this.zustandSensor.cleanup();
|
|
391
|
+
this.activeType = null;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -22,13 +22,14 @@ export class StateUISensor {
|
|
|
22
22
|
*/
|
|
23
23
|
async snapshot(page, contextSelector = null) {
|
|
24
24
|
try {
|
|
25
|
-
const snapshot = await page.evaluate((
|
|
25
|
+
const snapshot = await page.evaluate(() => {
|
|
26
26
|
const signals = {};
|
|
27
27
|
const rawSnapshot = {};
|
|
28
28
|
|
|
29
29
|
// 1. Dialog/Modal signals
|
|
30
30
|
signals.dialogs = [];
|
|
31
31
|
const dialogs = document.querySelectorAll('[role="dialog"]');
|
|
32
|
+
// @ts-expect-error - NodeListOf is iterable in browser context
|
|
32
33
|
for (const dialog of dialogs) {
|
|
33
34
|
const isVisible = dialog.offsetParent !== null || dialog.hasAttribute('open');
|
|
34
35
|
const ariaModal = dialog.getAttribute('aria-modal');
|
|
@@ -46,6 +47,7 @@ export class StateUISensor {
|
|
|
46
47
|
// 2. Expansion state (aria-expanded)
|
|
47
48
|
signals.expandedElements = [];
|
|
48
49
|
const expandables = document.querySelectorAll('[aria-expanded]');
|
|
50
|
+
// @ts-expect-error - NodeListOf is iterable in browser context
|
|
49
51
|
for (const el of expandables) {
|
|
50
52
|
const expanded = el.getAttribute('aria-expanded') === 'true';
|
|
51
53
|
signals.expandedElements.push({
|
|
@@ -58,6 +60,7 @@ export class StateUISensor {
|
|
|
58
60
|
// 3. Tab selection (role=tab with aria-selected)
|
|
59
61
|
signals.selectedTabs = [];
|
|
60
62
|
const tabs = document.querySelectorAll('[role="tab"]');
|
|
63
|
+
// @ts-expect-error - NodeListOf is iterable in browser context
|
|
61
64
|
for (const tab of tabs) {
|
|
62
65
|
const selected = tab.getAttribute('aria-selected') === 'true';
|
|
63
66
|
signals.selectedTabs.push({
|
|
@@ -70,6 +73,7 @@ export class StateUISensor {
|
|
|
70
73
|
// 4. Checkbox/toggle state (aria-checked)
|
|
71
74
|
signals.checkedElements = [];
|
|
72
75
|
const checkables = document.querySelectorAll('[aria-checked]');
|
|
76
|
+
// @ts-expect-error - NodeListOf is iterable in browser context
|
|
73
77
|
for (const el of checkables) {
|
|
74
78
|
const checked = el.getAttribute('aria-checked') === 'true';
|
|
75
79
|
signals.checkedElements.push({
|
|
@@ -82,6 +86,7 @@ export class StateUISensor {
|
|
|
82
86
|
// 5. Alert/Status changes (role=alert, role=status)
|
|
83
87
|
signals.alerts = [];
|
|
84
88
|
const alerts = document.querySelectorAll('[role="alert"], [role="status"]');
|
|
89
|
+
// @ts-expect-error - NodeListOf is iterable in browser context
|
|
85
90
|
for (const alert of alerts) {
|
|
86
91
|
const text = alert.textContent?.trim() || '';
|
|
87
92
|
signals.alerts.push({
|
|
@@ -96,6 +101,7 @@ export class StateUISensor {
|
|
|
96
101
|
// Count visible nodes and text nodes (excluding style/comment nodes)
|
|
97
102
|
const countMeaningfulNodes = () => {
|
|
98
103
|
let count = 0;
|
|
104
|
+
// @ts-expect-error - NodeListOf is iterable in browser context
|
|
99
105
|
for (const node of document.querySelectorAll('*')) {
|
|
100
106
|
if (node.offsetParent !== null) { // Visible
|
|
101
107
|
count++;
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timing Sensor
|
|
3
|
+
* Tracks timing of feedback signals (UI changes, ARIA, loading indicators)
|
|
4
|
+
* Detects delayed or missing feedback after interactions
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export class TimingSensor {
|
|
8
|
+
constructor(options = {}) {
|
|
9
|
+
this.feedbackGapThresholdMs = options.feedbackGapThresholdMs || 1500;
|
|
10
|
+
this.freezeLikeThresholdMs = options.freezeLikeThresholdMs || 3000;
|
|
11
|
+
|
|
12
|
+
this.t0 = null; // Interaction start time
|
|
13
|
+
this.tNetworkFirst = null; // First network request time
|
|
14
|
+
this.tLoadingStart = null; // Loading indicator appears
|
|
15
|
+
this.tAriaFirst = null; // First ARIA change
|
|
16
|
+
this.tUiFirst = null; // First DOM/UI change
|
|
17
|
+
this.tFeedback = null; // First feedback signal (min of above)
|
|
18
|
+
|
|
19
|
+
this.networkActivityDetected = false;
|
|
20
|
+
this.feedbackDetected = false;
|
|
21
|
+
this.feedbackDelayMs = 0;
|
|
22
|
+
this.workStartMs = 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Start timing from interaction initiation
|
|
27
|
+
*/
|
|
28
|
+
startTiming() {
|
|
29
|
+
this.t0 = Date.now();
|
|
30
|
+
return this.t0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Monitor for feedback signals during interaction
|
|
35
|
+
* Call periodically or at key moments to track timing
|
|
36
|
+
*/
|
|
37
|
+
async captureTimingSnapshot(page) {
|
|
38
|
+
if (!this.t0) {
|
|
39
|
+
this.t0 = Date.now();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
const elapsedMs = now - this.t0;
|
|
44
|
+
|
|
45
|
+
// Capture current state
|
|
46
|
+
const state = await page.evaluate(() => {
|
|
47
|
+
const result = {
|
|
48
|
+
loadingPresent: false,
|
|
49
|
+
ariaStatusPresent: false,
|
|
50
|
+
ariaLivePresent: false,
|
|
51
|
+
buttonDisabled: false,
|
|
52
|
+
domChanged: false
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Check for loading indicators
|
|
56
|
+
const loading = document.querySelectorAll('[aria-busy="true"], [class*="load"], [class*="spin"], .loader, .spinner');
|
|
57
|
+
result.loadingPresent = loading.length > 0;
|
|
58
|
+
|
|
59
|
+
// Check for ARIA status/alert
|
|
60
|
+
const ariaStatus = document.querySelectorAll('[role="status"], [role="alert"]');
|
|
61
|
+
ariaStatus.forEach(el => {
|
|
62
|
+
if (el.textContent?.length > 0) {
|
|
63
|
+
result.ariaStatusPresent = true;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Check for ARIA live regions
|
|
68
|
+
const ariaLive = document.querySelectorAll('[aria-live]');
|
|
69
|
+
ariaLive.forEach(el => {
|
|
70
|
+
if (el.textContent?.length > 0) {
|
|
71
|
+
result.ariaLivePresent = true;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Check for disabled submit buttons (common feedback)
|
|
76
|
+
const disabledButtons = document.querySelectorAll('button[type="submit"]:disabled, button:disabled');
|
|
77
|
+
result.buttonDisabled = disabledButtons.length > 0;
|
|
78
|
+
|
|
79
|
+
return result;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// First loading indicator
|
|
83
|
+
if (state.loadingPresent && !this.tLoadingStart) {
|
|
84
|
+
this.tLoadingStart = now;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// First ARIA change
|
|
88
|
+
if ((state.ariaStatusPresent || state.ariaLivePresent) && !this.tAriaFirst) {
|
|
89
|
+
this.tAriaFirst = now;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Record button disabled state as feedback signal
|
|
93
|
+
if (state.buttonDisabled) {
|
|
94
|
+
if (!this.tFeedback) {
|
|
95
|
+
this.tFeedback = now;
|
|
96
|
+
}
|
|
97
|
+
// Also record as button disabled time
|
|
98
|
+
this.recordButtonDisabled(now);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Determine first feedback signal
|
|
102
|
+
if (!this.tFeedback) {
|
|
103
|
+
const signals = [this.tLoadingStart, this.tAriaFirst, this.tUiFirst].filter(t => t !== null);
|
|
104
|
+
if (signals.length > 0) {
|
|
105
|
+
this.tFeedback = Math.min(...signals);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
elapsedMs,
|
|
111
|
+
state,
|
|
112
|
+
hasLoadingIndicator: state.loadingPresent,
|
|
113
|
+
hasAriaFeedback: state.ariaStatusPresent || state.ariaLivePresent,
|
|
114
|
+
hasButtonDisabled: state.buttonDisabled
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Analyze network summary to detect if work started
|
|
120
|
+
* Records the time when first network request was detected
|
|
121
|
+
*/
|
|
122
|
+
analyzeNetworkSummary(networkSummary) {
|
|
123
|
+
if (!networkSummary || networkSummary.totalRequests === 0) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!this.tNetworkFirst) {
|
|
128
|
+
// Network requests started - estimate based on interaction start + small delay
|
|
129
|
+
// Network sensor tracks when requests were made, but we estimate based on t0
|
|
130
|
+
// Most network requests start within 50-200ms after interaction
|
|
131
|
+
const estimatedNetworkStart = this.t0 + 100; // Conservative estimate
|
|
132
|
+
this.tNetworkFirst = estimatedNetworkStart;
|
|
133
|
+
this.networkActivityDetected = true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Record when loading sensor detected activity
|
|
139
|
+
* Called when loading indicators appear
|
|
140
|
+
*/
|
|
141
|
+
recordLoadingStart(timestamp = null) {
|
|
142
|
+
if (!this.tLoadingStart) {
|
|
143
|
+
this.tLoadingStart = timestamp || Date.now();
|
|
144
|
+
// Loading indicates work started even if network not detected yet
|
|
145
|
+
if (!this.networkActivityDetected) {
|
|
146
|
+
this.networkActivityDetected = true;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Record when UI changed (from sensor)
|
|
153
|
+
*/
|
|
154
|
+
recordUiChange(timestamp = null) {
|
|
155
|
+
if (!this.tUiFirst) {
|
|
156
|
+
this.tUiFirst = timestamp || Date.now();
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Record when button disabled state detected (feedback signal)
|
|
162
|
+
*/
|
|
163
|
+
recordButtonDisabled(timestamp = null) {
|
|
164
|
+
if (!this.tFeedback) {
|
|
165
|
+
const now = timestamp || Date.now();
|
|
166
|
+
// Check if this is earlier than other feedback signals
|
|
167
|
+
const signals = [this.tLoadingStart, this.tAriaFirst, this.tUiFirst].filter(t => t !== null);
|
|
168
|
+
if (signals.length === 0 || now < Math.min(...signals)) {
|
|
169
|
+
this.tFeedback = now;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get final timing analysis
|
|
176
|
+
*/
|
|
177
|
+
getTimingAnalysis() {
|
|
178
|
+
if (!this.t0) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const now = Date.now();
|
|
183
|
+
|
|
184
|
+
// Determine when work started (network or loading indicator)
|
|
185
|
+
const workStartTime = this.tNetworkFirst || this.tLoadingStart;
|
|
186
|
+
this.workStartMs = workStartTime ? workStartTime - this.t0 : -1;
|
|
187
|
+
|
|
188
|
+
// Determine when feedback appeared (first of: loading, ARIA, UI change, button disabled)
|
|
189
|
+
const feedbackTimes = [this.tLoadingStart, this.tAriaFirst, this.tUiFirst].filter(t => t !== null);
|
|
190
|
+
if (this.tFeedback && !feedbackTimes.includes(this.tFeedback)) {
|
|
191
|
+
feedbackTimes.push(this.tFeedback);
|
|
192
|
+
}
|
|
193
|
+
this.tFeedback = feedbackTimes.length > 0 ? Math.min(...feedbackTimes) : null;
|
|
194
|
+
this.feedbackDelayMs = this.tFeedback ? this.tFeedback - this.t0 : -1;
|
|
195
|
+
|
|
196
|
+
// Check if feedback gap exists: work started but no feedback within threshold
|
|
197
|
+
const hasFeedbackGap =
|
|
198
|
+
this.networkActivityDetected &&
|
|
199
|
+
(!this.tFeedback || this.feedbackDelayMs > this.feedbackGapThresholdMs);
|
|
200
|
+
|
|
201
|
+
// Check if freeze-like: significant delay (>3000ms) before feedback
|
|
202
|
+
const isFreezeLike =
|
|
203
|
+
this.networkActivityDetected &&
|
|
204
|
+
this.tFeedback !== null &&
|
|
205
|
+
this.feedbackDelayMs > this.freezeLikeThresholdMs;
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
t0: this.t0,
|
|
209
|
+
tNetworkFirst: this.tNetworkFirst,
|
|
210
|
+
tLoadingStart: this.tLoadingStart,
|
|
211
|
+
tAriaFirst: this.tAriaFirst,
|
|
212
|
+
tUiFirst: this.tUiFirst,
|
|
213
|
+
tFeedback: this.tFeedback,
|
|
214
|
+
|
|
215
|
+
elapsedMs: now - this.t0,
|
|
216
|
+
workStartMs: this.workStartMs,
|
|
217
|
+
feedbackDelayMs: this.feedbackDelayMs,
|
|
218
|
+
|
|
219
|
+
networkActivityDetected: this.networkActivityDetected,
|
|
220
|
+
feedbackDetected: this.tFeedback !== null,
|
|
221
|
+
hasFeedbackGap,
|
|
222
|
+
isFreezeLike,
|
|
223
|
+
|
|
224
|
+
feedbackGapThreshold: this.feedbackGapThresholdMs,
|
|
225
|
+
freezeLikeThreshold: this.freezeLikeThresholdMs
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|