@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,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardian Flag Validation
|
|
3
|
+
* Early validation of flags and options to catch errors before module load.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const VALID_SUBCOMMANDS = [
|
|
7
|
+
'init', 'protect', 'reality', 'attempt', 'baseline',
|
|
8
|
+
'presets', 'evaluate', 'version', 'flow', 'scan', 'smoke'
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
const VALID_GLOBAL_FLAGS = [
|
|
12
|
+
'--help', '-h', '--version', '-v', '--debug'
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const VALID_SUBCOMMAND_FLAGS = {
|
|
16
|
+
'scan': ['--url', '--preset', '--artifacts', '--policy', '--headful', '--no-trace', '--no-screenshots', '--watch', '-w', '--fast', '--fail-fast', '--timeout-profile', '--attempts', '--parallel', '--help', '-h'],
|
|
17
|
+
'protect': ['--url', '--policy', '--webhook', '--watch', '-w', '--fast', '--fail-fast', '--timeout-profile', '--attempts', '--parallel', '--help', '-h'],
|
|
18
|
+
'reality': ['--url', '--attempts', '--artifacts', '--policy', '--discover', '--universal', '--webhook', '--headful', '--watch', '-w', '--no-trace', '--no-screenshots', '--fast', '--fail-fast', '--timeout-profile', '--parallel', '--help', '-h'],
|
|
19
|
+
'attempt': ['--url', '--attempt', '--artifacts', '--headful', '--no-trace', '--no-screenshots', '--help', '-h'],
|
|
20
|
+
'smoke': ['--url', '--headful', '--budget-ms', '--help', '-h'],
|
|
21
|
+
'baseline': [],
|
|
22
|
+
'init': ['--preset', '--help', '-h'],
|
|
23
|
+
'presets': ['--help', '-h']
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function validateFlags(argv) {
|
|
27
|
+
const args = argv.slice(2);
|
|
28
|
+
if (args.length === 0) return { valid: true };
|
|
29
|
+
|
|
30
|
+
const subcommand = args[0];
|
|
31
|
+
|
|
32
|
+
// Check if it's a global flag or help
|
|
33
|
+
if (VALID_GLOBAL_FLAGS.includes(subcommand)) {
|
|
34
|
+
return { valid: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check if subcommand is valid
|
|
38
|
+
if (subcommand && !subcommand.startsWith('-') && !VALID_SUBCOMMANDS.includes(subcommand)) {
|
|
39
|
+
return {
|
|
40
|
+
valid: false,
|
|
41
|
+
error: `Unknown command '${subcommand}'`,
|
|
42
|
+
hint: `Valid commands: ${VALID_SUBCOMMANDS.slice(0, 5).join(', ')}, …`
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// If we have a valid subcommand, validate its flags
|
|
47
|
+
if (VALID_SUBCOMMANDS.includes(subcommand)) {
|
|
48
|
+
const validFlags = VALID_SUBCOMMAND_FLAGS[subcommand] || [];
|
|
49
|
+
const subArgs = args.slice(1);
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < subArgs.length; i++) {
|
|
52
|
+
const arg = subArgs[i];
|
|
53
|
+
if (arg.startsWith('--') || arg.startsWith('-')) {
|
|
54
|
+
const flagName = arg.split('=')[0]; // Handle --flag=value
|
|
55
|
+
if (!validFlags.includes(flagName)) {
|
|
56
|
+
return {
|
|
57
|
+
valid: false,
|
|
58
|
+
error: `Unknown flag '${flagName}' for command '${subcommand}'`,
|
|
59
|
+
hint: `Run 'guardian ${subcommand} --help' for valid options.`
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
// Check if flag expects a value
|
|
63
|
+
if ((flagName.includes('--') && !arg.includes('=')) || (flagName === '-w')) {
|
|
64
|
+
const expectsValue = ![
|
|
65
|
+
'--headful', '--watch', '-w', '--discover', '--universal',
|
|
66
|
+
'--no-trace', '--no-screenshots', '--help', '-h'
|
|
67
|
+
].includes(flagName);
|
|
68
|
+
|
|
69
|
+
if (expectsValue && i + 1 < subArgs.length && subArgs[i + 1].startsWith('-')) {
|
|
70
|
+
return {
|
|
71
|
+
valid: false,
|
|
72
|
+
error: `Flag '${flagName}' requires a value`,
|
|
73
|
+
hint: `Usage: guardian ${subcommand} ${flagName} <value>`
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { valid: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function reportFlagError(validation) {
|
|
85
|
+
if (!validation.valid) {
|
|
86
|
+
console.error(`Error: ${validation.error}`);
|
|
87
|
+
if (validation.hint) {
|
|
88
|
+
console.error(`Hint: ${validation.hint}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
validateFlags,
|
|
95
|
+
reportFlagError,
|
|
96
|
+
VALID_SUBCOMMANDS
|
|
97
|
+
};
|
|
@@ -5,12 +5,85 @@
|
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
|
+
const { waitForOutcome } = require('./wait-for-outcome');
|
|
9
|
+
|
|
10
|
+
const MAX_ACTION_RETRIES = 1;
|
|
11
|
+
const RETRY_BACKOFF_MS = 150; // deterministic, small backoff
|
|
12
|
+
|
|
13
|
+
function validateFlowDefinition(flow) {
|
|
14
|
+
if (!flow || !Array.isArray(flow.steps) || flow.steps.length === 0) {
|
|
15
|
+
return { ok: false, reason: 'Flow misconfigured: no steps defined (add at least one step).' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
for (let i = 0; i < flow.steps.length; i++) {
|
|
19
|
+
const step = flow.steps[i] || {};
|
|
20
|
+
const needsTarget = ['click', 'type', 'submit', 'waitFor', 'navigate'];
|
|
21
|
+
if (needsTarget.includes(step.type) && !step.target) {
|
|
22
|
+
return { ok: false, reason: `Flow misconfigured: step ${i + 1} (${step.type}) missing target selector.` };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return { ok: true };
|
|
27
|
+
}
|
|
8
28
|
|
|
9
29
|
class GuardianFlowExecutor {
|
|
10
30
|
constructor(options = {}) {
|
|
11
31
|
this.timeout = options.timeout || 30000; // 30 seconds per step
|
|
12
32
|
this.screenshotOnStep = options.screenshotOnStep !== false; // Screenshot each step by default
|
|
13
33
|
this.safety = options.safety || null; // Safety guard instance (optional)
|
|
34
|
+
this.baseUrl = options.baseUrl || null; // For origin checks
|
|
35
|
+
this.quiet = options.quiet === true;
|
|
36
|
+
this.log = (...args) => {
|
|
37
|
+
if (!this.quiet) console.log(...args);
|
|
38
|
+
};
|
|
39
|
+
this.warn = (...args) => {
|
|
40
|
+
if (!this.quiet) console.warn(...args);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async runActionWithRetry(step, actionLabel, attemptFn) {
|
|
45
|
+
let attempt = 0;
|
|
46
|
+
let lastErr = null;
|
|
47
|
+
let lastResult = null;
|
|
48
|
+
|
|
49
|
+
while (attempt <= MAX_ACTION_RETRIES) {
|
|
50
|
+
try {
|
|
51
|
+
lastResult = await attemptFn();
|
|
52
|
+
if (lastResult && lastResult.success) {
|
|
53
|
+
if (attempt > 0) {
|
|
54
|
+
lastResult.retried = true;
|
|
55
|
+
lastResult.retryCount = attempt;
|
|
56
|
+
this.log(` ✅ Retry succeeded for action ${actionLabel}`);
|
|
57
|
+
}
|
|
58
|
+
return lastResult;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (lastResult && lastResult.retryable === false) {
|
|
62
|
+
return lastResult;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (lastResult && lastResult.error) {
|
|
66
|
+
lastErr = lastResult.error instanceof Error ? lastResult.error : new Error(lastResult.error);
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
lastErr = error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (attempt >= MAX_ACTION_RETRIES || !isRetryableActionError(lastErr)) {
|
|
73
|
+
const errorMsg = lastErr ? lastErr.message : 'Action failed';
|
|
74
|
+
const severity = classifyError(step, lastErr || new Error('Action failed'));
|
|
75
|
+
if (attempt > 0) {
|
|
76
|
+
this.log(` ❌ Retry ${attempt}/${MAX_ACTION_RETRIES} failed for action ${actionLabel}: ${errorMsg}`);
|
|
77
|
+
}
|
|
78
|
+
return { success: false, error: errorMsg, severity, retryCount: attempt > 0 ? attempt : 0 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
attempt++;
|
|
82
|
+
this.log(` 🔁 Retry ${attempt}/${MAX_ACTION_RETRIES} attempted for action ${actionLabel}${lastErr && lastErr.message ? ` (${lastErr.message})` : ''}`);
|
|
83
|
+
await deterministicDelay(RETRY_BACKOFF_MS);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return lastResult || { success: false, error: 'Action failed', severity: 'hard' };
|
|
14
87
|
}
|
|
15
88
|
|
|
16
89
|
/**
|
|
@@ -37,7 +110,7 @@ class GuardianFlowExecutor {
|
|
|
37
110
|
*/
|
|
38
111
|
async executeStep(page, step, stepIndex) {
|
|
39
112
|
try {
|
|
40
|
-
|
|
113
|
+
this.log(` Step ${stepIndex + 1}: ${step.type} ${step.target || ''}`);
|
|
41
114
|
|
|
42
115
|
// Safety check for destructive actions
|
|
43
116
|
if (this.safety) {
|
|
@@ -73,12 +146,14 @@ class GuardianFlowExecutor {
|
|
|
73
146
|
return {
|
|
74
147
|
success: false,
|
|
75
148
|
error: `Unknown step type: ${step.type}`,
|
|
149
|
+
severity: 'hard'
|
|
76
150
|
};
|
|
77
151
|
}
|
|
78
152
|
} catch (error) {
|
|
79
153
|
return {
|
|
80
154
|
success: false,
|
|
81
155
|
error: error.message,
|
|
156
|
+
severity: classifyError(step, error)
|
|
82
157
|
};
|
|
83
158
|
}
|
|
84
159
|
}
|
|
@@ -97,7 +172,7 @@ class GuardianFlowExecutor {
|
|
|
97
172
|
});
|
|
98
173
|
return { success: true, error: null };
|
|
99
174
|
} catch (error) {
|
|
100
|
-
return { success: false, error: error.message };
|
|
175
|
+
return { success: false, error: error.message, severity: classifyError(step, error) };
|
|
101
176
|
}
|
|
102
177
|
}
|
|
103
178
|
|
|
@@ -108,18 +183,20 @@ class GuardianFlowExecutor {
|
|
|
108
183
|
* @returns {Promise<object>} Result
|
|
109
184
|
*/
|
|
110
185
|
async stepClick(page, step) {
|
|
111
|
-
|
|
186
|
+
return this.runActionWithRetry(step, 'click', async () => {
|
|
187
|
+
const initialUrl = page.url();
|
|
188
|
+
const waitPromise = waitForOutcome(page, {
|
|
189
|
+
actionType: 'click',
|
|
190
|
+
baseOrigin: this.baseUrl ? new URL(this.baseUrl).origin : null,
|
|
191
|
+
initialUrl,
|
|
192
|
+
expectNavigation: !!step.waitForNavigation,
|
|
193
|
+
maxWait: Math.min(this.timeout, 3500)
|
|
194
|
+
});
|
|
112
195
|
await page.click(step.target, { timeout: this.timeout });
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (step.waitForNavigation) {
|
|
116
|
-
await page.waitForLoadState('domcontentloaded');
|
|
117
|
-
}
|
|
118
|
-
|
|
196
|
+
const waitResult = await waitPromise;
|
|
197
|
+
this.log(` ⏱️ wait resolved by ${waitResult.reason}`);
|
|
119
198
|
return { success: true, error: null };
|
|
120
|
-
}
|
|
121
|
-
return { success: false, error: error.message };
|
|
122
|
-
}
|
|
199
|
+
});
|
|
123
200
|
}
|
|
124
201
|
|
|
125
202
|
/**
|
|
@@ -129,21 +206,18 @@ class GuardianFlowExecutor {
|
|
|
129
206
|
* @returns {Promise<object>} Result
|
|
130
207
|
*/
|
|
131
208
|
async stepType(page, step) {
|
|
132
|
-
|
|
133
|
-
// Clear field first if specified
|
|
209
|
+
return this.runActionWithRetry(step, 'type', async () => {
|
|
134
210
|
if (step.clear !== false) {
|
|
135
211
|
await page.fill(step.target, '');
|
|
136
212
|
}
|
|
137
|
-
|
|
213
|
+
|
|
138
214
|
await page.type(step.target, step.value, {
|
|
139
215
|
timeout: this.timeout,
|
|
140
216
|
delay: step.delay || 50, // Simulate human typing
|
|
141
217
|
});
|
|
142
218
|
|
|
143
219
|
return { success: true, error: null };
|
|
144
|
-
}
|
|
145
|
-
return { success: false, error: error.message };
|
|
146
|
-
}
|
|
220
|
+
});
|
|
147
221
|
}
|
|
148
222
|
|
|
149
223
|
/**
|
|
@@ -153,22 +227,132 @@ class GuardianFlowExecutor {
|
|
|
153
227
|
* @returns {Promise<object>} Result
|
|
154
228
|
*/
|
|
155
229
|
async stepSubmit(page, step) {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
230
|
+
return this.runActionWithRetry(step, 'submit', async () => {
|
|
231
|
+
const { captureBeforeState, captureAfterState, evaluateSuccess } = require('./success-evaluator');
|
|
232
|
+
|
|
233
|
+
const submitSelector = step.target || 'button[type="submit"]';
|
|
234
|
+
const submitHandle = await page.$(submitSelector);
|
|
235
|
+
const before = await captureBeforeState(page, submitHandle);
|
|
236
|
+
|
|
237
|
+
const requests = [];
|
|
238
|
+
const responses = [];
|
|
239
|
+
const consoleErrors = [];
|
|
240
|
+
let initialUrl = page.url();
|
|
241
|
+
let navChanged = false;
|
|
242
|
+
|
|
243
|
+
const onRequest = (req) => { requests.push(req); };
|
|
244
|
+
const onResponse = (res) => {
|
|
245
|
+
try {
|
|
246
|
+
const req = res.request();
|
|
247
|
+
if (!req || requests.includes(req)) {
|
|
248
|
+
responses.push(res);
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
responses.push(res);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
const onConsole = (msg) => {
|
|
255
|
+
if (msg.type() === 'error') {
|
|
256
|
+
consoleErrors.push({ type: msg.type(), text: msg.text() });
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
const onFrameNav = () => {
|
|
260
|
+
try {
|
|
261
|
+
const nowUrl = page.url();
|
|
262
|
+
if (nowUrl && nowUrl !== initialUrl) navChanged = true;
|
|
263
|
+
} catch {}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
page.on('request', onRequest);
|
|
267
|
+
page.on('response', onResponse);
|
|
268
|
+
page.on('console', onConsole);
|
|
269
|
+
page.on('framenavigated', onFrameNav);
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
if (submitHandle) {
|
|
273
|
+
await submitHandle.click({ timeout: this.timeout });
|
|
274
|
+
} else {
|
|
275
|
+
await page.click(submitSelector, { timeout: this.timeout });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const baseOrigin = this.baseUrl ? new URL(this.baseUrl).origin : (new URL(initialUrl)).origin;
|
|
279
|
+
const waitResult = await waitForOutcome(page, {
|
|
280
|
+
actionType: 'submit',
|
|
281
|
+
baseOrigin: baseOrigin,
|
|
282
|
+
initialUrl,
|
|
283
|
+
expectNavigation: true,
|
|
284
|
+
includeUrlChange: false,
|
|
285
|
+
maxWait: Math.min(4000, this.timeout)
|
|
286
|
+
});
|
|
287
|
+
this.log(` ⏱️ wait resolved by ${waitResult.reason}`);
|
|
288
|
+
|
|
289
|
+
const originalFormSelector = before.state.formSelector;
|
|
290
|
+
const after = await captureAfterState(page, originalFormSelector);
|
|
291
|
+
|
|
292
|
+
const evalResult = evaluateSuccess(before, after, {
|
|
293
|
+
requests,
|
|
294
|
+
responses,
|
|
295
|
+
consoleErrors,
|
|
296
|
+
navChanged,
|
|
297
|
+
baseOrigin,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const topReasonsArr = (evalResult.reasons || []).slice(0, 3);
|
|
301
|
+
const topReasons = topReasonsArr.join(' + ');
|
|
302
|
+
this.log(` SUBMIT: ${evalResult.status.toUpperCase()} (confidence: ${evalResult.confidence}) — ${topReasons || 'no signals'}`);
|
|
303
|
+
if (topReasonsArr.length > 0) {
|
|
304
|
+
this.log(' Reasons:');
|
|
305
|
+
topReasonsArr.forEach(r => this.log(` - ${r}`));
|
|
306
|
+
}
|
|
307
|
+
const ev = evalResult.evidence || {};
|
|
308
|
+
const net = Array.isArray(ev.network) ? ev.network : [];
|
|
309
|
+
const primary = net.find(n => (n.method === 'POST' || n.method === 'PUT') && n.status != null) || net[0];
|
|
310
|
+
const reqLine = (() => {
|
|
311
|
+
if (!primary) return null;
|
|
312
|
+
try {
|
|
313
|
+
const p = new URL(primary.url);
|
|
314
|
+
return `request: ${primary.method} ${p.pathname} → ${primary.status}`;
|
|
315
|
+
} catch { return `request: ${primary.method} ${primary.url} → ${primary.status}`; }
|
|
316
|
+
})();
|
|
317
|
+
const navLine = (ev.urlChanged || navChanged) ? (() => {
|
|
318
|
+
try {
|
|
319
|
+
const from = new URL(before.url).pathname;
|
|
320
|
+
const to = new URL(after.url).pathname;
|
|
321
|
+
return `navigation: ${from} → ${to}`;
|
|
322
|
+
} catch { return `navigation: changed`; }
|
|
323
|
+
})() : null;
|
|
324
|
+
const formStates = [];
|
|
325
|
+
if (ev.formCleared) formStates.push('cleared');
|
|
326
|
+
if (ev.formDisabled) formStates.push('disabled');
|
|
327
|
+
if (ev.formDisappeared) formStates.push('disappeared');
|
|
328
|
+
const formLine = formStates.length ? `form: ${formStates.join(', ')}` : null;
|
|
329
|
+
const errorLines = [];
|
|
330
|
+
if ((ev.ariaInvalidDelta || 0) > 0) errorLines.push('aria-invalid increased');
|
|
331
|
+
if ((ev.alertRegionDelta || 0) > 0) errorLines.push('role=alert updated');
|
|
332
|
+
const consoleErr = (consoleErrors || [])[0];
|
|
333
|
+
const consoleLine = consoleErr ? `console.error: "${String(consoleErr.text).slice(0, 120)}"` : null;
|
|
334
|
+
const evidenceLines = [reqLine, navLine, formLine, ...errorLines.map(e => `error: ${e}`), consoleLine].filter(Boolean);
|
|
335
|
+
if (evidenceLines.length > 0) {
|
|
336
|
+
this.log(' Evidence:');
|
|
337
|
+
evidenceLines.slice(0, 3).forEach(line => this.log(` - ${line}`));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const ok = evalResult.status === 'success';
|
|
341
|
+
const errMsg = ok ? null : `Outcome evaluation: ${evalResult.status}`;
|
|
342
|
+
return {
|
|
343
|
+
success: ok,
|
|
344
|
+
error: errMsg,
|
|
345
|
+
successEval: evalResult,
|
|
346
|
+
severity: evalResult.status === 'failure' ? 'hard' : 'soft',
|
|
347
|
+
retryable: ok ? undefined : false
|
|
348
|
+
};
|
|
349
|
+
} finally {
|
|
350
|
+
page.off('request', onRequest);
|
|
351
|
+
page.off('response', onResponse);
|
|
352
|
+
page.off('console', onConsole);
|
|
353
|
+
page.off('framenavigated', onFrameNav);
|
|
163
354
|
}
|
|
164
|
-
|
|
165
|
-
// Wait for navigation
|
|
166
|
-
await page.waitForLoadState('domcontentloaded');
|
|
167
|
-
|
|
168
|
-
return { success: true, error: null };
|
|
169
|
-
} catch (error) {
|
|
170
|
-
return { success: false, error: error.message };
|
|
171
|
-
}
|
|
355
|
+
});
|
|
172
356
|
}
|
|
173
357
|
|
|
174
358
|
/**
|
|
@@ -186,7 +370,7 @@ class GuardianFlowExecutor {
|
|
|
186
370
|
|
|
187
371
|
return { success: true, error: null };
|
|
188
372
|
} catch (error) {
|
|
189
|
-
return { success: false, error: error.message };
|
|
373
|
+
return { success: false, error: error.message, severity: classifyError(step, error) };
|
|
190
374
|
}
|
|
191
375
|
}
|
|
192
376
|
|
|
@@ -202,7 +386,7 @@ class GuardianFlowExecutor {
|
|
|
202
386
|
await page.waitForTimeout(duration);
|
|
203
387
|
return { success: true, error: null };
|
|
204
388
|
} catch (error) {
|
|
205
|
-
return { success: false, error: error.message };
|
|
389
|
+
return { success: false, error: error.message, severity: classifyError(step, error) };
|
|
206
390
|
}
|
|
207
391
|
}
|
|
208
392
|
|
|
@@ -242,6 +426,7 @@ class GuardianFlowExecutor {
|
|
|
242
426
|
* @returns {Promise<object>} Flow result
|
|
243
427
|
*/
|
|
244
428
|
async executeFlow(page, flow, artifactsDir, baseUrl = null) {
|
|
429
|
+
this.baseUrl = baseUrl || this.baseUrl;
|
|
245
430
|
const result = {
|
|
246
431
|
flowId: flow.id,
|
|
247
432
|
flowName: flow.name,
|
|
@@ -251,8 +436,11 @@ class GuardianFlowExecutor {
|
|
|
251
436
|
failedStep: null,
|
|
252
437
|
error: null,
|
|
253
438
|
screenshots: [],
|
|
254
|
-
durationMs: 0
|
|
439
|
+
durationMs: 0,
|
|
440
|
+
outcome: 'SUCCESS',
|
|
441
|
+
failureReasons: []
|
|
255
442
|
};
|
|
443
|
+
let hadRetrySuccess = false;
|
|
256
444
|
|
|
257
445
|
// Normalize steps with optional baseUrl substitution
|
|
258
446
|
const steps = (flow.steps || []).map((step) => {
|
|
@@ -270,8 +458,8 @@ class GuardianFlowExecutor {
|
|
|
270
458
|
|
|
271
459
|
let startedAt = Date.now();
|
|
272
460
|
try {
|
|
273
|
-
|
|
274
|
-
|
|
461
|
+
this.log(`\n🎬 Executing flow: ${flow.name}`);
|
|
462
|
+
this.log(`📋 Steps: ${steps.length}`);
|
|
275
463
|
|
|
276
464
|
for (let i = 0; i < steps.length; i++) {
|
|
277
465
|
const step = steps[i];
|
|
@@ -295,21 +483,63 @@ class GuardianFlowExecutor {
|
|
|
295
483
|
}
|
|
296
484
|
}
|
|
297
485
|
|
|
486
|
+
// If submit step returns evaluator details, attach them
|
|
487
|
+
if (step.type === 'submit' && stepResult && stepResult.successEval) {
|
|
488
|
+
result.successEval = stepResult.successEval;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (stepResult.retried || (stepResult.retryCount && stepResult.retryCount > 0)) {
|
|
492
|
+
if (stepResult.success) {
|
|
493
|
+
hadRetrySuccess = true;
|
|
494
|
+
}
|
|
495
|
+
result.retryCount = (result.retryCount || 0) + (stepResult.retryCount || 0);
|
|
496
|
+
}
|
|
497
|
+
|
|
298
498
|
// Check if step failed
|
|
299
499
|
if (!stepResult.success) {
|
|
300
500
|
result.failedStep = i + 1;
|
|
301
501
|
result.error = stepResult.error;
|
|
302
|
-
|
|
303
|
-
|
|
502
|
+
const severity = stepResult.severity || 'hard';
|
|
503
|
+
const reason = `${step.type} failed: ${stepResult.error}`;
|
|
504
|
+
result.failureReasons.push(reason);
|
|
505
|
+
if (severity === 'soft') {
|
|
506
|
+
result.outcome = result.outcome === 'FAILURE' ? 'FAILURE' : 'FRICTION';
|
|
507
|
+
this.log(` ⚠️ Soft failure: ${stepResult.error}`);
|
|
508
|
+
continue;
|
|
509
|
+
} else {
|
|
510
|
+
result.outcome = 'FAILURE';
|
|
511
|
+
this.log(` ❌ Step failed: ${stepResult.error}`);
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
304
514
|
}
|
|
305
515
|
|
|
306
516
|
result.stepsExecuted++;
|
|
307
|
-
|
|
517
|
+
this.log(` ✅ Step ${i + 1} completed`);
|
|
308
518
|
}
|
|
309
519
|
|
|
310
|
-
|
|
520
|
+
// If we have an outcome evaluation from a submit, trust it for overall flow success
|
|
521
|
+
if (result.successEval) {
|
|
522
|
+
result.success = result.successEval.status === 'success';
|
|
523
|
+
if (!result.success && result.outcome !== 'FAILURE') {
|
|
524
|
+
result.outcome = result.successEval.status === 'friction' ? 'FRICTION' : 'FAILURE';
|
|
525
|
+
}
|
|
526
|
+
} else {
|
|
527
|
+
result.success = result.outcome !== 'FAILURE';
|
|
528
|
+
}
|
|
529
|
+
if (hadRetrySuccess && result.outcome === 'SUCCESS') {
|
|
530
|
+
result.outcome = 'FRICTION';
|
|
531
|
+
}
|
|
532
|
+
if (result.outcome === 'SUCCESS' && !result.success) {
|
|
533
|
+
result.outcome = 'FAILURE';
|
|
534
|
+
}
|
|
311
535
|
result.durationMs = Date.now() - startedAt;
|
|
312
|
-
|
|
536
|
+
if (result.outcome === 'SUCCESS') {
|
|
537
|
+
this.log(`✅ Flow completed successfully`);
|
|
538
|
+
} else if (result.outcome === 'FRICTION') {
|
|
539
|
+
this.log(`⚠️ Flow completed with friction`);
|
|
540
|
+
} else {
|
|
541
|
+
this.log(`❌ Flow completed with failure`);
|
|
542
|
+
}
|
|
313
543
|
|
|
314
544
|
return result;
|
|
315
545
|
} catch (error) {
|
|
@@ -318,6 +548,7 @@ class GuardianFlowExecutor {
|
|
|
318
548
|
}
|
|
319
549
|
result.error = error.message;
|
|
320
550
|
console.error(`❌ Flow execution failed: ${error.message}`);
|
|
551
|
+
result.outcome = result.outcome === 'SUCCESS' ? 'FAILURE' : result.outcome;
|
|
321
552
|
return result;
|
|
322
553
|
}
|
|
323
554
|
}
|
|
@@ -371,4 +602,38 @@ class GuardianFlowExecutor {
|
|
|
371
602
|
}
|
|
372
603
|
}
|
|
373
604
|
|
|
374
|
-
|
|
605
|
+
function classifyError(step, error) {
|
|
606
|
+
const msg = (error && error.message) ? error.message.toLowerCase() : '';
|
|
607
|
+
const isTimeout = msg.includes('timeout');
|
|
608
|
+
const isNavCrash = msg.includes('target closed') || msg.includes('page crashed') || msg.includes('net::');
|
|
609
|
+
const missingSelector = msg.includes('not found') || msg.includes('waiting for selector');
|
|
610
|
+
|
|
611
|
+
if (isNavCrash) return 'hard';
|
|
612
|
+
if (missingSelector) return 'hard';
|
|
613
|
+
if (step && step.type !== 'waitFor' && isTimeout) return 'hard';
|
|
614
|
+
if (isTimeout) return 'soft';
|
|
615
|
+
return 'hard';
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function deterministicDelay(ms) {
|
|
619
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function isRetryableActionError(error) {
|
|
623
|
+
if (!error) return false;
|
|
624
|
+
const msg = (error.message || String(error)).toLowerCase();
|
|
625
|
+
if (!msg) return false;
|
|
626
|
+
|
|
627
|
+
const isTargetClosed = msg.includes('target closed') || msg.includes('page crashed') || msg.includes('browser has disconnected');
|
|
628
|
+
const isMissingSelector = msg.includes('not found') || msg.includes('waiting for selector') || msg.includes('failed to find element') || msg.includes('selector resolved to') && msg.includes('null');
|
|
629
|
+
if (isTargetClosed) return false;
|
|
630
|
+
if (isMissingSelector) return false;
|
|
631
|
+
|
|
632
|
+
const isTimeout = msg.includes('timeout');
|
|
633
|
+
const isDetached = msg.includes('detached') || msg.includes('not attached') || msg.includes('stale element');
|
|
634
|
+
const isNavRace = msg.includes('execution context was destroyed') || (msg.includes('navigation') && msg.includes('race'));
|
|
635
|
+
|
|
636
|
+
return isTimeout || isDetached || isNavRace;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
module.exports = { GuardianFlowExecutor, validateFlowDefinition };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Language Detection
|
|
3
|
+
*
|
|
4
|
+
* Deterministic language detection from HTML attributes.
|
|
5
|
+
* No guessing, no AI. Only reads explicit language declarations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Detect page language from HTML
|
|
10
|
+
*
|
|
11
|
+
* Detection order:
|
|
12
|
+
* 1. <html lang="..."> attribute
|
|
13
|
+
* 2. <meta http-equiv="content-language" ...> attribute
|
|
14
|
+
* 3. fallback: "unknown"
|
|
15
|
+
*
|
|
16
|
+
* @param {Page} page - Playwright page object
|
|
17
|
+
* @returns {Promise<string>} BCP-47 language code or "unknown"
|
|
18
|
+
*/
|
|
19
|
+
async function detectLanguage(page) {
|
|
20
|
+
try {
|
|
21
|
+
// Try <html lang="...">
|
|
22
|
+
const htmlLang = await page.evaluate(() => {
|
|
23
|
+
const htmlElement = document.documentElement;
|
|
24
|
+
return htmlElement.getAttribute('lang');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
if (htmlLang && htmlLang.trim()) {
|
|
28
|
+
// Return the language code (e.g., "de", "de-DE", "en", "en-US")
|
|
29
|
+
return htmlLang.trim().toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Try <meta http-equiv="content-language" ...>
|
|
33
|
+
const metaLang = await page.evaluate(() => {
|
|
34
|
+
const meta = document.querySelector('meta[http-equiv="content-language"]');
|
|
35
|
+
return meta ? meta.getAttribute('content') : null;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (metaLang && metaLang.trim()) {
|
|
39
|
+
return metaLang.trim().toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fallback
|
|
43
|
+
return 'unknown';
|
|
44
|
+
} catch (error) {
|
|
45
|
+
// If evaluation fails, return unknown
|
|
46
|
+
return 'unknown';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parse BCP-47 language code to get primary language
|
|
52
|
+
* e.g., "de-DE" -> "de", "en-US" -> "en"
|
|
53
|
+
*/
|
|
54
|
+
function getPrimaryLanguage(languageCode) {
|
|
55
|
+
if (!languageCode || languageCode === 'unknown') {
|
|
56
|
+
return 'unknown';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Extract primary language (before first hyphen)
|
|
60
|
+
const primary = languageCode.split('-')[0].toLowerCase();
|
|
61
|
+
return primary || 'unknown';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get human-readable language name from code
|
|
66
|
+
*/
|
|
67
|
+
const LANGUAGE_NAMES = {
|
|
68
|
+
'de': 'German',
|
|
69
|
+
'en': 'English',
|
|
70
|
+
'es': 'Spanish',
|
|
71
|
+
'fr': 'French',
|
|
72
|
+
'pt': 'Portuguese',
|
|
73
|
+
'it': 'Italian',
|
|
74
|
+
'nl': 'Dutch',
|
|
75
|
+
'sv': 'Swedish',
|
|
76
|
+
'ar': 'Arabic',
|
|
77
|
+
'zh': 'Chinese',
|
|
78
|
+
'ja': 'Japanese',
|
|
79
|
+
'ko': 'Korean',
|
|
80
|
+
'ru': 'Russian',
|
|
81
|
+
'pl': 'Polish',
|
|
82
|
+
'unknown': 'Unknown'
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
function getLanguageName(languageCode) {
|
|
86
|
+
if (!languageCode || languageCode === 'unknown') {
|
|
87
|
+
return 'Unknown';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const primary = getPrimaryLanguage(languageCode);
|
|
91
|
+
return LANGUAGE_NAMES[primary] || `Unknown (${primary})`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
detectLanguage,
|
|
96
|
+
getPrimaryLanguage,
|
|
97
|
+
getLanguageName,
|
|
98
|
+
LANGUAGE_NAMES
|
|
99
|
+
};
|