@veraxhq/verax 0.1.0 → 0.2.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/README.md +123 -88
- package/bin/verax.js +11 -452
- package/package.json +14 -36
- package/src/cli/commands/default.js +523 -0
- package/src/cli/commands/doctor.js +165 -0
- package/src/cli/commands/inspect.js +109 -0
- package/src/cli/commands/run.js +402 -0
- package/src/cli/entry.js +196 -0
- package/src/cli/util/atomic-write.js +37 -0
- package/src/cli/util/detection-engine.js +296 -0
- package/src/cli/util/env-url.js +33 -0
- package/src/cli/util/errors.js +44 -0
- package/src/cli/util/events.js +34 -0
- package/src/cli/util/expectation-extractor.js +378 -0
- package/src/cli/util/findings-writer.js +31 -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 +366 -0
- package/src/cli/util/observe-writer.js +25 -0
- package/src/cli/util/paths.js +29 -0
- package/src/cli/util/project-discovery.js +277 -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/summary-writer.js +32 -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 +101 -0
- package/src/verax/cli/wizard.js +98 -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 +403 -0
- package/src/verax/core/incremental-store.js +237 -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 +521 -0
- package/src/verax/detect/comparison.js +2 -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 +177 -0
- package/src/verax/detect/expectation-model.js +194 -172
- 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 +44 -8
- package/src/verax/detect/flow-detector.js +366 -0
- package/src/verax/detect/index.js +172 -286
- package/src/verax/detect/interactive-findings.js +613 -0
- package/src/verax/detect/signal-mapper.js +308 -0
- package/src/verax/detect/verdict-engine.js +563 -0
- package/src/verax/evidence-index-writer.js +61 -0
- package/src/verax/index.js +90 -14
- 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 +579 -0
- package/src/verax/intel/vue-router-extractor.js +323 -0
- package/src/verax/learn/action-contract-extractor.js +335 -101
- package/src/verax/learn/ast-contract-extractor.js +95 -5
- package/src/verax/learn/flow-extractor.js +172 -0
- package/src/verax/learn/manifest-writer.js +97 -47
- package/src/verax/learn/project-detector.js +40 -0
- package/src/verax/learn/route-extractor.js +27 -96
- 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 +112 -4
- package/src/verax/learn/truth-assessor.js +24 -21
- package/src/verax/observe/aria-sensor.js +211 -0
- package/src/verax/observe/browser.js +10 -5
- package/src/verax/observe/console-sensor.js +1 -17
- package/src/verax/observe/domain-boundary.js +10 -1
- package/src/verax/observe/expectation-executor.js +512 -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 +643 -275
- package/src/verax/observe/index.js +908 -27
- package/src/verax/observe/index.js.backup +1 -0
- package/src/verax/observe/interaction-discovery.js +365 -14
- package/src/verax/observe/interaction-runner.js +563 -198
- package/src/verax/observe/loading-sensor.js +139 -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 +37 -17
- package/src/verax/observe/state-sensor.js +389 -0
- package/src/verax/observe/timing-sensor.js +228 -0
- package/src/verax/observe/traces-writer.js +61 -20
- package/src/verax/observe/ui-signal-sensor.js +136 -17
- package/src/verax/scan-summary-writer.js +77 -15
- package/src/verax/shared/artifact-manager.js +110 -8
- package/src/verax/shared/budget-profiles.js +136 -0
- package/src/verax/shared/ci-detection.js +39 -0
- package/src/verax/shared/config-loader.js +170 -0
- package/src/verax/shared/dynamic-route-utils.js +218 -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 +14 -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 +65 -0
- package/src/verax/validate/context-validator.js +244 -0
- package/src/verax/validate/context-validator.js.bak +0 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page Frontier — Multi-page traversal manager
|
|
3
|
+
*
|
|
4
|
+
* Manages a queue of pages to visit and enforces:
|
|
5
|
+
* - Same-origin only
|
|
6
|
+
* - No re-visiting (canonical URLs)
|
|
7
|
+
* - Budget limits (maxPages, maxScanDurationMs, maxUniqueUrls)
|
|
8
|
+
* - Skipping dangerous actions (logout, delete)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { canonicalizeUrl } from '../shared/url-normalizer.js';
|
|
12
|
+
|
|
13
|
+
// Normalize a textual label for deterministic matching
|
|
14
|
+
function normalizeLabel(label) {
|
|
15
|
+
return (label || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Deterministic destructive-action classifier
|
|
19
|
+
// Returns { skip: boolean, reasonCode: string|null, matched: string|null }
|
|
20
|
+
function isDestructiveLabel(label) {
|
|
21
|
+
const normalized = normalizeLabel(label);
|
|
22
|
+
if (!normalized) {
|
|
23
|
+
return { skip: false, reasonCode: null, matched: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Strong destructive keywords (word-boundary guarded)
|
|
27
|
+
const strongKeywords = ['delete', 'remove', 'erase', 'wipe', 'destroy', 'drop', 'reset', 'terminate', 'unsubscribe', 'deactivate'];
|
|
28
|
+
const strongRegex = new RegExp(`\\b(${strongKeywords.join('|')})\\b`, 'i');
|
|
29
|
+
|
|
30
|
+
// Financial/destructive-ish actions we still treat conservatively
|
|
31
|
+
const financialRegex = /\b(pay|purchase|checkout)\b/i;
|
|
32
|
+
|
|
33
|
+
// Safe allowlist for clear-related actions (explicitly non-destructive)
|
|
34
|
+
const safeClearRegex = /\bclear\b[^\w]*(filters?|search|selection|input|form|field|query|results?)\b/i;
|
|
35
|
+
|
|
36
|
+
// Destructive uses of "clear" require sensitive nouns
|
|
37
|
+
const destructiveClearRegex = /\bclear\b[^\w]*(data|account|all|history|cache|storage|database|everything|session|profile|settings|config)\b/i;
|
|
38
|
+
|
|
39
|
+
// If it is an explicit safe clear phrase, allow
|
|
40
|
+
if (safeClearRegex.test(normalized)) {
|
|
41
|
+
return { skip: false, reasonCode: null, matched: 'safe_clear' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// If strong destructive or financial keyword appears, skip
|
|
45
|
+
const strongMatch = normalized.match(strongRegex);
|
|
46
|
+
if (strongMatch) {
|
|
47
|
+
return { skip: true, reasonCode: 'destructive_keyword', matched: strongMatch[1] };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const financialMatch = normalized.match(financialRegex);
|
|
51
|
+
if (financialMatch) {
|
|
52
|
+
return { skip: true, reasonCode: 'financial_action', matched: financialMatch[1] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Handle ambiguous "clear" only when paired with sensitive nouns
|
|
56
|
+
const clearMatch = normalized.match(destructiveClearRegex);
|
|
57
|
+
if (clearMatch) {
|
|
58
|
+
return { skip: true, reasonCode: 'clear_sensitive', matched: clearMatch[0] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { skip: false, reasonCode: null, matched: null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class PageFrontier {
|
|
65
|
+
constructor(startUrl, baseOrigin, scanBudget, startTime) {
|
|
66
|
+
this.baseOrigin = baseOrigin;
|
|
67
|
+
this.scanBudget = scanBudget;
|
|
68
|
+
this.startTime = startTime;
|
|
69
|
+
|
|
70
|
+
this.queue = [startUrl]; // URLs to visit
|
|
71
|
+
this.visited = new Set(); // Visited URLs (canonical form)
|
|
72
|
+
this.pagesVisited = 0;
|
|
73
|
+
this.pagesDiscovered = 1; // include start page
|
|
74
|
+
this.frontierCapped = false; // Track if maxUniqueUrls was exceeded
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Normalize a URL to canonical form using shared URL normalizer.
|
|
79
|
+
* - Remove hash fragments
|
|
80
|
+
* - Sort query params
|
|
81
|
+
* - Drop tracking params (utm_*, gclid, fbclid, etc.)
|
|
82
|
+
*/
|
|
83
|
+
normalizeUrl(urlString) {
|
|
84
|
+
return canonicalizeUrl(urlString);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if URL is same-origin.
|
|
89
|
+
*/
|
|
90
|
+
isSameOrigin(urlString) {
|
|
91
|
+
try {
|
|
92
|
+
const url = new URL(urlString);
|
|
93
|
+
if (url.protocol === 'file:' && this.baseOrigin.startsWith('file:')) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return url.origin === this.baseOrigin;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if we've exceeded page limit.
|
|
104
|
+
*/
|
|
105
|
+
isPageLimitExceeded() {
|
|
106
|
+
if (this.scanBudget.maxPages && this.pagesVisited >= this.scanBudget.maxPages) {
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if we've exceeded time limit.
|
|
114
|
+
*/
|
|
115
|
+
isTimeLimitExceeded() {
|
|
116
|
+
const elapsed = Date.now() - this.startTime;
|
|
117
|
+
return elapsed > this.scanBudget.maxScanDurationMs;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get next URL to visit (if any and within budget).
|
|
122
|
+
* Returns null if queue empty or limits exceeded.
|
|
123
|
+
*/
|
|
124
|
+
getNextUrl() {
|
|
125
|
+
// Check limits
|
|
126
|
+
if (this.isPageLimitExceeded() || this.isTimeLimitExceeded()) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Find next unvisited same-origin URL
|
|
131
|
+
while (this.queue.length > 0) {
|
|
132
|
+
const nextUrl = this.queue.shift();
|
|
133
|
+
const normalized = this.normalizeUrl(nextUrl);
|
|
134
|
+
|
|
135
|
+
// Skip if already visited
|
|
136
|
+
if (this.visited.has(normalized)) {
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Skip if not same-origin
|
|
141
|
+
if (!this.isSameOrigin(nextUrl)) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.visited.add(normalized);
|
|
146
|
+
return nextUrl;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Add a new URL to the frontier (discovered during interaction).
|
|
154
|
+
* Only adds if same-origin, not already visited, and within maxUniqueUrls limit.
|
|
155
|
+
* Returns true if added, false if skipped (with reason).
|
|
156
|
+
*/
|
|
157
|
+
addUrl(urlString) {
|
|
158
|
+
if (!this.isSameOrigin(urlString)) {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const normalized = this.normalizeUrl(urlString);
|
|
163
|
+
if (this.visited.has(normalized)) {
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check maxUniqueUrls cap
|
|
168
|
+
const maxUniqueUrls = this.scanBudget.maxUniqueUrls || Infinity;
|
|
169
|
+
if (this.pagesDiscovered >= maxUniqueUrls) {
|
|
170
|
+
this.frontierCapped = true;
|
|
171
|
+
return false; // Frontier capped, don't add
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Add the normalized URL to visited set and original to queue
|
|
175
|
+
this.visited.add(normalized);
|
|
176
|
+
this.queue.push(urlString);
|
|
177
|
+
this.pagesDiscovered++;
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Mark that we've visited a page.
|
|
183
|
+
*/
|
|
184
|
+
markVisited() {
|
|
185
|
+
this.pagesVisited++;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Check if an interaction should be skipped (destructive/safe-action policy).
|
|
190
|
+
* Returns { skip: boolean, reason: string } with strict rules.
|
|
191
|
+
* Only uses properties available from interaction discovery (text, label, selector).
|
|
192
|
+
* Note: label may contain aria-label if that was the source (from extractLabel).
|
|
193
|
+
*/
|
|
194
|
+
shouldSkipInteraction(interaction) {
|
|
195
|
+
// Allow explicit auth flows when labeled by type (keep auth testing intact)
|
|
196
|
+
if (interaction.type === 'login' || interaction.type === 'logout') {
|
|
197
|
+
return { skip: false, reason: null };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Aggregate all human-visible labels
|
|
201
|
+
const text = (interaction.text || '').trim();
|
|
202
|
+
const label = (interaction.label || '').trim();
|
|
203
|
+
const ariaLabel = (interaction.ariaLabel || '').trim();
|
|
204
|
+
const combinedText = `${text} ${label} ${ariaLabel}`.trim();
|
|
205
|
+
|
|
206
|
+
const destructiveCheck = isDestructiveLabel(combinedText);
|
|
207
|
+
if (destructiveCheck.skip) {
|
|
208
|
+
return { skip: true, reason: 'safety_policy', matched: destructiveCheck.matched };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check selector for explicit danger markers
|
|
212
|
+
const selector = (interaction.selector || '').toLowerCase();
|
|
213
|
+
if (selector.includes('data-danger') || selector.includes('data-destructive')) {
|
|
214
|
+
return { skip: true, reason: 'safety_policy', matched: 'data-danger' };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return { skip: false, reason: null };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get frontier stats.
|
|
222
|
+
*/
|
|
223
|
+
getStats() {
|
|
224
|
+
return {
|
|
225
|
+
pagesVisited: this.pagesVisited,
|
|
226
|
+
pagesDiscovered: this.pagesDiscovered,
|
|
227
|
+
queueLength: this.queue.length,
|
|
228
|
+
isPageLimitExceeded: this.isPageLimitExceeded(),
|
|
229
|
+
isTimeLimitExceeded: this.isTimeLimitExceeded()
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export { normalizeLabel, isDestructiveLabel };
|
|
@@ -3,23 +3,27 @@
|
|
|
3
3
|
* Waits for page to stabilize after navigation or interaction
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import { DEFAULT_SCAN_BUDGET } from '../shared/scan-budget.js';
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Wait for page to settle after navigation or interaction.
|
|
8
10
|
* Combines multiple signals: load event, network idle, DOM mutation stabilization.
|
|
11
|
+
* With adaptive stabilization, extends windows if DOM/network still changing.
|
|
9
12
|
*
|
|
10
13
|
* @param {Page} page - Playwright page object
|
|
11
|
-
* @param {Object}
|
|
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)
|
|
14
|
+
* @param {Object} scanBudget - ScanBudget with settle timing parameters
|
|
15
15
|
* @returns {Promise<void>}
|
|
16
16
|
*/
|
|
17
|
-
export async function waitForSettle(page,
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
export async function waitForSettle(page, scanBudget = DEFAULT_SCAN_BUDGET) {
|
|
18
|
+
const baseTimeoutMs = scanBudget.settleTimeoutMs;
|
|
19
|
+
const baseIdleMs = scanBudget.settleIdleMs;
|
|
20
|
+
const baseDomStableMs = scanBudget.settleDomStableMs;
|
|
21
|
+
const adaptiveStabilization = scanBudget.adaptiveStabilization || false;
|
|
22
|
+
|
|
23
|
+
// With adaptive stabilization, allow up to 1.5x the base timeout for difficult pages
|
|
24
|
+
const timeoutMs = adaptiveStabilization ? Math.round(baseTimeoutMs * 1.5) : baseTimeoutMs;
|
|
25
|
+
const idleMs = baseIdleMs;
|
|
26
|
+
const domStableMs = baseDomStableMs;
|
|
23
27
|
|
|
24
28
|
const startTime = Date.now();
|
|
25
29
|
|
|
@@ -31,10 +35,10 @@ export async function waitForSettle(page, options = {}) {
|
|
|
31
35
|
]).catch(() => {});
|
|
32
36
|
|
|
33
37
|
// Signal 2: Network idle detection using Playwright Request/Response events
|
|
34
|
-
await waitForNetworkIdle(page, idleMs, timeoutMs - (Date.now() - startTime));
|
|
38
|
+
await waitForNetworkIdle(page, idleMs, timeoutMs - (Date.now() - startTime), adaptiveStabilization);
|
|
35
39
|
|
|
36
40
|
// Signal 3: DOM mutation stabilization
|
|
37
|
-
await waitForDomStability(page, domStableMs, timeoutMs - (Date.now() - startTime));
|
|
41
|
+
await waitForDomStability(page, domStableMs, timeoutMs - (Date.now() - startTime), adaptiveStabilization);
|
|
38
42
|
} catch (err) {
|
|
39
43
|
// Timeout is acceptable - page may have settled despite timeout
|
|
40
44
|
if (!err.message?.includes('Timeout')) {
|
|
@@ -46,16 +50,23 @@ export async function waitForSettle(page, options = {}) {
|
|
|
46
50
|
/**
|
|
47
51
|
* Wait for network to become idle (no inflight requests for idleMs).
|
|
48
52
|
* Uses Playwright's Request/Response event listening.
|
|
53
|
+
* With adaptive stabilization, may extend the idle window if network restarts.
|
|
49
54
|
*/
|
|
50
|
-
async function waitForNetworkIdle(page, idleMs, timeoutMs) {
|
|
55
|
+
async function waitForNetworkIdle(page, idleMs, timeoutMs, adaptiveStabilization = false) {
|
|
51
56
|
if (timeoutMs <= 0) return;
|
|
52
57
|
|
|
53
58
|
return new Promise((resolve) => {
|
|
54
59
|
let lastNetworkActivityTime = Date.now();
|
|
55
60
|
let hasFinished = false;
|
|
61
|
+
let extensionCount = 0;
|
|
62
|
+
const maxExtensions = adaptiveStabilization ? 2 : 0;
|
|
56
63
|
|
|
57
64
|
const onRequest = () => {
|
|
58
65
|
lastNetworkActivityTime = Date.now();
|
|
66
|
+
// With adaptive stabilization, if network restarts, we extend the idle window
|
|
67
|
+
if (adaptiveStabilization && extensionCount < maxExtensions) {
|
|
68
|
+
extensionCount++;
|
|
69
|
+
}
|
|
59
70
|
};
|
|
60
71
|
|
|
61
72
|
const onResponse = () => {
|
|
@@ -97,18 +108,26 @@ async function waitForNetworkIdle(page, idleMs, timeoutMs) {
|
|
|
97
108
|
/**
|
|
98
109
|
* Wait for DOM mutations to stabilize (no mutations for domStableMs).
|
|
99
110
|
* Uses MutationObserver to track DOM changes.
|
|
111
|
+
* With adaptive stabilization, may extend if DOM changes restart.
|
|
100
112
|
*/
|
|
101
|
-
async function waitForDomStability(page, domStableMs, timeoutMs) {
|
|
113
|
+
async function waitForDomStability(page, domStableMs, timeoutMs, adaptiveStabilization = false) {
|
|
102
114
|
if (timeoutMs <= 0) return;
|
|
103
115
|
|
|
104
116
|
await page.evaluate(
|
|
105
|
-
async (domStableMs, timeoutMs) => {
|
|
117
|
+
async (domStableMs, timeoutMs, shouldAdapt) => {
|
|
106
118
|
return new Promise((resolve) => {
|
|
107
119
|
let lastMutationTime = Date.now();
|
|
108
120
|
let hasFinished = false;
|
|
121
|
+
let extensionCount = 0;
|
|
122
|
+
const maxExtensions = shouldAdapt ? 2 : 0;
|
|
109
123
|
|
|
110
124
|
const observer = new MutationObserver(() => {
|
|
111
|
-
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
// With adaptive stabilization, if mutations restart, extend window
|
|
127
|
+
if (shouldAdapt && now - lastMutationTime >= domStableMs && extensionCount < maxExtensions) {
|
|
128
|
+
extensionCount++;
|
|
129
|
+
}
|
|
130
|
+
lastMutationTime = now;
|
|
112
131
|
});
|
|
113
132
|
|
|
114
133
|
observer.observe(document.documentElement, {
|
|
@@ -148,7 +167,8 @@ async function waitForDomStability(page, domStableMs, timeoutMs) {
|
|
|
148
167
|
});
|
|
149
168
|
},
|
|
150
169
|
domStableMs,
|
|
151
|
-
timeoutMs
|
|
170
|
+
timeoutMs,
|
|
171
|
+
adaptiveStabilization
|
|
152
172
|
).catch(() => {
|
|
153
173
|
// Page may have navigated, ignore
|
|
154
174
|
});
|