arc-1 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/arc1-cli.js +3 -7
- package/bin/arc1.js +4 -9
- package/dist/adt/abapgit.d.ts +10 -5
- package/dist/adt/abapgit.d.ts.map +1 -1
- package/dist/adt/abapgit.js +357 -76
- package/dist/adt/abapgit.js.map +1 -1
- package/dist/adt/atc.d.ts +49 -0
- package/dist/adt/atc.d.ts.map +1 -0
- package/dist/adt/atc.js +294 -0
- package/dist/adt/atc.js.map +1 -0
- package/dist/adt/aunit-package.d.ts +22 -0
- package/dist/adt/aunit-package.d.ts.map +1 -0
- package/dist/adt/aunit-package.js +78 -0
- package/dist/adt/aunit-package.js.map +1 -0
- package/dist/adt/aunit.d.ts +143 -0
- package/dist/adt/aunit.d.ts.map +1 -0
- package/dist/adt/aunit.js +987 -0
- package/dist/adt/aunit.js.map +1 -0
- package/dist/adt/client.d.ts +9 -7
- package/dist/adt/client.d.ts.map +1 -1
- package/dist/adt/client.js +55 -64
- package/dist/adt/client.js.map +1 -1
- package/dist/adt/config.d.ts +2 -0
- package/dist/adt/config.d.ts.map +1 -1
- package/dist/adt/config.js +1 -0
- package/dist/adt/config.js.map +1 -1
- package/dist/adt/devtools.d.ts +8 -30
- package/dist/adt/devtools.d.ts.map +1 -1
- package/dist/adt/devtools.js +41 -218
- package/dist/adt/devtools.js.map +1 -1
- package/dist/adt/diagnostics.d.ts +1 -1
- package/dist/adt/diagnostics.d.ts.map +1 -1
- package/dist/adt/diagnostics.js +12 -12
- package/dist/adt/diagnostics.js.map +1 -1
- package/dist/adt/errors.d.ts +7 -0
- package/dist/adt/errors.d.ts.map +1 -1
- package/dist/adt/errors.js +7 -0
- package/dist/adt/errors.js.map +1 -1
- package/dist/adt/features.d.ts +9 -3
- package/dist/adt/features.d.ts.map +1 -1
- package/dist/adt/features.js +40 -8
- package/dist/adt/features.js.map +1 -1
- package/dist/adt/gcts.d.ts +12 -45
- package/dist/adt/gcts.d.ts.map +1 -1
- package/dist/adt/gcts.js +299 -171
- package/dist/adt/gcts.js.map +1 -1
- package/dist/adt/http-deadline.d.ts +23 -0
- package/dist/adt/http-deadline.d.ts.map +1 -0
- package/dist/adt/http-deadline.js +106 -0
- package/dist/adt/http-deadline.js.map +1 -0
- package/dist/adt/http-wire-body.d.ts +2 -0
- package/dist/adt/http-wire-body.d.ts.map +1 -0
- package/dist/adt/http-wire-body.js +14 -0
- package/dist/adt/http-wire-body.js.map +1 -0
- package/dist/adt/http.d.ts +10 -24
- package/dist/adt/http.d.ts.map +1 -1
- package/dist/adt/http.js +89 -53
- package/dist/adt/http.js.map +1 -1
- package/dist/adt/path-safety.d.ts +16 -0
- package/dist/adt/path-safety.d.ts.map +1 -0
- package/dist/adt/path-safety.js +101 -0
- package/dist/adt/path-safety.js.map +1 -0
- package/dist/adt/semaphore.d.ts +2 -2
- package/dist/adt/semaphore.d.ts.map +1 -1
- package/dist/adt/semaphore.js +26 -9
- package/dist/adt/semaphore.js.map +1 -1
- package/dist/adt/source-diff.d.ts +10 -1
- package/dist/adt/source-diff.d.ts.map +1 -1
- package/dist/adt/source-diff.js +33 -7
- package/dist/adt/source-diff.js.map +1 -1
- package/dist/adt/text-search.d.ts +25 -0
- package/dist/adt/text-search.d.ts.map +1 -0
- package/dist/adt/text-search.js +152 -0
- package/dist/adt/text-search.js.map +1 -0
- package/dist/adt/transport-diff.d.ts +128 -0
- package/dist/adt/transport-diff.d.ts.map +1 -0
- package/dist/adt/transport-diff.js +524 -0
- package/dist/adt/transport-diff.js.map +1 -0
- package/dist/adt/transport.d.ts +78 -13
- package/dist/adt/transport.d.ts.map +1 -1
- package/dist/adt/transport.js +443 -41
- package/dist/adt/transport.js.map +1 -1
- package/dist/adt/types.d.ts +23 -12
- package/dist/adt/types.d.ts.map +1 -1
- package/dist/adt/xml-parser.d.ts +1 -8
- package/dist/adt/xml-parser.d.ts.map +1 -1
- package/dist/adt/xml-parser.js +54 -60
- package/dist/adt/xml-parser.js.map +1 -1
- package/dist/authz/policy.d.ts.map +1 -1
- package/dist/authz/policy.js +5 -2
- package/dist/authz/policy.js.map +1 -1
- package/dist/cli-args.d.ts +2 -2
- package/dist/cli-args.js +2 -2
- package/dist/cli-checks.d.ts +56 -0
- package/dist/cli-checks.d.ts.map +1 -0
- package/dist/cli-checks.js +368 -0
- package/dist/cli-checks.js.map +1 -0
- package/dist/cli.d.ts +68 -15
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +677 -297
- package/dist/cli.js.map +1 -1
- package/dist/handlers/cds-hints.d.ts.map +1 -1
- package/dist/handlers/cds-hints.js +1 -10
- package/dist/handlers/cds-hints.js.map +1 -1
- package/dist/handlers/diagnose.d.ts +4 -0
- package/dist/handlers/diagnose.d.ts.map +1 -1
- package/dist/handlers/diagnose.js +616 -9
- package/dist/handlers/diagnose.js.map +1 -1
- package/dist/handlers/dispatch.d.ts.map +1 -1
- package/dist/handlers/dispatch.js +40 -1
- package/dist/handlers/dispatch.js.map +1 -1
- package/dist/handlers/git.d.ts.map +1 -1
- package/dist/handlers/git.js +165 -84
- package/dist/handlers/git.js.map +1 -1
- package/dist/handlers/read.d.ts.map +1 -1
- package/dist/handlers/read.js +15 -0
- package/dist/handlers/read.js.map +1 -1
- package/dist/handlers/schemas.d.ts +15 -2
- package/dist/handlers/schemas.d.ts.map +1 -1
- package/dist/handlers/schemas.js +75 -6
- package/dist/handlers/schemas.js.map +1 -1
- package/dist/handlers/search.js +1 -1
- package/dist/handlers/search.js.map +1 -1
- package/dist/handlers/tools.d.ts.map +1 -1
- package/dist/handlers/tools.js +51 -42
- package/dist/handlers/tools.js.map +1 -1
- package/dist/handlers/transport.d.ts +2 -1
- package/dist/handlers/transport.d.ts.map +1 -1
- package/dist/handlers/transport.js +128 -27
- package/dist/handlers/transport.js.map +1 -1
- package/dist/handlers/write/create.d.ts.map +1 -1
- package/dist/handlers/write/create.js +11 -0
- package/dist/handlers/write/create.js.map +1 -1
- package/dist/handlers/write/update-delete.d.ts.map +1 -1
- package/dist/handlers/write/update-delete.js +33 -10
- package/dist/handlers/write/update-delete.js.map +1 -1
- package/dist/handlers/write-helpers.d.ts.map +1 -1
- package/dist/handlers/write-helpers.js +3 -2
- package/dist/handlers/write-helpers.js.map +1 -1
- package/dist/server/audit.d.ts +1 -1
- package/dist/server/audit.d.ts.map +1 -1
- package/dist/server/audit.js +228 -21
- package/dist/server/audit.js.map +1 -1
- package/dist/server/config.d.ts +16 -0
- package/dist/server/config.d.ts.map +1 -1
- package/dist/server/config.js +145 -17
- package/dist/server/config.js.map +1 -1
- package/dist/server/effective-policy-log.d.ts +2 -1
- package/dist/server/effective-policy-log.d.ts.map +1 -1
- package/dist/server/effective-policy-log.js +8 -2
- package/dist/server/effective-policy-log.js.map +1 -1
- package/dist/server/multi-target-runtime.d.ts.map +1 -1
- package/dist/server/multi-target-runtime.js +3 -0
- package/dist/server/multi-target-runtime.js.map +1 -1
- package/dist/server/multi-target-tools.d.ts.map +1 -1
- package/dist/server/multi-target-tools.js +5 -0
- package/dist/server/multi-target-tools.js.map +1 -1
- package/dist/server/server.d.ts +15 -13
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +36 -26
- package/dist/server/server.js.map +1 -1
- package/dist/server/types.d.ts +2 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/types.js +1 -0
- package/dist/server/types.js.map +1 -1
- package/package.json +13 -13
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
/** ABAP Unit result semantics and the public async/JUnit API. */
|
|
2
|
+
import { AdtApiError, AdtNetworkError } from './errors.js';
|
|
3
|
+
import { assertCanonicalHostRelativeAdtPath } from './path-safety.js';
|
|
4
|
+
import { checkOperation, OperationType } from './safety.js';
|
|
5
|
+
import { decodeXmlEntities, escapeXmlAttr, findDeepNodes, getNestedArray, parseXml } from './xml-parser.js';
|
|
6
|
+
export class AunitIncompleteError extends Error {
|
|
7
|
+
evidence;
|
|
8
|
+
constructor(message, evidence, options) {
|
|
9
|
+
super(message, options);
|
|
10
|
+
this.evidence = evidence;
|
|
11
|
+
this.name = 'AunitIncompleteError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export const DEFAULT_PUBLIC_AUNIT_TIMEOUT_MS = 300_000;
|
|
15
|
+
const PUBLIC_RUNS = '/sap/bc/adt/api/abapunit/runs/';
|
|
16
|
+
const PUBLIC_RESULTS = '/sap/bc/adt/api/abapunit/results/';
|
|
17
|
+
const ZERO_RUN_ID = '00000000000000000000000000000000';
|
|
18
|
+
const RUN_STATUS_ACCEPT = 'application/vnd.sap.adt.api.abapunit.run-status.v1+xml';
|
|
19
|
+
const RUN_CONTENT_TYPE = 'application/vnd.sap.adt.api.abapunit.run.v2+xml';
|
|
20
|
+
const JUNIT_ACCEPT = 'application/vnd.sap.adt.api.junit.run-result.v1+xml';
|
|
21
|
+
function nodeArray(value) {
|
|
22
|
+
if (!value || typeof value !== 'object')
|
|
23
|
+
return [];
|
|
24
|
+
return (Array.isArray(value) ? value : [value]).filter((entry) => !!entry && typeof entry === 'object' && !Array.isArray(entry));
|
|
25
|
+
}
|
|
26
|
+
function nodeText(value) {
|
|
27
|
+
if (value == null)
|
|
28
|
+
return '';
|
|
29
|
+
if (typeof value === 'string')
|
|
30
|
+
return value;
|
|
31
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
32
|
+
return String(value['#text'] ?? '');
|
|
33
|
+
}
|
|
34
|
+
return String(value);
|
|
35
|
+
}
|
|
36
|
+
function collectDetails(node) {
|
|
37
|
+
const result = [];
|
|
38
|
+
for (const detail of getNestedArray(node, 'details', 'detail')) {
|
|
39
|
+
const text = String(detail['@_text'] ?? '').trim();
|
|
40
|
+
if (text)
|
|
41
|
+
result.push(decodeXmlEntities(text));
|
|
42
|
+
result.push(...collectDetails(detail));
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
function parseAlert(node, scope, context) {
|
|
47
|
+
const title = decodeXmlEntities(nodeText(node.title)).trim();
|
|
48
|
+
const details = collectDetails(node);
|
|
49
|
+
const message = [title, ...details].filter(Boolean).join(' — ');
|
|
50
|
+
const stack = getNestedArray(node, 'stack', 'stackEntry').map((entry) => ({
|
|
51
|
+
...(entry['@_uri'] ? { uri: String(entry['@_uri']) } : {}),
|
|
52
|
+
...(entry['@_type'] ? { type: String(entry['@_type']) } : {}),
|
|
53
|
+
...(entry['@_name'] ? { name: String(entry['@_name']) } : {}),
|
|
54
|
+
...(entry['@_description'] ? { description: decodeXmlEntities(String(entry['@_description'])) } : {}),
|
|
55
|
+
}));
|
|
56
|
+
return {
|
|
57
|
+
scope,
|
|
58
|
+
...context,
|
|
59
|
+
kind: String(node['@_kind'] ?? ''),
|
|
60
|
+
severity: String(node['@_severity'] ?? ''),
|
|
61
|
+
title,
|
|
62
|
+
details,
|
|
63
|
+
message,
|
|
64
|
+
stack,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function isRiskRefusal(alert) {
|
|
68
|
+
return /risk level.+exceeds|upper limit.+risk|no execution.+risk/i.test(alert.message);
|
|
69
|
+
}
|
|
70
|
+
function isWarning(alert) {
|
|
71
|
+
return alert.kind.toLowerCase() === 'warning' || alert.severity.toLowerCase() === 'tolerable';
|
|
72
|
+
}
|
|
73
|
+
function isInfrastructureError(alert) {
|
|
74
|
+
const kind = alert.kind.toLowerCase();
|
|
75
|
+
if (kind === 'failedassertion')
|
|
76
|
+
return alert.scope !== 'method';
|
|
77
|
+
if (['exception', 'error', 'fatal', 'generationerror'].includes(kind))
|
|
78
|
+
return true;
|
|
79
|
+
return alert.severity.toLowerCase() === 'critical' && !isWarning(alert);
|
|
80
|
+
}
|
|
81
|
+
function methodStatus(alerts) {
|
|
82
|
+
if (alerts.some((alert) => alert.kind.toLowerCase() === 'failedassertion'))
|
|
83
|
+
return 'failed';
|
|
84
|
+
if (alerts.some((alert) => isRiskRefusal(alert) || /skip|abortion|not executed/i.test(alert.kind)))
|
|
85
|
+
return 'skipped';
|
|
86
|
+
if (alerts.some((alert) => isInfrastructureError(alert)))
|
|
87
|
+
return 'error';
|
|
88
|
+
return 'passed';
|
|
89
|
+
}
|
|
90
|
+
function durationMs(method) {
|
|
91
|
+
const raw = Number(method['@_executionTime']);
|
|
92
|
+
if (!Number.isFinite(raw) || raw < 0)
|
|
93
|
+
return undefined;
|
|
94
|
+
const unit = String(method['@_unit'] ?? 's').toLowerCase();
|
|
95
|
+
if (unit === 'ms' || unit === 'millisecond' || unit === 'milliseconds')
|
|
96
|
+
return raw;
|
|
97
|
+
if (unit === 'us' || unit === 'µs')
|
|
98
|
+
return raw / 1000;
|
|
99
|
+
return raw * 1000;
|
|
100
|
+
}
|
|
101
|
+
function skipAbapQuotedLiteral(source, start, delimiter) {
|
|
102
|
+
let index = start + 1;
|
|
103
|
+
while (index < source.length) {
|
|
104
|
+
if (source[index] === delimiter) {
|
|
105
|
+
if (source[index + 1] === delimiter) {
|
|
106
|
+
index += 2;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
return index + 1;
|
|
110
|
+
}
|
|
111
|
+
index += 1;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
/** Skip an ABAP pragma (`##NAME` or `##NAME[...]`) without exposing its payload as code tokens. */
|
|
116
|
+
function skipAbapPragma(source, start) {
|
|
117
|
+
let index = start + 2;
|
|
118
|
+
const nameStart = index;
|
|
119
|
+
while (index < source.length && /[A-Za-z0-9_/$%-]/.test(source[index]))
|
|
120
|
+
index += 1;
|
|
121
|
+
if (index === nameStart)
|
|
122
|
+
return undefined;
|
|
123
|
+
// ABAP permits any number of adjacent parameter groups: ##PRAGMA[one][two].
|
|
124
|
+
while (source[index] === '[') {
|
|
125
|
+
let depth = 1;
|
|
126
|
+
index += 1;
|
|
127
|
+
while (index < source.length && depth > 0) {
|
|
128
|
+
const char = source[index];
|
|
129
|
+
if (char === "'" || char === '`') {
|
|
130
|
+
const afterLiteral = skipAbapQuotedLiteral(source, index, char);
|
|
131
|
+
if (afterLiteral === undefined)
|
|
132
|
+
return undefined;
|
|
133
|
+
index = afterLiteral;
|
|
134
|
+
}
|
|
135
|
+
else if (char === '[') {
|
|
136
|
+
depth += 1;
|
|
137
|
+
index += 1;
|
|
138
|
+
}
|
|
139
|
+
else if (char === ']') {
|
|
140
|
+
depth -= 1;
|
|
141
|
+
index += 1;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
index += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (depth > 0)
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
return index;
|
|
151
|
+
}
|
|
152
|
+
/** Skip one ABAP string template, including nested templates/literals in `{ ... }` expressions. */
|
|
153
|
+
function skipAbapTemplate(source, start) {
|
|
154
|
+
let index = start + 1;
|
|
155
|
+
let expressionDepth = 0;
|
|
156
|
+
while (index < source.length) {
|
|
157
|
+
const char = source[index];
|
|
158
|
+
if (expressionDepth === 0) {
|
|
159
|
+
if (char === '\\') {
|
|
160
|
+
index += 2;
|
|
161
|
+
}
|
|
162
|
+
else if (char === '{') {
|
|
163
|
+
expressionDepth = 1;
|
|
164
|
+
index += 1;
|
|
165
|
+
}
|
|
166
|
+
else if (char === '|') {
|
|
167
|
+
return index + 1;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
index += 1;
|
|
171
|
+
}
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const atColumnZero = index === 0 || source[index - 1] === '\n' || source[index - 1] === '\r';
|
|
175
|
+
if (atColumnZero && char === '*') {
|
|
176
|
+
while (index < source.length && source[index] !== '\n')
|
|
177
|
+
index += 1;
|
|
178
|
+
}
|
|
179
|
+
else if (char === '#' && source[index + 1] === '#') {
|
|
180
|
+
const afterPragma = skipAbapPragma(source, index);
|
|
181
|
+
if (afterPragma === undefined)
|
|
182
|
+
return undefined;
|
|
183
|
+
index = afterPragma;
|
|
184
|
+
}
|
|
185
|
+
else if (char === "'" || char === '`') {
|
|
186
|
+
const afterLiteral = skipAbapQuotedLiteral(source, index, char);
|
|
187
|
+
if (afterLiteral === undefined)
|
|
188
|
+
return undefined;
|
|
189
|
+
index = afterLiteral;
|
|
190
|
+
}
|
|
191
|
+
else if (char === '|') {
|
|
192
|
+
const afterTemplate = skipAbapTemplate(source, index);
|
|
193
|
+
if (afterTemplate === undefined)
|
|
194
|
+
return undefined;
|
|
195
|
+
index = afterTemplate;
|
|
196
|
+
}
|
|
197
|
+
else if (char === '"') {
|
|
198
|
+
while (index < source.length && source[index] !== '\n')
|
|
199
|
+
index += 1;
|
|
200
|
+
}
|
|
201
|
+
else if (char === '{') {
|
|
202
|
+
expressionDepth += 1;
|
|
203
|
+
index += 1;
|
|
204
|
+
}
|
|
205
|
+
else if (char === '}') {
|
|
206
|
+
expressionDepth -= 1;
|
|
207
|
+
index += 1;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
index += 1;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Tokenize complete ABAP statements while discarding comments and literals. This implements only
|
|
217
|
+
* the lexical boundary needed by the source audit; malformed/unterminated source is reported so it
|
|
218
|
+
* cannot silently become verified evidence.
|
|
219
|
+
*/
|
|
220
|
+
function abapStatements(source) {
|
|
221
|
+
const statements = [];
|
|
222
|
+
let tokens = [];
|
|
223
|
+
let index = 0;
|
|
224
|
+
let atColumnZero = true;
|
|
225
|
+
const finishStatement = () => {
|
|
226
|
+
if (tokens.length > 0)
|
|
227
|
+
statements.push(tokens);
|
|
228
|
+
tokens = [];
|
|
229
|
+
};
|
|
230
|
+
while (index < source.length) {
|
|
231
|
+
const char = source[index];
|
|
232
|
+
if (char === '\r' || char === '\n') {
|
|
233
|
+
atColumnZero = true;
|
|
234
|
+
index += 1;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (/\s/.test(char)) {
|
|
238
|
+
atColumnZero = false;
|
|
239
|
+
index += 1;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
if (atColumnZero && char === '*') {
|
|
243
|
+
while (index < source.length && source[index] !== '\n')
|
|
244
|
+
index += 1;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
atColumnZero = false;
|
|
248
|
+
if (char === '"') {
|
|
249
|
+
while (index < source.length && source[index] !== '\n')
|
|
250
|
+
index += 1;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (char === "'" || char === '`') {
|
|
254
|
+
const afterLiteral = skipAbapQuotedLiteral(source, index, char);
|
|
255
|
+
if (afterLiteral === undefined) {
|
|
256
|
+
return { statements, complete: false, reason: 'The inspected ABAP source ended inside a text literal.' };
|
|
257
|
+
}
|
|
258
|
+
index = afterLiteral;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (char === '|') {
|
|
262
|
+
const afterTemplate = skipAbapTemplate(source, index);
|
|
263
|
+
if (afterTemplate === undefined) {
|
|
264
|
+
return { statements, complete: false, reason: 'The inspected ABAP source ended inside a string template.' };
|
|
265
|
+
}
|
|
266
|
+
index = afterTemplate;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (char === '#' && source[index + 1] === '#') {
|
|
270
|
+
const afterPragma = skipAbapPragma(source, index);
|
|
271
|
+
if (afterPragma === undefined) {
|
|
272
|
+
return { statements, complete: false, reason: 'The inspected ABAP source contained a malformed pragma.' };
|
|
273
|
+
}
|
|
274
|
+
index = afterPragma;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (char === '.') {
|
|
278
|
+
finishStatement();
|
|
279
|
+
index += 1;
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
if (/[A-Za-z_/$%]/.test(char)) {
|
|
283
|
+
const start = index;
|
|
284
|
+
index += 1;
|
|
285
|
+
while (index < source.length && /[A-Za-z0-9_/$%-]/.test(source[index]))
|
|
286
|
+
index += 1;
|
|
287
|
+
tokens.push(source.slice(start, index));
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (char === '(' || char === ')' || char === ':' || char === ',') {
|
|
291
|
+
tokens.push(char);
|
|
292
|
+
index += 1;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
index += 1;
|
|
296
|
+
}
|
|
297
|
+
if (tokens.length > 0) {
|
|
298
|
+
return { statements, complete: false, reason: 'The inspected ABAP source ended inside a statement.' };
|
|
299
|
+
}
|
|
300
|
+
return { statements, complete: true };
|
|
301
|
+
}
|
|
302
|
+
function adjacentTokens(tokens, first, second, start = 0) {
|
|
303
|
+
for (let index = start; index < tokens.length - 1; index += 1) {
|
|
304
|
+
if (tokens[index] === first && tokens[index + 1] === second)
|
|
305
|
+
return index;
|
|
306
|
+
}
|
|
307
|
+
return -1;
|
|
308
|
+
}
|
|
309
|
+
const GLOBAL_AUNIT_NON_TEST_METHODS = new Set([
|
|
310
|
+
'CLASS_CONSTRUCTOR',
|
|
311
|
+
'CLASS_SETUP',
|
|
312
|
+
'CLASS_TEARDOWN',
|
|
313
|
+
'CONSTRUCTOR',
|
|
314
|
+
'SETUP',
|
|
315
|
+
'TEARDOWN',
|
|
316
|
+
]);
|
|
317
|
+
function testMethodsInDefinition(statements, globalTestClass) {
|
|
318
|
+
const methods = new Set();
|
|
319
|
+
for (const statement of statements) {
|
|
320
|
+
const upper = statement.map((token) => token.toUpperCase());
|
|
321
|
+
if (upper[0] !== 'METHODS' && upper[0] !== 'CLASS-METHODS')
|
|
322
|
+
continue;
|
|
323
|
+
const segments = [[]];
|
|
324
|
+
for (const token of statement.slice(1)) {
|
|
325
|
+
if (token === ',')
|
|
326
|
+
segments.push([]);
|
|
327
|
+
else if (token !== ':')
|
|
328
|
+
segments.at(-1).push(token);
|
|
329
|
+
}
|
|
330
|
+
for (const segment of segments) {
|
|
331
|
+
const segmentUpper = segment.map((token) => token.toUpperCase());
|
|
332
|
+
const method = segmentUpper[0];
|
|
333
|
+
if (!method)
|
|
334
|
+
continue;
|
|
335
|
+
const explicitLocalTest = adjacentTokens(segmentUpper, 'FOR', 'TESTING') >= 0;
|
|
336
|
+
const implicitGlobalTest = globalTestClass && upper[0] === 'METHODS' && !GLOBAL_AUNIT_NON_TEST_METHODS.has(method);
|
|
337
|
+
if (explicitLocalTest || implicitGlobalTest)
|
|
338
|
+
methods.add(method);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return [...methods];
|
|
342
|
+
}
|
|
343
|
+
function inspectSourceDeclaredAunitClasses(source) {
|
|
344
|
+
const scan = abapStatements(source);
|
|
345
|
+
const macroNames = new Set(scan.statements
|
|
346
|
+
.filter((statement) => statement[0]?.toUpperCase() === 'DEFINE' && statement[1])
|
|
347
|
+
.map((statement) => statement[1].toUpperCase()));
|
|
348
|
+
const candidates = new Map();
|
|
349
|
+
let complete = scan.complete;
|
|
350
|
+
let reason = scan.reason;
|
|
351
|
+
for (let index = 0; index < scan.statements.length; index += 1) {
|
|
352
|
+
const statement = scan.statements[index];
|
|
353
|
+
const upper = statement.map((token) => token.toUpperCase());
|
|
354
|
+
if (upper[0] !== 'CLASS' || !statement[1] || upper[2] !== 'DEFINITION' || upper.includes('DEFERRED')) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
if (adjacentTokens(upper, 'FOR', 'TESTING', 3) < 0)
|
|
358
|
+
continue;
|
|
359
|
+
let end = index + 1;
|
|
360
|
+
while (end < scan.statements.length && scan.statements[end]?.[0]?.toUpperCase() !== 'ENDCLASS')
|
|
361
|
+
end += 1;
|
|
362
|
+
if (end >= scan.statements.length) {
|
|
363
|
+
complete = false;
|
|
364
|
+
reason = `The FOR TESTING definition of ${statement[1].toUpperCase()} had no matching ENDCLASS.`;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
const risk = adjacentTokens(upper, 'RISK', 'LEVEL', 3);
|
|
368
|
+
const declaredRisk = risk >= 0 ? upper[risk + 2] : undefined;
|
|
369
|
+
// SAP Learning's "Implementing Code Tests with ABAP Unit" documents CRITICAL as the
|
|
370
|
+
// default when RISK LEVEL is omitted.
|
|
371
|
+
const riskLevel = declaredRisk === 'HARMLESS' || declaredRisk === 'DANGEROUS'
|
|
372
|
+
? declaredRisk.toLowerCase()
|
|
373
|
+
: 'critical';
|
|
374
|
+
const inheritance = adjacentTokens(upper, 'INHERITING', 'FROM', 3);
|
|
375
|
+
const testClass = statement[1].toUpperCase();
|
|
376
|
+
// `PUBLIC` immediately after DEFINITION identifies a global test class. Its instance methods
|
|
377
|
+
// are implicit ABAP Unit methods even without a method-level FOR TESTING addition.
|
|
378
|
+
const globalTestClass = upper[3] === 'PUBLIC';
|
|
379
|
+
const definitionStatements = scan.statements.slice(index + 1, end);
|
|
380
|
+
const macroInvocation = definitionStatements.find((tokens) => macroNames.has(tokens[0]?.toUpperCase() ?? ''));
|
|
381
|
+
if (macroInvocation) {
|
|
382
|
+
complete = false;
|
|
383
|
+
reason ??= `The FOR TESTING definition of ${testClass} invokes ABAP macro ${macroInvocation[0].toUpperCase()}; expanded test methods could not be verified.`;
|
|
384
|
+
}
|
|
385
|
+
candidates.set(testClass, {
|
|
386
|
+
testClass,
|
|
387
|
+
riskLevel,
|
|
388
|
+
explicitRiskLevel: declaredRisk === 'HARMLESS' || declaredRisk === 'DANGEROUS' || declaredRisk === 'CRITICAL',
|
|
389
|
+
abstract: upper.includes('ABSTRACT'),
|
|
390
|
+
...(inheritance >= 0 && upper[inheritance + 2] ? { baseClass: upper[inheritance + 2] } : {}),
|
|
391
|
+
globalTestClass,
|
|
392
|
+
ownTestMethods: testMethodsInDefinition(definitionStatements, globalTestClass),
|
|
393
|
+
});
|
|
394
|
+
index = end;
|
|
395
|
+
}
|
|
396
|
+
const riskRank = { harmless: 0, dangerous: 1, critical: 2 };
|
|
397
|
+
const resolve = (candidate, seen = new Set()) => {
|
|
398
|
+
if (seen.has(candidate.testClass)) {
|
|
399
|
+
return { hasTests: false, riskLevel: candidate.riskLevel, unknownBase: candidate.testClass };
|
|
400
|
+
}
|
|
401
|
+
seen.add(candidate.testClass);
|
|
402
|
+
const base = candidate.baseClass ? candidates.get(candidate.baseClass) : undefined;
|
|
403
|
+
const inherited = base ? resolve(base, seen) : undefined;
|
|
404
|
+
return {
|
|
405
|
+
hasTests: candidate.ownTestMethods.length > 0 || inherited?.hasTests === true,
|
|
406
|
+
riskLevel: inherited && riskRank[inherited.riskLevel] > riskRank[candidate.riskLevel]
|
|
407
|
+
? inherited.riskLevel
|
|
408
|
+
: candidate.riskLevel,
|
|
409
|
+
...(candidate.baseClass && !base
|
|
410
|
+
? { unknownBase: candidate.baseClass }
|
|
411
|
+
: inherited?.unknownBase
|
|
412
|
+
? { unknownBase: inherited.unknownBase }
|
|
413
|
+
: {}),
|
|
414
|
+
};
|
|
415
|
+
};
|
|
416
|
+
const testClasses = [];
|
|
417
|
+
for (const candidate of candidates.values()) {
|
|
418
|
+
const resolved = resolve(candidate);
|
|
419
|
+
if (!candidate.abstract && !resolved.hasTests && resolved.unknownBase) {
|
|
420
|
+
complete = false;
|
|
421
|
+
reason ??= `The concrete FOR TESTING class ${candidate.testClass} inherits from source-external ${resolved.unknownBase}; inherited ABAP Unit methods could not be verified.`;
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
// FOR TESTING is also valid for test doubles/helpers. Only concrete classes with a direct or
|
|
425
|
+
// source-visible inherited test method declaration are executable test classes. A global test
|
|
426
|
+
// class's ordinary instance methods are implicit ABAP Unit methods.
|
|
427
|
+
if (!candidate.abstract && resolved.hasTests) {
|
|
428
|
+
testClasses.push({
|
|
429
|
+
testClass: candidate.testClass,
|
|
430
|
+
riskLevel: resolved.riskLevel,
|
|
431
|
+
explicitRiskLevel: candidate.explicitRiskLevel,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return { testClasses, complete, ...(reason ? { reason } : {}) };
|
|
436
|
+
}
|
|
437
|
+
/** Find executable, non-deferred ABAP Unit class definitions without matching comments/literals. */
|
|
438
|
+
export function findSourceDeclaredAunitClasses(source) {
|
|
439
|
+
return inspectSourceDeclaredAunitClasses(source).testClasses;
|
|
440
|
+
}
|
|
441
|
+
/** Static INCLUDE names used to expand program source before auditing test declarations. */
|
|
442
|
+
export function findStaticAbapIncludes(source) {
|
|
443
|
+
const names = new Set();
|
|
444
|
+
for (const statement of abapStatements(source).statements) {
|
|
445
|
+
if (statement[0]?.toUpperCase() !== 'INCLUDE' || !statement[1] || statement[2] === ')')
|
|
446
|
+
continue;
|
|
447
|
+
const name = statement[1].toUpperCase();
|
|
448
|
+
if (name === '(' || name === 'TYPE' || name === 'STRUCTURE')
|
|
449
|
+
continue;
|
|
450
|
+
const trailing = statement.slice(2).map((token) => token.toUpperCase());
|
|
451
|
+
if (trailing.length > 0 && !(trailing.length === 2 && trailing[0] === 'IF' && trailing[1] === 'FOUND'))
|
|
452
|
+
continue;
|
|
453
|
+
if (/^(?:[A-Z0-9_$%]+|\/[A-Z0-9_$%]+\/[A-Z0-9_$%]+)$/.test(name))
|
|
454
|
+
names.add(name);
|
|
455
|
+
}
|
|
456
|
+
return [...names];
|
|
457
|
+
}
|
|
458
|
+
function sourceClassKey(program, testClass, qualifyProgram) {
|
|
459
|
+
return `${qualifyProgram ? (program ?? '').toUpperCase() : ''}\u0000${testClass.toUpperCase()}`;
|
|
460
|
+
}
|
|
461
|
+
function reconcileAunitDeclaredClasses(result, declaredTestClasses, sourceWasComplete, sourceIncompleteReason, qualifyProgram) {
|
|
462
|
+
const observedRows = [
|
|
463
|
+
...result.tests.map((test) => ({ program: test.program, testClass: test.testClass })),
|
|
464
|
+
...result.alerts
|
|
465
|
+
.filter((alert) => alert.testClass)
|
|
466
|
+
.map((alert) => ({ program: alert.program ?? '', testClass: alert.testClass ?? '' })),
|
|
467
|
+
];
|
|
468
|
+
const observedClasses = new Map(observedRows.map((row) => [sourceClassKey(row.program, row.testClass, qualifyProgram), row]));
|
|
469
|
+
const omittedTestClasses = declaredTestClasses.filter((testClass) => !observedClasses.has(sourceClassKey(testClass.program, testClass.testClass, qualifyProgram)));
|
|
470
|
+
const omittedNonHarmlessTestClasses = omittedTestClasses.filter((testClass) => testClass.riskLevel !== 'harmless');
|
|
471
|
+
const declaredNames = new Set(declaredTestClasses.map((testClass) => sourceClassKey(testClass.program, testClass.testClass, qualifyProgram)));
|
|
472
|
+
const unverifiedObservedClasses = [...observedClasses.entries()]
|
|
473
|
+
.filter(([key]) => !declaredNames.has(key))
|
|
474
|
+
.map(([, row]) => `${qualifyProgram ? `${row.program}:` : ''}${row.testClass}`);
|
|
475
|
+
const resultNeedsDeclarations = result.summary.tests > 0 && unverifiedObservedClasses.length > 0;
|
|
476
|
+
const status = sourceWasComplete && !resultNeedsDeclarations ? 'verified' : 'unavailable';
|
|
477
|
+
const reason = !sourceWasComplete
|
|
478
|
+
? (sourceIncompleteReason ?? 'One or more ABAP source blocks could not be inspected.')
|
|
479
|
+
: resultNeedsDeclarations
|
|
480
|
+
? `SAP reported class evidence that could not be matched to an executable source declaration: ${unverifiedObservedClasses.join(', ')}.`
|
|
481
|
+
: undefined;
|
|
482
|
+
const sourceSelectionEvidence = {
|
|
483
|
+
status,
|
|
484
|
+
declaredTestClasses,
|
|
485
|
+
omittedTestClasses,
|
|
486
|
+
omittedNonHarmlessTestClasses,
|
|
487
|
+
...(reason ? { reason } : {}),
|
|
488
|
+
};
|
|
489
|
+
const additions = omittedNonHarmlessTestClasses.map((testClass) => ({
|
|
490
|
+
scope: 'class',
|
|
491
|
+
...(testClass.program ? { program: testClass.program } : {}),
|
|
492
|
+
testClass: testClass.testClass,
|
|
493
|
+
kind: 'sourceRiskSelection',
|
|
494
|
+
severity: 'tolerable',
|
|
495
|
+
title: 'Source-declared non-harmless test class was omitted from SAP results',
|
|
496
|
+
details: [`Declared risk level: ${testClass.riskLevel}`],
|
|
497
|
+
message: `Source declares ${testClass.testClass} FOR TESTING RISK LEVEL ${testClass.riskLevel.toUpperCase()}, but SAP's harmless-only result contained no class or method evidence for it.`,
|
|
498
|
+
stack: [],
|
|
499
|
+
}));
|
|
500
|
+
for (const testClass of omittedTestClasses) {
|
|
501
|
+
if (testClass.riskLevel !== 'harmless')
|
|
502
|
+
continue;
|
|
503
|
+
additions.push({
|
|
504
|
+
scope: 'class',
|
|
505
|
+
...(testClass.program ? { program: testClass.program } : {}),
|
|
506
|
+
testClass: testClass.testClass,
|
|
507
|
+
kind: 'sourceTestOmission',
|
|
508
|
+
severity: 'tolerable',
|
|
509
|
+
title: 'Source-declared harmless test class was omitted from SAP results',
|
|
510
|
+
details: ['Declared risk level: harmless'],
|
|
511
|
+
message: `Source declares executable harmless test class ${testClass.testClass}, but SAP returned no class or method evidence for it.`,
|
|
512
|
+
stack: [],
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
for (const testClass of declaredTestClasses) {
|
|
516
|
+
if (testClass.riskLevel === 'harmless' ||
|
|
517
|
+
!observedClasses.has(sourceClassKey(testClass.program, testClass.testClass, qualifyProgram)))
|
|
518
|
+
continue;
|
|
519
|
+
additions.push({
|
|
520
|
+
scope: 'class',
|
|
521
|
+
...(testClass.program ? { program: testClass.program } : {}),
|
|
522
|
+
testClass: testClass.testClass,
|
|
523
|
+
kind: 'sourceRiskOutsideSelection',
|
|
524
|
+
severity: 'tolerable',
|
|
525
|
+
title: 'Non-harmless source declaration is outside the requested test selection',
|
|
526
|
+
details: [`Declared risk level: ${testClass.riskLevel}`],
|
|
527
|
+
message: `Source declares ${testClass.testClass} with risk level ${testClass.riskLevel}, so a harmless-only run cannot certify the complete suite even though SAP returned class evidence.`,
|
|
528
|
+
stack: [],
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
if (status === 'unavailable') {
|
|
532
|
+
additions.push({
|
|
533
|
+
scope: 'run',
|
|
534
|
+
kind: 'sourceAuditUnavailable',
|
|
535
|
+
severity: 'tolerable',
|
|
536
|
+
title: 'ABAP Unit source-selection audit was incomplete',
|
|
537
|
+
details: reason ? [reason] : [],
|
|
538
|
+
message: reason ?? 'ARC-1 could not inspect the complete ABAP source used by this test run.',
|
|
539
|
+
stack: [],
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
if (additions.length === 0)
|
|
543
|
+
return { ...result, sourceSelectionEvidence };
|
|
544
|
+
return {
|
|
545
|
+
...result,
|
|
546
|
+
outcome: result.outcome === 'failed' ? 'failed' : 'incomplete',
|
|
547
|
+
summary: { ...result.summary, warnings: result.summary.warnings + additions.filter(isWarning).length },
|
|
548
|
+
alerts: [...result.alerts, ...additions],
|
|
549
|
+
sourceSelectionEvidence,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Reconcile SAP's harmless-only result with test classes declared in one inspected source tree.
|
|
554
|
+
* SAP 7.58 can omit a dangerous/critical class from both result formats without an alert; source
|
|
555
|
+
* evidence closes that false-green while ordinary and deferred helper classes remain ignored.
|
|
556
|
+
*/
|
|
557
|
+
export function reconcileAunitSourceDeclarations(result, source, options = {}) {
|
|
558
|
+
const declarationScan = inspectSourceDeclaredAunitClasses(source);
|
|
559
|
+
const complete = options.complete !== false && declarationScan.complete;
|
|
560
|
+
const reason = options.complete === false
|
|
561
|
+
? options.incompleteReason
|
|
562
|
+
: declarationScan.complete
|
|
563
|
+
? undefined
|
|
564
|
+
: declarationScan.reason;
|
|
565
|
+
return reconcileAunitDeclaredClasses(result, declarationScan.testClasses, complete, reason, false);
|
|
566
|
+
}
|
|
567
|
+
/** Reconcile a multi-program package run without conflating common local names such as LTCL_TEST. */
|
|
568
|
+
export function reconcileAunitProgramSources(result, sources, options = {}) {
|
|
569
|
+
const declaredTestClasses = [];
|
|
570
|
+
let complete = options.complete !== false;
|
|
571
|
+
let reason = options.incompleteReason;
|
|
572
|
+
for (const source of sources) {
|
|
573
|
+
const declarationScan = inspectSourceDeclaredAunitClasses(source.source);
|
|
574
|
+
declaredTestClasses.push(...declarationScan.testClasses.map((testClass) => ({
|
|
575
|
+
...testClass,
|
|
576
|
+
program: source.program.toUpperCase(),
|
|
577
|
+
})));
|
|
578
|
+
if (!declarationScan.complete) {
|
|
579
|
+
complete = false;
|
|
580
|
+
reason ??= declarationScan.reason ?? `The ABAP source declaration scan for ${source.program} was incomplete.`;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return reconcileAunitDeclaredClasses(result, declaredTestClasses, complete, reason, true);
|
|
584
|
+
}
|
|
585
|
+
/** Parse legacy `/abapunit/testruns` XML without turning non-method alerts into fake tests. */
|
|
586
|
+
export function parseAunitRunResult(xml) {
|
|
587
|
+
const parsed = parseXml(xml);
|
|
588
|
+
const runResult = (parsed.runResult ?? {});
|
|
589
|
+
const tests = [];
|
|
590
|
+
const alerts = [];
|
|
591
|
+
for (const alert of getNestedArray(runResult, 'alerts', 'alert')) {
|
|
592
|
+
alerts.push(parseAlert(alert, 'run', {}));
|
|
593
|
+
}
|
|
594
|
+
for (const program of nodeArray(runResult.program)) {
|
|
595
|
+
const programName = String(program['@_name'] ?? '').trim();
|
|
596
|
+
if (!programName) {
|
|
597
|
+
alerts.push({
|
|
598
|
+
scope: 'program',
|
|
599
|
+
kind: 'malformedResult',
|
|
600
|
+
severity: 'tolerable',
|
|
601
|
+
title: 'ABAP Unit program result had no identity',
|
|
602
|
+
details: [],
|
|
603
|
+
message: 'SAP returned an ABAP Unit program node without a program name.',
|
|
604
|
+
stack: [],
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
for (const alert of getNestedArray(program, 'alerts', 'alert')) {
|
|
608
|
+
alerts.push(parseAlert(alert, 'program', { program: programName }));
|
|
609
|
+
}
|
|
610
|
+
for (const testClass of getNestedArray(program, 'testClasses', 'testClass')) {
|
|
611
|
+
const testClassName = String(testClass['@_name'] ?? '').trim();
|
|
612
|
+
if (!testClassName) {
|
|
613
|
+
alerts.push({
|
|
614
|
+
scope: 'program',
|
|
615
|
+
...(programName ? { program: programName } : {}),
|
|
616
|
+
kind: 'malformedResult',
|
|
617
|
+
severity: 'tolerable',
|
|
618
|
+
title: 'ABAP Unit class result had no identity',
|
|
619
|
+
details: [],
|
|
620
|
+
message: 'SAP returned an ABAP Unit test-class node without a class name.',
|
|
621
|
+
stack: [],
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
const riskLevel = String(testClass['@_riskLevel'] ?? '') || undefined;
|
|
625
|
+
const durationCategory = String(testClass['@_durationCategory'] ?? '') || undefined;
|
|
626
|
+
const classAlerts = getNestedArray(testClass, 'alerts', 'alert').map((alert) => parseAlert(alert, 'class', { program: programName, testClass: testClassName }));
|
|
627
|
+
alerts.push(...classAlerts);
|
|
628
|
+
const methods = getNestedArray(testClass, 'testMethods', 'testMethod');
|
|
629
|
+
if (methods.length === 0 && !classAlerts.some(isInfrastructureError)) {
|
|
630
|
+
const riskEvidence = riskLevel ? ` (reported risk level: ${riskLevel})` : '';
|
|
631
|
+
alerts.push({
|
|
632
|
+
scope: 'class',
|
|
633
|
+
program: programName,
|
|
634
|
+
testClass: testClassName,
|
|
635
|
+
kind: 'emptyClass',
|
|
636
|
+
severity: 'tolerable',
|
|
637
|
+
title: 'Test class reported no executed test methods',
|
|
638
|
+
details: riskLevel ? [`Reported risk level: ${riskLevel}`] : [],
|
|
639
|
+
message: `Test class reported no executed test methods${riskEvidence}`,
|
|
640
|
+
stack: [],
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
for (const method of methods) {
|
|
644
|
+
const methodName = String(method['@_name'] ?? '').trim();
|
|
645
|
+
if (!programName || !testClassName || !methodName) {
|
|
646
|
+
if (!methodName) {
|
|
647
|
+
alerts.push({
|
|
648
|
+
scope: 'class',
|
|
649
|
+
...(programName ? { program: programName } : {}),
|
|
650
|
+
...(testClassName ? { testClass: testClassName } : {}),
|
|
651
|
+
kind: 'malformedResult',
|
|
652
|
+
severity: 'tolerable',
|
|
653
|
+
title: 'ABAP Unit method result had no identity',
|
|
654
|
+
details: [],
|
|
655
|
+
message: 'SAP returned an ABAP Unit test-method node without a method name.',
|
|
656
|
+
stack: [],
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
const methodAlerts = getNestedArray(method, 'alerts', 'alert').map((alert) => parseAlert(alert, 'method', {
|
|
662
|
+
program: programName,
|
|
663
|
+
testClass: testClassName,
|
|
664
|
+
testMethod: methodName,
|
|
665
|
+
}));
|
|
666
|
+
alerts.push(...methodAlerts);
|
|
667
|
+
tests.push({
|
|
668
|
+
program: programName,
|
|
669
|
+
testClass: testClassName,
|
|
670
|
+
testMethod: methodName,
|
|
671
|
+
status: methodStatus(methodAlerts),
|
|
672
|
+
...(durationMs(method) !== undefined ? { durationMs: durationMs(method) } : {}),
|
|
673
|
+
...(riskLevel ? { riskLevel } : {}),
|
|
674
|
+
...(durationCategory ? { durationCategory } : {}),
|
|
675
|
+
alerts: methodAlerts,
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
const elevatedExecutions = new Map();
|
|
681
|
+
for (const test of tests) {
|
|
682
|
+
if (test.riskLevel && test.riskLevel.toLowerCase() !== 'harmless') {
|
|
683
|
+
elevatedExecutions.set(`${test.program}:${test.testClass}`, test);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
for (const test of elevatedExecutions.values()) {
|
|
687
|
+
alerts.push({
|
|
688
|
+
scope: 'class',
|
|
689
|
+
program: test.program,
|
|
690
|
+
testClass: test.testClass,
|
|
691
|
+
kind: 'riskSelectionViolation',
|
|
692
|
+
severity: 'tolerable',
|
|
693
|
+
title: 'SAP executed a test class above the requested harmless risk level',
|
|
694
|
+
details: [`Reported risk level: ${test.riskLevel}`],
|
|
695
|
+
message: `SAP reported executed methods for ${test.testClass} at risk level ${test.riskLevel}; a harmless-only run cannot be certified complete.`,
|
|
696
|
+
stack: [],
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
const summary = {
|
|
700
|
+
tests: tests.length,
|
|
701
|
+
passed: tests.filter((test) => test.status === 'passed').length,
|
|
702
|
+
failures: tests.filter((test) => test.status === 'failed').length,
|
|
703
|
+
errors: tests.filter((test) => test.status === 'error').length +
|
|
704
|
+
alerts.filter((alert) => alert.scope !== 'method' && isInfrastructureError(alert)).length,
|
|
705
|
+
skipped: tests.filter((test) => test.status === 'skipped').length,
|
|
706
|
+
warnings: alerts.filter(isWarning).length,
|
|
707
|
+
};
|
|
708
|
+
const hasIncompleteEvidence = alerts.some((alert) => isRiskRefusal(alert) ||
|
|
709
|
+
alert.kind === 'emptyClass' ||
|
|
710
|
+
alert.kind === 'malformedResult' ||
|
|
711
|
+
alert.kind === 'riskSelectionViolation' ||
|
|
712
|
+
/not executed|abortion|skip/i.test(alert.message));
|
|
713
|
+
let outcome;
|
|
714
|
+
if (summary.failures > 0 || summary.errors > 0)
|
|
715
|
+
outcome = 'failed';
|
|
716
|
+
else if (summary.tests === 0)
|
|
717
|
+
outcome = alerts.length === 0 ? 'no_tests' : 'incomplete';
|
|
718
|
+
else if (summary.skipped === summary.tests || hasIncompleteEvidence)
|
|
719
|
+
outcome = 'incomplete';
|
|
720
|
+
else
|
|
721
|
+
outcome = 'passed';
|
|
722
|
+
return {
|
|
723
|
+
outcome,
|
|
724
|
+
summary,
|
|
725
|
+
selection: { maxRisk: 'harmless', durations: ['short', 'medium', 'long'] },
|
|
726
|
+
tests,
|
|
727
|
+
alerts,
|
|
728
|
+
coverageEvidence: 'not_requested',
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
export function withAunitCoverage(result, coverage, unavailableReason) {
|
|
732
|
+
return {
|
|
733
|
+
...result,
|
|
734
|
+
...(coverage ? { coverage } : {}),
|
|
735
|
+
coverageEvidence: coverage ? 'available' : 'unavailable',
|
|
736
|
+
...(!coverage && unavailableReason ? { coverageUnavailableReason: unavailableReason } : {}),
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
/** Generate JUnit from the corrected legacy model. Non-method alerts stay in system-err. */
|
|
740
|
+
export function aunitResultToJunit(result, suiteName = 'ABAP Unit') {
|
|
741
|
+
const cases = result.tests
|
|
742
|
+
.map((test) => {
|
|
743
|
+
const attrs = `classname="${escapeXmlAttr(`${test.program}.${test.testClass}`)}" name="${escapeXmlAttr(test.testMethod)}" time="${((test.durationMs ?? 0) / 1000).toFixed(3)}"`;
|
|
744
|
+
const message = test.alerts
|
|
745
|
+
.map((alert) => alert.message)
|
|
746
|
+
.filter(Boolean)
|
|
747
|
+
.join('\n');
|
|
748
|
+
if (test.status === 'failed') {
|
|
749
|
+
return `<testcase ${attrs}><failure type="Assert Failure" message="${escapeXmlAttr(message)}">${escapeXmlAttr(message)}</failure></testcase>`;
|
|
750
|
+
}
|
|
751
|
+
if (test.status === 'error') {
|
|
752
|
+
return `<testcase ${attrs}><error type="ABAP Unit Error" message="${escapeXmlAttr(message)}">${escapeXmlAttr(message)}</error></testcase>`;
|
|
753
|
+
}
|
|
754
|
+
if (test.status === 'skipped') {
|
|
755
|
+
return `<testcase ${attrs}><skipped message="${escapeXmlAttr(message || 'Skipped by SAP')}"/></testcase>`;
|
|
756
|
+
}
|
|
757
|
+
return `<testcase ${attrs}/>`;
|
|
758
|
+
})
|
|
759
|
+
.join('');
|
|
760
|
+
const nonMethodAlerts = result.alerts
|
|
761
|
+
.filter((alert) => alert.scope !== 'method')
|
|
762
|
+
.map((alert) => {
|
|
763
|
+
const location = [alert.program, alert.testClass, alert.testMethod].filter(Boolean).join('.');
|
|
764
|
+
return `[${alert.scope}/${alert.severity}/${alert.kind}${location ? ` ${location}` : ''}] ${alert.message}`;
|
|
765
|
+
})
|
|
766
|
+
.join('\n');
|
|
767
|
+
const time = result.tests.reduce((sum, test) => sum + (test.durationMs ?? 0), 0) / 1000;
|
|
768
|
+
const junit = `<?xml version="1.0" encoding="UTF-8"?>` +
|
|
769
|
+
`<testsuites name="${escapeXmlAttr(suiteName)}" tests="${result.summary.tests}" failures="${result.summary.failures}" errors="${result.summary.errors}" skipped="${result.summary.skipped}" time="${time.toFixed(3)}">` +
|
|
770
|
+
`<testsuite name="${escapeXmlAttr(suiteName)}" tests="${result.summary.tests}" failures="${result.summary.failures}" errors="${result.summary.errors}" skipped="${result.summary.skipped}" time="${time.toFixed(3)}">` +
|
|
771
|
+
cases +
|
|
772
|
+
(nonMethodAlerts ? `<system-err>${escapeXmlAttr(nonMethodAlerts)}</system-err>` : '') +
|
|
773
|
+
`</testsuite></testsuites>`;
|
|
774
|
+
if (result.outcome !== 'incomplete')
|
|
775
|
+
return junit;
|
|
776
|
+
return appendAunitJunitDiagnostic(junit, {
|
|
777
|
+
tests: result.summary.tests,
|
|
778
|
+
failures: result.summary.failures,
|
|
779
|
+
errors: result.summary.errors,
|
|
780
|
+
skipped: result.summary.skipped,
|
|
781
|
+
outcome: result.outcome,
|
|
782
|
+
}, 'incomplete', 'ARC-1 could not verify a complete harmless ABAP Unit run. See the preserved SAP alerts for details.');
|
|
783
|
+
}
|
|
784
|
+
export function aunitIncompleteToJunit(message, suiteName = 'ABAP Unit') {
|
|
785
|
+
const name = escapeXmlAttr(suiteName);
|
|
786
|
+
const junit = `<?xml version="1.0" encoding="UTF-8"?>` +
|
|
787
|
+
`<testsuites name="${name}" tests="0" failures="0" errors="0" skipped="0">` +
|
|
788
|
+
`<testsuite name="${name}" tests="0" failures="0" errors="0" skipped="0">` +
|
|
789
|
+
`<system-err>${escapeXmlAttr(message)}</system-err></testsuite></testsuites>`;
|
|
790
|
+
return appendAunitJunitDiagnostic(junit, { tests: 0, failures: 0, errors: 0, skipped: 0, outcome: 'incomplete' }, 'incomplete', message);
|
|
791
|
+
}
|
|
792
|
+
function requiredCountAttr(node, name) {
|
|
793
|
+
const raw = node[`@_${name}`];
|
|
794
|
+
const parsed = typeof raw === 'string' && raw.trim() === '' ? Number.NaN : Number(raw);
|
|
795
|
+
if (raw == null || !Number.isSafeInteger(parsed) || parsed < 0) {
|
|
796
|
+
throw new Error(`ABAP Unit public API returned an invalid JUnit ${name} count.`);
|
|
797
|
+
}
|
|
798
|
+
return parsed;
|
|
799
|
+
}
|
|
800
|
+
export function parseNativeJunitSummary(xml) {
|
|
801
|
+
const root = parseXml(xml).testsuites;
|
|
802
|
+
if (!root || typeof root !== 'object' || Array.isArray(root)) {
|
|
803
|
+
throw new Error('ABAP Unit public API returned a non-JUnit result.');
|
|
804
|
+
}
|
|
805
|
+
const node = root;
|
|
806
|
+
const tests = requiredCountAttr(node, 'tests');
|
|
807
|
+
const failures = requiredCountAttr(node, 'failures');
|
|
808
|
+
const errors = requiredCountAttr(node, 'errors');
|
|
809
|
+
const skipped = requiredCountAttr(node, 'skipped');
|
|
810
|
+
if (failures + errors + skipped > tests) {
|
|
811
|
+
throw new Error('ABAP Unit public API returned inconsistent JUnit counters.');
|
|
812
|
+
}
|
|
813
|
+
const outcome = failures > 0 || errors > 0 ? 'failed' : tests === 0 || skipped === tests ? 'incomplete' : 'passed';
|
|
814
|
+
return { tests, failures, errors, skipped, outcome };
|
|
815
|
+
}
|
|
816
|
+
function setJunitCountAttribute(openingTag, name, value) {
|
|
817
|
+
const attribute = new RegExp(`(\\s${name}\\s*=\\s*)(["'])([^"']*)\\2`, 'i');
|
|
818
|
+
if (attribute.test(openingTag)) {
|
|
819
|
+
return openingTag.replace(attribute, (_match, prefix, quote) => {
|
|
820
|
+
return `${prefix}${quote}${value}${quote}`;
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
const closing = openingTag.match(/\s*\/?\s*>$/)?.[0];
|
|
824
|
+
if (!closing)
|
|
825
|
+
throw new Error('ABAP Unit public API returned a malformed JUnit root element.');
|
|
826
|
+
return `${openingTag.slice(0, -closing.length)} ${name}="${value}"${closing}`;
|
|
827
|
+
}
|
|
828
|
+
function escapedRegExp(value) {
|
|
829
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
830
|
+
}
|
|
831
|
+
/** Preserve SAP-native suites and append one red reconciliation testcase with truthful root counters. */
|
|
832
|
+
export function appendAunitJunitDiagnostic(junit, summary, outcome, message) {
|
|
833
|
+
const openingMatch = /<((?:[A-Za-z_][\w.-]*:)?testsuites)\b[^>]*?(?:\/\s*>|>)/i.exec(junit);
|
|
834
|
+
if (!openingMatch || openingMatch.index === undefined) {
|
|
835
|
+
throw new Error('ABAP Unit public API returned a non-JUnit result.');
|
|
836
|
+
}
|
|
837
|
+
const tagName = openingMatch[1];
|
|
838
|
+
const originalOpening = openingMatch[0];
|
|
839
|
+
const selfClosing = /\/\s*>$/.test(originalOpening);
|
|
840
|
+
const failureIncrement = outcome === 'failed' ? 1 : 0;
|
|
841
|
+
const errorIncrement = outcome === 'incomplete' ? 1 : 0;
|
|
842
|
+
let opening = originalOpening;
|
|
843
|
+
opening = setJunitCountAttribute(opening, 'tests', summary.tests + 1);
|
|
844
|
+
opening = setJunitCountAttribute(opening, 'failures', summary.failures + failureIncrement);
|
|
845
|
+
opening = setJunitCountAttribute(opening, 'errors', summary.errors + errorIncrement);
|
|
846
|
+
opening = setJunitCountAttribute(opening, 'skipped', summary.skipped);
|
|
847
|
+
const escapedMessage = escapeXmlAttr(message);
|
|
848
|
+
const resultElement = outcome === 'failed' ? 'failure' : 'error';
|
|
849
|
+
const resultType = outcome === 'failed' ? 'ARC1ReconciledFailure' : 'ARC1IncompleteEvidence';
|
|
850
|
+
const diagnostic = `<testsuite name="ARC-1 ABAP Unit reconciliation" tests="1" failures="${failureIncrement}" errors="${errorIncrement}" skipped="0">` +
|
|
851
|
+
`<testcase classname="ARC-1" name="Native and legacy ABAP Unit evidence">` +
|
|
852
|
+
`<${resultElement} type="${resultType}" message="${escapedMessage}">${escapedMessage}</${resultElement}>` +
|
|
853
|
+
`</testcase></testsuite>`;
|
|
854
|
+
const beforeOpening = junit.slice(0, openingMatch.index);
|
|
855
|
+
const afterOpening = junit.slice(openingMatch.index + originalOpening.length);
|
|
856
|
+
if (selfClosing) {
|
|
857
|
+
return `${beforeOpening}${opening.replace(/\/\s*>$/, '>')}${diagnostic}</${tagName}>${afterOpening}`;
|
|
858
|
+
}
|
|
859
|
+
const withUpdatedOpening = `${beforeOpening}${opening}${afterOpening}`;
|
|
860
|
+
const closingPattern = new RegExp(`</${escapedRegExp(tagName)}\\s*>`, 'gi');
|
|
861
|
+
let closingMatch;
|
|
862
|
+
let lastClosing;
|
|
863
|
+
while ((closingMatch = closingPattern.exec(withUpdatedOpening)) !== null)
|
|
864
|
+
lastClosing = closingMatch;
|
|
865
|
+
if (!lastClosing || lastClosing.index === undefined) {
|
|
866
|
+
throw new Error('ABAP Unit public API returned JUnit without a closing testsuites element.');
|
|
867
|
+
}
|
|
868
|
+
return `${withUpdatedOpening.slice(0, lastClosing.index)}${diagnostic}${withUpdatedOpening.slice(lastClosing.index)}`;
|
|
869
|
+
}
|
|
870
|
+
function isDeadlineFailure(error) {
|
|
871
|
+
if (!(error instanceof Error))
|
|
872
|
+
return false;
|
|
873
|
+
if (error.name === 'TimeoutError' || /deadline was exceeded|timed out|\btimeout\b/i.test(error.message))
|
|
874
|
+
return true;
|
|
875
|
+
return error instanceof AdtNetworkError && error.cause?.name === 'TimeoutError';
|
|
876
|
+
}
|
|
877
|
+
async function withinPublicAunitDeadline(operation, now, started, polls, status) {
|
|
878
|
+
try {
|
|
879
|
+
return await operation();
|
|
880
|
+
}
|
|
881
|
+
catch (error) {
|
|
882
|
+
if (!isDeadlineFailure(error))
|
|
883
|
+
throw error;
|
|
884
|
+
throw new AunitIncompleteError(`ABAP Unit public run did not complete within its deadline (last status: ${status() || 'unknown'}).`, { elapsedMs: now() - started, polls: polls(), status: status() }, { cause: error });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function runStatus(xml) {
|
|
888
|
+
const parsed = parseXml(xml);
|
|
889
|
+
const progress = findDeepNodes(parsed, 'progress')[0];
|
|
890
|
+
const status = String(progress?.['@_status'] ?? '').trim();
|
|
891
|
+
const link = findDeepNodes(parsed, 'link').find((candidate) => {
|
|
892
|
+
const rel = String(candidate['@_rel'] ?? '');
|
|
893
|
+
const type = String(candidate['@_type'] ?? '');
|
|
894
|
+
return rel.includes('run-result') || type.includes('junit.run-result');
|
|
895
|
+
});
|
|
896
|
+
return {
|
|
897
|
+
status,
|
|
898
|
+
...(link?.['@_href']
|
|
899
|
+
? { resultPath: assertCanonicalHostRelativeAdtPath(String(link['@_href']), PUBLIC_RESULTS) }
|
|
900
|
+
: {}),
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
export async function probePublicAunit(http, safety, options) {
|
|
904
|
+
checkOperation(safety, OperationType.Read, 'ProbePublicAunit');
|
|
905
|
+
try {
|
|
906
|
+
await http.get(`${PUBLIC_RUNS}${ZERO_RUN_ID}`, { Accept: RUN_STATUS_ACCEPT }, { ...options, probe: true });
|
|
907
|
+
return true;
|
|
908
|
+
}
|
|
909
|
+
catch (error) {
|
|
910
|
+
if (error instanceof AdtApiError && [404, 405, 406, 415].includes(error.statusCode))
|
|
911
|
+
return false;
|
|
912
|
+
throw error;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
/** Run harmless-only AUnit through the public async endpoint and return SAP-native JUnit. */
|
|
916
|
+
export async function runPublicAunit(http, safety, type, name, options = {}) {
|
|
917
|
+
checkOperation(safety, OperationType.Test, 'RunPublicAunit');
|
|
918
|
+
const now = options.now ?? Date.now;
|
|
919
|
+
const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
920
|
+
const started = now();
|
|
921
|
+
const relativeDeadline = started + (options.timeoutMs ?? DEFAULT_PUBLIC_AUNIT_TIMEOUT_MS);
|
|
922
|
+
const deadline = options.deadline === undefined ? relativeDeadline : Math.min(relativeDeadline, options.deadline);
|
|
923
|
+
const requestOptions = () => ({ deadline, signal: options.signal });
|
|
924
|
+
const normalizedType = type.toUpperCase();
|
|
925
|
+
const normalizedName = name.toUpperCase();
|
|
926
|
+
const objectSet = normalizedType === 'DEVC'
|
|
927
|
+
? `<osl:objectSet xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:osl="http://www.sap.com/api/osl" xsi:type="osl:packageSet">` +
|
|
928
|
+
`<osl:package includeSubpackages="${options.includeSubpackages === true}" name="${escapeXmlAttr(normalizedName)}"/>` +
|
|
929
|
+
`</osl:objectSet>`
|
|
930
|
+
: `<osl:objectSet xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:osl="http://www.sap.com/api/osl" xsi:type="osl:flatObjectSet">` +
|
|
931
|
+
`<osl:object name="${escapeXmlAttr(normalizedName)}" type="${escapeXmlAttr(normalizedType)}"/>` +
|
|
932
|
+
`</osl:objectSet>`;
|
|
933
|
+
const body = `<?xml version="1.0" encoding="UTF-8"?>` +
|
|
934
|
+
`<aunit:run xmlns:aunit="http://www.sap.com/adt/api/aunit" title="ARC-1 ABAP Unit" context="ARC-1 CLI">` +
|
|
935
|
+
// Source reconciliation can prove only tests owned by the selected object. Keep the public
|
|
936
|
+
// request aligned with the legacy same-program/assignedTests=false corroboration instead of
|
|
937
|
+
// silently including foreign @testing relations that are outside the inspected source tree.
|
|
938
|
+
`<aunit:options><aunit:measurements type="none"/><aunit:scope ownTests="true" foreignTests="false" addForeignTestsAsPreview="false"/>` +
|
|
939
|
+
`<aunit:riskLevel harmless="true" dangerous="false" critical="false"/>` +
|
|
940
|
+
`<aunit:duration short="true" medium="true" long="true"/></aunit:options>` +
|
|
941
|
+
`${objectSet}</aunit:run>`;
|
|
942
|
+
let polls = 0;
|
|
943
|
+
let status = '';
|
|
944
|
+
const created = await withinPublicAunitDeadline(() => http.post(PUBLIC_RUNS.slice(0, -1), body, RUN_CONTENT_TYPE, { Accept: RUN_STATUS_ACCEPT }, requestOptions()), now, started, () => polls, () => status);
|
|
945
|
+
const location = created.headers.location ?? created.headers.Location;
|
|
946
|
+
if (!location)
|
|
947
|
+
throw new Error('ABAP Unit public API created a run without a Location header.');
|
|
948
|
+
const runPath = assertCanonicalHostRelativeAdtPath(location, PUBLIC_RUNS);
|
|
949
|
+
let delayMs = 100;
|
|
950
|
+
let resultPath;
|
|
951
|
+
let terminal = false;
|
|
952
|
+
while (now() < deadline) {
|
|
953
|
+
const separator = runPath.includes('?') ? '&' : '?';
|
|
954
|
+
const polled = await withinPublicAunitDeadline(() => http.get(`${runPath}${separator}withLongPolling=true`, { Accept: RUN_STATUS_ACCEPT }, requestOptions()), now, started, () => polls, () => status);
|
|
955
|
+
polls += 1;
|
|
956
|
+
const state = runStatus(polled.body);
|
|
957
|
+
status = state.status;
|
|
958
|
+
resultPath = state.resultPath;
|
|
959
|
+
terminal = /^(completed|finished)$/i.test(status);
|
|
960
|
+
if (terminal && resultPath)
|
|
961
|
+
break;
|
|
962
|
+
if (!status || /not created|failed|cancelled/i.test(status)) {
|
|
963
|
+
throw new Error(`ABAP Unit public run did not complete: ${status || 'empty status'}`);
|
|
964
|
+
}
|
|
965
|
+
const remaining = deadline - now();
|
|
966
|
+
if (remaining <= 0)
|
|
967
|
+
break;
|
|
968
|
+
await sleep(Math.min(delayMs, remaining));
|
|
969
|
+
delayMs = Math.min(delayMs * 2, 1_000);
|
|
970
|
+
}
|
|
971
|
+
if (!terminal || !resultPath) {
|
|
972
|
+
throw new AunitIncompleteError(`ABAP Unit public run timed out after ${now() - started}ms (last status: ${status || 'unknown'}).`, { elapsedMs: now() - started, polls, status });
|
|
973
|
+
}
|
|
974
|
+
const result = await withinPublicAunitDeadline(() => http.get(resultPath, { Accept: JUNIT_ACCEPT }, requestOptions()), now, started, () => polls, () => status);
|
|
975
|
+
const summary = parseNativeJunitSummary(result.body);
|
|
976
|
+
return {
|
|
977
|
+
protocol: 'public-api',
|
|
978
|
+
runPath,
|
|
979
|
+
resultPath,
|
|
980
|
+
status,
|
|
981
|
+
junit: result.body,
|
|
982
|
+
summary,
|
|
983
|
+
elapsedMs: now() - started,
|
|
984
|
+
polls,
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
//# sourceMappingURL=aunit.js.map
|