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.
@@ -1,14 +1,12 @@
1
1
  /**
2
- * Unit command - Run AUnit tests for ABAP test class files
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 JUnit XML from unit test results array.
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
- * Maps to JUnit schema:
25
- * <testsuites>
26
- * <testsuite name="ZCL_MY_TEST" tests="10" failures="2" errors="0">
27
- * <properties>
28
- * <property name="coverage.rate" value="67"/>
29
- * <property name="coverage.lines.total" value="120"/>
30
- * <property name="coverage.lines.covered" value="80"/>
31
- * </properties>
32
- * <testcase name="TEST_METHOD_1" classname="ZCL_MY_TEST"/>
33
- * <testcase name="TEST_METHOD_2" classname="ZCL_MY_TEST">
34
- * <failure type="FAILURE" message="...">detail</failure>
35
- * </testcase>
36
- * </testsuite>
37
- * </testsuites>
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
- * One testsuite per test class file. Each failed test method becomes a <failure>.
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 testCount = res.TEST_COUNT || res.test_count || 0;
46
- const passedCount = res.PASSED_COUNT || res.passed_count || 0;
47
- const failedCount = res.FAILED_COUNT || res.failed_count || 0;
48
- const errors = res.ERRORS || res.errors || [];
49
- const className = res._className || 'UNKNOWN'; // injected by caller
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="${rate}"/>`);
61
- lines.push(` <property name="coverage.lines.total" value="${total}"/>`);
62
- lines.push(` <property name="coverage.lines.covered" value="${covered}"/>`);
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
- // One <testcase> per failed test
67
- for (const err of errors) {
68
- const errClassName = err.CLASS_NAME || err.class_name || className;
69
- const methodName = err.METHOD_NAME || err.method_name || '?';
70
- const errorKind = err.ERROR_KIND || err.error_kind || 'FAILURE';
71
- const errorText = err.ERROR_TEXT || err.error_text || 'Test failed';
72
- lines.push(
73
- ` <testcase name="${escapeXml(methodName)}" classname="${escapeXml(errClassName)}">\n` +
74
- ` <failure type="${escapeXml(errorKind)}" message="${escapeXml(errorText)}">${escapeXml(errorText)}</failure>\n` +
75
- ` </testcase>`
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
- // Aggregate passing testcase (ABAP doesn't return individual passing method names)
80
- if (passedCount > 0) {
196
+ if (thresholdFailure) {
81
197
  lines.push(
82
- ` <testcase name="(${passedCount} passing test(s))" classname="${escapeXml(className)}"/>`
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="${failedCount}" errors="0">\n` +
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 unit test for a single file
221
+ * Run AUnit tests for a single .testclasses.abap file via ADT.
108
222
  */
109
- async function runUnitTestForFile(sourceFile, csrfToken, config, coverage, http, jsonOutput = false, verbose = false) {
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
- try {
115
- // Read file content
116
- const absolutePath = pathModule.isAbsolute(sourceFile)
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
- // Extract object type and name from file path
133
- // e.g., "zcl_my_test.clas.abap" -> CLAS, ZCL_MY_TEST
134
- const fileName = pathModule.basename(sourceFile).toUpperCase();
135
- const parts = fileName.split('.');
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
- // obj_name is first part (may contain path), obj_type is second part
149
- const objType = parts[1] === 'CLASS' ? 'CLAS' : parts[1];
150
- let objName = parts[0];
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
- // Handle subdirectory paths
153
- const lastSlash = objName.lastIndexOf('/');
154
- if (lastSlash >= 0) {
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
- // Send files array to unit endpoint (ABAP expects string_table of file names)
159
- const data = {
160
- files: [sourceFile],
161
- coverage: coverage
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 = await http.post('/sap/bc/z_abapgit_agent/unit', data, { csrfToken });
253
+ const result = parseRunResult(resp.body || '', className);
165
254
 
166
- // Handle uppercase keys from ABAP
167
- const success = result.SUCCESS || result.success;
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(` ➖ ${objName} - No unit tests`);
181
- } else if (success === 'X' || success === true) {
182
- console.log(` ✅ ${objName} - All tests passed`);
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(` ❌ ${objName} - Tests failed`);
265
+ console.log(` ❌ ${className} - Tests failed`);
185
266
  }
186
-
187
267
  console.log(` Tests: ${testCount} | Passed: ${passedCount} | Failed: ${failedCount}`);
188
268
 
189
- // Display coverage if available
190
- if (coverage && coverageStats) {
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
- if (failedCount > 0 && errors.length > 0) {
201
- for (const err of errors) {
202
- const className = err.CLASS_NAME || err.class_name || '?';
203
- const methodName = err.METHOD_NAME || err.method_name || '?';
204
- const errorText = err.ERROR_TEXT || err.error_text || 'Unknown error';
205
- console.log(` ✗ ${className}=>${methodName}: ${errorText}`);
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
- const raw = typeof error.body === 'object' ? JSON.stringify(error.body, null, 2) : String(error.body);
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, AbapHttp } = context;
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 http = new AbapHttp(config);
306
- const csrfToken = await http.fetchCsrfToken();
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, csrfToken, config, coverage, http, jsonOutput, verbose);
314
- if (result) {
315
- // Inject class name derived from file path for JUnit builder
316
- const fileName = pathModule.basename(sourceFile).toUpperCase();
317
- result._className = fileName.split('.')[0];
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, runs BEFORE JUnit output
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
- const stats = result.COVERAGE_STATS || result.coverage_stats;
337
- if (!stats) continue;
422
+ if (!result.coverageStats) continue;
338
423
  anyData = true;
339
- const totalLines = stats.TOTAL_LINES || stats.total_lines || 0;
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 rate = Math.round((coveredLines / totalLines) * 100);
343
- const className = result._className || 'UNKNOWN';
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
- // Inject failure into this class's own testsuite so Jenkins shows
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
- if (!jsonOutput) console.warn('⚠️ Coverage data unavailable — threshold not enforced');
441
+ if (!anyData && !jsonOutput) {
442
+ console.warn('⚠️ Coverage data unavailable — threshold not enforced');
369
443
  }
370
444
  }
371
445
 
372
- // JUnit output mode — write XML after threshold check so synthetic failure is included
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 mode
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
  };