@odavl/guardian 0.1.0-rc1 → 1.0.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 (101) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +155 -97
  3. package/bin/guardian.js +1544 -55
  4. package/config/README.md +59 -0
  5. package/config/profiles/landing-demo.yaml +16 -0
  6. package/package.json +26 -11
  7. package/policies/landing-demo.json +22 -0
  8. package/src/enterprise/audit-logger.js +166 -0
  9. package/src/enterprise/pdf-exporter.js +267 -0
  10. package/src/enterprise/rbac-gate.js +142 -0
  11. package/src/enterprise/rbac.js +239 -0
  12. package/src/enterprise/site-manager.js +180 -0
  13. package/src/founder/feedback-system.js +156 -0
  14. package/src/founder/founder-tracker.js +213 -0
  15. package/src/founder/usage-signals.js +141 -0
  16. package/src/guardian/alert-ledger.js +121 -0
  17. package/src/guardian/attempt-engine.js +587 -12
  18. package/src/guardian/attempt-registry.js +42 -1
  19. package/src/guardian/attempt-relevance.js +106 -0
  20. package/src/guardian/attempt.js +85 -39
  21. package/src/guardian/attempts-filter.js +63 -0
  22. package/src/guardian/baseline.js +50 -8
  23. package/src/guardian/breakage-intelligence.js +1 -0
  24. package/src/guardian/browser-pool.js +131 -0
  25. package/src/guardian/browser.js +28 -1
  26. package/src/guardian/ci-cli.js +121 -0
  27. package/src/guardian/ci-mode.js +15 -0
  28. package/src/guardian/ci-output.js +38 -0
  29. package/src/guardian/cli-summary.js +167 -67
  30. package/src/guardian/config-loader.js +162 -0
  31. package/src/guardian/data-guardian-detector.js +189 -0
  32. package/src/guardian/detection-layers.js +271 -0
  33. package/src/guardian/drift-detector.js +100 -0
  34. package/src/guardian/enhanced-html-reporter.js +221 -4
  35. package/src/guardian/env-guard.js +127 -0
  36. package/src/guardian/failure-intelligence.js +173 -0
  37. package/src/guardian/first-run-profile.js +89 -0
  38. package/src/guardian/first-run.js +54 -0
  39. package/src/guardian/flag-validator.js +111 -0
  40. package/src/guardian/flow-executor.js +309 -44
  41. package/src/guardian/html-reporter.js +2 -0
  42. package/src/guardian/human-reporter.js +431 -0
  43. package/src/guardian/index.js +22 -19
  44. package/src/guardian/init-command.js +9 -5
  45. package/src/guardian/intent-detector.js +146 -0
  46. package/src/guardian/journey-definitions.js +132 -0
  47. package/src/guardian/journey-scan-cli.js +145 -0
  48. package/src/guardian/journey-scanner.js +583 -0
  49. package/src/guardian/junit-reporter.js +18 -1
  50. package/src/guardian/language-detection.js +99 -0
  51. package/src/guardian/live-cli.js +95 -0
  52. package/src/guardian/live-scheduler-runner.js +137 -0
  53. package/src/guardian/live-scheduler.js +146 -0
  54. package/src/guardian/market-reporter.js +357 -82
  55. package/src/guardian/parallel-executor.js +116 -0
  56. package/src/guardian/pattern-analyzer.js +348 -0
  57. package/src/guardian/policy.js +80 -3
  58. package/src/guardian/prerequisite-checker.js +101 -0
  59. package/src/guardian/preset-loader.js +27 -18
  60. package/src/guardian/profile-loader.js +96 -0
  61. package/src/guardian/reality.js +1612 -115
  62. package/src/guardian/reporter.js +27 -41
  63. package/src/guardian/run-artifacts.js +212 -0
  64. package/src/guardian/run-cleanup.js +207 -0
  65. package/src/guardian/run-latest.js +90 -0
  66. package/src/guardian/run-list.js +211 -0
  67. package/src/guardian/run-summary.js +20 -0
  68. package/src/guardian/scan-presets.js +100 -11
  69. package/src/guardian/selector-fallbacks.js +394 -0
  70. package/src/guardian/semantic-contact-detection.js +255 -0
  71. package/src/guardian/semantic-contact-finder.js +201 -0
  72. package/src/guardian/semantic-targets.js +234 -0
  73. package/src/guardian/site-introspection.js +257 -0
  74. package/src/guardian/smoke.js +258 -0
  75. package/src/guardian/snapshot-schema.js +25 -1
  76. package/src/guardian/snapshot.js +69 -3
  77. package/src/guardian/stability-scorer.js +169 -0
  78. package/src/guardian/success-evaluator.js +214 -0
  79. package/src/guardian/template-command.js +184 -0
  80. package/src/guardian/text-formatters.js +426 -0
  81. package/src/guardian/timeout-profiles.js +57 -0
  82. package/src/guardian/verdict.js +320 -0
  83. package/src/guardian/verdicts.js +74 -0
  84. package/src/guardian/wait-for-outcome.js +120 -0
  85. package/src/guardian/watch-runner.js +181 -0
  86. package/src/payments/stripe-checkout.js +169 -0
  87. package/src/plans/plan-definitions.js +148 -0
  88. package/src/plans/plan-manager.js +211 -0
  89. package/src/plans/usage-tracker.js +210 -0
  90. package/src/recipes/recipe-engine.js +188 -0
  91. package/src/recipes/recipe-failure-analysis.js +159 -0
  92. package/src/recipes/recipe-registry.js +134 -0
  93. package/src/recipes/recipe-runtime.js +507 -0
  94. package/src/recipes/recipe-store.js +410 -0
  95. package/guardian-contract-v1.md +0 -149
  96. /package/{guardian.config.json → config/guardian.config.json} +0 -0
  97. /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
  98. /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
  99. /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
  100. /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
  101. /package/{guardian.profile.saas.yaml → config/profiles/saas.yaml} +0 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Guardian First-Run Detection & Initialization
3
+ * Deterministically detects first run and coordinates welcome behavior.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ const STATE_FILE = '.odavl-guardian/.first-run-state.json';
10
+
11
+ function hasRunBefore(stateDir = '.odavl-guardian') {
12
+ try {
13
+ const filePath = path.join(stateDir, '.first-run-state.json');
14
+ return fs.existsSync(filePath);
15
+ } catch {
16
+ return false;
17
+ }
18
+ }
19
+
20
+ function markAsRun(stateDir = '.odavl-guardian') {
21
+ try {
22
+ fs.mkdirSync(stateDir, { recursive: true });
23
+ const filePath = path.join(stateDir, '.first-run-state.json');
24
+ fs.writeFileSync(filePath, JSON.stringify({ firstRunAt: new Date().toISOString() }, null, 2));
25
+ } catch (e) {
26
+ // Silently ignore state write failures (e.g., permission issues)
27
+ }
28
+ }
29
+
30
+ function isFirstRun(stateDir = '.odavl-guardian') {
31
+ return !hasRunBefore(stateDir);
32
+ }
33
+
34
+ function printWelcome(label = 'ODAVL Guardian') {
35
+ const lines = [
36
+ '',
37
+ `Welcome to ${label}!`,
38
+ 'Running first-time setup…',
39
+ ''
40
+ ];
41
+ console.log(lines.join('\n'));
42
+ }
43
+
44
+ function printFirstRunHint() {
45
+ console.log("\nTip: Try 'guardian smoke <url>' for a fast CI-ready check.\n");
46
+ }
47
+
48
+ module.exports = {
49
+ isFirstRun,
50
+ hasRunBefore,
51
+ markAsRun,
52
+ printWelcome,
53
+ printFirstRunHint
54
+ };
@@ -0,0 +1,111 @@
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', 'template', 'list', 'cleanup', 'evaluate', 'version', 'flow', 'scan', 'smoke', 'check',
9
+ 'journey-scan', 'journey', 'live', 'plan', 'upgrade', 'ci', 'feedback',
10
+ 'sites', 'users', 'audit', 'export', 'recipe'
11
+ ];
12
+
13
+ const VALID_GLOBAL_FLAGS = [
14
+ '--help', '-h', '--version', '-v', '--debug'
15
+ ];
16
+
17
+ const VALID_SUBCOMMAND_FLAGS = {
18
+ 'scan': ['--url', '--preset', '--artifacts', '--policy', '--headful', '--no-trace', '--no-screenshots', '--watch', '-w', '--fast', '--fail-fast', '--timeout-profile', '--attempts', '--parallel', '--help', '-h'],
19
+ 'journey-scan': ['--url', '--preset', '--out', '--artifacts', '--timeout', '--headful', '--help', '-h'],
20
+ 'journey': ['--url', '--preset', '--out', '--artifacts', '--timeout', '--headful', '--help', '-h'],
21
+ 'live': ['--url', '--preset', '--out', '--artifacts', '--timeout', '--interval', '--cooldown', '--headful', '--help', '-h'],
22
+ 'protect': ['--url', '--policy', '--webhook', '--watch', '-w', '--fast', '--fail-fast', '--timeout-profile', '--attempts', '--parallel', '--help', '-h'],
23
+ 'reality': ['--url', '--attempts', '--artifacts', '--policy', '--preset', '--discover', '--universal', '--webhook', '--headful', '--watch', '-w', '--no-trace', '--no-screenshots', '--fast', '--fail-fast', '--timeout-profile', '--parallel', '--help', '-h', '--max-pages', '--max-depth', '--timeout'],
24
+ 'attempt': ['--url', '--attempt', '--artifacts', '--headful', '--no-trace', '--no-screenshots', '--help', '-h'],
25
+ 'smoke': ['--url', '--headful', '--budget-ms', '--help', '-h'],
26
+ 'check': ['--url', '--headful', '--budget-ms', '--help', '-h'],
27
+ 'baseline': [],
28
+ 'init': ['--preset', '--help', '-h'],
29
+ 'list': ['--artifacts', '--failed', '--site', '--limit', '--help', '-h'],
30
+ 'cleanup': ['--artifacts', '--older-than', '--keep-latest', '--failed-only', '--help', '-h'],
31
+ 'presets': ['--help', '-h'],
32
+ 'template': ['--output', '--help', '-h'],
33
+ 'sites': ['--project', '--help', '-h'],
34
+ 'users': ['--help', '-h'],
35
+ 'audit': ['--limit', '--action', '--user', '--help', '-h'],
36
+ 'export': ['--format', '--output', '--help', '-h'],
37
+ 'recipe': ['--url', '--file', '--out', '--force', '--help', '-h']
38
+ };
39
+
40
+ function validateFlags(argv) {
41
+ const args = argv.slice(2);
42
+ if (args.length === 0) return { valid: true };
43
+
44
+ const subcommand = args[0];
45
+
46
+ // Check if it's a global flag or help
47
+ if (VALID_GLOBAL_FLAGS.includes(subcommand)) {
48
+ return { valid: true };
49
+ }
50
+
51
+ // Check if subcommand is valid
52
+ if (subcommand && !subcommand.startsWith('-') && !VALID_SUBCOMMANDS.includes(subcommand)) {
53
+ return {
54
+ valid: false,
55
+ error: `Unknown command '${subcommand}'`,
56
+ hint: `Valid commands: ${VALID_SUBCOMMANDS.slice(0, 5).join(', ')}, …`
57
+ };
58
+ }
59
+
60
+ // If we have a valid subcommand, validate its flags
61
+ if (VALID_SUBCOMMANDS.includes(subcommand)) {
62
+ const validFlags = VALID_SUBCOMMAND_FLAGS[subcommand] || [];
63
+ const subArgs = args.slice(1);
64
+
65
+ for (let i = 0; i < subArgs.length; i++) {
66
+ const arg = subArgs[i];
67
+ if (arg.startsWith('--') || arg.startsWith('-')) {
68
+ const flagName = arg.split('=')[0]; // Handle --flag=value
69
+ if (!validFlags.includes(flagName)) {
70
+ return {
71
+ valid: false,
72
+ error: `Unknown flag '${flagName}' for command '${subcommand}'`,
73
+ hint: `Run 'guardian ${subcommand} --help' for valid options.`
74
+ };
75
+ }
76
+ // Check if flag expects a value
77
+ if ((flagName.includes('--') && !arg.includes('=')) || (flagName === '-w')) {
78
+ const expectsValue = ![
79
+ '--headful', '--watch', '-w', '--discover', '--universal',
80
+ '--no-trace', '--no-screenshots', '--help', '-h'
81
+ ].includes(flagName);
82
+
83
+ if (expectsValue && i + 1 < subArgs.length && subArgs[i + 1].startsWith('-')) {
84
+ return {
85
+ valid: false,
86
+ error: `Flag '${flagName}' requires a value`,
87
+ hint: `Usage: guardian ${subcommand} ${flagName} <value>`
88
+ };
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ return { valid: true };
96
+ }
97
+
98
+ function reportFlagError(validation) {
99
+ if (!validation.valid) {
100
+ console.error(`Error: ${validation.error}`);
101
+ if (validation.hint) {
102
+ console.error(`Hint: ${validation.hint}`);
103
+ }
104
+ }
105
+ }
106
+
107
+ module.exports = {
108
+ validateFlags,
109
+ reportFlagError,
110
+ VALID_SUBCOMMANDS
111
+ };
@@ -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
- console.log(` Step ${stepIndex + 1}: ${step.type} ${step.target || ''}`);
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
- try {
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
- // Wait for navigation if expected
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
- } catch (error) {
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
- try {
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
- } catch (error) {
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
- try {
157
- // Find submit button within form or use provided selector
158
- if (step.target) {
159
- await page.click(step.target, { timeout: this.timeout });
160
- } else {
161
- // Find first submit button
162
- await page.click('button[type="submit"]', { timeout: this.timeout });
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
- console.log(`\nšŸŽ¬ Executing flow: ${flow.name}`);
274
- console.log(`šŸ“‹ Steps: ${steps.length}`);
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
- console.log(` āŒ Step failed: ${stepResult.error}`);
303
- return result;
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
- console.log(` āœ… Step ${i + 1} completed`);
517
+ this.log(` āœ… Step ${i + 1} completed`);
308
518
  }
309
519
 
310
- result.success = true;
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
- console.log(`āœ… Flow completed successfully`);
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
- module.exports = { GuardianFlowExecutor };
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 };
@@ -5,6 +5,7 @@
5
5
 
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
+ const { getFounderBadgeHTML } = require('../founder/founder-tracker');
8
9
 
9
10
  class GuardianHTMLReporter {
10
11
  /**
@@ -207,6 +208,7 @@ class GuardianHTMLReporter {
207
208
  <div class="subtitle">Market Reality Testing Report</div>
208
209
  </div>
209
210
 
211
+ ${getFounderBadgeHTML()}
210
212
  ${this.generateVerdictSection(jsonReport)}
211
213
  ${this.generateMetricsSection(jsonReport)}
212
214
  ${this.generateReasonsSection(jsonReport)}