@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.
- package/CHANGELOG.md +146 -0
- package/README.md +155 -97
- package/bin/guardian.js +1544 -55
- package/config/README.md +59 -0
- package/config/profiles/landing-demo.yaml +16 -0
- package/package.json +26 -11
- package/policies/landing-demo.json +22 -0
- package/src/enterprise/audit-logger.js +166 -0
- package/src/enterprise/pdf-exporter.js +267 -0
- package/src/enterprise/rbac-gate.js +142 -0
- package/src/enterprise/rbac.js +239 -0
- package/src/enterprise/site-manager.js +180 -0
- package/src/founder/feedback-system.js +156 -0
- package/src/founder/founder-tracker.js +213 -0
- package/src/founder/usage-signals.js +141 -0
- package/src/guardian/alert-ledger.js +121 -0
- package/src/guardian/attempt-engine.js +587 -12
- package/src/guardian/attempt-registry.js +42 -1
- package/src/guardian/attempt-relevance.js +106 -0
- package/src/guardian/attempt.js +85 -39
- package/src/guardian/attempts-filter.js +63 -0
- package/src/guardian/baseline.js +50 -8
- package/src/guardian/breakage-intelligence.js +1 -0
- package/src/guardian/browser-pool.js +131 -0
- package/src/guardian/browser.js +28 -1
- package/src/guardian/ci-cli.js +121 -0
- package/src/guardian/ci-mode.js +15 -0
- package/src/guardian/ci-output.js +38 -0
- package/src/guardian/cli-summary.js +167 -67
- package/src/guardian/config-loader.js +162 -0
- package/src/guardian/data-guardian-detector.js +189 -0
- package/src/guardian/detection-layers.js +271 -0
- package/src/guardian/drift-detector.js +100 -0
- package/src/guardian/enhanced-html-reporter.js +221 -4
- package/src/guardian/env-guard.js +127 -0
- package/src/guardian/failure-intelligence.js +173 -0
- package/src/guardian/first-run-profile.js +89 -0
- package/src/guardian/first-run.js +54 -0
- package/src/guardian/flag-validator.js +111 -0
- package/src/guardian/flow-executor.js +309 -44
- package/src/guardian/html-reporter.js +2 -0
- package/src/guardian/human-reporter.js +431 -0
- package/src/guardian/index.js +22 -19
- package/src/guardian/init-command.js +9 -5
- package/src/guardian/intent-detector.js +146 -0
- package/src/guardian/journey-definitions.js +132 -0
- package/src/guardian/journey-scan-cli.js +145 -0
- package/src/guardian/journey-scanner.js +583 -0
- package/src/guardian/junit-reporter.js +18 -1
- package/src/guardian/language-detection.js +99 -0
- package/src/guardian/live-cli.js +95 -0
- package/src/guardian/live-scheduler-runner.js +137 -0
- package/src/guardian/live-scheduler.js +146 -0
- package/src/guardian/market-reporter.js +357 -82
- package/src/guardian/parallel-executor.js +116 -0
- package/src/guardian/pattern-analyzer.js +348 -0
- package/src/guardian/policy.js +80 -3
- package/src/guardian/prerequisite-checker.js +101 -0
- package/src/guardian/preset-loader.js +27 -18
- package/src/guardian/profile-loader.js +96 -0
- package/src/guardian/reality.js +1612 -115
- package/src/guardian/reporter.js +27 -41
- package/src/guardian/run-artifacts.js +212 -0
- package/src/guardian/run-cleanup.js +207 -0
- package/src/guardian/run-latest.js +90 -0
- package/src/guardian/run-list.js +211 -0
- package/src/guardian/run-summary.js +20 -0
- package/src/guardian/scan-presets.js +100 -11
- package/src/guardian/selector-fallbacks.js +394 -0
- package/src/guardian/semantic-contact-detection.js +255 -0
- package/src/guardian/semantic-contact-finder.js +201 -0
- package/src/guardian/semantic-targets.js +234 -0
- package/src/guardian/site-introspection.js +257 -0
- package/src/guardian/smoke.js +258 -0
- package/src/guardian/snapshot-schema.js +25 -1
- package/src/guardian/snapshot.js +69 -3
- package/src/guardian/stability-scorer.js +169 -0
- package/src/guardian/success-evaluator.js +214 -0
- package/src/guardian/template-command.js +184 -0
- package/src/guardian/text-formatters.js +426 -0
- package/src/guardian/timeout-profiles.js +57 -0
- package/src/guardian/verdict.js +320 -0
- package/src/guardian/verdicts.js +74 -0
- package/src/guardian/wait-for-outcome.js +120 -0
- package/src/guardian/watch-runner.js +181 -0
- package/src/payments/stripe-checkout.js +169 -0
- package/src/plans/plan-definitions.js +148 -0
- package/src/plans/plan-manager.js +211 -0
- package/src/plans/usage-tracker.js +210 -0
- package/src/recipes/recipe-engine.js +188 -0
- package/src/recipes/recipe-failure-analysis.js +159 -0
- package/src/recipes/recipe-registry.js +134 -0
- package/src/recipes/recipe-runtime.js +507 -0
- package/src/recipes/recipe-store.js +410 -0
- package/guardian-contract-v1.md +0 -149
- /package/{guardian.config.json → config/guardian.config.json} +0 -0
- /package/{guardian.policy.json → config/guardian.policy.json} +0 -0
- /package/{guardian.profile.docs.yaml → config/profiles/docs.yaml} +0 -0
- /package/{guardian.profile.ecommerce.yaml → config/profiles/ecommerce.yaml} +0 -0
- /package/{guardian.profile.marketing.yaml → config/profiles/marketing.yaml} +0 -0
- /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 = [
|
|
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
|
+
};
|
package/src/guardian/attempt.js
CHANGED
|
@@ -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
|
-
|
|
65
|
+
log(`\n📁 Artifacts: ${runDir}`);
|
|
57
66
|
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
90
|
+
log(`📹 Trace recording started`);
|
|
74
91
|
}
|
|
75
92
|
}
|
|
76
93
|
|
|
77
94
|
// Execute attempt
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
+
log(`✅ Trace saved: trace.zip`);
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
// Generate reports
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
+
log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
|
120
159
|
|
|
121
160
|
const outcomeEmoji = attemptResult.outcome === 'SUCCESS' ? '🟢' :
|
|
122
161
|
attemptResult.outcome === 'FRICTION' ? '🟡' : '🔴';
|
|
123
162
|
|
|
124
|
-
|
|
163
|
+
log(`\n${outcomeEmoji} ${attemptResult.outcome}`);
|
|
125
164
|
|
|
126
165
|
if (attemptResult.outcome === 'SUCCESS') {
|
|
127
|
-
|
|
128
|
-
|
|
166
|
+
log(`\n✅ User successfully completed the attempt!`);
|
|
167
|
+
log(` ${attemptResult.successReason}`);
|
|
129
168
|
} else if (attemptResult.outcome === 'FRICTION') {
|
|
130
|
-
|
|
169
|
+
log(`\n⚠️ Attempt succeeded but with friction:`);
|
|
131
170
|
attemptResult.friction.reasons.forEach(reason => {
|
|
132
|
-
|
|
171
|
+
log(` • ${reason}`);
|
|
133
172
|
});
|
|
134
173
|
} else {
|
|
135
|
-
|
|
136
|
-
|
|
174
|
+
log(`\n❌ Attempt failed:`);
|
|
175
|
+
log(` ${attemptResult.error}`);
|
|
137
176
|
}
|
|
138
177
|
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
184
|
+
log(`❌ Failed steps: ${failedSteps.length}`);
|
|
146
185
|
failedSteps.forEach(step => {
|
|
147
|
-
|
|
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
|
-
|
|
192
|
+
log(`🔄 Steps with retries: ${retriedSteps.length}`);
|
|
154
193
|
retriedSteps.forEach(step => {
|
|
155
|
-
|
|
194
|
+
log(` • ${step.id}: ${step.retries} retries`);
|
|
156
195
|
});
|
|
157
196
|
}
|
|
158
197
|
}
|
|
159
198
|
|
|
160
|
-
|
|
161
|
-
|
|
199
|
+
log(`\n💾 Full report: ${runDir}`);
|
|
200
|
+
log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
|
162
201
|
|
|
163
|
-
// Close browser
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/src/guardian/baseline.js
CHANGED
|
@@ -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
|
-
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
|
@@ -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 };
|