abapgit-agent 1.18.1 → 1.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/abap/CLAUDE.md +2 -3
- package/abap/guidelines/abapgit-xml-only.md +63 -3
- package/abap/guidelines/debug-session.md +2 -3
- package/package.json +2 -1
- package/src/commands/debug.js +23 -40
- package/src/commands/inspect.js +174 -70
- package/src/commands/pull.js +44 -1
- package/src/commands/unit.js +290 -200
- package/src/commands/view.js +46 -1
- package/src/utils/adt-http.js +5 -2
package/src/commands/unit.js
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit command - Run AUnit tests
|
|
2
|
+
* Unit command - Run AUnit tests via ADT /sap/bc/adt/abapunit/testruns
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
const pathModule = require('path');
|
|
6
6
|
const fs = require('fs');
|
|
7
|
+
const { AdtHttp } = require('../utils/adt-http');
|
|
7
8
|
const { formatHttpError } = require('../utils/format-error');
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
-
* Escape a string for safe embedding in XML text/attribute content
|
|
11
|
-
*/
|
|
12
10
|
function escapeXml(str) {
|
|
13
11
|
return String(str)
|
|
14
12
|
.replace(/&/g, '&')
|
|
@@ -19,77 +17,193 @@ function escapeXml(str) {
|
|
|
19
17
|
}
|
|
20
18
|
|
|
21
19
|
/**
|
|
22
|
-
* Build
|
|
20
|
+
* Build the ADT AUnit run configuration XML for one class URI.
|
|
21
|
+
*/
|
|
22
|
+
function buildRunConfigXml(adtUri, coverage) {
|
|
23
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
24
|
+
<aunit:runConfiguration xmlns:aunit="http://www.sap.com/adt/aunit">
|
|
25
|
+
<external>
|
|
26
|
+
<coverage active="${coverage ? 'true' : 'false'}"/>
|
|
27
|
+
</external>
|
|
28
|
+
<options>
|
|
29
|
+
<uriType value="semantic"/>
|
|
30
|
+
<testDeterminationStrategy sameProgram="true" assignedTests="false"/>
|
|
31
|
+
<testRiskLevels harmless="true" dangerous="false" critical="false"/>
|
|
32
|
+
<testDurations short="true" medium="false" long="false"/>
|
|
33
|
+
<withNavigationUri enabled="true"/>
|
|
34
|
+
</options>
|
|
35
|
+
<adtcore:objectSets xmlns:adtcore="http://www.sap.com/adt/core">
|
|
36
|
+
<objectSet kind="inclusive">
|
|
37
|
+
<adtcore:objectReferences>
|
|
38
|
+
<adtcore:objectReference adtcore:uri="${adtUri}"/>
|
|
39
|
+
</adtcore:objectReferences>
|
|
40
|
+
</objectSet>
|
|
41
|
+
</adtcore:objectSets>
|
|
42
|
+
</aunit:runConfiguration>`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse the ADT AUnit run result XML into a structured result object.
|
|
23
47
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
* Returns:
|
|
49
|
+
* {
|
|
50
|
+
* className,
|
|
51
|
+
* methods: [{
|
|
52
|
+
* name, testClassName, passed, executionTime,
|
|
53
|
+
* kind?, title?, details?: string[], stack?: [{name, uri}]
|
|
54
|
+
* }],
|
|
55
|
+
* coverageStats: { totalLines, coveredLines, coverageRate } | null
|
|
56
|
+
* }
|
|
57
|
+
*/
|
|
58
|
+
function parseRunResult(xml, className) {
|
|
59
|
+
const methods = [];
|
|
60
|
+
|
|
61
|
+
// Split on <testClass blocks to capture test class name per method
|
|
62
|
+
const testClassBlocks = xml.split(/<testClass\s/);
|
|
63
|
+
for (let ci = 1; ci < testClassBlocks.length; ci++) {
|
|
64
|
+
const classBlock = testClassBlocks[ci];
|
|
65
|
+
|
|
66
|
+
const classNameMatch = classBlock.match(/adtcore:name="([^"]*)"/);
|
|
67
|
+
const testClassName = classNameMatch ? classNameMatch[1] : className;
|
|
68
|
+
|
|
69
|
+
// Split methods within this test class block
|
|
70
|
+
const methodBlocks = classBlock.split(/<testMethod\s/);
|
|
71
|
+
for (let i = 1; i < methodBlocks.length; i++) {
|
|
72
|
+
const block = methodBlocks[i];
|
|
73
|
+
|
|
74
|
+
const nameMatch = block.match(/adtcore:name="([^"]*)"/);
|
|
75
|
+
if (!nameMatch) continue;
|
|
76
|
+
const name = nameMatch[1];
|
|
77
|
+
|
|
78
|
+
const execTimeMatch = block.match(/executionTime="([^"]*)"/);
|
|
79
|
+
const executionTime = execTimeMatch ? parseFloat(execTimeMatch[1]) : 0;
|
|
80
|
+
|
|
81
|
+
// Find the <alerts> section
|
|
82
|
+
const alertsMatch = block.match(/<alerts>([\s\S]*?)<\/alerts>/);
|
|
83
|
+
const alertsContent = alertsMatch ? alertsMatch[1].trim() : '';
|
|
84
|
+
|
|
85
|
+
if (!alertsContent || alertsContent === '<alerts/>') {
|
|
86
|
+
methods.push({ name, testClassName, passed: true, executionTime });
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Extract alert kind
|
|
91
|
+
const kindMatch = block.match(/<alert\s[^>]*kind="([^"]*)"/);
|
|
92
|
+
const kind = kindMatch ? kindMatch[1] : 'failedAssertion';
|
|
93
|
+
|
|
94
|
+
// Extract title
|
|
95
|
+
const titleMatch = block.match(/<title[^>]*>([^<]*)<\/title>/);
|
|
96
|
+
const title = titleMatch ? titleMatch[1].trim() : 'Test failed';
|
|
97
|
+
|
|
98
|
+
// Extract all detail texts (may be multiple)
|
|
99
|
+
const details = [];
|
|
100
|
+
const detailRe = /<detail\s[^>]*text="([^"]*)"/g;
|
|
101
|
+
let dm;
|
|
102
|
+
while ((dm = detailRe.exec(block)) !== null) {
|
|
103
|
+
const t = dm[1].trim();
|
|
104
|
+
if (t) details.push(t);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Extract stack entries: adtcore:name + adtcore:uri (contains #start=N for line)
|
|
108
|
+
const stack = [];
|
|
109
|
+
const stackRe = /<stackEntry\s([^>]*\/?>)/g;
|
|
110
|
+
let sm;
|
|
111
|
+
while ((sm = stackRe.exec(block)) !== null) {
|
|
112
|
+
const entry = sm[1];
|
|
113
|
+
const snMatch = entry.match(/adtcore:name="([^"]*)"/);
|
|
114
|
+
const suMatch = entry.match(/adtcore:uri="([^"]*)"/);
|
|
115
|
+
if (snMatch) {
|
|
116
|
+
const lineMatch = suMatch && suMatch[1].match(/#start=(\d+)/);
|
|
117
|
+
stack.push({
|
|
118
|
+
name: snMatch[1],
|
|
119
|
+
line: lineMatch ? parseInt(lineMatch[1], 10) : null,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
methods.push({ name, testClassName, passed: false, executionTime, kind, title, details, stack });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Parse coverage stats if present
|
|
129
|
+
let coverageStats = null;
|
|
130
|
+
const covMatch = xml.match(/<coverage\b[^>]*>/);
|
|
131
|
+
if (covMatch) {
|
|
132
|
+
const covEl = covMatch[0];
|
|
133
|
+
const totalMatch = covEl.match(/adtcore:lines_total="([^"]*)"/);
|
|
134
|
+
const coveredMatch = covEl.match(/adtcore:lines_covered="([^"]*)"/);
|
|
135
|
+
const rateMatch = covEl.match(/adtcore:coverage_rate="([^"]*)"/);
|
|
136
|
+
if (totalMatch || coveredMatch || rateMatch) {
|
|
137
|
+
const totalLines = totalMatch ? parseInt(totalMatch[1], 10) : 0;
|
|
138
|
+
const coveredLines = coveredMatch ? parseInt(coveredMatch[1], 10) : 0;
|
|
139
|
+
const coverageRate = rateMatch ? parseFloat(rateMatch[1]) : 0;
|
|
140
|
+
coverageStats = { totalLines, coveredLines, coverageRate };
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { className, methods, coverageStats };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Build JUnit XML from parsed ADT AUnit results.
|
|
38
149
|
*
|
|
39
|
-
*
|
|
40
|
-
* Passing methods are listed as empty <testcase> elements (Jenkins counts them).
|
|
150
|
+
* Each test method gets its own <testcase> element (pass or fail).
|
|
41
151
|
* Coverage stats (if present) are emitted as <properties> on the testsuite.
|
|
152
|
+
* A synthetic coverage_threshold <failure> is injected when threshold is breached.
|
|
42
153
|
*/
|
|
43
154
|
function buildUnitJUnit(results) {
|
|
44
155
|
const suites = results.map(res => {
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const coverageStats = res.COVERAGE_STATS || res.coverage_stats;
|
|
156
|
+
const { className, methods, coverageStats, thresholdFailure } = res;
|
|
157
|
+
const testCount = methods.length;
|
|
158
|
+
const failedMethods = methods.filter(m => !m.passed);
|
|
159
|
+
const syntheticFailures = thresholdFailure ? 1 : 0;
|
|
160
|
+
const totalFailures = failedMethods.length + syntheticFailures;
|
|
51
161
|
|
|
52
162
|
const lines = [];
|
|
53
163
|
|
|
54
|
-
// Coverage <properties> block — only emitted when coverage data is present
|
|
55
164
|
if (coverageStats) {
|
|
56
|
-
const rate = coverageStats.COVERAGE_RATE || coverageStats.coverage_rate || 0;
|
|
57
|
-
const total = coverageStats.TOTAL_LINES || coverageStats.total_lines || 0;
|
|
58
|
-
const covered = coverageStats.COVERED_LINES || coverageStats.covered_lines || 0;
|
|
59
165
|
lines.push(' <properties>');
|
|
60
|
-
lines.push(` <property name="coverage.rate" value="${
|
|
61
|
-
lines.push(` <property name="coverage.lines.total" value="${
|
|
62
|
-
lines.push(` <property name="coverage.lines.covered" value="${
|
|
166
|
+
lines.push(` <property name="coverage.rate" value="${coverageStats.coverageRate}"/>`);
|
|
167
|
+
lines.push(` <property name="coverage.lines.total" value="${coverageStats.totalLines}"/>`);
|
|
168
|
+
lines.push(` <property name="coverage.lines.covered" value="${coverageStats.coveredLines}"/>`);
|
|
63
169
|
lines.push(' </properties>');
|
|
64
170
|
}
|
|
65
171
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
172
|
+
if (testCount === 0) {
|
|
173
|
+
lines.push(` <testcase name="(no tests)" classname="${escapeXml(className)}"/>`);
|
|
174
|
+
} else {
|
|
175
|
+
for (const m of methods) {
|
|
176
|
+
const timeAttr = m.executionTime != null ? ` time="${m.executionTime}"` : '';
|
|
177
|
+
// Use "ZCL_MY_TEST.LTCL_UNIT_TEST" so Jenkins groups by ABAP class first,
|
|
178
|
+
// then local test class — giving a clear package hierarchy in the test report.
|
|
179
|
+
const testClass = m.testClassName ? `${className}.${m.testClassName}` : className;
|
|
180
|
+
if (m.passed) {
|
|
181
|
+
lines.push(` <testcase name="${escapeXml(m.name)}" classname="${escapeXml(testClass)}"${timeAttr}/>`);
|
|
182
|
+
} else {
|
|
183
|
+
const msg = escapeXml(m.title || 'Test failed');
|
|
184
|
+
const bodyParts = [m.title || 'Test failed'];
|
|
185
|
+
if (m.details && m.details.length) bodyParts.push(...m.details);
|
|
186
|
+
const body = escapeXml(bodyParts.join('\n'));
|
|
187
|
+
lines.push(
|
|
188
|
+
` <testcase name="${escapeXml(m.name)}" classname="${escapeXml(testClass)}"${timeAttr}>\n` +
|
|
189
|
+
` <failure type="${escapeXml(m.kind || 'failedAssertion')}" message="${msg}">${body}</failure>\n` +
|
|
190
|
+
` </testcase>`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
77
194
|
}
|
|
78
195
|
|
|
79
|
-
|
|
80
|
-
if (passedCount > 0) {
|
|
196
|
+
if (thresholdFailure) {
|
|
81
197
|
lines.push(
|
|
82
|
-
` <testcase name="
|
|
198
|
+
` <testcase name="coverage_threshold" classname="${escapeXml(className)}">\n` +
|
|
199
|
+
` <failure type="FAILURE" message="${escapeXml(thresholdFailure)}">${escapeXml(thresholdFailure)}</failure>\n` +
|
|
200
|
+
` </testcase>`
|
|
83
201
|
);
|
|
84
202
|
}
|
|
85
203
|
|
|
86
|
-
if (testCount === 0) {
|
|
87
|
-
lines.push(` <testcase name="(no tests)" classname="${escapeXml(className)}"/>`);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
204
|
return (
|
|
91
205
|
` <testsuite name="${escapeXml(className)}" ` +
|
|
92
|
-
`tests="${Math.max(testCount, 1)}" failures="${
|
|
206
|
+
`tests="${Math.max(testCount + syntheticFailures, 1)}" failures="${totalFailures}" errors="0">\n` +
|
|
93
207
|
lines.join('\n') + '\n' +
|
|
94
208
|
` </testsuite>`
|
|
95
209
|
);
|
|
@@ -104,133 +218,118 @@ function buildUnitJUnit(results) {
|
|
|
104
218
|
}
|
|
105
219
|
|
|
106
220
|
/**
|
|
107
|
-
* Run
|
|
221
|
+
* Run AUnit tests for a single .testclasses.abap file via ADT.
|
|
108
222
|
*/
|
|
109
|
-
async function runUnitTestForFile(sourceFile,
|
|
223
|
+
async function runUnitTestForFile(sourceFile, adt, coverage, jsonOutput, verbose) {
|
|
110
224
|
if (!jsonOutput) {
|
|
111
225
|
console.log(` Running unit test for: ${sourceFile}`);
|
|
112
226
|
}
|
|
113
227
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
? sourceFile
|
|
118
|
-
: pathModule.join(process.cwd(), sourceFile);
|
|
119
|
-
|
|
120
|
-
if (!fs.existsSync(absolutePath)) {
|
|
121
|
-
const error = {
|
|
122
|
-
file: sourceFile,
|
|
123
|
-
error: 'File not found',
|
|
124
|
-
statusCode: 404
|
|
125
|
-
};
|
|
126
|
-
if (!jsonOutput) {
|
|
127
|
-
console.error(` ❌ File not found: ${absolutePath}`);
|
|
128
|
-
}
|
|
129
|
-
return error;
|
|
130
|
-
}
|
|
228
|
+
const absolutePath = pathModule.isAbsolute(sourceFile)
|
|
229
|
+
? sourceFile
|
|
230
|
+
: pathModule.join(process.cwd(), sourceFile);
|
|
131
231
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
if (parts.length < 3) {
|
|
137
|
-
const error = {
|
|
138
|
-
file: sourceFile,
|
|
139
|
-
error: 'Invalid file format',
|
|
140
|
-
statusCode: 400
|
|
141
|
-
};
|
|
142
|
-
if (!jsonOutput) {
|
|
143
|
-
console.error(` ❌ Invalid file format: ${sourceFile}`);
|
|
144
|
-
}
|
|
145
|
-
return error;
|
|
146
|
-
}
|
|
232
|
+
if (!fs.existsSync(absolutePath)) {
|
|
233
|
+
if (!jsonOutput) console.error(` ❌ File not found: ${absolutePath}`);
|
|
234
|
+
return { error: 'File not found', statusCode: 404, file: sourceFile };
|
|
235
|
+
}
|
|
147
236
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
237
|
+
const fileName = pathModule.basename(sourceFile);
|
|
238
|
+
if (!fileName.toLowerCase().includes('.testclasses.abap')) {
|
|
239
|
+
if (!jsonOutput) console.error(` ❌ Invalid file format: ${sourceFile}`);
|
|
240
|
+
return { error: 'Invalid file format: must be .testclasses.abap', statusCode: 400, file: sourceFile };
|
|
241
|
+
}
|
|
151
242
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
objName = objName.substring(lastSlash + 1);
|
|
156
|
-
}
|
|
243
|
+
const className = fileName.split('.')[0].toUpperCase();
|
|
244
|
+
const adtUri = `/sap/bc/adt/oo/classes/${className.toLowerCase()}`;
|
|
245
|
+
const xmlBody = buildRunConfigXml(adtUri, coverage);
|
|
157
246
|
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
};
|
|
247
|
+
try {
|
|
248
|
+
const resp = await adt.post('/sap/bc/adt/abapunit/testruns', xmlBody, {
|
|
249
|
+
contentType: 'application/*',
|
|
250
|
+
accept: 'application/*',
|
|
251
|
+
});
|
|
163
252
|
|
|
164
|
-
const result =
|
|
253
|
+
const result = parseRunResult(resp.body || '', className);
|
|
165
254
|
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
const testCount = result.TEST_COUNT || result.test_count || 0;
|
|
169
|
-
const failedCount = result.FAILED_COUNT || result.failed_count || 0;
|
|
170
|
-
// ABAP AUnit API does not return individual passing method names — derive passed count
|
|
255
|
+
const testCount = result.methods.length;
|
|
256
|
+
const failedCount = result.methods.filter(m => !m.passed).length;
|
|
171
257
|
const passedCount = testCount - failedCount;
|
|
172
|
-
const message = result.MESSAGE || result.message || '';
|
|
173
|
-
const errors = result.ERRORS || result.errors || [];
|
|
174
|
-
|
|
175
|
-
// Handle coverage data
|
|
176
|
-
const coverageStats = result.COVERAGE_STATS || result.coverage_stats;
|
|
177
258
|
|
|
178
259
|
if (!jsonOutput) {
|
|
179
260
|
if (testCount === 0) {
|
|
180
|
-
console.log(` ➖ ${
|
|
181
|
-
} else if (
|
|
182
|
-
console.log(` ✅ ${
|
|
261
|
+
console.log(` ➖ ${className} - No unit tests`);
|
|
262
|
+
} else if (failedCount === 0) {
|
|
263
|
+
console.log(` ✅ ${className} - All tests passed`);
|
|
183
264
|
} else {
|
|
184
|
-
console.log(` ❌ ${
|
|
265
|
+
console.log(` ❌ ${className} - Tests failed`);
|
|
185
266
|
}
|
|
186
|
-
|
|
187
267
|
console.log(` Tests: ${testCount} | Passed: ${passedCount} | Failed: ${failedCount}`);
|
|
188
268
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const totalLines = coverageStats.TOTAL_LINES || coverageStats.total_lines || 0;
|
|
192
|
-
const coveredLines = coverageStats.COVERED_LINES || coverageStats.covered_lines || 0;
|
|
193
|
-
const coverageRate = coverageStats.COVERAGE_RATE || coverageStats.coverage_rate || 0;
|
|
194
|
-
|
|
269
|
+
if (coverage && result.coverageStats) {
|
|
270
|
+
const { totalLines, coveredLines, coverageRate } = result.coverageStats;
|
|
195
271
|
console.log(` 📊 Coverage: ${coverageRate}%`);
|
|
196
272
|
console.log(` Total Lines: ${totalLines}`);
|
|
197
273
|
console.log(` Covered Lines: ${coveredLines}`);
|
|
198
274
|
}
|
|
199
275
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
276
|
+
// Slow test summary: show methods with executionTime > 0.1s
|
|
277
|
+
const slowThreshold = 0.1;
|
|
278
|
+
const slowMethods = result.methods
|
|
279
|
+
.filter(m => m.executionTime > slowThreshold)
|
|
280
|
+
.sort((a, b) => b.executionTime - a.executionTime);
|
|
281
|
+
if (slowMethods.length > 0) {
|
|
282
|
+
const slowList = slowMethods.map(m => `${m.name} (${m.executionTime.toFixed(3)}s)`).join(', ');
|
|
283
|
+
console.log(` ⏱ Slowest: ${slowList}`);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (failedCount > 0) {
|
|
287
|
+
for (const m of result.methods.filter(m => !m.passed)) {
|
|
288
|
+
const prefix = m.testClassName || className;
|
|
289
|
+
console.log(`\n ✗ ${prefix}=>${m.name}`);
|
|
290
|
+
console.log(` ${m.title || 'Test failed'}`);
|
|
291
|
+
if (m.details && m.details.length) {
|
|
292
|
+
for (const d of m.details) console.log(` ${d}`);
|
|
293
|
+
}
|
|
294
|
+
if (m.stack && m.stack.length) {
|
|
295
|
+
for (const s of m.stack) {
|
|
296
|
+
const loc = s.line != null ? ` (line ${s.line})` : '';
|
|
297
|
+
console.log(` at ${s.name}${loc}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
206
300
|
}
|
|
301
|
+
console.log('');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --verbose: per-method time table
|
|
305
|
+
if (verbose && testCount > 0) {
|
|
306
|
+
console.log(` Method times:`);
|
|
307
|
+
for (const m of result.methods) {
|
|
308
|
+
const mark = m.passed ? '✓' : '✗';
|
|
309
|
+
console.log(` ${mark} ${m.name.padEnd(40)} ${(m.executionTime || 0).toFixed(3)}s`);
|
|
310
|
+
}
|
|
311
|
+
console.log('');
|
|
207
312
|
}
|
|
208
313
|
}
|
|
209
314
|
|
|
210
315
|
return result;
|
|
211
316
|
} catch (error) {
|
|
212
|
-
// Build error response object
|
|
213
317
|
const errorResponse = {
|
|
214
|
-
file: sourceFile,
|
|
215
318
|
error: error.message || 'Unknown error',
|
|
216
|
-
statusCode: error.statusCode || 500
|
|
319
|
+
statusCode: error.statusCode || 500,
|
|
320
|
+
file: sourceFile,
|
|
321
|
+
className,
|
|
322
|
+
methods: [],
|
|
323
|
+
coverageStats: null,
|
|
217
324
|
};
|
|
218
|
-
|
|
219
|
-
// Add additional error details if available
|
|
220
|
-
if (error.body) {
|
|
221
|
-
errorResponse.body = error.body;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
325
|
if (!jsonOutput) {
|
|
225
326
|
console.error(`\n ❌ Error: ${formatHttpError(error)}`);
|
|
226
327
|
if (verbose && error.body) {
|
|
227
328
|
console.error('\n--- Raw response body ---');
|
|
228
|
-
|
|
229
|
-
console.error(raw);
|
|
329
|
+
console.error(typeof error.body === 'object' ? JSON.stringify(error.body, null, 2) : String(error.body));
|
|
230
330
|
console.error('--- End of response body ---');
|
|
231
331
|
}
|
|
232
332
|
}
|
|
233
|
-
|
|
234
333
|
return errorResponse;
|
|
235
334
|
}
|
|
236
335
|
}
|
|
@@ -239,10 +338,9 @@ module.exports = {
|
|
|
239
338
|
name: 'unit',
|
|
240
339
|
description: 'Run AUnit tests for ABAP test class files',
|
|
241
340
|
requiresAbapConfig: true,
|
|
242
|
-
requiresVersionCheck: true,
|
|
243
341
|
|
|
244
342
|
async execute(args, context) {
|
|
245
|
-
const { loadConfig
|
|
343
|
+
const { loadConfig } = context;
|
|
246
344
|
|
|
247
345
|
if (args.includes('--help') || args.includes('-h')) {
|
|
248
346
|
console.log(`
|
|
@@ -252,6 +350,7 @@ Usage:
|
|
|
252
350
|
Description:
|
|
253
351
|
Run AUnit tests for ABAP test class files (.testclasses.abap).
|
|
254
352
|
Objects must be already active in the ABAP system (run pull first).
|
|
353
|
+
Calls the standard ADT endpoint /sap/bc/adt/abapunit/testruns directly.
|
|
255
354
|
|
|
256
355
|
Parameters:
|
|
257
356
|
--files <file1,...> Comma-separated .testclasses.abap files (required).
|
|
@@ -260,6 +359,7 @@ Parameters:
|
|
|
260
359
|
--coverage-mode <warn|fail> Action when below threshold: warn = UNSTABLE, fail = error. Default: fail.
|
|
261
360
|
--junit-output <file> Write results as JUnit XML to this file.
|
|
262
361
|
--json Output as JSON.
|
|
362
|
+
--verbose Show per-method execution times; also print raw HTTP error responses.
|
|
263
363
|
|
|
264
364
|
Examples:
|
|
265
365
|
abapgit-agent unit --files src/zcl_my_test.clas.testclasses.abap
|
|
@@ -271,6 +371,7 @@ Examples:
|
|
|
271
371
|
|
|
272
372
|
const jsonOutput = args.includes('--json');
|
|
273
373
|
const verbose = args.includes('--verbose');
|
|
374
|
+
|
|
274
375
|
const filesArgIndex = args.indexOf('--files');
|
|
275
376
|
if (filesArgIndex === -1 || filesArgIndex + 1 >= args.length) {
|
|
276
377
|
console.error('Error: --files parameter required');
|
|
@@ -279,8 +380,6 @@ Examples:
|
|
|
279
380
|
}
|
|
280
381
|
|
|
281
382
|
const files = args[filesArgIndex + 1].split(',').map(f => f.trim());
|
|
282
|
-
|
|
283
|
-
// Coverage options
|
|
284
383
|
const coverage = args.includes('--coverage');
|
|
285
384
|
|
|
286
385
|
const coverageThresholdIdx = args.indexOf('--coverage-threshold');
|
|
@@ -289,58 +388,43 @@ Examples:
|
|
|
289
388
|
const coverageModeIdx = args.indexOf('--coverage-mode');
|
|
290
389
|
const coverageMode = coverageModeIdx !== -1 ? args[coverageModeIdx + 1] : 'fail';
|
|
291
390
|
|
|
292
|
-
// Parse optional --junit-output parameter
|
|
293
391
|
const junitArgIndex = args.indexOf('--junit-output');
|
|
294
392
|
const junitOutput = junitArgIndex !== -1 ? args[junitArgIndex + 1] : null;
|
|
295
393
|
|
|
296
394
|
if (!jsonOutput) {
|
|
297
395
|
console.log(`\n Running unit tests for ${files.length} file(s)${coverage ? ' (with coverage)' : ''}`);
|
|
298
|
-
if (junitOutput) {
|
|
299
|
-
console.log(` JUnit output: ${junitOutput}`);
|
|
300
|
-
}
|
|
396
|
+
if (junitOutput) console.log(` JUnit output: ${junitOutput}`);
|
|
301
397
|
console.log('');
|
|
302
398
|
}
|
|
303
399
|
|
|
304
400
|
const config = loadConfig();
|
|
305
|
-
const
|
|
306
|
-
|
|
401
|
+
const adt = new AdtHttp(config);
|
|
402
|
+
await adt.fetchCsrfToken();
|
|
307
403
|
|
|
308
|
-
// Collect results for JSON / JUnit output
|
|
309
404
|
const results = [];
|
|
310
405
|
let hasErrors = false;
|
|
311
406
|
|
|
312
407
|
for (const sourceFile of files) {
|
|
313
|
-
const result = await runUnitTestForFile(sourceFile,
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
results.push(result);
|
|
319
|
-
|
|
320
|
-
if (result.error || result.statusCode >= 400) {
|
|
321
|
-
hasErrors = true;
|
|
322
|
-
}
|
|
323
|
-
// Also treat failed tests as an error for exit code
|
|
324
|
-
const failedCount = result.FAILED_COUNT || result.failed_count || 0;
|
|
325
|
-
if (failedCount > 0) {
|
|
326
|
-
hasErrors = true;
|
|
327
|
-
}
|
|
408
|
+
const result = await runUnitTestForFile(sourceFile, adt, coverage, jsonOutput, verbose);
|
|
409
|
+
results.push(result);
|
|
410
|
+
|
|
411
|
+
if (result.error || result.statusCode >= 400) {
|
|
412
|
+
hasErrors = true;
|
|
328
413
|
}
|
|
414
|
+
const methods = result.methods || [];
|
|
415
|
+
if (methods.some(m => !m.passed)) hasErrors = true;
|
|
329
416
|
}
|
|
330
417
|
|
|
331
|
-
// Coverage threshold enforcement — per file
|
|
332
|
-
// so the injected failure testcase lands in the correct class's testsuite.
|
|
418
|
+
// Coverage threshold enforcement — per file
|
|
333
419
|
if (coverage && coverageThreshold > 0) {
|
|
334
420
|
let anyData = false;
|
|
335
421
|
for (const result of results) {
|
|
336
|
-
|
|
337
|
-
if (!stats) continue;
|
|
422
|
+
if (!result.coverageStats) continue;
|
|
338
423
|
anyData = true;
|
|
339
|
-
const totalLines
|
|
340
|
-
const coveredLines = stats.COVERED_LINES || stats.covered_lines || 0;
|
|
424
|
+
const { totalLines, coveredLines, coverageRate } = result.coverageStats;
|
|
341
425
|
if (totalLines === 0) continue;
|
|
342
|
-
const
|
|
343
|
-
const
|
|
426
|
+
const className = result.className || 'UNKNOWN';
|
|
427
|
+
const rate = Math.round(coveredLines / totalLines * 100);
|
|
344
428
|
if (rate < coverageThreshold) {
|
|
345
429
|
const msg = `${className}: coverage ${rate}% is below threshold ${coverageThreshold}%`;
|
|
346
430
|
if (coverageMode === 'warn') {
|
|
@@ -348,54 +432,60 @@ Examples:
|
|
|
348
432
|
} else {
|
|
349
433
|
if (!jsonOutput) console.error(`❌ ${msg}`);
|
|
350
434
|
hasErrors = true;
|
|
351
|
-
|
|
352
|
-
// which class failed the gate and what its actual coverage was.
|
|
353
|
-
const errors = result.ERRORS || result.errors || [];
|
|
354
|
-
errors.push({
|
|
355
|
-
CLASS_NAME: className,
|
|
356
|
-
METHOD_NAME: 'coverage_threshold',
|
|
357
|
-
ERROR_KIND: 'FAILURE',
|
|
358
|
-
ERROR_TEXT: msg
|
|
359
|
-
});
|
|
360
|
-
result.ERRORS = errors;
|
|
361
|
-
result.FAILED_COUNT = (result.FAILED_COUNT || result.failed_count || 0) + 1;
|
|
435
|
+
result.thresholdFailure = msg;
|
|
362
436
|
}
|
|
363
437
|
} else {
|
|
364
438
|
if (!jsonOutput) console.log(`✅ ${className}: coverage ${rate}% meets threshold ${coverageThreshold}%`);
|
|
365
439
|
}
|
|
366
440
|
}
|
|
367
|
-
if (!anyData) {
|
|
368
|
-
|
|
441
|
+
if (!anyData && !jsonOutput) {
|
|
442
|
+
console.warn('⚠️ Coverage data unavailable — threshold not enforced');
|
|
369
443
|
}
|
|
370
444
|
}
|
|
371
445
|
|
|
372
|
-
//
|
|
446
|
+
// Failed tests summary
|
|
447
|
+
if (!jsonOutput) {
|
|
448
|
+
const allFailed = results.flatMap(r => (r.methods || []).filter(m => !m.passed).map(m => ({ ...m, className: r.className })));
|
|
449
|
+
if (allFailed.length > 0) {
|
|
450
|
+
console.log('\nFailed Tests:');
|
|
451
|
+
console.log('─'.repeat(80));
|
|
452
|
+
for (const m of allFailed) {
|
|
453
|
+
const prefix = m.testClassName || m.className;
|
|
454
|
+
console.log(` ✗ ${prefix}=>${m.name}`);
|
|
455
|
+
console.log(` ${m.title || 'Test failed'}`);
|
|
456
|
+
if (m.details && m.details.length) {
|
|
457
|
+
for (const d of m.details) console.log(` ${d}`);
|
|
458
|
+
}
|
|
459
|
+
console.log('');
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// JUnit output
|
|
373
465
|
if (junitOutput) {
|
|
374
466
|
const xml = buildUnitJUnit(results);
|
|
375
467
|
const outputPath = pathModule.isAbsolute(junitOutput)
|
|
376
468
|
? junitOutput
|
|
377
469
|
: pathModule.join(process.cwd(), junitOutput);
|
|
378
470
|
const dir = pathModule.dirname(outputPath);
|
|
379
|
-
if (!fs.existsSync(dir)) {
|
|
380
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
381
|
-
}
|
|
471
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
382
472
|
fs.writeFileSync(outputPath, xml, 'utf8');
|
|
383
|
-
if (!jsonOutput) {
|
|
384
|
-
console.log(` JUnit report written to: ${outputPath}`);
|
|
385
|
-
}
|
|
473
|
+
if (!jsonOutput) console.log(` JUnit report written to: ${outputPath}`);
|
|
386
474
|
}
|
|
387
475
|
|
|
388
|
-
// JSON output
|
|
476
|
+
// JSON output
|
|
389
477
|
if (jsonOutput) {
|
|
390
478
|
console.log(JSON.stringify(results, null, 2));
|
|
391
479
|
}
|
|
392
480
|
|
|
393
|
-
// Exit with error code if any tests failed or had errors
|
|
394
481
|
if (hasErrors) {
|
|
395
|
-
if (!jsonOutput)
|
|
396
|
-
console.error('\n❌ Unit tests completed with errors');
|
|
397
|
-
}
|
|
482
|
+
if (!jsonOutput) console.error('\n❌ Unit tests completed with errors');
|
|
398
483
|
process.exit(1);
|
|
399
484
|
}
|
|
400
|
-
}
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
// Exported for testing
|
|
488
|
+
_buildRunConfigXml: buildRunConfigXml,
|
|
489
|
+
_parseRunResult: parseRunResult,
|
|
490
|
+
_buildUnitJUnit: buildUnitJUnit,
|
|
401
491
|
};
|