@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/LICENSE +21 -0
  3. package/README.md +141 -0
  4. package/bin/guardian.js +690 -0
  5. package/flows/example-login-flow.json +36 -0
  6. package/flows/example-signup-flow.json +44 -0
  7. package/guardian-contract-v1.md +149 -0
  8. package/guardian.config.json +54 -0
  9. package/guardian.policy.json +12 -0
  10. package/guardian.profile.docs.yaml +18 -0
  11. package/guardian.profile.ecommerce.yaml +17 -0
  12. package/guardian.profile.marketing.yaml +18 -0
  13. package/guardian.profile.saas.yaml +21 -0
  14. package/package.json +69 -0
  15. package/policies/enterprise.json +12 -0
  16. package/policies/saas.json +12 -0
  17. package/policies/startup.json +12 -0
  18. package/src/guardian/attempt-engine.js +454 -0
  19. package/src/guardian/attempt-registry.js +227 -0
  20. package/src/guardian/attempt-reporter.js +507 -0
  21. package/src/guardian/attempt.js +227 -0
  22. package/src/guardian/auto-attempt-builder.js +283 -0
  23. package/src/guardian/baseline-reporter.js +143 -0
  24. package/src/guardian/baseline-storage.js +285 -0
  25. package/src/guardian/baseline.js +492 -0
  26. package/src/guardian/behavioral-signals.js +261 -0
  27. package/src/guardian/breakage-intelligence.js +223 -0
  28. package/src/guardian/browser.js +92 -0
  29. package/src/guardian/cli-summary.js +141 -0
  30. package/src/guardian/crawler.js +142 -0
  31. package/src/guardian/discovery-engine.js +661 -0
  32. package/src/guardian/enhanced-html-reporter.js +305 -0
  33. package/src/guardian/failure-taxonomy.js +169 -0
  34. package/src/guardian/flow-executor.js +374 -0
  35. package/src/guardian/flow-registry.js +67 -0
  36. package/src/guardian/html-reporter.js +414 -0
  37. package/src/guardian/index.js +218 -0
  38. package/src/guardian/init-command.js +139 -0
  39. package/src/guardian/junit-reporter.js +264 -0
  40. package/src/guardian/market-criticality.js +335 -0
  41. package/src/guardian/market-reporter.js +305 -0
  42. package/src/guardian/network-trace.js +178 -0
  43. package/src/guardian/policy.js +357 -0
  44. package/src/guardian/preset-loader.js +148 -0
  45. package/src/guardian/reality.js +547 -0
  46. package/src/guardian/reporter.js +181 -0
  47. package/src/guardian/root-cause-analysis.js +171 -0
  48. package/src/guardian/safety.js +248 -0
  49. package/src/guardian/scan-presets.js +60 -0
  50. package/src/guardian/screenshot.js +152 -0
  51. package/src/guardian/sitemap.js +225 -0
  52. package/src/guardian/snapshot-schema.js +266 -0
  53. package/src/guardian/snapshot.js +327 -0
  54. package/src/guardian/validators.js +323 -0
  55. package/src/guardian/visual-diff.js +247 -0
  56. 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
+ };