@odavl/guardian 0.1.0-rc1
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 +20 -0
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/bin/guardian.js +690 -0
- package/flows/example-login-flow.json +36 -0
- package/flows/example-signup-flow.json +44 -0
- package/guardian-contract-v1.md +149 -0
- package/guardian.config.json +54 -0
- package/guardian.policy.json +12 -0
- package/guardian.profile.docs.yaml +18 -0
- package/guardian.profile.ecommerce.yaml +17 -0
- package/guardian.profile.marketing.yaml +18 -0
- package/guardian.profile.saas.yaml +21 -0
- package/package.json +69 -0
- package/policies/enterprise.json +12 -0
- package/policies/saas.json +12 -0
- package/policies/startup.json +12 -0
- package/src/guardian/attempt-engine.js +454 -0
- package/src/guardian/attempt-registry.js +227 -0
- package/src/guardian/attempt-reporter.js +507 -0
- package/src/guardian/attempt.js +227 -0
- package/src/guardian/auto-attempt-builder.js +283 -0
- package/src/guardian/baseline-reporter.js +143 -0
- package/src/guardian/baseline-storage.js +285 -0
- package/src/guardian/baseline.js +492 -0
- package/src/guardian/behavioral-signals.js +261 -0
- package/src/guardian/breakage-intelligence.js +223 -0
- package/src/guardian/browser.js +92 -0
- package/src/guardian/cli-summary.js +141 -0
- package/src/guardian/crawler.js +142 -0
- package/src/guardian/discovery-engine.js +661 -0
- package/src/guardian/enhanced-html-reporter.js +305 -0
- package/src/guardian/failure-taxonomy.js +169 -0
- package/src/guardian/flow-executor.js +374 -0
- package/src/guardian/flow-registry.js +67 -0
- package/src/guardian/html-reporter.js +414 -0
- package/src/guardian/index.js +218 -0
- package/src/guardian/init-command.js +139 -0
- package/src/guardian/junit-reporter.js +264 -0
- package/src/guardian/market-criticality.js +335 -0
- package/src/guardian/market-reporter.js +305 -0
- package/src/guardian/network-trace.js +178 -0
- package/src/guardian/policy.js +357 -0
- package/src/guardian/preset-loader.js +148 -0
- package/src/guardian/reality.js +547 -0
- package/src/guardian/reporter.js +181 -0
- package/src/guardian/root-cause-analysis.js +171 -0
- package/src/guardian/safety.js +248 -0
- package/src/guardian/scan-presets.js +60 -0
- package/src/guardian/screenshot.js +152 -0
- package/src/guardian/sitemap.js +225 -0
- package/src/guardian/snapshot-schema.js +266 -0
- package/src/guardian/snapshot.js +327 -0
- package/src/guardian/validators.js +323 -0
- package/src/guardian/visual-diff.js +247 -0
- package/src/guardian/webhook.js +206 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Market Reality Snapshot Builder
|
|
3
|
+
* Assembles crawl results, attempt results, evidence, and signals into a snapshot
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { createEmptySnapshot, validateSnapshot } = require('./snapshot-schema');
|
|
9
|
+
|
|
10
|
+
class SnapshotBuilder {
|
|
11
|
+
constructor(baseUrl, runId, toolVersion) {
|
|
12
|
+
this.snapshot = createEmptySnapshot(baseUrl, runId, toolVersion);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Add crawl results to snapshot
|
|
17
|
+
*/
|
|
18
|
+
addCrawlResults(crawlResult) {
|
|
19
|
+
if (!crawlResult) return;
|
|
20
|
+
|
|
21
|
+
this.snapshot.crawl = {
|
|
22
|
+
discoveredUrls: (crawlResult.visited || []).map(p => p.url),
|
|
23
|
+
visitedCount: crawlResult.totalVisited || 0,
|
|
24
|
+
failedCount: (crawlResult.visited || []).filter(p => p.error).length,
|
|
25
|
+
safetyBlockedCount: crawlResult.safetyStats?.urlsBlocked || 0,
|
|
26
|
+
httpFailures: (crawlResult.visited || [])
|
|
27
|
+
.filter(p => p.error)
|
|
28
|
+
.map(p => ({
|
|
29
|
+
url: p.url,
|
|
30
|
+
error: p.error,
|
|
31
|
+
timestamp: p.timestamp
|
|
32
|
+
})),
|
|
33
|
+
notes: `Discovered ${crawlResult.totalDiscovered || 0} URLs, visited ${crawlResult.totalVisited || 0}`
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Add attempt result to snapshot
|
|
39
|
+
*/
|
|
40
|
+
addAttempt(attemptResult, artifactDir) {
|
|
41
|
+
const signal = {
|
|
42
|
+
id: `attempt_${attemptResult.attemptId}`,
|
|
43
|
+
severity: attemptResult.outcome === 'FAILURE' ? 'high' : 'medium',
|
|
44
|
+
type: attemptResult.outcome === 'FAILURE' ? 'failure' : 'friction',
|
|
45
|
+
description: `${attemptResult.attemptName}: ${attemptResult.outcome}`,
|
|
46
|
+
affectedAttemptId: attemptResult.attemptId
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
if (attemptResult.outcome === 'FAILURE' && attemptResult.error) {
|
|
50
|
+
signal.details = attemptResult.error;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.snapshot.attempts.push({
|
|
54
|
+
attemptId: attemptResult.attemptId,
|
|
55
|
+
attemptName: attemptResult.attemptName,
|
|
56
|
+
goal: attemptResult.goal,
|
|
57
|
+
outcome: attemptResult.outcome,
|
|
58
|
+
totalDurationMs: attemptResult.attemptResult?.totalDurationMs || 0,
|
|
59
|
+
stepCount: (attemptResult.steps || []).length,
|
|
60
|
+
failedStepIndex: (attemptResult.steps || []).findIndex(s => s.status === 'failed'),
|
|
61
|
+
friction: attemptResult.friction || null
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Track artifacts
|
|
65
|
+
if (artifactDir) {
|
|
66
|
+
this.snapshot.evidence.attemptArtifacts[attemptResult.attemptId] = {
|
|
67
|
+
reportJson: path.join(attemptResult.attemptId, 'attempt-report.json'),
|
|
68
|
+
reportHtml: path.join(attemptResult.attemptId, 'attempt-report.html'),
|
|
69
|
+
screenshotDir: path.join(attemptResult.attemptId, 'attempt-screenshots')
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Add signal
|
|
74
|
+
this.snapshot.signals.push(signal);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Add market reality results (from executeReality)
|
|
79
|
+
*/
|
|
80
|
+
addMarketResults(marketResults, runDir) {
|
|
81
|
+
if (!marketResults || !marketResults.attemptResults) return;
|
|
82
|
+
|
|
83
|
+
// Add individual attempt results
|
|
84
|
+
for (const result of marketResults.attemptResults) {
|
|
85
|
+
this.addAttempt(result, runDir);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Add intent flow results
|
|
89
|
+
if (marketResults.flowResults && Array.isArray(marketResults.flowResults)) {
|
|
90
|
+
for (const flow of marketResults.flowResults) {
|
|
91
|
+
this.addFlow(flow, runDir);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Track market report files
|
|
96
|
+
if (marketResults.marketJsonPath) {
|
|
97
|
+
this.snapshot.evidence.marketReportJson = path.relative(runDir, marketResults.marketJsonPath);
|
|
98
|
+
}
|
|
99
|
+
if (marketResults.marketHtmlPath) {
|
|
100
|
+
this.snapshot.evidence.marketReportHtml = path.relative(runDir, marketResults.marketHtmlPath);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Set artifact directory
|
|
106
|
+
*/
|
|
107
|
+
setArtifactDir(artifactDir) {
|
|
108
|
+
this.snapshot.evidence.artifactDir = artifactDir;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Add flow results to snapshot
|
|
113
|
+
*/
|
|
114
|
+
addFlow(flowResult, runDir) {
|
|
115
|
+
if (!flowResult) return;
|
|
116
|
+
|
|
117
|
+
this.snapshot.flows.push({
|
|
118
|
+
flowId: flowResult.flowId,
|
|
119
|
+
flowName: flowResult.flowName,
|
|
120
|
+
outcome: flowResult.outcome,
|
|
121
|
+
riskCategory: flowResult.riskCategory || 'TRUST/UX',
|
|
122
|
+
stepsExecuted: flowResult.stepsExecuted || 0,
|
|
123
|
+
stepsTotal: flowResult.stepsTotal || 0,
|
|
124
|
+
durationMs: flowResult.durationMs || 0,
|
|
125
|
+
failedStep: flowResult.failedStep || null,
|
|
126
|
+
error: flowResult.error || null
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
if (runDir) {
|
|
130
|
+
this.snapshot.evidence.flowArtifacts[flowResult.flowId] = {
|
|
131
|
+
screenshots: flowResult.screenshots || [],
|
|
132
|
+
artifactDir: path.join('flows', flowResult.flowId)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (flowResult.outcome === 'FAILURE') {
|
|
137
|
+
this.snapshot.signals.push({
|
|
138
|
+
id: `flow_${flowResult.flowId}_failed`,
|
|
139
|
+
severity: 'high',
|
|
140
|
+
type: 'failure',
|
|
141
|
+
description: `Flow ${flowResult.flowName} failed`,
|
|
142
|
+
affectedAttemptId: flowResult.flowId
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Add trace path if available
|
|
149
|
+
*/
|
|
150
|
+
setTracePath(tracePath) {
|
|
151
|
+
if (tracePath) {
|
|
152
|
+
this.snapshot.evidence.traceZip = tracePath;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Set baseline information
|
|
158
|
+
*/
|
|
159
|
+
setBaseline(baselineInfo) {
|
|
160
|
+
if (!baselineInfo) return;
|
|
161
|
+
|
|
162
|
+
this.snapshot.baseline = {
|
|
163
|
+
...this.snapshot.baseline,
|
|
164
|
+
...baselineInfo
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Set market impact summary (Phase 3)
|
|
170
|
+
*/
|
|
171
|
+
setMarketImpactSummary(marketImpactSummary) {
|
|
172
|
+
if (!marketImpactSummary) return;
|
|
173
|
+
|
|
174
|
+
this.snapshot.marketImpactSummary = {
|
|
175
|
+
highestSeverity: marketImpactSummary.highestSeverity || 'INFO',
|
|
176
|
+
totalRiskCount: marketImpactSummary.totalRiskCount || 0,
|
|
177
|
+
countsBySeverity: marketImpactSummary.countsBySeverity || { CRITICAL: 0, WARNING: 0, INFO: 0 },
|
|
178
|
+
topRisks: (marketImpactSummary.topRisks || []).slice(0, 10)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Set discovery results (Phase 4)
|
|
184
|
+
*/
|
|
185
|
+
setDiscoveryResults(discoveryResult) {
|
|
186
|
+
if (!discoveryResult) return;
|
|
187
|
+
|
|
188
|
+
this.snapshot.discovery = {
|
|
189
|
+
pagesVisited: discoveryResult.pagesVisited || [],
|
|
190
|
+
pagesVisitedCount: discoveryResult.pagesVisitedCount || 0,
|
|
191
|
+
interactionsDiscovered: discoveryResult.interactionsDiscovered || 0,
|
|
192
|
+
interactionsExecuted: discoveryResult.interactionsExecuted || 0,
|
|
193
|
+
interactionsByType: discoveryResult.interactionsByType || { NAVIGATE: 0, CLICK: 0, FORM_FILL: 0 },
|
|
194
|
+
interactionsByRisk: discoveryResult.interactionsByRisk || { safe: 0, risky: 0 },
|
|
195
|
+
results: (discoveryResult.results || []).slice(0, 20), // Top 20 results
|
|
196
|
+
summary: discoveryResult.summary || ''
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Add breakage intelligence (Phase 4)
|
|
202
|
+
*/
|
|
203
|
+
addIntelligence(intelligence) {
|
|
204
|
+
if (!intelligence) return;
|
|
205
|
+
|
|
206
|
+
this.snapshot.intelligence = {
|
|
207
|
+
totalFailures: intelligence.totalFailures || 0,
|
|
208
|
+
failures: (intelligence.failures || []).slice(0, 50), // Top 50 failures
|
|
209
|
+
byDomain: intelligence.byDomain || {},
|
|
210
|
+
bySeverity: intelligence.bySeverity || {},
|
|
211
|
+
escalationSignals: intelligence.escalationSignals || [],
|
|
212
|
+
summary: intelligence.summary || ''
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Add regression detection results
|
|
218
|
+
*/
|
|
219
|
+
addDiff(diffResult) {
|
|
220
|
+
if (!diffResult) return;
|
|
221
|
+
|
|
222
|
+
this.snapshot.baseline.diff = {
|
|
223
|
+
regressions: diffResult.regressions || [],
|
|
224
|
+
improvements: diffResult.improvements || [],
|
|
225
|
+
attemptsDriftCount: diffResult.attemptsDriftCount || 0
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// Add regression signals
|
|
229
|
+
if (diffResult.regressions && Object.keys(diffResult.regressions).length > 0) {
|
|
230
|
+
for (const [attemptId, regression] of Object.entries(diffResult.regressions)) {
|
|
231
|
+
this.snapshot.signals.push({
|
|
232
|
+
id: `regression_${attemptId}`,
|
|
233
|
+
severity: 'high',
|
|
234
|
+
type: 'regression',
|
|
235
|
+
description: `Regression in ${attemptId}: ${regression.reason}`,
|
|
236
|
+
affectedAttemptId: attemptId,
|
|
237
|
+
details: regression
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Set market impact summary (Phase 3)
|
|
245
|
+
*/
|
|
246
|
+
setMarketImpactSummary(marketImpactSummary) {
|
|
247
|
+
if (!marketImpactSummary) return;
|
|
248
|
+
|
|
249
|
+
this.snapshot.marketImpactSummary = {
|
|
250
|
+
highestSeverity: marketImpactSummary.highestSeverity || 'INFO',
|
|
251
|
+
totalRiskCount: marketImpactSummary.totalRiskCount || 0,
|
|
252
|
+
countsBySeverity: marketImpactSummary.countsBySeverity || {
|
|
253
|
+
CRITICAL: 0,
|
|
254
|
+
WARNING: 0,
|
|
255
|
+
INFO: 0
|
|
256
|
+
},
|
|
257
|
+
topRisks: (marketImpactSummary.topRisks || []).slice(0, 10) // Keep top 10
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Get the built snapshot
|
|
263
|
+
*/
|
|
264
|
+
getSnapshot() {
|
|
265
|
+
return this.snapshot;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Validate snapshot and return validation result
|
|
270
|
+
*/
|
|
271
|
+
validate() {
|
|
272
|
+
return validateSnapshot(this.snapshot);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Convert to JSON
|
|
277
|
+
*/
|
|
278
|
+
toJSON() {
|
|
279
|
+
return JSON.stringify(this.snapshot, null, 2);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Save snapshot to file atomically (write temp, rename)
|
|
285
|
+
*/
|
|
286
|
+
async function saveSnapshot(snapshot, filePath) {
|
|
287
|
+
const dir = path.dirname(filePath);
|
|
288
|
+
if (!fs.existsSync(dir)) {
|
|
289
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const tempPath = `${filePath}.tmp`;
|
|
293
|
+
const json = typeof snapshot === 'string' ? snapshot : JSON.stringify(snapshot, null, 2);
|
|
294
|
+
|
|
295
|
+
return new Promise((resolve, reject) => {
|
|
296
|
+
fs.writeFile(tempPath, json, 'utf8', (err) => {
|
|
297
|
+
if (err) return reject(err);
|
|
298
|
+
|
|
299
|
+
fs.rename(tempPath, filePath, (err) => {
|
|
300
|
+
if (err) return reject(err);
|
|
301
|
+
resolve(filePath);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Load snapshot from file
|
|
309
|
+
*/
|
|
310
|
+
function loadSnapshot(filePath) {
|
|
311
|
+
if (!fs.existsSync(filePath)) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
try {
|
|
316
|
+
const json = fs.readFileSync(filePath, 'utf8');
|
|
317
|
+
return JSON.parse(json);
|
|
318
|
+
} catch (err) {
|
|
319
|
+
throw new Error(`Failed to load snapshot from ${filePath}: ${err.message}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
module.exports = {
|
|
324
|
+
SnapshotBuilder,
|
|
325
|
+
saveSnapshot,
|
|
326
|
+
loadSnapshot
|
|
327
|
+
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Market Reality Validators Framework
|
|
3
|
+
*
|
|
4
|
+
* Pure deterministic checks that detect soft failures:
|
|
5
|
+
* - Interactions technically succeeded (no Playwright exception)
|
|
6
|
+
* - But user would NOT succeed (no confirmation, wrong state, etc)
|
|
7
|
+
*
|
|
8
|
+
* Validators return: { id, type, status: PASS|FAIL|WARN, message, evidence? }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const validator = {
|
|
12
|
+
PASS: 'PASS',
|
|
13
|
+
FAIL: 'FAIL',
|
|
14
|
+
WARN: 'WARN'
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* URL includes substring
|
|
19
|
+
*/
|
|
20
|
+
function urlIncludes(page, substring) {
|
|
21
|
+
try {
|
|
22
|
+
const url = page.url();
|
|
23
|
+
return {
|
|
24
|
+
id: `url_includes_${substring}`,
|
|
25
|
+
type: 'urlIncludes',
|
|
26
|
+
status: url.includes(substring) ? validator.PASS : validator.FAIL,
|
|
27
|
+
message: url.includes(substring)
|
|
28
|
+
? `URL contains "${substring}"`
|
|
29
|
+
: `URL does not contain "${substring}". Current: ${url}`,
|
|
30
|
+
evidence: { url, expected: substring }
|
|
31
|
+
};
|
|
32
|
+
} catch (err) {
|
|
33
|
+
return {
|
|
34
|
+
id: `url_includes_${substring}`,
|
|
35
|
+
type: 'urlIncludes',
|
|
36
|
+
status: validator.FAIL,
|
|
37
|
+
message: `Error checking URL: ${err.message}`,
|
|
38
|
+
evidence: { error: err.message }
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* URL matches regex pattern
|
|
45
|
+
*/
|
|
46
|
+
function urlMatches(page, pattern) {
|
|
47
|
+
try {
|
|
48
|
+
const url = page.url();
|
|
49
|
+
const regex = new RegExp(pattern);
|
|
50
|
+
return {
|
|
51
|
+
id: `url_matches_${pattern}`,
|
|
52
|
+
type: 'urlMatches',
|
|
53
|
+
status: regex.test(url) ? validator.PASS : validator.FAIL,
|
|
54
|
+
message: regex.test(url)
|
|
55
|
+
? `URL matches pattern "${pattern}"`
|
|
56
|
+
: `URL does not match pattern "${pattern}". Current: ${url}`,
|
|
57
|
+
evidence: { url, pattern }
|
|
58
|
+
};
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return {
|
|
61
|
+
id: `url_matches_${pattern}`,
|
|
62
|
+
type: 'urlMatches',
|
|
63
|
+
status: validator.FAIL,
|
|
64
|
+
message: `Error checking URL pattern: ${err.message}`,
|
|
65
|
+
evidence: { error: err.message }
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Element is visible (exists and not hidden)
|
|
72
|
+
*/
|
|
73
|
+
async function elementVisible(page, selector) {
|
|
74
|
+
try {
|
|
75
|
+
const element = page.locator(selector);
|
|
76
|
+
const visible = await element.isVisible().catch(() => false);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
id: `element_visible_${selector}`,
|
|
80
|
+
type: 'elementVisible',
|
|
81
|
+
status: visible ? validator.PASS : validator.FAIL,
|
|
82
|
+
message: visible
|
|
83
|
+
? `Element visible: ${selector}`
|
|
84
|
+
: `Element not visible: ${selector}`,
|
|
85
|
+
evidence: { selector, visible }
|
|
86
|
+
};
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return {
|
|
89
|
+
id: `element_visible_${selector}`,
|
|
90
|
+
type: 'elementVisible',
|
|
91
|
+
status: validator.FAIL,
|
|
92
|
+
message: `Error checking element visibility: ${err.message}`,
|
|
93
|
+
evidence: { selector, error: err.message }
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Element is NOT visible
|
|
100
|
+
*/
|
|
101
|
+
async function elementNotVisible(page, selector) {
|
|
102
|
+
try {
|
|
103
|
+
const element = page.locator(selector);
|
|
104
|
+
const visible = await element.isVisible().catch(() => false);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
id: `element_not_visible_${selector}`,
|
|
108
|
+
type: 'elementNotVisible',
|
|
109
|
+
status: !visible ? validator.PASS : validator.FAIL,
|
|
110
|
+
message: !visible
|
|
111
|
+
? `Element not visible: ${selector}`
|
|
112
|
+
: `Element is visible but should not be: ${selector}`,
|
|
113
|
+
evidence: { selector, visible }
|
|
114
|
+
};
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return {
|
|
117
|
+
id: `element_not_visible_${selector}`,
|
|
118
|
+
type: 'elementNotVisible',
|
|
119
|
+
status: validator.PASS, // If element doesn't exist, that's good
|
|
120
|
+
message: `Element not found (expected)`,
|
|
121
|
+
evidence: { selector }
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Element contains text
|
|
128
|
+
*/
|
|
129
|
+
async function elementContainsText(page, selector, expectedText) {
|
|
130
|
+
try {
|
|
131
|
+
const element = page.locator(selector);
|
|
132
|
+
const text = await element.textContent().catch(() => '');
|
|
133
|
+
const contains = text.includes(expectedText);
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
id: `element_contains_${selector}_${expectedText}`,
|
|
137
|
+
type: 'elementContainsText',
|
|
138
|
+
status: contains ? validator.PASS : validator.FAIL,
|
|
139
|
+
message: contains
|
|
140
|
+
? `Element contains "${expectedText}"`
|
|
141
|
+
: `Element text does not contain "${expectedText}". Found: "${text}"`,
|
|
142
|
+
evidence: { selector, expectedText, actualText: text }
|
|
143
|
+
};
|
|
144
|
+
} catch (err) {
|
|
145
|
+
return {
|
|
146
|
+
id: `element_contains_${selector}_${expectedText}`,
|
|
147
|
+
type: 'elementContainsText',
|
|
148
|
+
status: validator.FAIL,
|
|
149
|
+
message: `Error checking element text: ${err.message}`,
|
|
150
|
+
evidence: { selector, expectedText, error: err.message }
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Page contains ANY of the provided text strings
|
|
157
|
+
* Useful for language detection or success keywords
|
|
158
|
+
*/
|
|
159
|
+
async function pageContainsAnyText(page, textList) {
|
|
160
|
+
try {
|
|
161
|
+
const pageText = await page.content();
|
|
162
|
+
const found = textList.find(text => pageText.includes(text));
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
id: `page_contains_any_${textList.join('_')}`,
|
|
166
|
+
type: 'pageContainsAnyText',
|
|
167
|
+
status: found ? validator.PASS : validator.FAIL,
|
|
168
|
+
message: found
|
|
169
|
+
? `Page contains expected text: "${found}"`
|
|
170
|
+
: `Page does not contain any of: ${textList.join(', ')}`,
|
|
171
|
+
evidence: { searchTerms: textList, found }
|
|
172
|
+
};
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return {
|
|
175
|
+
id: `page_contains_any_${textList.join('_')}`,
|
|
176
|
+
type: 'pageContainsAnyText',
|
|
177
|
+
status: validator.FAIL,
|
|
178
|
+
message: `Error checking page content: ${err.message}`,
|
|
179
|
+
evidence: { searchTerms: textList, error: err.message }
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* HTML lang attribute matches (for language detection)
|
|
186
|
+
*/
|
|
187
|
+
async function htmlLangAttribute(page, expectedLang) {
|
|
188
|
+
try {
|
|
189
|
+
const lang = await page.locator('html').getAttribute('lang');
|
|
190
|
+
const matches = lang === expectedLang;
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
id: `html_lang_${expectedLang}`,
|
|
194
|
+
type: 'htmlLangAttribute',
|
|
195
|
+
status: matches ? validator.PASS : validator.FAIL,
|
|
196
|
+
message: matches
|
|
197
|
+
? `HTML lang attribute is "${expectedLang}"`
|
|
198
|
+
: `HTML lang is "${lang}", expected "${expectedLang}"`,
|
|
199
|
+
evidence: { attribute: 'lang', expected: expectedLang, actual: lang }
|
|
200
|
+
};
|
|
201
|
+
} catch (err) {
|
|
202
|
+
return {
|
|
203
|
+
id: `html_lang_${expectedLang}`,
|
|
204
|
+
type: 'htmlLangAttribute',
|
|
205
|
+
status: validator.FAIL,
|
|
206
|
+
message: `Error checking lang attribute: ${err.message}`,
|
|
207
|
+
evidence: { error: err.message }
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* No console errors above severity level
|
|
214
|
+
* Requires console messages to be captured during attempt
|
|
215
|
+
*/
|
|
216
|
+
function noConsoleErrorsAbove(consoleMessages, minSeverity = 'error') {
|
|
217
|
+
const severities = { log: 0, warning: 1, error: 2 };
|
|
218
|
+
const minLevel = severities[minSeverity] || 2;
|
|
219
|
+
|
|
220
|
+
const violatingMessages = consoleMessages.filter(msg =>
|
|
221
|
+
severities[msg.type] >= minLevel
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
id: `no_console_errors_${minSeverity}`,
|
|
226
|
+
type: 'noConsoleErrorsAbove',
|
|
227
|
+
status: violatingMessages.length === 0 ? validator.PASS : validator.FAIL,
|
|
228
|
+
message: violatingMessages.length === 0
|
|
229
|
+
? `No console errors (threshold: ${minSeverity})`
|
|
230
|
+
: `Found ${violatingMessages.length} console errors: ${violatingMessages.map(m => m.text).join('; ')}`,
|
|
231
|
+
evidence: { minSeverity, count: violatingMessages.length, messages: violatingMessages }
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Validator runner: execute all validators and return results
|
|
237
|
+
*/
|
|
238
|
+
async function runValidators(validatorSpecs, context) {
|
|
239
|
+
const results = [];
|
|
240
|
+
|
|
241
|
+
for (const spec of validatorSpecs) {
|
|
242
|
+
try {
|
|
243
|
+
let result;
|
|
244
|
+
|
|
245
|
+
switch (spec.type) {
|
|
246
|
+
case 'urlIncludes':
|
|
247
|
+
result = urlIncludes(context.page, spec.param);
|
|
248
|
+
break;
|
|
249
|
+
case 'urlMatches':
|
|
250
|
+
result = urlMatches(context.page, spec.param);
|
|
251
|
+
break;
|
|
252
|
+
case 'elementVisible':
|
|
253
|
+
result = await elementVisible(context.page, spec.selector);
|
|
254
|
+
break;
|
|
255
|
+
case 'elementNotVisible':
|
|
256
|
+
result = await elementNotVisible(context.page, spec.selector);
|
|
257
|
+
break;
|
|
258
|
+
case 'elementContainsText':
|
|
259
|
+
result = await elementContainsText(context.page, spec.selector, spec.text);
|
|
260
|
+
break;
|
|
261
|
+
case 'pageContainsAnyText':
|
|
262
|
+
result = await pageContainsAnyText(context.page, spec.textList);
|
|
263
|
+
break;
|
|
264
|
+
case 'htmlLangAttribute':
|
|
265
|
+
result = await htmlLangAttribute(context.page, spec.lang);
|
|
266
|
+
break;
|
|
267
|
+
case 'noConsoleErrorsAbove':
|
|
268
|
+
result = noConsoleErrorsAbove(context.consoleMessages || [], spec.minSeverity);
|
|
269
|
+
break;
|
|
270
|
+
default:
|
|
271
|
+
result = {
|
|
272
|
+
id: spec.type,
|
|
273
|
+
type: spec.type,
|
|
274
|
+
status: validator.WARN,
|
|
275
|
+
message: `Unknown validator type: ${spec.type}`
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
results.push(result);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
results.push({
|
|
282
|
+
id: spec.type,
|
|
283
|
+
type: spec.type,
|
|
284
|
+
status: validator.FAIL,
|
|
285
|
+
message: `Validator error: ${err.message}`,
|
|
286
|
+
evidence: { error: err.message }
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return results;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Determine if validators indicate a soft failure
|
|
296
|
+
* Returns: { hasSoftFailure: boolean, failureCount: number, warnCount: number }
|
|
297
|
+
*/
|
|
298
|
+
function analyzeSoftFailures(validatorResults) {
|
|
299
|
+
const failures = validatorResults.filter(r => r.status === validator.FAIL);
|
|
300
|
+
const warnings = validatorResults.filter(r => r.status === validator.WARN);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
hasSoftFailure: failures.length > 0,
|
|
304
|
+
failureCount: failures.length,
|
|
305
|
+
warnCount: warnings.length,
|
|
306
|
+
failedValidators: failures,
|
|
307
|
+
warnedValidators: warnings
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
module.exports = {
|
|
312
|
+
validator,
|
|
313
|
+
urlIncludes,
|
|
314
|
+
urlMatches,
|
|
315
|
+
elementVisible,
|
|
316
|
+
elementNotVisible,
|
|
317
|
+
elementContainsText,
|
|
318
|
+
pageContainsAnyText,
|
|
319
|
+
htmlLangAttribute,
|
|
320
|
+
noConsoleErrorsAbove,
|
|
321
|
+
runValidators,
|
|
322
|
+
analyzeSoftFailures
|
|
323
|
+
};
|