@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
|
@@ -121,6 +121,21 @@ class MarketReporter {
|
|
|
121
121
|
const flowRows = flows.map((flow, idx) => {
|
|
122
122
|
const color = flow.outcome === 'SUCCESS' ? '#10b981' : '#ef4444';
|
|
123
123
|
const badge = flow.outcome === 'SUCCESS' ? '✅ SUCCESS' : '❌ FAILURE';
|
|
124
|
+
const evalSummary = flow.successEval ? (() => {
|
|
125
|
+
const reasons = (flow.successEval.reasons || []).slice(0, 3).map(r => `• ${r}`).join('<br/>');
|
|
126
|
+
const ev = flow.successEval.evidence || {};
|
|
127
|
+
const net = Array.isArray(ev.network) ? ev.network : [];
|
|
128
|
+
const primary = net.find(n => (n.method === 'POST' || n.method === 'PUT') && n.status != null) || net[0];
|
|
129
|
+
const reqLine = primary ? (() => { try { const p = new URL(primary.url); return `${primary.method} ${p.pathname} → ${primary.status}`; } catch { return `${primary.method} ${primary.url} → ${primary.status}`; } })() : null;
|
|
130
|
+
const navLine = ev.urlChanged ? 'navigation: changed' : null;
|
|
131
|
+
const formStates = [];
|
|
132
|
+
if (ev.formCleared) formStates.push('cleared');
|
|
133
|
+
if (ev.formDisabled) formStates.push('disabled');
|
|
134
|
+
if (ev.formDisappeared) formStates.push('disappeared');
|
|
135
|
+
const formLine = formStates.length ? `form: ${formStates.join(', ')}` : null;
|
|
136
|
+
const evidences = [reqLine, navLine, formLine].filter(Boolean).map(e => `• ${e}`).join('<br/>' );
|
|
137
|
+
return `<div><strong>Reasons:</strong><br/>${reasons || '—'}<br/><strong>Evidence:</strong><br/>${evidences || '—'}</div>`;
|
|
138
|
+
})() : '';
|
|
124
139
|
return `
|
|
125
140
|
<tr>
|
|
126
141
|
<td>${idx + 1}</td>
|
|
@@ -128,7 +143,7 @@ class MarketReporter {
|
|
|
128
143
|
<td>${flow.flowName || ''}</td>
|
|
129
144
|
<td><span class="badge" style="background:${color}">${badge}</span></td>
|
|
130
145
|
<td>${flow.stepsExecuted || 0}/${flow.stepsTotal || 0}</td>
|
|
131
|
-
<td>${flow.error || ''}</td>
|
|
146
|
+
<td>${flow.error || evalSummary || ''}</td>
|
|
132
147
|
</tr>
|
|
133
148
|
`;
|
|
134
149
|
}).join('');
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 7.2 - Parallel Executor
|
|
3
|
+
* Controlled parallel execution of attempts with bounded concurrency
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Execute attempts with bounded parallelism
|
|
8
|
+
* @param {Array} attempts - Array of attempts to execute
|
|
9
|
+
* @param {Function} executeAttemptFn - Async function(attempt) => result
|
|
10
|
+
* @param {number} maxConcurrency - Max concurrent attempts (must be >= 1)
|
|
11
|
+
* @param {Object} options - { shouldStop?: Function }
|
|
12
|
+
* @returns {Promise<Array>} Results in original input order
|
|
13
|
+
*/
|
|
14
|
+
async function executeParallel(attempts, executeAttemptFn, maxConcurrency = 1, options = {}) {
|
|
15
|
+
if (maxConcurrency < 1) {
|
|
16
|
+
throw new Error('maxConcurrency must be >= 1');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Handle empty queue immediately
|
|
20
|
+
if (!attempts || attempts.length === 0) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const results = new Array(attempts.length);
|
|
25
|
+
const queue = attempts.map((attempt, index) => ({ attempt, index }));
|
|
26
|
+
let executing = 0;
|
|
27
|
+
let queueIndex = 0;
|
|
28
|
+
let shouldStop = false;
|
|
29
|
+
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
const processNext = async () => {
|
|
32
|
+
// Check if we should stop (e.g., fail-fast triggered)
|
|
33
|
+
if (options.shouldStop && options.shouldStop()) {
|
|
34
|
+
shouldStop = true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// If no more work and nothing executing, we're done
|
|
38
|
+
if (queueIndex >= queue.length && executing === 0) {
|
|
39
|
+
resolve(results);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// If we've hit the concurrency limit or have no more items, wait
|
|
44
|
+
if (executing >= maxConcurrency || queueIndex >= queue.length) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Get next item from queue
|
|
49
|
+
const { attempt, index } = queue[queueIndex];
|
|
50
|
+
queueIndex++;
|
|
51
|
+
executing++;
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
// Only execute if we haven't stopped
|
|
55
|
+
if (!shouldStop) {
|
|
56
|
+
results[index] = await executeAttemptFn(attempt);
|
|
57
|
+
} else {
|
|
58
|
+
// Mark as skipped if we stopped
|
|
59
|
+
results[index] = { skipped: true };
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
results[index] = { error: err, skipped: false };
|
|
63
|
+
} finally {
|
|
64
|
+
executing--;
|
|
65
|
+
// Process next item(s)
|
|
66
|
+
processNext();
|
|
67
|
+
if (executing < maxConcurrency && queueIndex < queue.length && !shouldStop) {
|
|
68
|
+
processNext();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Start workers
|
|
74
|
+
for (let i = 0; i < Math.min(maxConcurrency, queue.length); i++) {
|
|
75
|
+
processNext();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validate parallel concurrency value
|
|
82
|
+
* @param {number|string} value - Value to validate
|
|
83
|
+
* @returns {{ valid: boolean, parallel?: number, error?: string, hint?: string }}
|
|
84
|
+
*/
|
|
85
|
+
function validateParallel(value) {
|
|
86
|
+
if (value === undefined || value === null) {
|
|
87
|
+
return { valid: true, parallel: 1 };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const num = parseInt(value, 10);
|
|
91
|
+
|
|
92
|
+
// Check for NaN
|
|
93
|
+
if (isNaN(num)) {
|
|
94
|
+
return {
|
|
95
|
+
valid: false,
|
|
96
|
+
error: `Invalid --parallel value: '${value}' (expected integer >= 1)`,
|
|
97
|
+
hint: 'Example: --parallel 2'
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check if less than 1
|
|
102
|
+
if (num < 1) {
|
|
103
|
+
return {
|
|
104
|
+
valid: false,
|
|
105
|
+
error: `Invalid --parallel value: ${num} (must be >= 1)`,
|
|
106
|
+
hint: 'Example: --parallel 2'
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { valid: true, parallel: num };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
executeParallel,
|
|
115
|
+
validateParallel
|
|
116
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 7.4: Prerequisite Checker
|
|
3
|
+
*
|
|
4
|
+
* Validates hard prerequisites before executing attempts.
|
|
5
|
+
* Enables early skip of impossible attempts to save time.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Define prerequisites for each attempt type
|
|
10
|
+
* Returns deterministic checks that can fail fast
|
|
11
|
+
*/
|
|
12
|
+
const ATTEMPT_PREREQUISITES = {
|
|
13
|
+
checkout: {
|
|
14
|
+
description: 'Checkout requires accessible checkout page or cart',
|
|
15
|
+
checks: [
|
|
16
|
+
{
|
|
17
|
+
type: 'elementExists',
|
|
18
|
+
selector: 'a[href*="checkout"], a[href*="cart"], [data-guardian="checkout-link"], [data-guardian="cart-link"]',
|
|
19
|
+
reason: 'No checkout/cart link found on page'
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
login: {
|
|
24
|
+
description: 'Login requires accessible login page',
|
|
25
|
+
checks: [
|
|
26
|
+
{
|
|
27
|
+
type: 'elementExists',
|
|
28
|
+
selector: 'a[href*="login"], a[href*="signin"], a:has-text("Login"), a:has-text("Sign in"), [data-guardian="account-login-link"], input[type="email"][id*="login"], input[type="email"][name*="login"]',
|
|
29
|
+
reason: 'No login link or login form found'
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
newsletter_signup: {
|
|
34
|
+
description: 'Newsletter requires email input',
|
|
35
|
+
checks: [
|
|
36
|
+
{
|
|
37
|
+
type: 'elementExists',
|
|
38
|
+
selector: 'input[type="email"], input[placeholder*="email" i], input[name*="email"], [data-guardian="email-input"]',
|
|
39
|
+
reason: 'No email input found for newsletter'
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
signup: {
|
|
44
|
+
description: 'Signup requires accessible signup page',
|
|
45
|
+
checks: [
|
|
46
|
+
{
|
|
47
|
+
type: 'elementExists',
|
|
48
|
+
selector: 'a[href*="signup"], a[href*="register"], a:has-text("Sign up"), a:has-text("Register"), [data-guardian="signup-link"]',
|
|
49
|
+
reason: 'No signup/register link found'
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if an attempt's prerequisites are met
|
|
57
|
+
* @param {Page} page - Playwright page
|
|
58
|
+
* @param {string} attemptId - Attempt identifier
|
|
59
|
+
* @param {number} timeout - Max time to check (short, e.g., 2000ms)
|
|
60
|
+
* @returns {Promise<{canProceed: boolean, reason: string|null}>}
|
|
61
|
+
*/
|
|
62
|
+
async function checkPrerequisites(page, attemptId, timeout = 2000) {
|
|
63
|
+
const prereqs = ATTEMPT_PREREQUISITES[attemptId];
|
|
64
|
+
|
|
65
|
+
// No prerequisites defined = can proceed
|
|
66
|
+
if (!prereqs || !prereqs.checks || prereqs.checks.length === 0) {
|
|
67
|
+
return { canProceed: true, reason: null };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check each prerequisite
|
|
71
|
+
for (const check of prereqs.checks) {
|
|
72
|
+
if (check.type === 'elementExists') {
|
|
73
|
+
try {
|
|
74
|
+
// Quick check with short timeout
|
|
75
|
+
const exists = await page.locator(check.selector).first().isVisible({ timeout });
|
|
76
|
+
if (!exists) {
|
|
77
|
+
return { canProceed: false, reason: check.reason };
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Element not found = prerequisite failed
|
|
81
|
+
return { canProceed: false, reason: check.reason };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { canProceed: true, reason: null };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get list of attempt IDs that have prerequisites defined
|
|
91
|
+
* @returns {string[]}
|
|
92
|
+
*/
|
|
93
|
+
function getAttemptsWithPrerequisites() {
|
|
94
|
+
return Object.keys(ATTEMPT_PREREQUISITES);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = {
|
|
98
|
+
checkPrerequisites,
|
|
99
|
+
getAttemptsWithPrerequisites,
|
|
100
|
+
ATTEMPT_PREREQUISITES
|
|
101
|
+
};
|
|
@@ -71,20 +71,26 @@ function parsePolicyOption(policyOption) {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
// Otherwise, treat as file path
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
74
|
+
// Fix for Wave 0.5: Validate policy file exists BEFORE attempting to load
|
|
75
|
+
if (!fs.existsSync(optionStr)) {
|
|
76
|
+
console.error(`Error: Policy file not found: ${optionStr}`);
|
|
77
|
+
console.error('');
|
|
78
|
+
console.error('Please provide a valid policy file or use a preset:');
|
|
79
|
+
console.error(' --policy preset:startup (Permissive for fast-moving startups)');
|
|
80
|
+
console.error(' --policy preset:saas (Balanced for SaaS products)');
|
|
81
|
+
console.error(' --policy preset:enterprise (Strict for enterprise deployments)');
|
|
82
|
+
process.exit(1);
|
|
84
83
|
}
|
|
85
84
|
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
try {
|
|
86
|
+
const content = fs.readFileSync(optionStr, 'utf-8');
|
|
87
|
+
const policy = JSON.parse(content);
|
|
88
|
+
console.log(`✅ Loaded policy from: ${optionStr}`);
|
|
89
|
+
return policy;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error(`Error: Failed to load policy from ${optionStr}: ${error.message}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
/**
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const PROFILE_PATH = path.join(__dirname, '../../data/guardian-profiles.json');
|
|
5
|
+
let cachedProfiles = null;
|
|
6
|
+
let cachedMtime = null;
|
|
7
|
+
|
|
8
|
+
function normalizeOrigin(value) {
|
|
9
|
+
if (!value) return null;
|
|
10
|
+
try {
|
|
11
|
+
const url = value.startsWith('http://') || value.startsWith('https://')
|
|
12
|
+
? new URL(value)
|
|
13
|
+
: new URL(`http://${value}`);
|
|
14
|
+
return url.origin;
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadProfilesFile() {
|
|
21
|
+
try {
|
|
22
|
+
if (!fs.existsSync(PROFILE_PATH)) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const stat = fs.statSync(PROFILE_PATH);
|
|
26
|
+
if (cachedProfiles && cachedMtime === stat.mtimeMs) {
|
|
27
|
+
return cachedProfiles;
|
|
28
|
+
}
|
|
29
|
+
const raw = fs.readFileSync(PROFILE_PATH, 'utf8');
|
|
30
|
+
const data = JSON.parse(raw);
|
|
31
|
+
const profiles = [];
|
|
32
|
+
|
|
33
|
+
if (Array.isArray(data)) {
|
|
34
|
+
data.forEach((entry) => {
|
|
35
|
+
const origin = normalizeOrigin(entry.origin || entry.site);
|
|
36
|
+
if (!origin || !entry.selectors) return;
|
|
37
|
+
profiles.push({ site: origin, selectors: entry.selectors, navigation: entry.navigation });
|
|
38
|
+
});
|
|
39
|
+
} else if (data && typeof data === 'object') {
|
|
40
|
+
Object.keys(data).forEach((key) => {
|
|
41
|
+
const origin = normalizeOrigin(key);
|
|
42
|
+
if (!origin) return;
|
|
43
|
+
const entry = data[key];
|
|
44
|
+
if (!entry || typeof entry !== 'object') return;
|
|
45
|
+
profiles.push({ site: origin, selectors: entry.selectors || entry, navigation: entry.navigation });
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
cachedProfiles = profiles;
|
|
50
|
+
cachedMtime = stat.mtimeMs;
|
|
51
|
+
return profiles;
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.log(`⚠️ Failed to load profiles: ${err.message}`);
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveProfileForUrl(url) {
|
|
59
|
+
if (!url) return null;
|
|
60
|
+
let origin = null;
|
|
61
|
+
let host = null;
|
|
62
|
+
try {
|
|
63
|
+
const u = new URL(url);
|
|
64
|
+
origin = u.origin;
|
|
65
|
+
host = u.hostname;
|
|
66
|
+
} catch (err) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const profiles = loadProfilesFile();
|
|
71
|
+
if (profiles.length === 0) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const exact = profiles.find((p) => p.site === origin);
|
|
76
|
+
if (exact) return exact;
|
|
77
|
+
|
|
78
|
+
const hostMatch = profiles.find((p) => {
|
|
79
|
+
try {
|
|
80
|
+
const u = new URL(p.site);
|
|
81
|
+
if (u.port && u.port !== '80') {
|
|
82
|
+
return u.hostname === host && u.port === (new URL(origin)).port;
|
|
83
|
+
}
|
|
84
|
+
return u.hostname === host;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
return hostMatch || null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
resolveProfileForUrl,
|
|
95
|
+
loadProfilesFile,
|
|
96
|
+
};
|