@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.
- package/CHANGELOG.md +62 -0
- package/README.md +3 -3
- package/bin/guardian.js +212 -8
- package/package.json +6 -1
- package/src/guardian/attempt-engine.js +19 -5
- package/src/guardian/attempt.js +61 -39
- package/src/guardian/attempts-filter.js +63 -0
- package/src/guardian/baseline.js +44 -10
- package/src/guardian/browser-pool.js +131 -0
- package/src/guardian/browser.js +28 -1
- package/src/guardian/ci-mode.js +15 -0
- package/src/guardian/ci-output.js +37 -0
- package/src/guardian/cli-summary.js +117 -4
- package/src/guardian/data-guardian-detector.js +189 -0
- package/src/guardian/detection-layers.js +271 -0
- package/src/guardian/first-run.js +49 -0
- package/src/guardian/flag-validator.js +97 -0
- package/src/guardian/flow-executor.js +309 -44
- package/src/guardian/language-detection.js +99 -0
- package/src/guardian/market-reporter.js +16 -1
- package/src/guardian/parallel-executor.js +116 -0
- package/src/guardian/prerequisite-checker.js +101 -0
- package/src/guardian/preset-loader.js +18 -12
- package/src/guardian/profile-loader.js +96 -0
- package/src/guardian/reality.js +382 -46
- package/src/guardian/run-summary.js +20 -0
- package/src/guardian/semantic-contact-detection.js +255 -0
- package/src/guardian/semantic-contact-finder.js +200 -0
- package/src/guardian/semantic-targets.js +234 -0
- package/src/guardian/smoke.js +258 -0
- package/src/guardian/snapshot.js +23 -1
- package/src/guardian/success-evaluator.js +214 -0
- package/src/guardian/timeout-profiles.js +57 -0
- package/src/guardian/wait-for-outcome.js +120 -0
- 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
|
+
};
|
package/src/guardian/snapshot.js
CHANGED
|
@@ -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 };
|