@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
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,81 @@ 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)}`);
|
|
117
134
|
|
|
118
135
|
// Display summary
|
|
119
|
-
|
|
136
|
+
log(`\nāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā`);
|
|
120
137
|
|
|
121
138
|
const outcomeEmoji = attemptResult.outcome === 'SUCCESS' ? 'š¢' :
|
|
122
139
|
attemptResult.outcome === 'FRICTION' ? 'š”' : 'š“';
|
|
123
140
|
|
|
124
|
-
|
|
141
|
+
log(`\n${outcomeEmoji} ${attemptResult.outcome}`);
|
|
125
142
|
|
|
126
143
|
if (attemptResult.outcome === 'SUCCESS') {
|
|
127
|
-
|
|
128
|
-
|
|
144
|
+
log(`\nā
User successfully completed the attempt!`);
|
|
145
|
+
log(` ${attemptResult.successReason}`);
|
|
129
146
|
} else if (attemptResult.outcome === 'FRICTION') {
|
|
130
|
-
|
|
147
|
+
log(`\nā ļø Attempt succeeded but with friction:`);
|
|
131
148
|
attemptResult.friction.reasons.forEach(reason => {
|
|
132
|
-
|
|
149
|
+
log(` ⢠${reason}`);
|
|
133
150
|
});
|
|
134
151
|
} else {
|
|
135
|
-
|
|
136
|
-
|
|
152
|
+
log(`\nā Attempt failed:`);
|
|
153
|
+
log(` ${attemptResult.error}`);
|
|
137
154
|
}
|
|
138
155
|
|
|
139
|
-
|
|
140
|
-
|
|
156
|
+
log(`\nā±ļø Duration: ${attemptResult.totalDurationMs}ms`);
|
|
157
|
+
log(`š Steps: ${attemptResult.steps.length}`);
|
|
141
158
|
|
|
142
159
|
if (attemptResult.steps.length > 0) {
|
|
143
160
|
const failedSteps = attemptResult.steps.filter(s => s.status === 'failed');
|
|
144
161
|
if (failedSteps.length > 0) {
|
|
145
|
-
|
|
162
|
+
log(`ā Failed steps: ${failedSteps.length}`);
|
|
146
163
|
failedSteps.forEach(step => {
|
|
147
|
-
|
|
164
|
+
log(` ⢠${step.id}: ${step.error}`);
|
|
148
165
|
});
|
|
149
166
|
}
|
|
150
167
|
|
|
151
168
|
const retriedSteps = attemptResult.steps.filter(s => s.retries > 0);
|
|
152
169
|
if (retriedSteps.length > 0) {
|
|
153
|
-
|
|
170
|
+
log(`š Steps with retries: ${retriedSteps.length}`);
|
|
154
171
|
retriedSteps.forEach(step => {
|
|
155
|
-
|
|
172
|
+
log(` ⢠${step.id}: ${step.retries} retries`);
|
|
156
173
|
});
|
|
157
174
|
}
|
|
158
175
|
}
|
|
159
176
|
|
|
160
|
-
|
|
161
|
-
|
|
177
|
+
log(`\nš¾ Full report: ${runDir}`);
|
|
178
|
+
log(`āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā\n`);
|
|
162
179
|
|
|
163
|
-
// Close browser
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
180
|
+
// Phase 7.3: Close browser only if we own it (not using pool)
|
|
181
|
+
if (!usingPoolContext) {
|
|
182
|
+
try {
|
|
183
|
+
await browser.close();
|
|
184
|
+
} catch (closeErr) {
|
|
185
|
+
// Ignore browser close errors
|
|
186
|
+
}
|
|
168
187
|
}
|
|
169
188
|
|
|
170
189
|
// Determine exit code
|
|
@@ -193,7 +212,10 @@ async function executeAttempt(config) {
|
|
|
193
212
|
};
|
|
194
213
|
|
|
195
214
|
} catch (err) {
|
|
196
|
-
|
|
215
|
+
// Phase 7.3: Only close if we own the browser
|
|
216
|
+
if (!usingPoolContext) {
|
|
217
|
+
await browser.close().catch(() => {});
|
|
218
|
+
}
|
|
197
219
|
throw err;
|
|
198
220
|
}
|
|
199
221
|
}
|
|
@@ -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,18 @@ 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
|
+
return {
|
|
358
|
+
exitCode: 0, // Exit code based on flows only, not baseline
|
|
359
|
+
runDir: current.runDir,
|
|
360
|
+
overallRegressionVerdict: baselineStatus === 'NO_BASELINE' ? 'NO_BASELINE' : 'BASELINE_UNUSABLE',
|
|
361
|
+
comparisons: [],
|
|
362
|
+
flowComparisons: [],
|
|
363
|
+
baselineStatus
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
341
367
|
// Use baseline attempts (includes auto-generated) for comparison
|
|
342
368
|
const comparisonAttempts = baseline.attempts || attempts;
|
|
343
369
|
|
|
@@ -441,22 +467,30 @@ async function checkBaseline(options) {
|
|
|
441
467
|
console.log(` - ${label}: ${r.regressionType} (${reasons})`);
|
|
442
468
|
}
|
|
443
469
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (
|
|
448
|
-
|
|
449
|
-
|
|
470
|
+
|
|
471
|
+
// Show improvements
|
|
472
|
+
const improvementList = comparisons.filter(c => c.improvements && c.improvements.length > 0);
|
|
473
|
+
if (improvementList.length > 0) {
|
|
474
|
+
console.log('\nā
Improvements detected:');
|
|
475
|
+
for (const i of improvementList.slice(0, 5)) {
|
|
476
|
+
const label = i.attemptId || 'unknown';
|
|
477
|
+
const improvementText = i.improvements.slice(0, 2).join('; ');
|
|
478
|
+
console.log(` + ${label}: ${improvementText}`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
450
481
|
|
|
482
|
+
// CRITICAL: Exit code is ALWAYS 0 from baseline check
|
|
483
|
+
// Exit codes come from flow outcomes only (Phase 2.x policy)
|
|
451
484
|
return {
|
|
452
|
-
exitCode,
|
|
485
|
+
exitCode: 0,
|
|
453
486
|
runDir: current.runDir,
|
|
454
487
|
reportJsonPath: jsonPath,
|
|
455
488
|
reportHtmlPath: htmlPath,
|
|
456
489
|
junitPath: junit || null,
|
|
457
490
|
overallRegressionVerdict,
|
|
458
491
|
comparisons,
|
|
459
|
-
flowComparisons: comparisonFlows
|
|
492
|
+
flowComparisons: comparisonFlows,
|
|
493
|
+
baselineStatus: 'LOADED'
|
|
460
494
|
};
|
|
461
495
|
}
|
|
462
496
|
|
|
@@ -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 };
|
package/src/guardian/browser.js
CHANGED
|
@@ -5,8 +5,15 @@ class GuardianBrowser {
|
|
|
5
5
|
this.browser = null;
|
|
6
6
|
this.context = null;
|
|
7
7
|
this.page = null;
|
|
8
|
+
this.ownsContext = false; // Track if we own the context for cleanup
|
|
8
9
|
}
|
|
9
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Launch browser with its own context (legacy mode)
|
|
13
|
+
* @param {number} timeout - Default timeout
|
|
14
|
+
* @param {Object} options - Launch options
|
|
15
|
+
* @returns {Promise<boolean>}
|
|
16
|
+
*/
|
|
10
17
|
async launch(timeout = 20000, options = {}) {
|
|
11
18
|
try {
|
|
12
19
|
const launchOptions = {
|
|
@@ -30,12 +37,28 @@ class GuardianBrowser {
|
|
|
30
37
|
this.context = await this.browser.newContext(contextOptions);
|
|
31
38
|
this.page = await this.context.newPage();
|
|
32
39
|
this.page.setDefaultTimeout(timeout);
|
|
40
|
+
this.ownsContext = true; // We own browser and context
|
|
33
41
|
return true;
|
|
34
42
|
} catch (err) {
|
|
35
43
|
throw new Error(`Failed to launch browser: ${err.message}`);
|
|
36
44
|
}
|
|
37
45
|
}
|
|
38
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Phase 7.3: Use an existing context from browser pool
|
|
49
|
+
* @param {BrowserContext} context - Playwright browser context
|
|
50
|
+
* @param {Page} page - Playwright page
|
|
51
|
+
* @param {number} timeout - Default timeout
|
|
52
|
+
*/
|
|
53
|
+
useContext(context, page, timeout = 20000) {
|
|
54
|
+
this.context = context;
|
|
55
|
+
this.page = page;
|
|
56
|
+
this.ownsContext = false; // Pool owns the context
|
|
57
|
+
if (timeout) {
|
|
58
|
+
this.page.setDefaultTimeout(timeout);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
39
62
|
async navigate(url, timeout = 20000) {
|
|
40
63
|
try {
|
|
41
64
|
const response = await this.page.goto(url, {
|
|
@@ -82,7 +105,11 @@ class GuardianBrowser {
|
|
|
82
105
|
|
|
83
106
|
async close() {
|
|
84
107
|
try {
|
|
85
|
-
|
|
108
|
+
// Phase 7.3: Only close browser if we own it (legacy mode)
|
|
109
|
+
// If using pool context, pool handles cleanup
|
|
110
|
+
if (this.ownsContext && this.browser) {
|
|
111
|
+
await this.browser.close();
|
|
112
|
+
}
|
|
86
113
|
} catch (err) {
|
|
87
114
|
// Ignore close errors
|
|
88
115
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
function isCiMode() {
|
|
2
|
+
const ci = process.env.CI;
|
|
3
|
+
const gha = process.env.GITHUB_ACTIONS;
|
|
4
|
+
const guardianCi = process.env.GUARDIAN_CI;
|
|
5
|
+
|
|
6
|
+
const normalize = (value) => String(value || '').toLowerCase();
|
|
7
|
+
const isTrueish = (value) => {
|
|
8
|
+
const v = normalize(value);
|
|
9
|
+
return v === 'true' || v === '1' || v === 'yes' || v === 'on';
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
return isTrueish(ci) || isTrueish(gha) || isTrueish(guardianCi);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = { isCiMode };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { formatRunSummary, deriveBaselineVerdict } = require('./run-summary');
|
|
2
|
+
const MAX_REASONS_DEFAULT = 3;
|
|
3
|
+
|
|
4
|
+
function pickReason(flow) {
|
|
5
|
+
if (Array.isArray(flow.failureReasons) && flow.failureReasons.length > 0) {
|
|
6
|
+
return flow.failureReasons[0];
|
|
7
|
+
}
|
|
8
|
+
if (flow.error) return flow.error;
|
|
9
|
+
if (flow.successEval && Array.isArray(flow.successEval.reasons) && flow.successEval.reasons.length > 0) {
|
|
10
|
+
return flow.successEval.reasons[0];
|
|
11
|
+
}
|
|
12
|
+
return 'no reason captured';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function formatCiSummary({ flowResults = [], diffResult = null, baselineCreated = false, exitCode = 0, maxReasons = MAX_REASONS_DEFAULT }) {
|
|
16
|
+
const lines = [];
|
|
17
|
+
lines.push('CI MODE: ON');
|
|
18
|
+
lines.push(formatRunSummary({ flowResults, diffResult, baselineCreated, exitCode }, { label: 'Summary' }));
|
|
19
|
+
|
|
20
|
+
if (exitCode !== 0) {
|
|
21
|
+
lines.push('Why CI failed:');
|
|
22
|
+
const troubled = flowResults.filter(f => f.outcome === 'FAILURE' || f.outcome === 'FRICTION');
|
|
23
|
+
troubled.slice(0, maxReasons).forEach(flow => {
|
|
24
|
+
const reason = pickReason(flow);
|
|
25
|
+
lines.push(` - ${flow.flowName || flow.flowId || 'flow'}: ${flow.outcome} | ${reason}`);
|
|
26
|
+
});
|
|
27
|
+
if (troubled.length > maxReasons) {
|
|
28
|
+
lines.push(` - ⦠${troubled.length - maxReasons} more issues`);
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
lines.push('Result: PASS');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return lines.join('\n');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { formatCiSummary, deriveBaselineVerdict };
|