abapgit-agent 1.18.1 → 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 +2 -3
- package/abap/guidelines/debug-session.md +2 -3
- package/package.json +1 -1
- package/src/commands/debug.js +23 -40
- package/src/commands/unit.js +197 -200
- package/src/utils/adt-http.js +5 -2
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. **
|
|
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
|
|
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 —
|
|
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,
|
|
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
package/src/commands/debug.js
CHANGED
|
@@ -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
|
-
//
|
|
657
|
-
//
|
|
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.
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
+
`¬ifyConflict=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
|
-
|
|
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 =
|
|
710
|
+
let sessionId = null;
|
|
715
711
|
let positionResult = null;
|
|
716
712
|
let session = null;
|
|
717
713
|
|
|
718
|
-
|
|
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
|
-
//
|
|
849
|
-
//
|
|
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
|
}
|
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,151 @@ 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: [{ 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
|
-
*
|
|
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
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
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="${
|
|
61
|
-
lines.push(` <property name="coverage.lines.total" value="${
|
|
62
|
-
lines.push(` <property name="coverage.lines.covered" value="${
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
if (passedCount > 0) {
|
|
154
|
+
if (thresholdFailure) {
|
|
81
155
|
lines.push(
|
|
82
|
-
` <testcase name="
|
|
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="${
|
|
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
|
|
179
|
+
* Run AUnit tests for a single .testclasses.abap file via ADT.
|
|
108
180
|
*/
|
|
109
|
-
async function runUnitTestForFile(sourceFile,
|
|
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
|
-
|
|
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
|
-
}
|
|
186
|
+
const absolutePath = pathModule.isAbsolute(sourceFile)
|
|
187
|
+
? sourceFile
|
|
188
|
+
: pathModule.join(process.cwd(), sourceFile);
|
|
131
189
|
|
|
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
|
-
}
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
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 =
|
|
211
|
+
const result = parseRunResult(resp.body || '', className);
|
|
165
212
|
|
|
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
|
|
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(` ➖ ${
|
|
181
|
-
} else if (
|
|
182
|
-
console.log(` ✅ ${
|
|
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(` ❌ ${
|
|
223
|
+
console.log(` ❌ ${className} - Tests failed`);
|
|
185
224
|
}
|
|
186
|
-
|
|
187
225
|
console.log(` Tests: ${testCount} | Passed: ${passedCount} | Failed: ${failedCount}`);
|
|
188
226
|
|
|
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
|
-
|
|
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
|
|
201
|
-
for (const
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
306
|
-
|
|
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,
|
|
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
|
-
}
|
|
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
|
|
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
|
-
|
|
337
|
-
if (!stats) continue;
|
|
347
|
+
if (!result.coverageStats) continue;
|
|
338
348
|
anyData = true;
|
|
339
|
-
const totalLines
|
|
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
|
|
343
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
366
|
+
if (!anyData && !jsonOutput) {
|
|
367
|
+
console.warn('⚠️ Coverage data unavailable — threshold not enforced');
|
|
369
368
|
}
|
|
370
369
|
}
|
|
371
370
|
|
|
372
|
-
// JUnit output
|
|
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
|
|
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/utils/adt-http.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = {}) {
|