@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,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Init Command
|
|
3
|
+
*
|
|
4
|
+
* One-command onboarding: `guardian init`
|
|
5
|
+
* Creates policy file, .gitignore, and prints next steps.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Initialize Guardian in current directory
|
|
13
|
+
* @param {object} options - Init options
|
|
14
|
+
* @returns {object} Result with created files
|
|
15
|
+
*/
|
|
16
|
+
function initGuardian(options = {}) {
|
|
17
|
+
const cwd = options.cwd || process.cwd();
|
|
18
|
+
const policyPreset = options.preset || 'startup';
|
|
19
|
+
|
|
20
|
+
const result = {
|
|
21
|
+
created: [],
|
|
22
|
+
updated: [],
|
|
23
|
+
errors: []
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
console.log('\n🛡️ Initializing Guardian...\n');
|
|
27
|
+
|
|
28
|
+
// 1. Create policy file
|
|
29
|
+
try {
|
|
30
|
+
const policyPath = path.join(cwd, 'guardian.policy.json');
|
|
31
|
+
|
|
32
|
+
if (fs.existsSync(policyPath)) {
|
|
33
|
+
console.log('⚠️ guardian.policy.json already exists. Skipping.');
|
|
34
|
+
} else {
|
|
35
|
+
// Load preset
|
|
36
|
+
const presetsDir = path.join(__dirname, '../../policies');
|
|
37
|
+
const presetPath = path.join(presetsDir, `${policyPreset}.json`);
|
|
38
|
+
|
|
39
|
+
let policyContent;
|
|
40
|
+
if (fs.existsSync(presetPath)) {
|
|
41
|
+
policyContent = fs.readFileSync(presetPath, 'utf-8');
|
|
42
|
+
} else {
|
|
43
|
+
// Fallback to default
|
|
44
|
+
policyContent = JSON.stringify({
|
|
45
|
+
name: 'Default Policy',
|
|
46
|
+
description: 'Default Guardian policy',
|
|
47
|
+
failOnSeverity: 'CRITICAL',
|
|
48
|
+
maxWarnings: 999,
|
|
49
|
+
maxInfo: 999,
|
|
50
|
+
maxTotalRisk: 999,
|
|
51
|
+
failOnNewRegression: false,
|
|
52
|
+
failOnSoftFailures: false,
|
|
53
|
+
softFailureThreshold: 999,
|
|
54
|
+
requireBaseline: false
|
|
55
|
+
}, null, 2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fs.writeFileSync(policyPath, policyContent, 'utf-8');
|
|
59
|
+
result.created.push('guardian.policy.json');
|
|
60
|
+
console.log('✅ Created guardian.policy.json');
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
result.errors.push(`Failed to create policy: ${error.message}`);
|
|
64
|
+
console.error(`❌ Failed to create policy: ${error.message}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 2. Update .gitignore
|
|
68
|
+
try {
|
|
69
|
+
const gitignorePath = path.join(cwd, '.gitignore');
|
|
70
|
+
const guardianIgnores = [
|
|
71
|
+
'# Guardian artifacts',
|
|
72
|
+
'artifacts/',
|
|
73
|
+
'test-artifacts/',
|
|
74
|
+
'.odavl-guardian/',
|
|
75
|
+
'tmp-artifacts/'
|
|
76
|
+
].join('\n');
|
|
77
|
+
|
|
78
|
+
if (fs.existsSync(gitignorePath)) {
|
|
79
|
+
const content = fs.readFileSync(gitignorePath, 'utf-8');
|
|
80
|
+
if (!content.includes('Guardian artifacts')) {
|
|
81
|
+
fs.appendFileSync(gitignorePath, '\n' + guardianIgnores + '\n');
|
|
82
|
+
result.updated.push('.gitignore');
|
|
83
|
+
console.log('✅ Updated .gitignore');
|
|
84
|
+
} else {
|
|
85
|
+
console.log('⚠️ .gitignore already has Guardian entries. Skipping.');
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
fs.writeFileSync(gitignorePath, guardianIgnores + '\n', 'utf-8');
|
|
89
|
+
result.created.push('.gitignore');
|
|
90
|
+
console.log('✅ Created .gitignore');
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
result.errors.push(`Failed to update .gitignore: ${error.message}`);
|
|
94
|
+
console.error(`❌ Failed to update .gitignore: ${error.message}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 3. Print next steps
|
|
98
|
+
console.log('\n━'.repeat(60));
|
|
99
|
+
console.log('✅ Guardian initialized successfully!\n');
|
|
100
|
+
console.log('📝 Next steps:\n');
|
|
101
|
+
console.log(' 1. Run Guardian:');
|
|
102
|
+
console.log(' npm start -- --url https://your-site.com\n');
|
|
103
|
+
console.log(' 2. Or use the protect shortcut:');
|
|
104
|
+
console.log(' npm start -- protect https://your-site.com\n');
|
|
105
|
+
console.log(' 3. Review the generated report:');
|
|
106
|
+
console.log(' Check artifacts/ directory\n');
|
|
107
|
+
console.log(' 4. Customize policy:');
|
|
108
|
+
console.log(' Edit guardian.policy.json\n');
|
|
109
|
+
console.log(' 5. Integrate with CI/CD:');
|
|
110
|
+
console.log(' Use .github/workflows/guardian.yml as template\n');
|
|
111
|
+
console.log('━'.repeat(60) + '\n');
|
|
112
|
+
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Detect project type
|
|
118
|
+
* @param {string} cwd - Current working directory
|
|
119
|
+
* @returns {string} Project type: 'node', 'empty', 'unknown'
|
|
120
|
+
*/
|
|
121
|
+
function detectProjectType(cwd) {
|
|
122
|
+
const packageJsonPath = path.join(cwd, 'package.json');
|
|
123
|
+
|
|
124
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
125
|
+
return 'node';
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const files = fs.readdirSync(cwd);
|
|
129
|
+
if (files.length === 0) {
|
|
130
|
+
return 'empty';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return 'unknown';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
initGuardian,
|
|
138
|
+
detectProjectType
|
|
139
|
+
};
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JUnit XML Reporter
|
|
3
|
+
*
|
|
4
|
+
* Generates JUnit-compatible XML for CI/CD integration.
|
|
5
|
+
* - Compatible with Jenkins, GitLab CI, GitHub Actions, etc.
|
|
6
|
+
* - Includes testcases for attempts and discovery results
|
|
7
|
+
* - Captures failures and system output
|
|
8
|
+
*
|
|
9
|
+
* NO AI. Deterministic XML generation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Escape XML special characters
|
|
17
|
+
*/
|
|
18
|
+
function escapeXml(str) {
|
|
19
|
+
if (!str) return '';
|
|
20
|
+
return String(str)
|
|
21
|
+
.replace(/&/g, '&')
|
|
22
|
+
.replace(/</g, '<')
|
|
23
|
+
.replace(/>/g, '>')
|
|
24
|
+
.replace(/"/g, '"')
|
|
25
|
+
.replace(/'/g, ''');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build JUnit XML from snapshot
|
|
30
|
+
*/
|
|
31
|
+
function generateJunitXml(snapshot, baseUrl = '') {
|
|
32
|
+
const runId = snapshot.meta?.runId || 'unknown';
|
|
33
|
+
const createdAt = snapshot.meta?.createdAt || new Date().toISOString();
|
|
34
|
+
const url = baseUrl || snapshot.meta?.url || 'unknown';
|
|
35
|
+
|
|
36
|
+
let totalTests = 0;
|
|
37
|
+
let totalFailures = 0;
|
|
38
|
+
let totalSkipped = 0;
|
|
39
|
+
const testCases = [];
|
|
40
|
+
|
|
41
|
+
// ============================================================================
|
|
42
|
+
// ATTEMPTS AS TESTCASES (Phase 1, 2)
|
|
43
|
+
// ============================================================================
|
|
44
|
+
|
|
45
|
+
if (snapshot.attempts && Array.isArray(snapshot.attempts)) {
|
|
46
|
+
for (const attempt of snapshot.attempts) {
|
|
47
|
+
const attemptId = attempt.attemptId || 'unknown';
|
|
48
|
+
const attemptName = attempt.attemptName || attemptId;
|
|
49
|
+
const outcome = attempt.outcome || 'UNKNOWN';
|
|
50
|
+
const duration = (attempt.totalDurationMs || 0) / 1000; // Convert to seconds
|
|
51
|
+
|
|
52
|
+
totalTests++;
|
|
53
|
+
|
|
54
|
+
let testCase = ` <testcase name="${escapeXml(attemptName)}" classname="attempt.${escapeXml(attemptId)}" time="${duration}">\n`;
|
|
55
|
+
|
|
56
|
+
if (outcome === 'FAILURE') {
|
|
57
|
+
totalFailures++;
|
|
58
|
+
const failMsg = `Attempt failed: ${attempt.goal}`;
|
|
59
|
+
testCase += ` <failure message="${escapeXml(failMsg)}" type="AttemptFailure">\n`;
|
|
60
|
+
if (attempt.error) {
|
|
61
|
+
testCase += `${escapeXml(attempt.error)}\n`;
|
|
62
|
+
}
|
|
63
|
+
testCase += ` </failure>\n`;
|
|
64
|
+
} else if (outcome === 'FRICTION') {
|
|
65
|
+
const fricMsg = `Friction detected: ${attempt.friction?.summary || 'See logs'}`;
|
|
66
|
+
testCase += ` <skipped message="${escapeXml(fricMsg)}" />\n`;
|
|
67
|
+
totalSkipped++;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Add soft failures as failure element
|
|
71
|
+
if (attempt.softFailureCount && attempt.softFailureCount > 0) {
|
|
72
|
+
totalFailures++;
|
|
73
|
+
testCase += ` <failure message="Soft failures detected" type="SoftFailure">\n`;
|
|
74
|
+
testCase += `${escapeXml(attempt.softFailureCount)} validator(s) failed\n`;
|
|
75
|
+
|
|
76
|
+
if (attempt.validators && Array.isArray(attempt.validators)) {
|
|
77
|
+
for (const validator of attempt.validators) {
|
|
78
|
+
if (validator.status === 'FAIL' || validator.status === 'WARN') {
|
|
79
|
+
testCase += ` - ${escapeXml(validator.id)}: ${escapeXml(validator.message)}\n`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
testCase += ` </failure>\n`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
testCase += ` </testcase>\n`;
|
|
88
|
+
testCases.push(testCase);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// DISCOVERY RESULTS AS TESTCASES (Phase 4)
|
|
94
|
+
// ============================================================================
|
|
95
|
+
|
|
96
|
+
if (snapshot.discovery?.results && Array.isArray(snapshot.discovery.results)) {
|
|
97
|
+
// Group results by outcome for cleaner reporting
|
|
98
|
+
const discoveryResults = snapshot.discovery.results;
|
|
99
|
+
|
|
100
|
+
// Summarize discovery: 1 testcase per outcome type
|
|
101
|
+
const successCount = discoveryResults.filter(r => r.outcome === 'SUCCESS').length;
|
|
102
|
+
const failureCount = discoveryResults.filter(r => r.outcome === 'FAILURE').length;
|
|
103
|
+
const frictionCount = discoveryResults.filter(r => r.outcome === 'FRICTION').length;
|
|
104
|
+
|
|
105
|
+
// Overall discovery testcase
|
|
106
|
+
totalTests++;
|
|
107
|
+
let discoveryTestCase = ` <testcase name="Discovery: Auto-Interaction Exploration" classname="discovery.autoexplore" time="0">\n`;
|
|
108
|
+
|
|
109
|
+
if (failureCount > 0) {
|
|
110
|
+
totalFailures++;
|
|
111
|
+
discoveryTestCase += ` <failure message="Discovery failures detected" type="DiscoveryFailure">\n`;
|
|
112
|
+
discoveryTestCase += `${failureCount} interaction(s) failed:\n`;
|
|
113
|
+
|
|
114
|
+
discoveryResults
|
|
115
|
+
.filter(r => r.outcome === 'FAILURE')
|
|
116
|
+
.slice(0, 5) // Top 5 failures
|
|
117
|
+
.forEach(r => {
|
|
118
|
+
discoveryTestCase += ` - ${escapeXml(r.interactionId || 'unknown')}: ${escapeXml(r.errorMessage || 'Unknown error')}\n`;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
if (failureCount > 5) {
|
|
122
|
+
discoveryTestCase += ` ... and ${failureCount - 5} more\n`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
discoveryTestCase += ` </failure>\n`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
discoveryTestCase += ` </testcase>\n`;
|
|
129
|
+
testCases.push(discoveryTestCase);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ============================================================================
|
|
133
|
+
// MARKET CRITICALITY AS TESTCASE (Phase 3)
|
|
134
|
+
// ============================================================================
|
|
135
|
+
|
|
136
|
+
const marketImpact = snapshot.marketImpactSummary || {};
|
|
137
|
+
if (Object.keys(marketImpact).length > 0) {
|
|
138
|
+
totalTests++;
|
|
139
|
+
|
|
140
|
+
let marketTestCase = ` <testcase name="Market Criticality Assessment" classname="market.criticality" time="0">\n`;
|
|
141
|
+
|
|
142
|
+
const criticalCount = marketImpact.countsBySeverity?.CRITICAL || 0;
|
|
143
|
+
const warningCount = marketImpact.countsBySeverity?.WARNING || 0;
|
|
144
|
+
|
|
145
|
+
if (criticalCount > 0) {
|
|
146
|
+
totalFailures++;
|
|
147
|
+
marketTestCase += ` <failure message="CRITICAL market risks detected" type="CriticalRisk">\n`;
|
|
148
|
+
marketTestCase += `${criticalCount} CRITICAL risk(s) found\n`;
|
|
149
|
+
|
|
150
|
+
if (marketImpact.topRisks && Array.isArray(marketImpact.topRisks)) {
|
|
151
|
+
marketImpact.topRisks.slice(0, 3).forEach(risk => {
|
|
152
|
+
marketTestCase += ` - ${escapeXml(risk.category || 'Unknown')}: ${escapeXml(risk.humanReadableReason || 'See logs')}\n`;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
marketTestCase += ` </failure>\n`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
marketTestCase += ` </testcase>\n`;
|
|
160
|
+
testCases.push(marketTestCase);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// BUILD XML
|
|
165
|
+
// ============================================================================
|
|
166
|
+
|
|
167
|
+
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
|
168
|
+
xml += `<testsuites>\n`;
|
|
169
|
+
xml += ` <testsuite name="ODAVL Guardian" tests="${totalTests}" failures="${totalFailures}" skipped="${totalSkipped}" timestamp="${escapeXml(createdAt)}" time="0" hostname="guardian">\n`;
|
|
170
|
+
|
|
171
|
+
// Properties
|
|
172
|
+
xml += ` <properties>\n`;
|
|
173
|
+
xml += ` <property name="runId" value="${escapeXml(runId)}" />\n`;
|
|
174
|
+
xml += ` <property name="url" value="${escapeXml(url)}" />\n`;
|
|
175
|
+
xml += ` <property name="createdAt" value="${escapeXml(createdAt)}" />\n`;
|
|
176
|
+
xml += ` </properties>\n`;
|
|
177
|
+
|
|
178
|
+
// Testcases
|
|
179
|
+
xml += testCases.join('');
|
|
180
|
+
|
|
181
|
+
// System output
|
|
182
|
+
xml += ` <system-out>\n`;
|
|
183
|
+
xml += `ODAVL Guardian Test Run\n`;
|
|
184
|
+
xml += `URL: ${escapeXml(url)}\n`;
|
|
185
|
+
xml += `Run ID: ${escapeXml(runId)}\n`;
|
|
186
|
+
xml += `Created: ${escapeXml(createdAt)}\n\n`;
|
|
187
|
+
|
|
188
|
+
xml += `Summary:\n`;
|
|
189
|
+
xml += ` Total Tests: ${totalTests}\n`;
|
|
190
|
+
xml += ` Failures: ${totalFailures}\n`;
|
|
191
|
+
xml += ` Skipped: ${totalSkipped}\n`;
|
|
192
|
+
|
|
193
|
+
if (marketImpact.countsBySeverity) {
|
|
194
|
+
xml += `\nMarket Impact:\n`;
|
|
195
|
+
xml += ` CRITICAL: ${marketImpact.countsBySeverity.CRITICAL || 0}\n`;
|
|
196
|
+
xml += ` WARNING: ${marketImpact.countsBySeverity.WARNING || 0}\n`;
|
|
197
|
+
xml += ` INFO: ${marketImpact.countsBySeverity.INFO || 0}\n`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (snapshot.discovery) {
|
|
201
|
+
xml += `\nDiscovery Results:\n`;
|
|
202
|
+
xml += ` Pages Visited: ${snapshot.discovery.pagesVisitedCount || 0}\n`;
|
|
203
|
+
xml += ` Interactions Discovered: ${snapshot.discovery.interactionsDiscovered || 0}\n`;
|
|
204
|
+
xml += ` Interactions Executed: ${snapshot.discovery.interactionsExecuted || 0}\n`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
xml += ` </system-out>\n`;
|
|
208
|
+
|
|
209
|
+
xml += ` </testsuite>\n`;
|
|
210
|
+
xml += `</testsuites>\n`;
|
|
211
|
+
|
|
212
|
+
return xml;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Write JUnit XML to file
|
|
217
|
+
*/
|
|
218
|
+
function writeJunitFile(snapshot, outputPath, baseUrl = '') {
|
|
219
|
+
const xml = generateJunitXml(snapshot, baseUrl);
|
|
220
|
+
|
|
221
|
+
const dir = path.dirname(outputPath);
|
|
222
|
+
if (!fs.existsSync(dir)) {
|
|
223
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
fs.writeFileSync(outputPath, xml, 'utf8');
|
|
227
|
+
return outputPath;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Validate XML is well-formed (basic check)
|
|
232
|
+
*/
|
|
233
|
+
function validateJunitXml(xmlContent) {
|
|
234
|
+
try {
|
|
235
|
+
// Basic XML validation: check opening and closing tags
|
|
236
|
+
if (!xmlContent.includes('<?xml')) {
|
|
237
|
+
return { valid: false, errors: ['Missing XML declaration'] };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!xmlContent.includes('<testsuites>') || !xmlContent.includes('</testsuites>')) {
|
|
241
|
+
return { valid: false, errors: ['Missing testsuites root element'] };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (!xmlContent.includes('<testsuite') || !xmlContent.includes('</testsuite>')) {
|
|
245
|
+
return { valid: false, errors: ['Missing testsuite element'] };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Check for unescaped characters
|
|
249
|
+
if (xmlContent.includes('&') && !xmlContent.includes('&')) {
|
|
250
|
+
return { valid: false, errors: ['Unescaped & character found'] };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return { valid: true, errors: [] };
|
|
254
|
+
} catch (e) {
|
|
255
|
+
return { valid: false, errors: [e.message] };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
generateJunitXml,
|
|
261
|
+
writeJunitFile,
|
|
262
|
+
validateJunitXml,
|
|
263
|
+
escapeXml
|
|
264
|
+
};
|