@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
@@ -3,7 +3,17 @@
3
3
  * Phase 3: supports multiple curated attempts
4
4
  */
5
5
 
6
- const DEFAULT_ATTEMPTS = ['contact_form', 'language_switch', 'newsletter_signup', 'signup', 'login', 'checkout'];
6
+ const DEFAULT_ATTEMPTS = [
7
+ 'site_smoke',
8
+ 'primary_ctas',
9
+ 'contact_discovery_v2',
10
+ 'contact_form',
11
+ 'language_switch',
12
+ 'newsletter_signup',
13
+ 'signup',
14
+ 'login',
15
+ 'checkout'
16
+ ];
7
17
 
8
18
  const attemptDefinitions = {
9
19
  contact_form: {
@@ -158,6 +168,37 @@ const attemptDefinitions = {
158
168
  ]
159
169
  },
160
170
 
171
+ // Universal Attempts (no instrumentation required)
172
+ site_smoke: {
173
+ id: 'site_smoke',
174
+ name: 'Site Smoke Navigation',
175
+ goal: 'Probe homepage stability and internal navigation',
176
+ riskCategory: 'TRUST/UX',
177
+ source: 'universal',
178
+ baseSteps: [],
179
+ successConditions: []
180
+ },
181
+
182
+ primary_ctas: {
183
+ id: 'primary_ctas',
184
+ name: 'Primary CTA Coverage',
185
+ goal: 'Verify primary CTAs navigate correctly',
186
+ riskCategory: 'LEAD',
187
+ source: 'universal',
188
+ baseSteps: [],
189
+ successConditions: []
190
+ },
191
+
192
+ contact_discovery_v2: {
193
+ id: 'contact_discovery_v2',
194
+ name: 'Contact Discovery v2',
195
+ goal: 'Find contact surfaces (mailto or contact route)',
196
+ riskCategory: 'TRUST/UX',
197
+ source: 'universal',
198
+ baseSteps: [],
199
+ successConditions: []
200
+ },
201
+
161
202
  // Universal Reality Pack: deterministic, zero-config safety checks
162
203
  universal_reality: {
163
204
  id: 'universal_reality',
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Attempt Relevance Decision Engine
3
+ * Determines which attempts should run based on site introspection.
4
+ */
5
+
6
+ /**
7
+ * Relevance rules mapping attempt IDs to required introspection flags
8
+ */
9
+ const RELEVANCE_RULES = {
10
+ 'contact_form': { requires: 'hasContactForm', reason: 'No contact form detected' },
11
+ 'language_switch': { requires: 'hasLanguageSwitch', reason: 'No language switch detected' },
12
+ 'signup': { requires: 'hasSignup', reason: 'No signup elements detected' },
13
+ 'login': { requires: 'hasLogin', reason: 'No login elements detected' },
14
+ 'checkout': { requires: 'hasCheckout', reason: 'No checkout elements detected' },
15
+ 'newsletter_signup': { requires: 'hasNewsletter', reason: 'No newsletter signup detected' }
16
+ };
17
+
18
+ /**
19
+ * Filter attempts based on site introspection
20
+ *
21
+ * @param {Array} attempts - Array of attempt objects with {id, ...}
22
+ * @param {Object} introspection - Result from inspectSite()
23
+ * @returns {Object} { toRun: Array, toSkip: Array }
24
+ */
25
+ function filterAttempts(attempts, introspection) {
26
+ const toRun = [];
27
+ const toSkip = [];
28
+
29
+ for (const attempt of attempts) {
30
+ const rule = RELEVANCE_RULES[attempt.id];
31
+
32
+ if (!rule) {
33
+ // No rule defined: always run (universal attempts)
34
+ toRun.push(attempt);
35
+ continue;
36
+ }
37
+
38
+ // Check if introspection flag is true
39
+ const flagValue = introspection[rule.requires];
40
+
41
+ if (flagValue === true) {
42
+ toRun.push(attempt);
43
+ } else {
44
+ toSkip.push({
45
+ attempt: attempt.id,
46
+ reason: rule.reason
47
+ });
48
+ }
49
+ }
50
+
51
+ return { toRun, toSkip };
52
+ }
53
+
54
+ /**
55
+ * Check if an attempt should be skipped
56
+ *
57
+ * @param {string} attemptId - The attempt ID
58
+ * @param {Object} introspection - Result from inspectSite()
59
+ * @returns {Object|null} Skip info {reason} or null if should run
60
+ */
61
+ function shouldSkipAttempt(attemptId, introspection) {
62
+ const rule = RELEVANCE_RULES[attemptId];
63
+
64
+ if (!rule) {
65
+ // No rule: never skip
66
+ return null;
67
+ }
68
+
69
+ const flagValue = introspection[rule.requires];
70
+
71
+ if (flagValue === true) {
72
+ return null; // Should run
73
+ } else {
74
+ return { reason: rule.reason };
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Get human-readable summary of introspection results
80
+ *
81
+ * @param {Object} introspection - Result from inspectSite()
82
+ * @returns {string} Summary text
83
+ */
84
+ function summarizeIntrospection(introspection) {
85
+ const detected = [];
86
+
87
+ if (introspection.hasLogin) detected.push('login');
88
+ if (introspection.hasSignup) detected.push('signup');
89
+ if (introspection.hasCheckout) detected.push('checkout');
90
+ if (introspection.hasNewsletter) detected.push('newsletter');
91
+ if (introspection.hasContactForm) detected.push('contact form');
92
+ if (introspection.hasLanguageSwitch) detected.push('language switch');
93
+
94
+ if (detected.length === 0) {
95
+ return 'No specific capabilities detected';
96
+ }
97
+
98
+ return `Detected: ${detected.join(', ')}`;
99
+ }
100
+
101
+ module.exports = {
102
+ RELEVANCE_RULES,
103
+ filterAttempts,
104
+ shouldSkipAttempt,
105
+ summarizeIntrospection
106
+ };
@@ -25,9 +25,17 @@ async function executeAttempt(config) {
25
25
  artifactsDir = './artifacts',
26
26
  enableTrace = true,
27
27
  enableScreenshots = true,
28
- headful = false
28
+ headful = false,
29
+ quiet = false,
30
+ // Phase 7.3: Accept browser context from pool
31
+ browserContext = null,
32
+ browserPage = null
29
33
  } = config;
30
34
 
35
+ const log = (...args) => {
36
+ if (!quiet) console.log(...args);
37
+ };
38
+
31
39
  // Validate baseUrl
32
40
  try {
33
41
  new URL(baseUrl);
@@ -38,6 +46,7 @@ async function executeAttempt(config) {
38
46
  const browser = new GuardianBrowser();
39
47
  let attemptResult = null;
40
48
  let runDir = null;
49
+ const usingPoolContext = browserContext && browserPage;
41
50
 
42
51
  try {
43
52
  // Prepare artifacts directory
@@ -53,16 +62,24 @@ async function executeAttempt(config) {
53
62
  fs.mkdirSync(runDir, { recursive: true });
54
63
  }
55
64
 
56
- console.log(`\n📁 Artifacts: ${runDir}`);
65
+ log(`\n📁 Artifacts: ${runDir}`);
57
66
 
58
- // Launch browser
59
- console.log(`\n🚀 Launching browser...`);
60
- const browserOptions = {
61
- headless: !headful,
62
- args: !headful ? ['--no-sandbox', '--disable-setuid-sandbox'] : []
63
- };
64
- await browser.launch(30000, browserOptions);
65
- console.log(`✅ Browser launched`);
67
+ // Phase 7.3: Use pool context or launch own browser
68
+ if (usingPoolContext) {
69
+ browser.useContext(browserContext, browserPage, config.timeout || 30000);
70
+ if (!quiet) {
71
+ // Silent - don't log for each attempt in pool mode
72
+ }
73
+ } else {
74
+ // Legacy mode: launch own browser
75
+ log(`\n🚀 Launching browser...`);
76
+ const browserOptions = {
77
+ headless: !headful,
78
+ args: !headful ? ['--no-sandbox', '--disable-setuid-sandbox'] : []
79
+ };
80
+ await browser.launch(30000, browserOptions);
81
+ log(`✅ Browser launched`);
82
+ }
66
83
 
67
84
  // Start trace if enabled
68
85
  let tracePath = null;
@@ -70,12 +87,12 @@ async function executeAttempt(config) {
70
87
  const networkTrace = new GuardianNetworkTrace({ enableTrace: true });
71
88
  tracePath = await networkTrace.startTrace(browser.context, runDir);
72
89
  if (tracePath) {
73
- console.log(`📹 Trace recording started`);
90
+ log(`📹 Trace recording started`);
74
91
  }
75
92
  }
76
93
 
77
94
  // Execute attempt
78
- console.log(`\n🎬 Executing attempt...`);
95
+ log(`\n🎬 Executing attempt...`);
79
96
  const engine = new AttemptEngine({
80
97
  attemptId,
81
98
  timeout: config.timeout || 30000,
@@ -92,79 +109,103 @@ async function executeAttempt(config) {
92
109
 
93
110
  attemptResult = await engine.executeAttempt(browser.page, attemptId, baseUrl, runDir, validators);
94
111
 
95
- console.log(`\n✅ Attempt completed: ${attemptResult.outcome}`);
112
+ log(`\n✅ Attempt completed: ${attemptResult.outcome}`);
96
113
 
97
114
  // Stop trace if enabled
98
115
  if (enableTrace && browser.context && tracePath) {
99
116
  const networkTrace = new GuardianNetworkTrace({ enableTrace: true });
100
117
  await networkTrace.stopTrace(browser.context, tracePath);
101
- console.log(`✅ Trace saved: trace.zip`);
118
+ log(`✅ Trace saved: trace.zip`);
102
119
  }
103
120
 
104
121
  // Generate reports
105
- console.log(`\n📊 Generating reports...`);
122
+ log(`\n📊 Generating reports...`);
106
123
  const reporter = new AttemptReporter();
107
124
  const report = reporter.createReport(attemptResult, baseUrl, attemptId);
108
125
 
109
126
  // Save JSON report
110
127
  const jsonPath = reporter.saveJsonReport(report, runDir);
111
- console.log(`✅ JSON report: ${path.basename(jsonPath)}`);
128
+ log(`✅ JSON report: ${path.basename(jsonPath)}`);
112
129
 
113
130
  // Save HTML report
114
131
  const htmlContent = reporter.generateHtmlReport(report);
115
132
  const htmlPath = reporter.saveHtmlReport(htmlContent, runDir);
116
- console.log(`✅ HTML report: ${path.basename(htmlPath)}`);
133
+ log(`✅ HTML report: ${path.basename(htmlPath)}`);
134
+
135
+ // Persist deterministic attempt evidence (attempt.json + steps log)
136
+ const attemptJsonPath = path.join(runDir, 'attempt.json');
137
+ const attemptSnapshot = {
138
+ attemptId,
139
+ baseUrl,
140
+ outcome: attemptResult.outcome,
141
+ startedAt: attemptResult.startedAt,
142
+ endedAt: attemptResult.endedAt,
143
+ totalDurationMs: attemptResult.totalDurationMs,
144
+ friction: attemptResult.friction,
145
+ validators: attemptResult.validators,
146
+ softFailures: attemptResult.softFailures,
147
+ successReason: attemptResult.successReason,
148
+ error: attemptResult.error,
149
+ steps: attemptResult.steps
150
+ };
151
+ fs.writeFileSync(attemptJsonPath, JSON.stringify(attemptSnapshot, null, 2));
152
+
153
+ const stepsLogPath = path.join(runDir, 'steps.jsonl');
154
+ const stepsLog = (attemptResult.steps || []).map(s => JSON.stringify(s)).join('\n');
155
+ fs.writeFileSync(stepsLogPath, stepsLog + (stepsLog ? '\n' : ''));
117
156
 
118
157
  // Display summary
119
- console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
158
+ log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
120
159
 
121
160
  const outcomeEmoji = attemptResult.outcome === 'SUCCESS' ? '🟢' :
122
161
  attemptResult.outcome === 'FRICTION' ? '🟡' : '🔴';
123
162
 
124
- console.log(`\n${outcomeEmoji} ${attemptResult.outcome}`);
163
+ log(`\n${outcomeEmoji} ${attemptResult.outcome}`);
125
164
 
126
165
  if (attemptResult.outcome === 'SUCCESS') {
127
- console.log(`\n✅ User successfully completed the attempt!`);
128
- console.log(` ${attemptResult.successReason}`);
166
+ log(`\n✅ User successfully completed the attempt!`);
167
+ log(` ${attemptResult.successReason}`);
129
168
  } else if (attemptResult.outcome === 'FRICTION') {
130
- console.log(`\n⚠️ Attempt succeeded but with friction:`);
169
+ log(`\n⚠️ Attempt succeeded but with friction:`);
131
170
  attemptResult.friction.reasons.forEach(reason => {
132
- console.log(` • ${reason}`);
171
+ log(` • ${reason}`);
133
172
  });
134
173
  } else {
135
- console.log(`\n❌ Attempt failed:`);
136
- console.log(` ${attemptResult.error}`);
174
+ log(`\n❌ Attempt failed:`);
175
+ log(` ${attemptResult.error}`);
137
176
  }
138
177
 
139
- console.log(`\n⏱️ Duration: ${attemptResult.totalDurationMs}ms`);
140
- console.log(`📋 Steps: ${attemptResult.steps.length}`);
178
+ log(`\n⏱️ Duration: ${attemptResult.totalDurationMs}ms`);
179
+ log(`📋 Steps: ${attemptResult.steps.length}`);
141
180
 
142
181
  if (attemptResult.steps.length > 0) {
143
182
  const failedSteps = attemptResult.steps.filter(s => s.status === 'failed');
144
183
  if (failedSteps.length > 0) {
145
- console.log(`❌ Failed steps: ${failedSteps.length}`);
184
+ log(`❌ Failed steps: ${failedSteps.length}`);
146
185
  failedSteps.forEach(step => {
147
- console.log(` • ${step.id}: ${step.error}`);
186
+ log(` • ${step.id}: ${step.error}`);
148
187
  });
149
188
  }
150
189
 
151
190
  const retriedSteps = attemptResult.steps.filter(s => s.retries > 0);
152
191
  if (retriedSteps.length > 0) {
153
- console.log(`🔄 Steps with retries: ${retriedSteps.length}`);
192
+ log(`🔄 Steps with retries: ${retriedSteps.length}`);
154
193
  retriedSteps.forEach(step => {
155
- console.log(` • ${step.id}: ${step.retries} retries`);
194
+ log(` • ${step.id}: ${step.retries} retries`);
156
195
  });
157
196
  }
158
197
  }
159
198
 
160
- console.log(`\n💾 Full report: ${runDir}`);
161
- console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
199
+ log(`\n💾 Full report: ${runDir}`);
200
+ log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
162
201
 
163
- // Close browser before returning
164
- try {
165
- await browser.close();
166
- } catch (closeErr) {
167
- // Ignore browser close errors
202
+ // Phase 7.3: Close browser only if we own it (not using pool)
203
+ if (!usingPoolContext) {
204
+ try {
205
+ await browser.close();
206
+ } catch (closeErr) {
207
+ // Ignore browser close errors
208
+ }
168
209
  }
169
210
 
170
211
  // Determine exit code
@@ -185,6 +226,8 @@ async function executeAttempt(config) {
185
226
  artifactsDir: runDir,
186
227
  reportJsonPath: path.join(runDir, 'attempt-report.json'),
187
228
  reportHtmlPath: path.join(runDir, 'attempt-report.html'),
229
+ attemptJsonPath,
230
+ stepsLogPath,
188
231
  tracePath: enableTrace ? path.join(runDir, 'trace.zip') : null,
189
232
  steps: attemptResult.steps,
190
233
  friction: attemptResult.friction,
@@ -193,7 +236,10 @@ async function executeAttempt(config) {
193
236
  };
194
237
 
195
238
  } catch (err) {
196
- await browser.close().catch(() => {});
239
+ // Phase 7.3: Only close if we own the browser
240
+ if (!usingPoolContext) {
241
+ await browser.close().catch(() => {});
242
+ }
197
243
  throw err;
198
244
  }
199
245
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Guardian Attempts Filter
3
+ * Validates and filters attempts/flows based on user input
4
+ */
5
+
6
+ const { getDefaultAttemptIds, getAttemptDefinition } = require('./attempt-registry');
7
+ const { getDefaultFlowIds, getFlowDefinition } = require('./flow-registry');
8
+
9
+ function validateAttemptFilter(filterString) {
10
+ if (!filterString || !filterString.trim()) {
11
+ return { valid: true, ids: [] };
12
+ }
13
+
14
+ const ids = filterString.split(',').map(s => s.trim()).filter(Boolean);
15
+
16
+ if (ids.length === 0) {
17
+ return { valid: true, ids: [] };
18
+ }
19
+
20
+ // Validate each ID exists
21
+ const invalid = [];
22
+ for (const id of ids) {
23
+ const attemptDef = getAttemptDefinition(id);
24
+ const flowDef = getFlowDefinition(id);
25
+ if (!attemptDef && !flowDef) {
26
+ invalid.push(id);
27
+ }
28
+ }
29
+
30
+ if (invalid.length > 0) {
31
+ return {
32
+ valid: false,
33
+ error: `Unknown attempt/flow: ${invalid[0]}`,
34
+ hint: `Run 'guardian presets' to see available attempts/flows`
35
+ };
36
+ }
37
+
38
+ return { valid: true, ids };
39
+ }
40
+
41
+ function filterAttempts(attempts, filterIds) {
42
+ if (!filterIds || filterIds.length === 0) {
43
+ return attempts;
44
+ }
45
+
46
+ const filterSet = new Set(filterIds);
47
+ return attempts.filter(id => filterSet.has(id));
48
+ }
49
+
50
+ function filterFlows(flows, filterIds) {
51
+ if (!filterIds || filterIds.length === 0) {
52
+ return flows;
53
+ }
54
+
55
+ const filterSet = new Set(filterIds);
56
+ return flows.filter(id => filterSet.has(id));
57
+ }
58
+
59
+ module.exports = {
60
+ validateAttemptFilter,
61
+ filterAttempts,
62
+ filterFlows
63
+ };
@@ -316,11 +316,25 @@ async function checkBaseline(options) {
316
316
 
317
317
  const baselinePath = path.join(baselineDir ? baselineDir : path.join(artifactsDir, 'baselines'), `${name}.json`);
318
318
  let baseline;
319
+ let baselineStatus = 'LOADED';
319
320
  try {
320
321
  baseline = loadBaselineOrThrow(baselinePath);
321
322
  } catch (err) {
322
- console.error(`\n❌ Baseline error: ${err.message}`);
323
- return { exitCode: 1, error: err.message };
323
+ // Handle missing baseline gracefully
324
+ if (err.code === 'BASELINE_MISSING') {
325
+ console.log(`Baseline: not found (no comparison)`);
326
+ baselineStatus = 'NO_BASELINE';
327
+ baseline = null;
328
+ } else if (err.code === 'SCHEMA_MISMATCH') {
329
+ console.warn(`⚠️ Baseline schema mismatch - skipping comparison`);
330
+ baselineStatus = 'BASELINE_UNUSABLE';
331
+ baseline = null;
332
+ } else {
333
+ // Assume JSON parse error or other corruption
334
+ console.warn(`⚠️ Baseline corrupt or unreadable - skipping comparison`);
335
+ baselineStatus = 'BASELINE_UNUSABLE';
336
+ baseline = null;
337
+ }
324
338
  }
325
339
 
326
340
  const current = await executeReality({
@@ -338,6 +352,19 @@ async function checkBaseline(options) {
338
352
  flowOptions
339
353
  });
340
354
 
355
+ // If baseline is not available, skip comparison and return early
356
+ if (!baseline || baselineStatus !== 'LOADED') {
357
+ const currentExitCode = typeof current.exitCode === 'number' ? current.exitCode : 0;
358
+ return {
359
+ exitCode: currentExitCode,
360
+ runDir: current.runDir,
361
+ overallRegressionVerdict: baselineStatus === 'NO_BASELINE' ? 'NO_BASELINE' : 'BASELINE_UNUSABLE',
362
+ comparisons: [],
363
+ flowComparisons: [],
364
+ baselineStatus
365
+ };
366
+ }
367
+
341
368
  // Use baseline attempts (includes auto-generated) for comparison
342
369
  const comparisonAttempts = baseline.attempts || attempts;
343
370
 
@@ -441,12 +468,26 @@ async function checkBaseline(options) {
441
468
  console.log(` - ${label}: ${r.regressionType} (${reasons})`);
442
469
  }
443
470
  }
444
- console.log(`Report: ${htmlPath}`);
471
+
472
+ // Show improvements
473
+ const improvementList = comparisons.filter(c => c.improvements && c.improvements.length > 0);
474
+ if (improvementList.length > 0) {
475
+ console.log('\n✅ Improvements detected:');
476
+ for (const i of improvementList.slice(0, 5)) {
477
+ const label = i.attemptId || 'unknown';
478
+ const improvementText = i.improvements.slice(0, 2).join('; ');
479
+ console.log(` + ${label}: ${improvementText}`);
480
+ }
481
+ }
445
482
 
446
- let exitCode = 0;
447
- if (overallRegressionVerdict === 'REGRESSION_FAILURE') exitCode = 4;
448
- else if (overallRegressionVerdict === 'REGRESSION_FRICTION') exitCode = 3;
449
- else exitCode = 0;
483
+ // Exit code reflects regression severity while preserving underlying run exit codes
484
+ const currentExitCode = typeof current.exitCode === 'number' ? current.exitCode : 0;
485
+ let exitCode = currentExitCode;
486
+ if (overallRegressionVerdict === 'REGRESSION_FAILURE') {
487
+ exitCode = Math.max(exitCode, 4);
488
+ } else if (overallRegressionVerdict === 'REGRESSION_FRICTION') {
489
+ exitCode = Math.max(exitCode, 3);
490
+ }
450
491
 
451
492
  return {
452
493
  exitCode,
@@ -456,7 +497,8 @@ async function checkBaseline(options) {
456
497
  junitPath: junit || null,
457
498
  overallRegressionVerdict,
458
499
  comparisons,
459
- flowComparisons: comparisonFlows
500
+ flowComparisons: comparisonFlows,
501
+ baselineStatus: 'LOADED'
460
502
  };
461
503
  }
462
504
 
@@ -34,6 +34,7 @@ function analyzeFailure(item, isFlow = false) {
34
34
  id: isFlow ? item.flowId : item.attemptId,
35
35
  name: isFlow ? item.flowName : item.attemptName,
36
36
  outcome: item.outcome,
37
+ source: isFlow ? 'flow' : 'attempt',
37
38
  breakType,
38
39
  domain,
39
40
  severity,
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Browser Pool - Phase 7.3
3
+ *
4
+ * Manages a single browser instance per run with context-based isolation.
5
+ * - One browser launched per run
6
+ * - Each attempt gets a fresh context (isolated cookies, storage, viewport)
7
+ * - Deterministic cleanup of contexts and browser
8
+ * - Compatible with parallel execution
9
+ */
10
+
11
+ const { chromium } = require('playwright');
12
+
13
+ class BrowserPool {
14
+ constructor() {
15
+ this.browser = null;
16
+ this.contexts = new Set();
17
+ this.launched = false;
18
+ }
19
+
20
+ /**
21
+ * Launch the shared browser instance
22
+ * @param {Object} options - Launch options (headless, args, timeout)
23
+ * @returns {Promise<void>}
24
+ */
25
+ async launch(options = {}) {
26
+ if (this.launched) {
27
+ return; // Already launched
28
+ }
29
+
30
+ const launchOptions = {
31
+ headless: options.headless !== undefined ? options.headless : true,
32
+ args: options.args || []
33
+ };
34
+
35
+ try {
36
+ this.browser = await chromium.launch(launchOptions);
37
+ this.launched = true;
38
+ } catch (err) {
39
+ throw new Error(`Failed to launch browser: ${err.message}`);
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Create a new isolated context for an attempt
45
+ * @param {Object} contextOptions - Context options (viewport, recordHar, etc.)
46
+ * @returns {Promise<Object>} { context, page }
47
+ */
48
+ async createContext(contextOptions = {}) {
49
+ if (!this.browser) {
50
+ throw new Error('Browser not launched. Call launch() first.');
51
+ }
52
+
53
+ try {
54
+ const options = { ...contextOptions };
55
+
56
+ // Enable HAR recording if requested
57
+ if (contextOptions.recordHar && contextOptions.harPath) {
58
+ options.recordHar = { path: contextOptions.harPath };
59
+ }
60
+
61
+ const context = await this.browser.newContext(options);
62
+ this.contexts.add(context);
63
+
64
+ const page = await context.newPage();
65
+ if (contextOptions.timeout) {
66
+ page.setDefaultTimeout(contextOptions.timeout);
67
+ }
68
+
69
+ return { context, page };
70
+ } catch (err) {
71
+ throw new Error(`Failed to create context: ${err.message}`);
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Close a specific context (deterministic cleanup)
77
+ * @param {BrowserContext} context - The context to close
78
+ * @returns {Promise<void>}
79
+ */
80
+ async closeContext(context) {
81
+ if (!context) return;
82
+
83
+ try {
84
+ this.contexts.delete(context);
85
+ await context.close();
86
+ } catch (err) {
87
+ // Ignore close errors (may already be closed)
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Close all contexts and the browser (end of run)
93
+ * @returns {Promise<void>}
94
+ */
95
+ async close() {
96
+ // Close all remaining contexts first
97
+ const contextArray = Array.from(this.contexts);
98
+ for (const context of contextArray) {
99
+ await this.closeContext(context);
100
+ }
101
+
102
+ // Close browser
103
+ if (this.browser) {
104
+ try {
105
+ await this.browser.close();
106
+ } catch (err) {
107
+ // Ignore close errors
108
+ }
109
+ this.browser = null;
110
+ this.launched = false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Check if browser is launched
116
+ * @returns {boolean}
117
+ */
118
+ isLaunched() {
119
+ return this.launched && this.browser !== null;
120
+ }
121
+
122
+ /**
123
+ * Get count of active contexts (for testing/debugging)
124
+ * @returns {number}
125
+ */
126
+ getActiveContextCount() {
127
+ return this.contexts.size;
128
+ }
129
+ }
130
+
131
+ module.exports = { BrowserPool };