abapgit-agent 1.18.0 → 1.18.2

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 CHANGED
@@ -511,8 +511,7 @@ Never assume — wait for the user's answer before proceeding.
511
511
  1. **Always use `--json`** for ALL debug commands (`attach`, `vars`, `stack`, `step`) — human output is not machine-parseable. This is non-negotiable.
512
512
  2. **Attach BEFORE trigger** — start `debug attach --json` in background first, wait for `"Listener active"`, THEN fire the trigger (`unit`/`pull`/`run`)
513
513
  3. **Never pull to trigger** if a simpler trigger works — use `unit` when a test exists, `run` for a class runner; use `pull` only when the bug is specifically in the pull flow
514
- 4. **Never pass `--session`** to `step/vars/stack`it bypasses the daemon and causes errors
515
- 5. **Always finish with `step --type continue --json`** — releases the frozen ABAP work process
514
+ 4. **Always finish with `step --type continue --json`** releases the frozen ABAP work process
516
515
 
517
516
  **Finding the right line number for a breakpoint:**
518
517
 
@@ -700,7 +699,7 @@ abapgit-agent pull --files src/<name>.clas.abap --sync-xml # --sync-xml amends
700
699
  ```
701
700
 
702
701
  <!-- AI-CONDENSED-START -->
703
- # AI Agent Instructions
702
+ # AI Agent Quick Reference
704
703
 
705
704
  ## Step 1: Read project config before doing anything
706
705
 
@@ -148,7 +148,7 @@ abapgit-agent debug list # confirm it was registered
148
148
 
149
149
  Best practice: individual sequential calls. Once the daemon is running and
150
150
  the session is saved to the state file, each `vars/stack/step` command is a
151
- plain standalone call — no `--session` flag needed.
151
+ plain standalone call — the session is loaded automatically.
152
152
 
153
153
  ```bash
154
154
  # Start attach listener in background (spawns a daemon, saves session to state file)
@@ -183,7 +183,7 @@ done
183
183
  # "position" contains "line" and "isActive":true. If position stays empty after the
184
184
  # trigger finishes, the breakpoint was missed — re-delete, re-set, re-attach, re-trigger.
185
185
 
186
- # Inspect and step — each is an individual call, no --session needed
186
+ # Inspect and step — each is an individual call, session loaded automatically
187
187
  abapgit-agent debug stack --json
188
188
  abapgit-agent debug vars --json
189
189
  abapgit-agent debug vars --expand LS_OBJECT --json
@@ -202,7 +202,6 @@ rm -f /tmp/attach.json /tmp/trigger.json
202
202
  > 1. Wait for `"Listener active"` in the attach output before firing the trigger — this message is printed to stderr (captured in `attach.json`) the moment the listener POST is about to reach ADT. A blind `sleep` is not reliable under system load.
203
203
  > 2. **Always run the trigger in background (`&`)** — whether using `unit` or `run --class`. A foreground trigger blocks the terminal; if it is interrupted before the debug session completes, the ABAP work process is left frozen. The trigger must stay alive for the entire session.
204
204
  > 3. Always finish with `step --type continue` — this releases the frozen work process so the trigger can complete normally
205
- > 4. **Never pass `--session` to `step/vars/stack`** — it bypasses the daemon IPC and causes `noSessionAttached`. Omit it and let commands auto-load from the saved state file.
206
205
 
207
206
  **Step 3 — step through and inspect**
208
207
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abapgit-agent",
3
- "version": "1.18.0",
3
+ "version": "1.18.2",
4
4
  "description": "ABAP Git Agent - Pull and activate ABAP code via abapGit from any git repository",
5
5
  "files": [
6
6
  "bin/",
@@ -637,7 +637,6 @@ async function cmdDelete(args, config, adt) {
637
637
  // ─── Sub-command: attach ──────────────────────────────────────────────────────
638
638
 
639
639
  async function cmdAttach(args, config, adt) {
640
- const sessionIdOverride = val(args, '--session');
641
640
  const jsonOutput = hasFlag(args, '--json');
642
641
  const pollTimeout = parseInt(val(args, '--timeout') || '30', 10);
643
642
  const maxListenSeconds = parseInt(val(args, '--max-listen') || '240', 10);
@@ -653,9 +652,8 @@ async function cmdAttach(args, config, adt) {
653
652
  process.stderr.write('\n Waiting for breakpoint hit... (run your ABAP program in a separate window)\n');
654
653
  }
655
654
 
656
- // Fresh session for attach flow. Don't set stateful here CSRF fetch
657
- // without stateful avoids tying up a dialog WP. Stateful is added per-call
658
- // by debug-session.js STATEFUL_HEADER on attach/getStack/step/vars.
655
+ // Refresh session for the attach flow. Destroy the keepAlive agent to get
656
+ // a fresh TCP connection (old connections may carry stale server-side state).
659
657
  adt.clearSession();
660
658
  if (adt._axios) {
661
659
  const agent = adt._axios.defaults.httpsAgent || adt._axios.defaults.httpAgent;
@@ -669,30 +667,25 @@ async function cmdAttach(args, config, adt) {
669
667
  await adt.fetchCsrfToken();
670
668
 
671
669
  // Delete any stale listener registered under our terminalId from a previous
672
- // (possibly crashed) run. A frozen ABAP work process holding a listener for
673
- // the same terminalId causes SAP ICM to return HTTP 400 "Service cannot be
674
- // reached" for every new listener POST. Deleting first clears that state.
675
- // Ignore errors — there may be no stale listener to delete.
676
- if (!sessionIdOverride) {
677
- try {
678
- const delUrl = `/sap/bc/adt/debugger/listeners` +
679
- `?debuggingMode=user` +
680
- `&requestUser=${encodeURIComponent((config.user || '').toUpperCase())}` +
681
- `&terminalId=${encodeURIComponent(ADT_CLIENT_ID)}` +
682
- `&ideId=${encodeURIComponent(ADT_CLIENT_ID)}` +
683
- `&checkConflict=false` +
684
- `&notifyConflict=true`;
685
- await adt.delete(delUrl);
686
- if (jsonOutput) process.stderr.write(`[debug-attach] deleted stale listener\n`);
687
- } catch (e) {
688
- if (jsonOutput) process.stderr.write(`[debug-attach] delete listener (cleanup): ${e.statusCode || e.message}\n`);
689
- }
670
+ // (possibly crashed) run.
671
+ try {
672
+ const delUrl = `/sap/bc/adt/debugger/listeners` +
673
+ `?debuggingMode=user` +
674
+ `&requestUser=${encodeURIComponent((config.user || '').toUpperCase())}` +
675
+ `&terminalId=${encodeURIComponent(ADT_CLIENT_ID)}` +
676
+ `&ideId=${encodeURIComponent(ADT_CLIENT_ID)}` +
677
+ `&checkConflict=false` +
678
+ `&notifyConflict=true`;
679
+ await adt.delete(delUrl);
680
+ if (jsonOutput) process.stderr.write(`[debug-attach] deleted stale listener\n`);
681
+ } catch (e) {
682
+ if (jsonOutput) process.stderr.write(`[debug-attach] delete listener (cleanup): ${e.statusCode || e.message}\n`);
690
683
  }
691
684
 
692
685
  // Re-POST local breakpoints to refresh them on the server before listening.
693
686
  // Breakpoints expire when the SAP session or work process is restarted.
694
687
  // This ensures they are active regardless of when they were originally set.
695
- if (!sessionIdOverride) {
688
+ {
696
689
  const localBps = _loadBpState ? _loadBpState(config) : [];
697
690
  if (localBps.length === 0) {
698
691
  console.error('\n No breakpoints set. Use "debug set --object <name> --line <n>" first.\n');
@@ -700,6 +693,9 @@ async function cmdAttach(args, config, adt) {
700
693
  }
701
694
  const { valid, stale } = await refreshBreakpoints(config, adt, localBps);
702
695
  if (_saveBpState) _saveBpState(config, valid);
696
+ if (jsonOutput && valid.length > 0) {
697
+ process.stderr.write(`[debug-attach] refreshed ${valid.length} breakpoint(s)\n`);
698
+ }
703
699
  if (stale.length > 0 && !jsonOutput) {
704
700
  process.stderr.write(` Warning: ${stale.length} breakpoint(s) could not be registered (invalid position):\n`);
705
701
  stale.forEach(({ object, line, error }) => {
@@ -711,11 +707,11 @@ async function cmdAttach(args, config, adt) {
711
707
  process.exit(1);
712
708
  }
713
709
  }
714
- let sessionId = sessionIdOverride;
710
+ let sessionId = null;
715
711
  let positionResult = null;
716
712
  let session = null;
717
713
 
718
- if (!sessionId) {
714
+ {
719
715
  // ADT listeners: POST long-polls until a breakpoint is hit (or timeout).
720
716
  // On breakpoint hit the response body contains <DEBUGGEE_ID>; on timeout
721
717
  // (no breakpoint) returns 200 with empty body — poll again.
@@ -845,8 +841,8 @@ async function cmdAttach(args, config, adt) {
845
841
  process.exit(1);
846
842
  }
847
843
  // attach() succeeded — skip getPosition() validation.
848
- // With keepAlive, the connection is shared and getStack via the
849
- // in-process daemon will use the same AdtHttp + same TCP socket.
844
+ // The in-process daemon shares the same AdtHttp instance, so
845
+ // getStack/vars/step via IPC use the identical TCP connection.
850
846
  positionResult = { position: {}, source: [] };
851
847
  session = candidateSession;
852
848
  }
@@ -881,20 +877,7 @@ async function cmdAttach(args, config, adt) {
881
877
  }
882
878
  }
883
879
 
884
- // When using --session override (no listener), do attach+getPosition here.
885
- if (sessionIdOverride) {
886
- session = new DebugSession(adt, sessionId);
887
- // getPosition is best-effort for --session override (e.g. human REPL recovery)
888
- try {
889
- positionResult = await session.getPosition();
890
- } catch (e) {
891
- positionResult = { position: {}, source: [] };
892
- }
893
- }
894
-
895
- // session and positionResult are now set (either via listener loop or --session override)
896
880
  if (!session) {
897
- // Should not reach here — listener loop either sets session or exits
898
881
  console.error('\n Internal error: session not initialized after attach.\n');
899
882
  process.exit(1);
900
883
  }
@@ -285,7 +285,7 @@ Examples:
285
285
  process.exit(1);
286
286
  }
287
287
 
288
- const filesSyntaxCheck = args[filesArgIndex + 1].split(',').map(f => f.trim());
288
+ let filesSyntaxCheck = args[filesArgIndex + 1].split(',').map(f => f.trim());
289
289
 
290
290
  // Parse optional --variant parameter; fall back to project config
291
291
  const variantArgIndex = args.indexOf('--variant');
@@ -293,6 +293,31 @@ Examples:
293
293
  const inspectConfig = getInspectConfig();
294
294
  const variant = variantArg || inspectConfig.variant || null;
295
295
 
296
+ // Filter out files matching inspect.exclude patterns from project config
297
+ const excludePatterns = inspectConfig.exclude || [];
298
+ if (excludePatterns.length > 0) {
299
+ const before = filesSyntaxCheck.length;
300
+ filesSyntaxCheck = filesSyntaxCheck.filter(f => {
301
+ const objName = pathModule.basename(f).split('.')[0].toUpperCase();
302
+ return !excludePatterns.some(pattern => {
303
+ const re = new RegExp('^' + pattern.toUpperCase().replace(/\*/g, '.*') + '$');
304
+ return re.test(objName);
305
+ });
306
+ });
307
+ const skipped = before - filesSyntaxCheck.length;
308
+ if (skipped > 0 && !args.includes('--json')) {
309
+ console.log(` Skipped ${skipped} file(s) excluded by inspect config`);
310
+ }
311
+ if (filesSyntaxCheck.length === 0) {
312
+ if (args.includes('--json')) {
313
+ console.log(JSON.stringify([]));
314
+ } else {
315
+ console.log('\n All files excluded by inspect config — nothing to check.\n');
316
+ }
317
+ return;
318
+ }
319
+ }
320
+
296
321
  // Parse optional --junit-output parameter
297
322
  const junitArgIndex = args.indexOf('--junit-output');
298
323
  const junitOutput = junitArgIndex !== -1 ? args[junitArgIndex + 1] : null;
@@ -316,6 +341,51 @@ Examples:
316
341
  // Send all files in one request
317
342
  const results = await inspectAllFiles(filesSyntaxCheck, csrfToken, config, variant, http, verbose);
318
343
 
344
+ // Apply inspect.suppress rules — downgrade matching errors/warnings to infos
345
+ const suppressRules = inspectConfig.suppress || [];
346
+ if (suppressRules.length > 0) {
347
+ for (const result of results) {
348
+ const objName = (result.OBJECT_NAME || result.object_name || '').toUpperCase();
349
+ for (const rule of suppressRules) {
350
+ const objPattern = new RegExp('^' + (rule.object || '*').toUpperCase().replace(/\*/g, '.*') + '$');
351
+ if (!objPattern.test(objName)) continue;
352
+ const msgPattern = new RegExp((rule.message || '*').replace(/\*/g, '.*'), 'i');
353
+
354
+ // Downgrade matching errors → infos
355
+ const errors = result.ERRORS || result.errors || [];
356
+ const kept = [];
357
+ for (const err of errors) {
358
+ const text = err.TEXT || err.text || '';
359
+ if (msgPattern.test(text)) {
360
+ const infos = result.INFOS || result.infos || [];
361
+ infos.push({ ...err, MESSAGE: `[suppressed] ${text}`, message: `[suppressed] ${text}` });
362
+ if (result.INFOS !== undefined) result.INFOS = infos; else result.infos = infos;
363
+ const ec = result.ERROR_COUNT !== undefined ? 'ERROR_COUNT' : 'error_count';
364
+ result[ec] = Math.max(0, (result[ec] || 0) - 1);
365
+ } else {
366
+ kept.push(err);
367
+ }
368
+ }
369
+ if (result.ERRORS !== undefined) result.ERRORS = kept; else result.errors = kept;
370
+
371
+ // Downgrade matching warnings → infos
372
+ const warnings = result.WARNINGS || result.warnings || [];
373
+ const keptW = [];
374
+ for (const warn of warnings) {
375
+ const text = warn.MESSAGE || warn.message || '';
376
+ if (msgPattern.test(text)) {
377
+ const infos = result.INFOS || result.infos || [];
378
+ infos.push({ ...warn, MESSAGE: `[suppressed] ${text}`, message: `[suppressed] ${text}` });
379
+ if (result.INFOS !== undefined) result.INFOS = infos; else result.infos = infos;
380
+ } else {
381
+ keptW.push(warn);
382
+ }
383
+ }
384
+ if (result.WARNINGS !== undefined) result.WARNINGS = keptW; else result.warnings = keptW;
385
+ }
386
+ }
387
+ }
388
+
319
389
  // JUnit output mode — write XML file, then continue to normal output
320
390
  if (junitOutput) {
321
391
  const xml = buildInspectJUnit(results);
@@ -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, '&amp;')
@@ -19,77 +17,151 @@ 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: [{ name, passed, kind?, title?, detail? }],
52
+ * coverageStats: { totalLines, coveredLines, coverageRate } | null
53
+ * }
54
+ */
55
+ function parseRunResult(xml, className) {
56
+ const methods = [];
57
+
58
+ // Split on <testMethod each block is one method
59
+ const methodBlocks = xml.split(/<testMethod\s/);
60
+ for (let i = 1; i < methodBlocks.length; i++) {
61
+ const block = methodBlocks[i];
62
+
63
+ const nameMatch = block.match(/adtcore:name="([^"]*)"/);
64
+ if (!nameMatch) continue;
65
+ const name = nameMatch[1];
66
+
67
+ // Find the <alerts> section — content between <alerts> and </alerts>
68
+ const alertsMatch = block.match(/<alerts>([\s\S]*?)<\/alerts>/);
69
+ const alertsContent = alertsMatch ? alertsMatch[1].trim() : '';
70
+
71
+ if (!alertsContent || alertsContent === '<alerts/>') {
72
+ methods.push({ name, passed: true });
73
+ continue;
74
+ }
75
+
76
+ // Extract alert kind
77
+ const kindMatch = block.match(/<alert\s[^>]*kind="([^"]*)"/);
78
+ const kind = kindMatch ? kindMatch[1] : 'failedAssertion';
79
+
80
+ // Extract title
81
+ const titleMatch = block.match(/<title[^>]*>([^<]*)<\/title>/);
82
+ const title = titleMatch ? titleMatch[1].trim() : 'Test failed';
83
+
84
+ // Extract first detail text
85
+ const detailMatch = block.match(/<detail\s[^>]*text="([^"]*)"/);
86
+ const detail = detailMatch ? detailMatch[1].trim() : '';
87
+
88
+ methods.push({ name, passed: false, kind, title, detail });
89
+ }
90
+
91
+ // Parse coverage stats if present
92
+ // <coverage ... adtcore:lines_total="N" adtcore:lines_covered="M" adtcore:coverage_rate="R"/>
93
+ let coverageStats = null;
94
+ const covMatch = xml.match(/<coverage\b[^>]*>/);
95
+ if (covMatch) {
96
+ const covEl = covMatch[0];
97
+ const totalMatch = covEl.match(/adtcore:lines_total="([^"]*)"/);
98
+ const coveredMatch = covEl.match(/adtcore:lines_covered="([^"]*)"/);
99
+ const rateMatch = covEl.match(/adtcore:coverage_rate="([^"]*)"/);
100
+ if (totalMatch || coveredMatch || rateMatch) {
101
+ const totalLines = totalMatch ? parseInt(totalMatch[1], 10) : 0;
102
+ const coveredLines = coveredMatch ? parseInt(coveredMatch[1], 10) : 0;
103
+ const coverageRate = rateMatch ? parseFloat(rateMatch[1]) : 0;
104
+ coverageStats = { totalLines, coveredLines, coverageRate };
105
+ }
106
+ }
107
+
108
+ return { className, methods, coverageStats };
109
+ }
110
+
111
+ /**
112
+ * Build JUnit XML from parsed ADT AUnit results.
38
113
  *
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).
114
+ * Each test method gets its own <testcase> element (pass or fail).
41
115
  * Coverage stats (if present) are emitted as <properties> on the testsuite.
116
+ * A synthetic coverage_threshold <failure> is injected when threshold is breached.
42
117
  */
43
118
  function buildUnitJUnit(results) {
44
119
  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;
120
+ const { className, methods, coverageStats, thresholdFailure } = res;
121
+ const testCount = methods.length;
122
+ const failedMethods = methods.filter(m => !m.passed);
123
+ const syntheticFailures = thresholdFailure ? 1 : 0;
124
+ const totalFailures = failedMethods.length + syntheticFailures;
51
125
 
52
126
  const lines = [];
53
127
 
54
- // Coverage <properties> block — only emitted when coverage data is present
55
128
  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
129
  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}"/>`);
130
+ lines.push(` <property name="coverage.rate" value="${coverageStats.coverageRate}"/>`);
131
+ lines.push(` <property name="coverage.lines.total" value="${coverageStats.totalLines}"/>`);
132
+ lines.push(` <property name="coverage.lines.covered" value="${coverageStats.coveredLines}"/>`);
63
133
  lines.push(' </properties>');
64
134
  }
65
135
 
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
- );
136
+ if (testCount === 0) {
137
+ lines.push(` <testcase name="(no tests)" classname="${escapeXml(className)}"/>`);
138
+ } else {
139
+ for (const m of methods) {
140
+ if (m.passed) {
141
+ lines.push(` <testcase name="${escapeXml(m.name)}" classname="${escapeXml(className)}"/>`);
142
+ } else {
143
+ const msg = escapeXml(m.title || 'Test failed');
144
+ const body = m.detail ? escapeXml(`${m.title}\n${m.detail}`) : msg;
145
+ lines.push(
146
+ ` <testcase name="${escapeXml(m.name)}" classname="${escapeXml(className)}">\n` +
147
+ ` <failure type="${escapeXml(m.kind || 'failedAssertion')}" message="${msg}">${body}</failure>\n` +
148
+ ` </testcase>`
149
+ );
150
+ }
151
+ }
77
152
  }
78
153
 
79
- // Aggregate passing testcase (ABAP doesn't return individual passing method names)
80
- if (passedCount > 0) {
154
+ if (thresholdFailure) {
81
155
  lines.push(
82
- ` <testcase name="(${passedCount} passing test(s))" classname="${escapeXml(className)}"/>`
156
+ ` <testcase name="coverage_threshold" classname="${escapeXml(className)}">\n` +
157
+ ` <failure type="FAILURE" message="${escapeXml(thresholdFailure)}">${escapeXml(thresholdFailure)}</failure>\n` +
158
+ ` </testcase>`
83
159
  );
84
160
  }
85
161
 
86
- if (testCount === 0) {
87
- lines.push(` <testcase name="(no tests)" classname="${escapeXml(className)}"/>`);
88
- }
89
-
90
162
  return (
91
163
  ` <testsuite name="${escapeXml(className)}" ` +
92
- `tests="${Math.max(testCount, 1)}" failures="${failedCount}" errors="0">\n` +
164
+ `tests="${Math.max(testCount + syntheticFailures, 1)}" failures="${totalFailures}" errors="0">\n` +
93
165
  lines.join('\n') + '\n' +
94
166
  ` </testsuite>`
95
167
  );
@@ -104,133 +176,86 @@ function buildUnitJUnit(results) {
104
176
  }
105
177
 
106
178
  /**
107
- * Run unit test for a single file
179
+ * Run AUnit tests for a single .testclasses.abap file via ADT.
108
180
  */
109
- async function runUnitTestForFile(sourceFile, csrfToken, config, coverage, http, jsonOutput = false, verbose = false) {
181
+ async function runUnitTestForFile(sourceFile, adt, coverage, jsonOutput, verbose) {
110
182
  if (!jsonOutput) {
111
183
  console.log(` Running unit test for: ${sourceFile}`);
112
184
  }
113
185
 
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
- }
186
+ const absolutePath = pathModule.isAbsolute(sourceFile)
187
+ ? sourceFile
188
+ : pathModule.join(process.cwd(), sourceFile);
131
189
 
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
- }
190
+ if (!fs.existsSync(absolutePath)) {
191
+ if (!jsonOutput) console.error(` ❌ File not found: ${absolutePath}`);
192
+ return { error: 'File not found', statusCode: 404, file: sourceFile };
193
+ }
147
194
 
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];
195
+ const fileName = pathModule.basename(sourceFile);
196
+ if (!fileName.toLowerCase().includes('.testclasses.abap')) {
197
+ if (!jsonOutput) console.error(` ❌ Invalid file format: ${sourceFile}`);
198
+ return { error: 'Invalid file format: must be .testclasses.abap', statusCode: 400, file: sourceFile };
199
+ }
151
200
 
152
- // Handle subdirectory paths
153
- const lastSlash = objName.lastIndexOf('/');
154
- if (lastSlash >= 0) {
155
- objName = objName.substring(lastSlash + 1);
156
- }
201
+ const className = fileName.split('.')[0].toUpperCase();
202
+ const adtUri = `/sap/bc/adt/oo/classes/${className.toLowerCase()}`;
203
+ const xmlBody = buildRunConfigXml(adtUri, coverage);
157
204
 
158
- // Send files array to unit endpoint (ABAP expects string_table of file names)
159
- const data = {
160
- files: [sourceFile],
161
- coverage: coverage
162
- };
205
+ try {
206
+ const resp = await adt.post('/sap/bc/adt/abapunit/testruns', xmlBody, {
207
+ contentType: 'application/*',
208
+ accept: 'application/*',
209
+ });
163
210
 
164
- const result = await http.post('/sap/bc/z_abapgit_agent/unit', data, { csrfToken });
211
+ const result = parseRunResult(resp.body || '', className);
165
212
 
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
213
+ const testCount = result.methods.length;
214
+ const failedCount = result.methods.filter(m => !m.passed).length;
171
215
  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
216
 
178
217
  if (!jsonOutput) {
179
218
  if (testCount === 0) {
180
- console.log(` ➖ ${objName} - No unit tests`);
181
- } else if (success === 'X' || success === true) {
182
- console.log(` ✅ ${objName} - All tests passed`);
219
+ console.log(` ➖ ${className} - No unit tests`);
220
+ } else if (failedCount === 0) {
221
+ console.log(` ✅ ${className} - All tests passed`);
183
222
  } else {
184
- console.log(` ❌ ${objName} - Tests failed`);
223
+ console.log(` ❌ ${className} - Tests failed`);
185
224
  }
186
-
187
225
  console.log(` Tests: ${testCount} | Passed: ${passedCount} | Failed: ${failedCount}`);
188
226
 
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
-
227
+ if (coverage && result.coverageStats) {
228
+ const { totalLines, coveredLines, coverageRate } = result.coverageStats;
195
229
  console.log(` 📊 Coverage: ${coverageRate}%`);
196
230
  console.log(` Total Lines: ${totalLines}`);
197
231
  console.log(` Covered Lines: ${coveredLines}`);
198
232
  }
199
233
 
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}`);
234
+ if (failedCount > 0) {
235
+ for (const m of result.methods.filter(m => !m.passed)) {
236
+ console.log(` ✗ ${className}=>${m.name}: ${m.title || 'Test failed'}`);
206
237
  }
207
238
  }
208
239
  }
209
240
 
210
241
  return result;
211
242
  } catch (error) {
212
- // Build error response object
213
243
  const errorResponse = {
214
- file: sourceFile,
215
244
  error: error.message || 'Unknown error',
216
- statusCode: error.statusCode || 500
245
+ statusCode: error.statusCode || 500,
246
+ file: sourceFile,
247
+ className,
248
+ methods: [],
249
+ coverageStats: null,
217
250
  };
218
-
219
- // Add additional error details if available
220
- if (error.body) {
221
- errorResponse.body = error.body;
222
- }
223
-
224
251
  if (!jsonOutput) {
225
252
  console.error(`\n ❌ Error: ${formatHttpError(error)}`);
226
253
  if (verbose && error.body) {
227
254
  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);
255
+ console.error(typeof error.body === 'object' ? JSON.stringify(error.body, null, 2) : String(error.body));
230
256
  console.error('--- End of response body ---');
231
257
  }
232
258
  }
233
-
234
259
  return errorResponse;
235
260
  }
236
261
  }
@@ -239,10 +264,9 @@ module.exports = {
239
264
  name: 'unit',
240
265
  description: 'Run AUnit tests for ABAP test class files',
241
266
  requiresAbapConfig: true,
242
- requiresVersionCheck: true,
243
267
 
244
268
  async execute(args, context) {
245
- const { loadConfig, AbapHttp } = context;
269
+ const { loadConfig } = context;
246
270
 
247
271
  if (args.includes('--help') || args.includes('-h')) {
248
272
  console.log(`
@@ -252,6 +276,7 @@ Usage:
252
276
  Description:
253
277
  Run AUnit tests for ABAP test class files (.testclasses.abap).
254
278
  Objects must be already active in the ABAP system (run pull first).
279
+ Calls the standard ADT endpoint /sap/bc/adt/abapunit/testruns directly.
255
280
 
256
281
  Parameters:
257
282
  --files <file1,...> Comma-separated .testclasses.abap files (required).
@@ -271,6 +296,7 @@ Examples:
271
296
 
272
297
  const jsonOutput = args.includes('--json');
273
298
  const verbose = args.includes('--verbose');
299
+
274
300
  const filesArgIndex = args.indexOf('--files');
275
301
  if (filesArgIndex === -1 || filesArgIndex + 1 >= args.length) {
276
302
  console.error('Error: --files parameter required');
@@ -279,8 +305,6 @@ Examples:
279
305
  }
280
306
 
281
307
  const files = args[filesArgIndex + 1].split(',').map(f => f.trim());
282
-
283
- // Coverage options
284
308
  const coverage = args.includes('--coverage');
285
309
 
286
310
  const coverageThresholdIdx = args.indexOf('--coverage-threshold');
@@ -289,58 +313,43 @@ Examples:
289
313
  const coverageModeIdx = args.indexOf('--coverage-mode');
290
314
  const coverageMode = coverageModeIdx !== -1 ? args[coverageModeIdx + 1] : 'fail';
291
315
 
292
- // Parse optional --junit-output parameter
293
316
  const junitArgIndex = args.indexOf('--junit-output');
294
317
  const junitOutput = junitArgIndex !== -1 ? args[junitArgIndex + 1] : null;
295
318
 
296
319
  if (!jsonOutput) {
297
320
  console.log(`\n Running unit tests for ${files.length} file(s)${coverage ? ' (with coverage)' : ''}`);
298
- if (junitOutput) {
299
- console.log(` JUnit output: ${junitOutput}`);
300
- }
321
+ if (junitOutput) console.log(` JUnit output: ${junitOutput}`);
301
322
  console.log('');
302
323
  }
303
324
 
304
325
  const config = loadConfig();
305
- const http = new AbapHttp(config);
306
- const csrfToken = await http.fetchCsrfToken();
326
+ const adt = new AdtHttp(config);
327
+ await adt.fetchCsrfToken();
307
328
 
308
- // Collect results for JSON / JUnit output
309
329
  const results = [];
310
330
  let hasErrors = false;
311
331
 
312
332
  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
- }
333
+ const result = await runUnitTestForFile(sourceFile, adt, coverage, jsonOutput, verbose);
334
+ results.push(result);
335
+
336
+ if (result.error || result.statusCode >= 400) {
337
+ hasErrors = true;
328
338
  }
339
+ const methods = result.methods || [];
340
+ if (methods.some(m => !m.passed)) hasErrors = true;
329
341
  }
330
342
 
331
- // Coverage threshold enforcement — per file, runs BEFORE JUnit output
332
- // so the injected failure testcase lands in the correct class's testsuite.
343
+ // Coverage threshold enforcement — per file
333
344
  if (coverage && coverageThreshold > 0) {
334
345
  let anyData = false;
335
346
  for (const result of results) {
336
- const stats = result.COVERAGE_STATS || result.coverage_stats;
337
- if (!stats) continue;
347
+ if (!result.coverageStats) continue;
338
348
  anyData = true;
339
- const totalLines = stats.TOTAL_LINES || stats.total_lines || 0;
340
- const coveredLines = stats.COVERED_LINES || stats.covered_lines || 0;
349
+ const { totalLines, coveredLines, coverageRate } = result.coverageStats;
341
350
  if (totalLines === 0) continue;
342
- const rate = Math.round((coveredLines / totalLines) * 100);
343
- const className = result._className || 'UNKNOWN';
351
+ const className = result.className || 'UNKNOWN';
352
+ const rate = Math.round(coveredLines / totalLines * 100);
344
353
  if (rate < coverageThreshold) {
345
354
  const msg = `${className}: coverage ${rate}% is below threshold ${coverageThreshold}%`;
346
355
  if (coverageMode === 'warn') {
@@ -348,54 +357,42 @@ Examples:
348
357
  } else {
349
358
  if (!jsonOutput) console.error(`❌ ${msg}`);
350
359
  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;
360
+ result.thresholdFailure = msg;
362
361
  }
363
362
  } else {
364
363
  if (!jsonOutput) console.log(`✅ ${className}: coverage ${rate}% meets threshold ${coverageThreshold}%`);
365
364
  }
366
365
  }
367
- if (!anyData) {
368
- if (!jsonOutput) console.warn('⚠️ Coverage data unavailable — threshold not enforced');
366
+ if (!anyData && !jsonOutput) {
367
+ console.warn('⚠️ Coverage data unavailable — threshold not enforced');
369
368
  }
370
369
  }
371
370
 
372
- // JUnit output mode — write XML after threshold check so synthetic failure is included
371
+ // JUnit output
373
372
  if (junitOutput) {
374
373
  const xml = buildUnitJUnit(results);
375
374
  const outputPath = pathModule.isAbsolute(junitOutput)
376
375
  ? junitOutput
377
376
  : pathModule.join(process.cwd(), junitOutput);
378
377
  const dir = pathModule.dirname(outputPath);
379
- if (!fs.existsSync(dir)) {
380
- fs.mkdirSync(dir, { recursive: true });
381
- }
378
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
382
379
  fs.writeFileSync(outputPath, xml, 'utf8');
383
- if (!jsonOutput) {
384
- console.log(` JUnit report written to: ${outputPath}`);
385
- }
380
+ if (!jsonOutput) console.log(` JUnit report written to: ${outputPath}`);
386
381
  }
387
382
 
388
- // JSON output mode
383
+ // JSON output
389
384
  if (jsonOutput) {
390
385
  console.log(JSON.stringify(results, null, 2));
391
386
  }
392
387
 
393
- // Exit with error code if any tests failed or had errors
394
388
  if (hasErrors) {
395
- if (!jsonOutput) {
396
- console.error('\n❌ Unit tests completed with errors');
397
- }
389
+ if (!jsonOutput) console.error('\n❌ Unit tests completed with errors');
398
390
  process.exit(1);
399
391
  }
400
- }
392
+ },
393
+
394
+ // Exported for testing
395
+ _buildRunConfigXml: buildRunConfigXml,
396
+ _parseRunResult: parseRunResult,
397
+ _buildUnitJUnit: buildUnitJUnit,
401
398
  };
package/src/config.js CHANGED
@@ -255,6 +255,8 @@ function getInspectConfig() {
255
255
  const projectConfig = loadProjectConfig();
256
256
  return {
257
257
  variant: projectConfig?.inspect?.variant || null,
258
+ exclude: projectConfig?.inspect?.exclude || [],
259
+ suppress: projectConfig?.inspect?.suppress || [],
258
260
  };
259
261
  }
260
262
 
@@ -166,7 +166,8 @@ class AdtHttp {
166
166
  return await this._makeRequest(method, urlPath, body, options);
167
167
  } catch (error) {
168
168
  if (this._isAuthError(error) && !options.isRetry) {
169
- this.csrfToken = null;
169
+ // Clear stale session state and refresh
170
+ this.clearSession();
170
171
  await this.fetchCsrfToken();
171
172
  return await this._makeRequest(method, urlPath, body, { ...options, isRetry: true });
172
173
  }
@@ -178,7 +179,9 @@ class AdtHttp {
178
179
  if (error.statusCode === 401) return true;
179
180
  if (error.statusCode === 403) return true;
180
181
  const msg = (error.message || '').toLowerCase();
181
- return msg.includes('csrf') || msg.includes('unauthorized') || msg.includes('forbidden');
182
+ if (msg.includes('csrf') || msg.includes('unauthorized') || msg.includes('forbidden')) return true;
183
+ if (msg.includes('session timed out') || msg.includes('session not found')) return true;
184
+ return false;
182
185
  }
183
186
 
184
187
  async _makeRequest(method, urlPath, body = null, options = {}) {