@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,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardian Screenshot Module
|
|
3
|
+
* Captures and saves screenshots of visited pages
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
class GuardianScreenshot {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.quality = options.quality || 80; // JPEG quality 0-100
|
|
12
|
+
this.fullPage = options.fullPage !== false; // Capture full page by default
|
|
13
|
+
this.type = options.type || 'jpeg'; // jpeg or png
|
|
14
|
+
this.normalizedViewport = options.normalizedViewport || { width: 1280, height: 720 }; // Phase 5: Consistent viewport
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalize browser viewport for consistent screenshots (Phase 5)
|
|
19
|
+
* @param {Page} page - Playwright page object
|
|
20
|
+
* @returns {Promise<void>}
|
|
21
|
+
*/
|
|
22
|
+
async normalizeViewport(page) {
|
|
23
|
+
try {
|
|
24
|
+
await page.setViewportSize({
|
|
25
|
+
width: this.normalizedViewport.width,
|
|
26
|
+
height: this.normalizedViewport.height
|
|
27
|
+
});
|
|
28
|
+
// Wait for layout to settle
|
|
29
|
+
await page.waitForTimeout(100);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.warn(`⚠️ Viewport normalization failed: ${err.message}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Capture screenshot of current page
|
|
37
|
+
* @param {Page} page - Playwright page object
|
|
38
|
+
* @param {string} outputPath - Where to save the screenshot
|
|
39
|
+
* @param {object} options - Additional screenshot options
|
|
40
|
+
* @returns {Promise<boolean>} Success status
|
|
41
|
+
*/
|
|
42
|
+
async capture(page, outputPath, options = {}) {
|
|
43
|
+
try {
|
|
44
|
+
// Phase 5: Normalize viewport for consistent visuals
|
|
45
|
+
if (options.normalize !== false) {
|
|
46
|
+
await this.normalizeViewport(page);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const screenshotOptions = {
|
|
50
|
+
path: outputPath,
|
|
51
|
+
type: this.type,
|
|
52
|
+
fullPage: options.fullPage !== undefined ? options.fullPage : this.fullPage,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Add quality for JPEG
|
|
56
|
+
if (this.type === 'jpeg') {
|
|
57
|
+
screenshotOptions.quality = this.quality;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await page.screenshot(screenshotOptions);
|
|
61
|
+
return true;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
console.error(`❌ Screenshot failed: ${error.message}`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Generate filename for screenshot based on URL
|
|
70
|
+
* @param {string} url - Page URL
|
|
71
|
+
* @param {number} index - Page index
|
|
72
|
+
* @returns {string} Safe filename
|
|
73
|
+
*/
|
|
74
|
+
generateFilename(url, index) {
|
|
75
|
+
try {
|
|
76
|
+
const urlObj = new URL(url);
|
|
77
|
+
let pathname = urlObj.pathname;
|
|
78
|
+
|
|
79
|
+
// Root path
|
|
80
|
+
if (pathname === '/' || pathname === '') {
|
|
81
|
+
return `page-${index}-home.${this.type}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Clean pathname for filename
|
|
85
|
+
let safeName = pathname
|
|
86
|
+
.replace(/^\//, '') // Remove leading slash
|
|
87
|
+
.replace(/\/$/, '') // Remove trailing slash
|
|
88
|
+
.replace(/\//g, '-') // Replace slashes with dashes
|
|
89
|
+
.replace(/[^a-zA-Z0-9\-_.]/g, '_') // Replace unsafe chars
|
|
90
|
+
.substring(0, 100); // Limit length
|
|
91
|
+
|
|
92
|
+
return `page-${index}-${safeName}.${this.type}`;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// Fallback for invalid URLs
|
|
95
|
+
return `page-${index}-unknown.${this.type}`;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Save screenshot during crawl
|
|
101
|
+
* @param {Page} page - Playwright page
|
|
102
|
+
* @param {string} url - Current URL
|
|
103
|
+
* @param {number} index - Page index
|
|
104
|
+
* @param {string} artifactsDir - Artifacts directory
|
|
105
|
+
* @returns {Promise<string|null>} Path to saved screenshot or null
|
|
106
|
+
*/
|
|
107
|
+
async captureForCrawl(page, url, index, artifactsDir) {
|
|
108
|
+
try {
|
|
109
|
+
// Create pages subdirectory
|
|
110
|
+
const pagesDir = path.join(artifactsDir, 'pages');
|
|
111
|
+
if (!fs.existsSync(pagesDir)) {
|
|
112
|
+
fs.mkdirSync(pagesDir, { recursive: true });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Generate filename and full path
|
|
116
|
+
const filename = this.generateFilename(url, index);
|
|
117
|
+
const outputPath = path.join(pagesDir, filename);
|
|
118
|
+
|
|
119
|
+
// Capture screenshot
|
|
120
|
+
const success = await this.capture(page, outputPath);
|
|
121
|
+
|
|
122
|
+
if (success) {
|
|
123
|
+
return filename; // Return relative filename
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(`❌ Failed to capture screenshot for ${url}: ${error.message}`);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Validate that screenshot file exists and has reasonable size
|
|
134
|
+
* @param {string} filepath - Path to screenshot file
|
|
135
|
+
* @returns {boolean} True if valid
|
|
136
|
+
*/
|
|
137
|
+
validateScreenshot(filepath) {
|
|
138
|
+
try {
|
|
139
|
+
if (!fs.existsSync(filepath)) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const stats = fs.statSync(filepath);
|
|
144
|
+
// Screenshot should be at least 1KB
|
|
145
|
+
return stats.size > 1024;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = GuardianScreenshot;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Guardian Sitemap Discovery Module
|
|
3
|
+
* Discovers URLs from robots.txt and sitemap.xml
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const http = require('http');
|
|
8
|
+
|
|
9
|
+
class GuardianSitemap {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.timeout = options.timeout || 10000; // 10 seconds timeout
|
|
12
|
+
this.maxUrls = options.maxUrls || 200; // Maximum URLs to extract
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch content from URL
|
|
17
|
+
* @param {string} url - URL to fetch
|
|
18
|
+
* @returns {Promise<string|null>} Content or null if failed
|
|
19
|
+
*/
|
|
20
|
+
async fetch(url) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
try {
|
|
23
|
+
const urlObj = new URL(url);
|
|
24
|
+
const client = urlObj.protocol === 'https:' ? https : http;
|
|
25
|
+
|
|
26
|
+
const request = client.get(url, { timeout: this.timeout }, (response) => {
|
|
27
|
+
// Follow redirects
|
|
28
|
+
if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) {
|
|
29
|
+
return this.fetch(response.headers.location).then(resolve);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (response.statusCode !== 200) {
|
|
33
|
+
return resolve(null);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let data = '';
|
|
37
|
+
response.on('data', (chunk) => { data += chunk; });
|
|
38
|
+
response.on('end', () => resolve(data));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
request.on('error', () => resolve(null));
|
|
42
|
+
request.on('timeout', () => {
|
|
43
|
+
request.destroy();
|
|
44
|
+
resolve(null);
|
|
45
|
+
});
|
|
46
|
+
} catch (error) {
|
|
47
|
+
resolve(null);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Discover sitemap URLs from robots.txt
|
|
54
|
+
* @param {string} baseUrl - Base URL of the website
|
|
55
|
+
* @returns {Promise<string[]>} Array of sitemap URLs
|
|
56
|
+
*/
|
|
57
|
+
async discoverFromRobots(baseUrl) {
|
|
58
|
+
try {
|
|
59
|
+
const robotsUrl = new URL('/robots.txt', baseUrl).href;
|
|
60
|
+
const content = await this.fetch(robotsUrl);
|
|
61
|
+
|
|
62
|
+
if (!content) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const sitemaps = [];
|
|
67
|
+
const lines = content.split('\n');
|
|
68
|
+
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
const trimmed = line.trim();
|
|
71
|
+
if (trimmed.toLowerCase().startsWith('sitemap:')) {
|
|
72
|
+
const sitemapUrl = trimmed.substring(8).trim();
|
|
73
|
+
if (sitemapUrl) {
|
|
74
|
+
sitemaps.push(sitemapUrl);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return sitemaps;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error(`❌ Failed to fetch robots.txt: ${error.message}`);
|
|
82
|
+
return [];
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parse sitemap XML and extract URLs
|
|
88
|
+
* @param {string} xml - Sitemap XML content
|
|
89
|
+
* @returns {string[]} Array of URLs
|
|
90
|
+
*/
|
|
91
|
+
parseSitemap(xml) {
|
|
92
|
+
try {
|
|
93
|
+
const urls = [];
|
|
94
|
+
|
|
95
|
+
// Simple regex to extract <loc> tags (works for most sitemaps)
|
|
96
|
+
const locRegex = /<loc>(.*?)<\/loc>/gi;
|
|
97
|
+
let match;
|
|
98
|
+
|
|
99
|
+
while ((match = locRegex.exec(xml)) !== null && urls.length < this.maxUrls) {
|
|
100
|
+
const url = match[1].trim();
|
|
101
|
+
if (url) {
|
|
102
|
+
urls.push(url);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return urls;
|
|
107
|
+
} catch (error) {
|
|
108
|
+
console.error(`❌ Failed to parse sitemap: ${error.message}`);
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Check if URL is a sitemap index (contains other sitemaps)
|
|
115
|
+
* @param {string} xml - XML content
|
|
116
|
+
* @returns {boolean} True if sitemap index
|
|
117
|
+
*/
|
|
118
|
+
isSitemapIndex(xml) {
|
|
119
|
+
return xml.includes('<sitemapindex') || xml.includes('</sitemapindex>');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Discover all URLs from base URL (robots.txt + sitemaps)
|
|
124
|
+
* @param {string} baseUrl - Base URL of the website
|
|
125
|
+
* @returns {Promise<object>} Object with discovered URLs and stats
|
|
126
|
+
*/
|
|
127
|
+
async discover(baseUrl) {
|
|
128
|
+
const result = {
|
|
129
|
+
urls: [],
|
|
130
|
+
sitemapsChecked: 0,
|
|
131
|
+
source: 'none',
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
// Step 1: Check robots.txt for sitemap URLs
|
|
136
|
+
console.log('🗺️ Checking robots.txt for sitemaps...');
|
|
137
|
+
const sitemapUrls = await this.discoverFromRobots(baseUrl);
|
|
138
|
+
|
|
139
|
+
if (sitemapUrls.length === 0) {
|
|
140
|
+
// Try default sitemap.xml location
|
|
141
|
+
console.log('🗺️ Trying default sitemap.xml...');
|
|
142
|
+
sitemapUrls.push(new URL('/sitemap.xml', baseUrl).href);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Step 2: Fetch and parse each sitemap
|
|
146
|
+
for (const sitemapUrl of sitemapUrls) {
|
|
147
|
+
if (result.urls.length >= this.maxUrls) {
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(`🗺️ Fetching sitemap: ${sitemapUrl}`);
|
|
152
|
+
const xml = await this.fetch(sitemapUrl);
|
|
153
|
+
|
|
154
|
+
if (!xml) {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
result.sitemapsChecked++;
|
|
159
|
+
|
|
160
|
+
// Check if it's a sitemap index
|
|
161
|
+
if (this.isSitemapIndex(xml)) {
|
|
162
|
+
const childSitemaps = this.parseSitemap(xml);
|
|
163
|
+
console.log(`🗺️ Found sitemap index with ${childSitemaps.length} child sitemaps`);
|
|
164
|
+
|
|
165
|
+
// Fetch child sitemaps
|
|
166
|
+
for (const childUrl of childSitemaps) {
|
|
167
|
+
if (result.urls.length >= this.maxUrls) {
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const childXml = await this.fetch(childUrl);
|
|
172
|
+
if (childXml) {
|
|
173
|
+
const childUrls = this.parseSitemap(childXml);
|
|
174
|
+
result.urls.push(...childUrls.slice(0, this.maxUrls - result.urls.length));
|
|
175
|
+
result.sitemapsChecked++;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
// Regular sitemap
|
|
180
|
+
const urls = this.parseSitemap(xml);
|
|
181
|
+
result.urls.push(...urls.slice(0, this.maxUrls - result.urls.length));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Deduplicate URLs
|
|
186
|
+
result.urls = [...new Set(result.urls)];
|
|
187
|
+
|
|
188
|
+
if (result.urls.length > 0) {
|
|
189
|
+
result.source = 'sitemap';
|
|
190
|
+
console.log(`✅ Discovered ${result.urls.length} URLs from ${result.sitemapsChecked} sitemap(s)`);
|
|
191
|
+
} else {
|
|
192
|
+
console.log('⚠️ No URLs found in sitemaps');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return result;
|
|
196
|
+
} catch (error) {
|
|
197
|
+
console.error(`❌ Sitemap discovery failed: ${error.message}`);
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Filter URLs to same origin only
|
|
204
|
+
* @param {string[]} urls - Array of URLs
|
|
205
|
+
* @param {string} baseUrl - Base URL to compare against
|
|
206
|
+
* @returns {string[]} Filtered URLs
|
|
207
|
+
*/
|
|
208
|
+
filterSameOrigin(urls, baseUrl) {
|
|
209
|
+
try {
|
|
210
|
+
const baseOrigin = new URL(baseUrl).origin;
|
|
211
|
+
|
|
212
|
+
return urls.filter(url => {
|
|
213
|
+
try {
|
|
214
|
+
return new URL(url).origin === baseOrigin;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
} catch (error) {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
module.exports = GuardianSitemap;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Market Reality Snapshot v1 Schema Definition
|
|
3
|
+
*
|
|
4
|
+
* A snapshot captures a complete market reality test run:
|
|
5
|
+
* - what was discovered (crawl)
|
|
6
|
+
* - what was attempted (attempts)
|
|
7
|
+
* - what was observed (evidence: screenshots, traces, reports)
|
|
8
|
+
* - what signals were detected (friction, failures, regressions)
|
|
9
|
+
* - what the baseline was and how current differs
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const SNAPSHOT_SCHEMA_VERSION = 'v1';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} SnapshotMeta
|
|
16
|
+
* @property {string} schemaVersion - always 'v1'
|
|
17
|
+
* @property {string} createdAt - ISO timestamp
|
|
18
|
+
* @property {string} toolVersion - package.json version
|
|
19
|
+
* @property {string} url - base URL tested
|
|
20
|
+
* @property {string} runId - unique run identifier
|
|
21
|
+
* @property {string} [environment] - optional deployment environment
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} CrawlResult
|
|
26
|
+
* @property {string[]} discoveredUrls - all unique URLs found
|
|
27
|
+
* @property {number} visitedCount - pages successfully loaded
|
|
28
|
+
* @property {number} failedCount - pages that failed to load
|
|
29
|
+
* @property {number} safetyBlockedCount - pages blocked by safety rules
|
|
30
|
+
* @property {Array<{url: string, statusCode: number, error: string}>} [httpFailures] - detailed failures
|
|
31
|
+
* @property {string} [notes] - human-readable summary
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @typedef {Object} ValidatorResult
|
|
36
|
+
* @property {string} id - unique validator ID
|
|
37
|
+
* @property {string} type - validator type (urlIncludes, elementVisible, etc)
|
|
38
|
+
* @property {string} status - 'PASS', 'FAIL', or 'WARN'
|
|
39
|
+
* @property {string} message - human readable result
|
|
40
|
+
* @property {Object} [evidence] - supporting data (selector, url, snippet, etc)
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {Object} AttemptResult
|
|
45
|
+
* @property {string} attemptId - unique attempt identifier
|
|
46
|
+
* @property {string} attemptName - human-readable name
|
|
47
|
+
* @property {string} goal - what the user tried to achieve
|
|
48
|
+
* @property {string} outcome - 'SUCCESS', 'FAILURE', or 'FRICTION'
|
|
49
|
+
* @property {number} totalDurationMs - elapsed time
|
|
50
|
+
* @property {number} stepCount - how many steps executed
|
|
51
|
+
* @property {number} failedStepIndex - index of first failed step, or -1 if all succeeded
|
|
52
|
+
* @property {Object} friction - friction signals for this attempt
|
|
53
|
+
* @property {ValidatorResult[]} [validators] - soft failure detectors (Phase 2)
|
|
54
|
+
* @property {number} [softFailureCount] - count of failed validators
|
|
55
|
+
* @property {string} [riskCategory] - 'LEAD', 'REVENUE', 'TRUST/UX' (Phase 2)
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @typedef {Object} Evidence
|
|
60
|
+
* @property {string} artifactDir - root directory where all artifacts were saved
|
|
61
|
+
* @property {string} [marketReportJson] - path to market-report.json
|
|
62
|
+
* @property {string} [marketReportHtml] - path to market-report.html
|
|
63
|
+
* @property {string} [traceZip] - path to trace.zip if enabled
|
|
64
|
+
* @property {Object<string, string>} [attemptArtifacts] - { attemptId => { reportJson, reportHtml, screenshotDir } }
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {Object} Signal
|
|
69
|
+
* @property {string} id - unique signal ID
|
|
70
|
+
* @property {string} severity - 'low', 'medium', 'high', 'critical'
|
|
71
|
+
* @property {string} type - 'friction', 'failure', 'regression', 'timeout', 'missing_element', 'soft_failure'
|
|
72
|
+
* @property {string} description - human readable
|
|
73
|
+
* @property {string} [affectedAttemptId] - if specific to an attempt
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {Object} BaselineInfo
|
|
78
|
+
* @property {boolean} baselineFound - whether a baseline was loaded
|
|
79
|
+
* @property {boolean} baselineCreatedThisRun - true if baseline was auto-created in this run
|
|
80
|
+
* @property {string} [baselineCreatedAt] - ISO timestamp when baseline was first created
|
|
81
|
+
* @property {string} [baselinePath] - file system path to baseline
|
|
82
|
+
* @property {Object} [diff] - comparison result if baseline exists
|
|
83
|
+
* @property {Object} [diff.regressions] - { attemptId => {before, after, reason} }
|
|
84
|
+
* @property {Object} [diff.improvements] - { attemptId => {before, after, reason} }
|
|
85
|
+
* @property {number} [diff.attemptsDriftCount] - how many attempts changed outcome
|
|
86
|
+
* @property {Array} [diff.validatorsChanged] - validator regression details (Phase 2)
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @typedef {Object} MarketRisk
|
|
91
|
+
* @property {string} attemptId - which attempt
|
|
92
|
+
* @property {string} validatorId - which validator or friction signal
|
|
93
|
+
* @property {string} category - REVENUE|LEAD|TRUST|UX
|
|
94
|
+
* @property {string} severity - CRITICAL|WARNING|INFO
|
|
95
|
+
* @property {number} impactScore - 0-100 deterministic score
|
|
96
|
+
* @property {string} humanReadableReason - explanation
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @typedef {Object} MarketImpactSummary
|
|
101
|
+
* @property {string} highestSeverity - CRITICAL|WARNING|INFO
|
|
102
|
+
* @property {number} totalRiskCount - total number of identified risks
|
|
103
|
+
* @property {Object} countsBySeverity - { CRITICAL: N, WARNING: N, INFO: N }
|
|
104
|
+
* @property {MarketRisk[]} topRisks - top 10 risks, sorted by impact score
|
|
105
|
+
*/
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @typedef {Object} InteractionResult
|
|
109
|
+
* @property {string} interactionId - unique ID
|
|
110
|
+
* @property {string} pageUrl - URL where found
|
|
111
|
+
* @property {string} type - NAVIGATE|CLICK|FORM_FILL
|
|
112
|
+
* @property {string} selector - CSS selector to find element
|
|
113
|
+
* @property {string} outcome - SUCCESS|FAILURE|FRICTION
|
|
114
|
+
* @property {string} [notes] - details (target URL, error, etc)
|
|
115
|
+
* @property {number} [durationMs] - execution time
|
|
116
|
+
* @property {string} [errorMessage] - if FAILURE
|
|
117
|
+
* @property {string} [evidencePath] - path to screenshot
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @typedef {Object} DiscoverySummary
|
|
122
|
+
* @property {string[]} pagesVisited - URLs crawled
|
|
123
|
+
* @property {number} pagesVisitedCount - total pages
|
|
124
|
+
* @property {number} interactionsDiscovered - total candidates found
|
|
125
|
+
* @property {number} interactionsExecuted - candidates executed
|
|
126
|
+
* @property {Object} interactionsByType - { NAVIGATE: N, CLICK: N, FORM_FILL: N }
|
|
127
|
+
* @property {Object} interactionsByRisk - { safe: N, risky: N }
|
|
128
|
+
* @property {InteractionResult[]} results - execution results (failures + top successes)
|
|
129
|
+
* @property {string} [summary] - human readable summary
|
|
130
|
+
*/
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @typedef {Object} MarketRealitySnapshot
|
|
134
|
+
* @property {string} schemaVersion - always 'v1'
|
|
135
|
+
* @property {SnapshotMeta} meta
|
|
136
|
+
* @property {CrawlResult} [crawl]
|
|
137
|
+
* @property {AttemptResult[]} attempts
|
|
138
|
+
* @property {Array} flows
|
|
139
|
+
* @property {Signal[]} signals
|
|
140
|
+
* @property {Object} [riskSummary] - market risk analysis (Phase 2)
|
|
141
|
+
* @property {MarketImpactSummary} [marketImpactSummary] - market criticality (Phase 3)
|
|
142
|
+
* @property {DiscoverySummary} [discovery] - auto-discovered interactions (Phase 4)
|
|
143
|
+
* @property {Evidence} evidence
|
|
144
|
+
* @property {BaselineInfo} baseline
|
|
145
|
+
*/
|
|
146
|
+
|
|
147
|
+
function createEmptySnapshot(baseUrl, runId, toolVersion) {
|
|
148
|
+
return {
|
|
149
|
+
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
150
|
+
meta: {
|
|
151
|
+
schemaVersion: SNAPSHOT_SCHEMA_VERSION,
|
|
152
|
+
createdAt: new Date().toISOString(),
|
|
153
|
+
toolVersion,
|
|
154
|
+
url: baseUrl,
|
|
155
|
+
runId,
|
|
156
|
+
environment: process.env.GUARDIAN_ENV || 'production'
|
|
157
|
+
},
|
|
158
|
+
crawl: {
|
|
159
|
+
discoveredUrls: [],
|
|
160
|
+
visitedCount: 0,
|
|
161
|
+
failedCount: 0,
|
|
162
|
+
safetyBlockedCount: 0,
|
|
163
|
+
httpFailures: [],
|
|
164
|
+
notes: ''
|
|
165
|
+
},
|
|
166
|
+
attempts: [],
|
|
167
|
+
flows: [],
|
|
168
|
+
signals: [],
|
|
169
|
+
riskSummary: {
|
|
170
|
+
totalSoftFailures: 0,
|
|
171
|
+
totalFriction: 0,
|
|
172
|
+
failuresByCategory: {},
|
|
173
|
+
topRisks: []
|
|
174
|
+
},
|
|
175
|
+
marketImpactSummary: {
|
|
176
|
+
highestSeverity: 'INFO',
|
|
177
|
+
totalRiskCount: 0,
|
|
178
|
+
countsBySeverity: {
|
|
179
|
+
CRITICAL: 0,
|
|
180
|
+
WARNING: 0,
|
|
181
|
+
INFO: 0
|
|
182
|
+
},
|
|
183
|
+
topRisks: []
|
|
184
|
+
},
|
|
185
|
+
discovery: {
|
|
186
|
+
pagesVisited: [],
|
|
187
|
+
pagesVisitedCount: 0,
|
|
188
|
+
interactionsDiscovered: 0,
|
|
189
|
+
interactionsExecuted: 0,
|
|
190
|
+
interactionsByType: {
|
|
191
|
+
NAVIGATE: 0,
|
|
192
|
+
CLICK: 0,
|
|
193
|
+
FORM_FILL: 0
|
|
194
|
+
},
|
|
195
|
+
interactionsByRisk: {
|
|
196
|
+
safe: 0,
|
|
197
|
+
risky: 0
|
|
198
|
+
},
|
|
199
|
+
results: [],
|
|
200
|
+
summary: ''
|
|
201
|
+
},
|
|
202
|
+
evidence: {
|
|
203
|
+
artifactDir: '',
|
|
204
|
+
attemptArtifacts: {},
|
|
205
|
+
flowArtifacts: {}
|
|
206
|
+
},
|
|
207
|
+
intelligence: {
|
|
208
|
+
totalFailures: 0,
|
|
209
|
+
failures: [],
|
|
210
|
+
byDomain: {},
|
|
211
|
+
bySeverity: {},
|
|
212
|
+
escalationSignals: [],
|
|
213
|
+
summary: ''
|
|
214
|
+
},
|
|
215
|
+
baseline: {
|
|
216
|
+
baselineFound: false,
|
|
217
|
+
baselineCreatedThisRun: false,
|
|
218
|
+
baselineCreatedAt: null,
|
|
219
|
+
baselinePath: null,
|
|
220
|
+
diff: null
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function validateSnapshot(snapshot) {
|
|
226
|
+
const errors = [];
|
|
227
|
+
|
|
228
|
+
if (!snapshot.schemaVersion || snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
|
|
229
|
+
errors.push('Missing or invalid schemaVersion');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!snapshot.meta || !snapshot.meta.createdAt || !snapshot.meta.url || !snapshot.meta.runId) {
|
|
233
|
+
errors.push('Missing required meta fields: createdAt, url, runId');
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!Array.isArray(snapshot.attempts)) {
|
|
237
|
+
errors.push('attempts must be an array');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!Array.isArray(snapshot.signals)) {
|
|
241
|
+
errors.push('signals must be an array');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!Array.isArray(snapshot.flows)) {
|
|
245
|
+
errors.push('flows must be an array');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (!snapshot.evidence || !snapshot.evidence.artifactDir) {
|
|
249
|
+
errors.push('Missing evidence.artifactDir');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (!snapshot.baseline) {
|
|
253
|
+
errors.push('Missing baseline section');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
valid: errors.length === 0,
|
|
258
|
+
errors
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = {
|
|
263
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
264
|
+
createEmptySnapshot,
|
|
265
|
+
validateSnapshot
|
|
266
|
+
};
|