@odavl/guardian 0.1.0-rc1 → 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.
Files changed (35) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +3 -3
  3. package/bin/guardian.js +212 -8
  4. package/package.json +6 -1
  5. package/src/guardian/attempt-engine.js +19 -5
  6. package/src/guardian/attempt.js +61 -39
  7. package/src/guardian/attempts-filter.js +63 -0
  8. package/src/guardian/baseline.js +44 -10
  9. package/src/guardian/browser-pool.js +131 -0
  10. package/src/guardian/browser.js +28 -1
  11. package/src/guardian/ci-mode.js +15 -0
  12. package/src/guardian/ci-output.js +37 -0
  13. package/src/guardian/cli-summary.js +117 -4
  14. package/src/guardian/data-guardian-detector.js +189 -0
  15. package/src/guardian/detection-layers.js +271 -0
  16. package/src/guardian/first-run.js +49 -0
  17. package/src/guardian/flag-validator.js +97 -0
  18. package/src/guardian/flow-executor.js +309 -44
  19. package/src/guardian/language-detection.js +99 -0
  20. package/src/guardian/market-reporter.js +16 -1
  21. package/src/guardian/parallel-executor.js +116 -0
  22. package/src/guardian/prerequisite-checker.js +101 -0
  23. package/src/guardian/preset-loader.js +18 -12
  24. package/src/guardian/profile-loader.js +96 -0
  25. package/src/guardian/reality.js +382 -46
  26. package/src/guardian/run-summary.js +20 -0
  27. package/src/guardian/semantic-contact-detection.js +255 -0
  28. package/src/guardian/semantic-contact-finder.js +200 -0
  29. package/src/guardian/semantic-targets.js +234 -0
  30. package/src/guardian/smoke.js +258 -0
  31. package/src/guardian/snapshot.js +23 -1
  32. package/src/guardian/success-evaluator.js +214 -0
  33. package/src/guardian/timeout-profiles.js +57 -0
  34. package/src/guardian/wait-for-outcome.js +120 -0
  35. package/src/guardian/watch-runner.js +185 -0
@@ -0,0 +1,258 @@
1
+ const { AttemptEngine } = require('./attempt-engine');
2
+ const { getAttemptDefinition } = require('./attempt-registry');
3
+ const { BrowserPool } = require('./browser-pool');
4
+ const { checkPrerequisites } = require('./prerequisite-checker');
5
+ const { validateParallel, executeParallel } = require('./parallel-executor');
6
+ const { getTimeoutProfile } = require('./timeout-profiles');
7
+ const { isCiMode } = require('./ci-mode');
8
+
9
+ const SMOKE_ATTEMPTS = ['universal_reality', 'login', 'signup', 'contact_form'];
10
+ const DEFAULT_PARALLEL = 2;
11
+ const DEFAULT_BUDGET_MS = 30000;
12
+ const DEFAULT_PREREQ_TIMEOUT = 2000;
13
+ const SMOKE_BROWSER_ARGS = ['--no-sandbox', '--disable-setuid-sandbox', '--proxy-bypass-list=*'];
14
+
15
+ function validateUrl(url) {
16
+ try {
17
+ // eslint-disable-next-line no-new
18
+ new URL(url);
19
+ return true;
20
+ } catch (e) {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ function summarizeResults(results) {
26
+ const success = results.filter(r => r.outcome === 'SUCCESS').length;
27
+ const friction = results.filter(r => r.outcome === 'FRICTION').length;
28
+ const failure = results.filter(r => r.outcome === 'FAILURE').length;
29
+ const skipped = results.filter(r => r.outcome === 'SKIPPED').length;
30
+ return { success, friction, failure, skipped };
31
+ }
32
+
33
+ function authPathStatus(results) {
34
+ const authResults = results.filter(r => r.attemptId === 'login' || r.attemptId === 'signup');
35
+ const hasAuthSuccess = authResults.some(r => r.outcome === 'SUCCESS' || r.outcome === 'FRICTION');
36
+ const authFailures = authResults.filter(r => r.outcome === 'FAILURE').length;
37
+ return { hasAuthSuccess, authFailures };
38
+ }
39
+
40
+ function chooseExitCode({ failure, friction }, timedOut, authMissing, authFailuresToIgnore = 0) {
41
+ const effectiveFailures = Math.max(0, failure - authFailuresToIgnore);
42
+ if (timedOut || authMissing || effectiveFailures > 0) return 2;
43
+ if (friction > 0) return 1;
44
+ return 0;
45
+ }
46
+
47
+ async function executeSmoke(config) {
48
+ const baseUrl = config.baseUrl;
49
+ if (!validateUrl(baseUrl)) {
50
+ throw new Error(`Invalid URL: ${baseUrl}`);
51
+ }
52
+
53
+ const savedNoProxy = { NO_PROXY: process.env.NO_PROXY, no_proxy: process.env.no_proxy };
54
+ const forcedNoProxy = (process.env.NO_PROXY || process.env.no_proxy)
55
+ ? `${process.env.NO_PROXY || process.env.no_proxy},127.0.0.1,localhost`
56
+ : '127.0.0.1,localhost';
57
+ process.env.NO_PROXY = forcedNoProxy;
58
+ process.env.no_proxy = forcedNoProxy;
59
+
60
+ const ciMode = isCiMode();
61
+ const timeoutProfile = getTimeoutProfile('fast');
62
+ const resolvedTimeout = timeoutProfile.default;
63
+ const budgetMs = Number(process.env.GUARDIAN_SMOKE_BUDGET_MS || config.timeBudgetMs || DEFAULT_BUDGET_MS);
64
+
65
+ const parallelValidation = validateParallel(DEFAULT_PARALLEL);
66
+ if (!parallelValidation.valid) {
67
+ throw new Error(parallelValidation.error || 'Invalid parallel value');
68
+ }
69
+ const parallel = parallelValidation.parallel || DEFAULT_PARALLEL;
70
+
71
+ if (!ciMode) {
72
+ console.log('\nSMOKE MODE: ON');
73
+ console.log(`Target: ${baseUrl}`);
74
+ console.log(`Attempts: ${SMOKE_ATTEMPTS.join(', ')}`);
75
+ } else {
76
+ console.log('SMOKE MODE: ON');
77
+ console.log(`Target: ${baseUrl}`);
78
+ console.log(`Attempts: ${SMOKE_ATTEMPTS.join(', ')}`);
79
+ }
80
+
81
+ const browserPool = new BrowserPool();
82
+ await browserPool.launch({ headless: !config.headful, timeout: resolvedTimeout, args: SMOKE_BROWSER_ARGS });
83
+
84
+ const startedAt = Date.now();
85
+ let timedOut = false;
86
+ let shouldStop = false;
87
+ const attemptResults = [];
88
+
89
+ const budgetTimer = setTimeout(() => {
90
+ timedOut = true;
91
+ shouldStop = true;
92
+ }, budgetMs);
93
+
94
+ const attemptRunner = async (attemptId) => {
95
+ if (timedOut) {
96
+ return null;
97
+ }
98
+
99
+ const attemptDef = getAttemptDefinition(attemptId);
100
+ if (!attemptDef) {
101
+ return {
102
+ attemptId,
103
+ attemptName: attemptId,
104
+ outcome: 'FAILURE',
105
+ error: `Attempt ${attemptId} not found`,
106
+ friction: null
107
+ };
108
+ }
109
+
110
+ let context = null;
111
+ let page = null;
112
+ let result;
113
+
114
+ try {
115
+ const ctx = await browserPool.createContext({
116
+ timeout: resolvedTimeout,
117
+ ignoreHTTPSErrors: true
118
+ });
119
+ context = ctx.context;
120
+ page = ctx.page;
121
+ await page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: resolvedTimeout });
122
+ const prereq = await checkPrerequisites(page, attemptId, DEFAULT_PREREQ_TIMEOUT);
123
+ if (!prereq.canProceed) {
124
+ result = {
125
+ attemptId,
126
+ attemptName: attemptDef.name,
127
+ outcome: 'SKIPPED',
128
+ skipReason: prereq.reason,
129
+ friction: null,
130
+ error: null
131
+ };
132
+ } else {
133
+ const engine = new AttemptEngine({
134
+ attemptId,
135
+ timeout: resolvedTimeout,
136
+ frictionThresholds: {
137
+ totalDurationMs: 5000,
138
+ stepDurationMs: 2500,
139
+ retryCount: 0
140
+ },
141
+ maxStepRetries: 1
142
+ });
143
+
144
+ const attemptResult = await engine.executeAttempt(page, attemptId, baseUrl, null, attemptDef.validators || []);
145
+ result = {
146
+ attemptId,
147
+ attemptName: attemptDef.name,
148
+ outcome: attemptResult.outcome,
149
+ friction: attemptResult.friction,
150
+ error: attemptResult.error,
151
+ successReason: attemptResult.successReason,
152
+ skipReason: null
153
+ };
154
+ }
155
+ } catch (err) {
156
+ result = {
157
+ attemptId,
158
+ attemptName: attemptDef?.name || attemptId,
159
+ outcome: 'FAILURE',
160
+ friction: null,
161
+ error: err.message,
162
+ skipReason: null
163
+ };
164
+ } finally {
165
+ if (context) {
166
+ await browserPool.closeContext(context);
167
+ }
168
+ }
169
+
170
+ // Enforce fail-fast
171
+ if (result.outcome === 'FAILURE') {
172
+ shouldStop = true;
173
+ }
174
+
175
+ // Enforce budget after attempt completes
176
+ if (Date.now() - startedAt >= budgetMs) {
177
+ timedOut = true;
178
+ shouldStop = true;
179
+ }
180
+
181
+ return result;
182
+ };
183
+
184
+ const parallelResults = await executeParallel(
185
+ SMOKE_ATTEMPTS,
186
+ attemptRunner,
187
+ parallel,
188
+ { shouldStop: () => shouldStop }
189
+ );
190
+
191
+ clearTimeout(budgetTimer);
192
+ for (const r of parallelResults) {
193
+ if (r) {
194
+ attemptResults.push(r);
195
+ }
196
+ }
197
+
198
+ if (process.env.GUARDIAN_SMOKE_DEBUG) {
199
+ console.log('DEBUG attempt results:', JSON.stringify(attemptResults, null, 2));
200
+ }
201
+
202
+ const summary = summarizeResults(attemptResults);
203
+ const authStatus = authPathStatus(attemptResults);
204
+ const effectiveFailures = Math.max(0, summary.failure - (authStatus.hasAuthSuccess ? authStatus.authFailures : 0));
205
+ const exitCode = chooseExitCode({ ...summary, failure: effectiveFailures }, timedOut, !authStatus.hasAuthSuccess, 0);
206
+
207
+ const elapsed = Date.now() - startedAt;
208
+
209
+ const lines = [];
210
+ lines.push(`Summary: success=${summary.success}, friction=${summary.friction}, failure=${effectiveFailures}, skipped=${summary.skipped}`);
211
+ if (timedOut) {
212
+ lines.push(`Result: FAILURE (time budget exceeded at ${elapsed}ms)`);
213
+ } else if (!authStatus.hasAuthSuccess) {
214
+ lines.push('Result: FAILURE (auth path unreachable)');
215
+ } else if (exitCode === 2) {
216
+ lines.push('Result: FAILURE');
217
+ } else if (exitCode === 1) {
218
+ lines.push('Result: FRICTION');
219
+ } else {
220
+ lines.push('Result: PASS');
221
+ }
222
+
223
+ for (const line of lines) {
224
+ console.log(line);
225
+ }
226
+
227
+ await browserPool.close();
228
+
229
+ // Restore proxy env vars
230
+ if (savedNoProxy.NO_PROXY !== undefined) {
231
+ process.env.NO_PROXY = savedNoProxy.NO_PROXY;
232
+ } else {
233
+ delete process.env.NO_PROXY;
234
+ }
235
+ if (savedNoProxy.no_proxy !== undefined) {
236
+ process.env.no_proxy = savedNoProxy.no_proxy;
237
+ } else {
238
+ delete process.env.no_proxy;
239
+ }
240
+
241
+ return { exitCode, attemptResults, timedOut, authAvailable: authStatus.hasAuthSuccess, elapsed };
242
+ }
243
+
244
+ async function runSmokeCLI(config) {
245
+ try {
246
+ const result = await executeSmoke(config);
247
+ process.exit(result.exitCode);
248
+ } catch (err) {
249
+ console.error(`Error: ${err.message}`);
250
+ process.exit(2);
251
+ }
252
+ }
253
+
254
+ module.exports = {
255
+ executeSmoke,
256
+ runSmokeCLI,
257
+ SMOKE_ATTEMPTS
258
+ };
@@ -38,6 +38,22 @@ class SnapshotBuilder {
38
38
  * Add attempt result to snapshot
39
39
  */
40
40
  addAttempt(attemptResult, artifactDir) {
41
+ // Phase 7.4: Handle SKIPPED attempts (don't add as signal)
42
+ if (attemptResult.outcome === 'SKIPPED') {
43
+ this.snapshot.attempts.push({
44
+ attemptId: attemptResult.attemptId,
45
+ attemptName: attemptResult.attemptName,
46
+ goal: attemptResult.goal,
47
+ outcome: 'SKIPPED',
48
+ skipReason: attemptResult.skipReason || 'Prerequisites not met',
49
+ totalDurationMs: 0,
50
+ stepCount: 0,
51
+ failedStepIndex: -1,
52
+ friction: null
53
+ });
54
+ return; // Don't create signals for skipped attempts
55
+ }
56
+
41
57
  const signal = {
42
58
  id: `attempt_${attemptResult.attemptId}`,
43
59
  severity: attemptResult.outcome === 'FAILURE' ? 'high' : 'medium',
@@ -123,7 +139,13 @@ class SnapshotBuilder {
123
139
  stepsTotal: flowResult.stepsTotal || 0,
124
140
  durationMs: flowResult.durationMs || 0,
125
141
  failedStep: flowResult.failedStep || null,
126
- error: flowResult.error || null
142
+ error: flowResult.error || null,
143
+ successEval: flowResult.successEval ? {
144
+ status: flowResult.successEval.status,
145
+ confidence: flowResult.successEval.confidence,
146
+ reasons: (flowResult.successEval.reasons || []).slice(0, 3),
147
+ evidence: flowResult.successEval.evidence || {}
148
+ } : null
127
149
  });
128
150
 
129
151
  if (runDir) {
@@ -0,0 +1,214 @@
1
+ /**
2
+ * Wave 1.3 — Outcome-Based Success Evaluator
3
+ * Deterministic success evaluation for form submissions and key flows.
4
+ * No reliance on specific confirmation text; uses signals: Network, Navigation, DOM, Errors.
5
+ */
6
+
7
+ const URL_SAFE_STATUSES = new Set([200, 201, 202, 204, 302, 303]);
8
+
9
+ /**
10
+ * Capture pre-submit state around a submit element's owning form.
11
+ * @param {import('playwright').Page} page
12
+ * @param {import('playwright').ElementHandle|null} submitHandle
13
+ */
14
+ async function captureBeforeState(page, submitHandle) {
15
+ const url = page.url();
16
+ const state = await page.evaluate((submitEl) => {
17
+ const result = {
18
+ formSelector: null,
19
+ formExists: false,
20
+ inputsFilledCount: 0,
21
+ inputsTotal: 0,
22
+ ariaInvalidCount: 0,
23
+ hasAlertRegion: false,
24
+ alertTextLength: 0,
25
+ liveRegionTextLength: 0,
26
+ };
27
+
28
+ let form = null;
29
+ if (submitEl) {
30
+ form = submitEl.closest('form');
31
+ } else {
32
+ form = document.querySelector('form');
33
+ }
34
+
35
+ if (form) {
36
+ result.formSelector = form.getAttribute('id') ? `#${form.id}` : null;
37
+ result.formExists = true;
38
+ const inputs = Array.from(form.querySelectorAll('input, textarea, select'));
39
+ result.inputsTotal = inputs.length;
40
+ result.inputsFilledCount = inputs.filter((el) => {
41
+ const val = (el.value || '').trim();
42
+ return val.length > 0;
43
+ }).length;
44
+ result.ariaInvalidCount = inputs.filter((el) => el.getAttribute('aria-invalid') === 'true').length;
45
+ }
46
+
47
+ // role=alert or [aria-live]
48
+ const alertEl = document.querySelector('[role="alert"], .alert, .error, .invalid');
49
+ if (alertEl) {
50
+ result.hasAlertRegion = true;
51
+ result.alertTextLength = (alertEl.textContent || '').trim().length;
52
+ }
53
+ const liveEl = document.querySelector('[aria-live]');
54
+ if (liveEl) {
55
+ result.liveRegionTextLength = (liveEl.textContent || '').trim().length;
56
+ }
57
+ return result;
58
+ }, submitHandle);
59
+
60
+ return { url, state };
61
+ }
62
+
63
+ /**
64
+ * Capture post-submit state.
65
+ * @param {import('playwright').Page} page
66
+ * @param {string|null} originalFormSelector
67
+ */
68
+ async function captureAfterState(page, originalFormSelector) {
69
+ const url = page.url();
70
+ const state = await page.evaluate((formSelector) => {
71
+ const result = {
72
+ formExists: false,
73
+ formDisabled: false,
74
+ inputsFilledCount: 0,
75
+ inputsTotal: 0,
76
+ ariaInvalidCount: 0,
77
+ hasAlertRegion: false,
78
+ alertTextLength: 0,
79
+ liveRegionTextLength: 0,
80
+ };
81
+
82
+ let form = null;
83
+ if (formSelector) {
84
+ form = document.querySelector(formSelector);
85
+ }
86
+ if (!form) {
87
+ form = document.querySelector('form');
88
+ }
89
+
90
+ if (form) {
91
+ result.formExists = true;
92
+ result.formDisabled = !!form.getAttribute('disabled') || (form.classList.contains('disabled'));
93
+ const inputs = Array.from(form.querySelectorAll('input, textarea, select'));
94
+ result.inputsTotal = inputs.length;
95
+ result.inputsFilledCount = inputs.filter((el) => (el.value || '').trim().length > 0).length;
96
+ result.ariaInvalidCount = inputs.filter((el) => el.getAttribute('aria-invalid') === 'true').length;
97
+ }
98
+
99
+ const alertEl = document.querySelector('[role="alert"], .alert, .error, .invalid');
100
+ if (alertEl) {
101
+ result.hasAlertRegion = true;
102
+ result.alertTextLength = (alertEl.textContent || '').trim().length;
103
+ }
104
+ const liveEl = document.querySelector('[aria-live]');
105
+ if (liveEl) {
106
+ result.liveRegionTextLength = (liveEl.textContent || '').trim().length;
107
+ }
108
+ return result;
109
+ }, originalFormSelector);
110
+
111
+ return { url, state };
112
+ }
113
+
114
+ /**
115
+ * Evaluate success based on signals.
116
+ * @param {{url:string,state:Object}} before
117
+ * @param {{url:string,state:Object}} after
118
+ * @param {{requests:Array,responses:Array,consoleErrors:Array,navChanged:boolean,baseOrigin:string}} events
119
+ * @returns {{status:'success'|'friction'|'failure',confidence:'high'|'medium'|'low',reasons:string[],evidence:Object}}
120
+ */
121
+ function evaluateSuccess(before, after, events) {
122
+ const reasons = [];
123
+ const evidence = {
124
+ network: [],
125
+ urlChanged: before.url !== after.url,
126
+ formCleared: false,
127
+ formDisappeared: false,
128
+ formDisabled: false,
129
+ alertRegionDelta: 0,
130
+ liveRegionDelta: 0,
131
+ ariaInvalidDelta: 0,
132
+ consoleErrors: events.consoleErrors || [],
133
+ };
134
+
135
+ // A) Network success
136
+ const baseOrigin = events.baseOrigin;
137
+ let strongNetworkPositive = false;
138
+ for (const r of (events.responses || [])) {
139
+ const req = typeof r.request === 'function' ? r.request() : r.request;
140
+ if (!req || typeof req.url !== 'function') continue;
141
+ const url = req.url();
142
+ const originOk = safeSameOrAllowedOrigin(url, baseOrigin);
143
+ const method = (typeof req.method === 'function' ? req.method() : '').toUpperCase();
144
+ const status = typeof r.status === 'function' ? r.status() : r.status;
145
+ const statusOk = URL_SAFE_STATUSES.has(status);
146
+ evidence.network.push({ method, url, status, originOk, statusOk });
147
+ if (originOk && (method === 'POST' || method === 'PUT') && statusOk) {
148
+ strongNetworkPositive = true;
149
+ }
150
+ }
151
+
152
+ if (strongNetworkPositive) {
153
+ reasons.push('Network submit succeeded (safe status and origin)');
154
+ }
155
+
156
+ // B) Navigation
157
+ const navPositive = !!events.navChanged;
158
+ if (navPositive) reasons.push('URL changed after submit');
159
+
160
+ // C) DOM outcome
161
+ const beforeState = before.state;
162
+ const afterState = after.state;
163
+ evidence.formDisappeared = !!beforeState.formExists && !afterState.formExists;
164
+ evidence.formDisabled = !!afterState.formDisabled;
165
+ evidence.formCleared = beforeState.inputsFilledCount > 0 && afterState.inputsFilledCount < beforeState.inputsFilledCount;
166
+ evidence.alertRegionDelta = (afterState.alertTextLength || 0) - (beforeState.alertTextLength || 0);
167
+ evidence.liveRegionDelta = (afterState.liveRegionTextLength || 0) - (beforeState.liveRegionTextLength || 0);
168
+ evidence.ariaInvalidDelta = (afterState.ariaInvalidCount || 0) - (beforeState.ariaInvalidCount || 0);
169
+
170
+ const domPositive = evidence.formDisappeared || evidence.formDisabled || evidence.formCleared || evidence.liveRegionDelta > 0;
171
+ if (domPositive) reasons.push('Form outcome indicates completion (cleared/disabled/disappeared or live region updated)');
172
+
173
+ // D) Error outcome
174
+ const strongNegative = evidence.ariaInvalidDelta > 0 || (afterState.hasAlertRegion && evidence.alertRegionDelta > 0);
175
+ if (strongNegative) reasons.push('Error markers increased after submit');
176
+
177
+ // E) Console errors
178
+ const consoleNegative = (events.consoleErrors || []).length > 0;
179
+ if (consoleNegative) reasons.push('Console errors after submit');
180
+
181
+ // Decision table
182
+ let status = 'failure';
183
+ let confidence = 'low';
184
+ if (strongNetworkPositive && (navPositive || domPositive) && !strongNegative && !consoleNegative) {
185
+ status = 'success';
186
+ confidence = 'high';
187
+ } else if ((strongNetworkPositive || navPositive || domPositive) && (strongNegative || consoleNegative)) {
188
+ status = 'friction';
189
+ confidence = strongNetworkPositive ? 'medium' : 'low';
190
+ } else if (strongNetworkPositive || navPositive || domPositive) {
191
+ status = 'success';
192
+ confidence = 'medium';
193
+ } else {
194
+ status = 'failure';
195
+ confidence = strongNegative ? 'medium' : 'low';
196
+ }
197
+
198
+ return { status, confidence, reasons, evidence };
199
+ }
200
+
201
+ function safeSameOrAllowedOrigin(url, baseOrigin) {
202
+ try {
203
+ const u = new URL(url);
204
+ return u.origin === baseOrigin;
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ module.exports = {
211
+ captureBeforeState,
212
+ captureAfterState,
213
+ evaluateSuccess,
214
+ };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Guardian Timeout Profiles
3
+ * Defines deterministic timeout values for different performance modes
4
+ */
5
+
6
+ const TIMEOUT_PROFILES = {
7
+ fast: {
8
+ // Fast mode: aggressive timeouts for quick feedback
9
+ pageLoad: 8000, // page navigation
10
+ elementWait: 3000, // finding elements
11
+ actionWait: 2000, // click/type settlement (used by wait-for-outcome)
12
+ submitSettle: 2500, // form submission settlement
13
+ networkWait: 1500, // network response wait
14
+ default: 8000
15
+ },
16
+ default: {
17
+ // Default mode: current behavior (balanced)
18
+ pageLoad: 20000,
19
+ elementWait: 5000,
20
+ actionWait: 3500, // matches DEFAULT_MAX_WAIT in wait-for-outcome
21
+ submitSettle: 4000,
22
+ networkWait: 3500,
23
+ default: 20000
24
+ },
25
+ slow: {
26
+ // Slow mode: patient timeouts for flaky networks
27
+ pageLoad: 30000,
28
+ elementWait: 10000,
29
+ actionWait: 5000,
30
+ submitSettle: 6000,
31
+ networkWait: 5000,
32
+ default: 30000
33
+ }
34
+ };
35
+
36
+ function getTimeoutProfile(profileName = 'default') {
37
+ const profile = TIMEOUT_PROFILES[profileName];
38
+ if (!profile) {
39
+ throw new Error(`Invalid timeout profile: ${profileName}. Valid values: ${Object.keys(TIMEOUT_PROFILES).join(', ')}`);
40
+ }
41
+ return profile;
42
+ }
43
+
44
+ function resolveTimeout(configValue, profile) {
45
+ // If config explicitly sets a timeout, use it (allows override)
46
+ if (configValue && typeof configValue === 'number') {
47
+ return configValue;
48
+ }
49
+ // Otherwise use profile default
50
+ return profile.default;
51
+ }
52
+
53
+ module.exports = {
54
+ TIMEOUT_PROFILES,
55
+ getTimeoutProfile,
56
+ resolveTimeout
57
+ };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Signal-based wait helper for actions.
3
+ * Prefers navigation/network/DOM quiet signals; falls back to bounded timeout.
4
+ */
5
+
6
+ const DEFAULT_MAX_WAIT = 3500; // bounded cap
7
+ const QUIET_WINDOW_MS = 300; // DOM must be quiet for this window
8
+
9
+ async function waitForOutcome(page, options = {}) {
10
+ const {
11
+ actionType = 'click',
12
+ baseOrigin = null,
13
+ initialUrl = null,
14
+ expectNavigation = false,
15
+ includeUrlChange = true,
16
+ maxWait = DEFAULT_MAX_WAIT,
17
+ } = options;
18
+
19
+ let resolved = false;
20
+ const cleanupFns = [];
21
+
22
+ const resolveWith = (reason, details = {}) => {
23
+ if (resolved) return;
24
+ resolved = true;
25
+ cleanupFns.forEach((fn) => {
26
+ try { fn(); } catch {}
27
+ });
28
+ return { reason, details };
29
+ };
30
+
31
+ const tasks = [];
32
+ const start = Date.now();
33
+
34
+ // Navigation signal
35
+ if (expectNavigation) {
36
+ const navPromise = page.waitForNavigation({ timeout: maxWait }).then(() => resolveWith('navigation')).catch(() => {});
37
+ tasks.push(navPromise);
38
+ }
39
+
40
+ // URL change signal (even without full nav event)
41
+ if (expectNavigation && includeUrlChange && initialUrl) {
42
+ const urlPromise = page.waitForURL((u) => u !== initialUrl, { timeout: maxWait }).then(() => resolveWith('url-change')).catch(() => {});
43
+ tasks.push(urlPromise);
44
+ }
45
+
46
+ // Network signal: watch for POST/PUT same-origin
47
+ const networkPromise = new Promise((res) => {
48
+ const handler = (response) => {
49
+ try {
50
+ const req = response.request();
51
+ const method = (req.method() || '').toUpperCase();
52
+ const url = req.url();
53
+ const status = response.status();
54
+ const originOk = baseOrigin ? new URL(url).origin === baseOrigin : true;
55
+ if ((method === 'POST' || method === 'PUT') && originOk) {
56
+ const out = resolveWith('network', { method, status, url });
57
+ if (out) res(out);
58
+ }
59
+ } catch {}
60
+ };
61
+ page.on('response', handler);
62
+ cleanupFns.push(() => page.off('response', handler));
63
+ });
64
+ tasks.push(networkPromise);
65
+
66
+ // DOM quiet signal via MutationObserver
67
+ const domPromise = new Promise((res) => {
68
+ let lastMutation = Date.now();
69
+ let hasMutated = false;
70
+ const quietCheck = () => {
71
+ if (!hasMutated) return;
72
+ if (Date.now() - lastMutation >= QUIET_WINDOW_MS) {
73
+ const out = resolveWith('dom-quiet');
74
+ if (out) res(out);
75
+ }
76
+ };
77
+ const interval = setInterval(quietCheck, 80);
78
+ cleanupFns.push(() => clearInterval(interval));
79
+ page.evaluate(() => {
80
+ window.__guardianLastMutation = Date.now();
81
+ window.__guardianHasMutated = false;
82
+ if (window.__guardianDomObserver) {
83
+ try { window.__guardianDomObserver.disconnect(); } catch (e) {}
84
+ }
85
+ const obs = new MutationObserver(() => {
86
+ window.__guardianLastMutation = Date.now();
87
+ window.__guardianHasMutated = true;
88
+ });
89
+ obs.observe(document.documentElement || document.body, { childList: true, subtree: true, attributes: true, characterData: true });
90
+ window.__guardianDomObserver = obs;
91
+ }).catch(() => {});
92
+ const pollMutation = setInterval(async () => {
93
+ try {
94
+ const data = await page.evaluate(() => ({
95
+ last: window.__guardianLastMutation || Date.now(),
96
+ mutated: !!window.__guardianHasMutated
97
+ }));
98
+ lastMutation = data.last;
99
+ if (data.mutated) hasMutated = true;
100
+ } catch {}
101
+ }, 120);
102
+ cleanupFns.push(() => clearInterval(pollMutation));
103
+ });
104
+ tasks.push(domPromise);
105
+
106
+ // Bounded fallback
107
+ const timeoutPromise = new Promise((res) => {
108
+ const t = setTimeout(() => {
109
+ const out = resolveWith('timeout', { elapsedMs: Date.now() - start });
110
+ if (out) res(out);
111
+ }, maxWait);
112
+ cleanupFns.push(() => clearTimeout(t));
113
+ });
114
+ tasks.push(timeoutPromise);
115
+
116
+ const first = await Promise.race(tasks);
117
+ return first || { reason: 'timeout', details: { elapsedMs: maxWait } };
118
+ }
119
+
120
+ module.exports = { waitForOutcome };