diff --git a/packages/adt-aunit/src/commands/aunit.ts b/packages/adt-aunit/src/commands/aunit.ts
index adfe867..dd2e662 100644
--- a/packages/adt-aunit/src/commands/aunit.ts
+++ b/packages/adt-aunit/src/commands/aunit.ts
@@ -190,6 +190,55 @@ function buildRunConfiguration(targetUris: string[]): RunConfigurationBody {
};
}
+/**
+ * Convert a single test method alert to an AunitAlert, updating the status.
+ */
+function convertAlerts(
+ rawAlerts: Array<{
+ kind?: string;
+ severity?: string;
+ title?: string;
+ details?: { detail?: Array<{ text?: string }> };
+ stack?: {
+ stackEntry?: Array<{
+ uri?: string;
+ type?: string;
+ name?: string;
+ description?: string;
+ }>;
+ };
+ }>,
+): { alerts: AunitAlert[]; status: AunitTestMethod['status'] } {
+ const alerts: AunitAlert[] = [];
+ let status: AunitTestMethod['status'] = 'pass';
+
+ for (const alert of rawAlerts) {
+ const details = (alert.details?.detail ?? []).map((d) => d.text || '');
+ const stack = (alert.stack?.stackEntry ?? []).map((s) => ({
+ uri: s.uri,
+ type: s.type,
+ name: s.name,
+ description: s.description,
+ }));
+
+ alerts.push({
+ kind: alert.kind || 'unknown',
+ severity: alert.severity || 'unknown',
+ title: alert.title || '',
+ details,
+ stack,
+ });
+
+ if (alert.severity === 'critical' || alert.kind === 'failedAssertion') {
+ status = 'fail';
+ } else if (alert.severity === 'fatal' || alert.kind === 'error') {
+ status = 'error';
+ }
+ }
+
+ return { alerts, status };
+}
+
/**
* Convert SAP AUnit response to our normalized AunitResult
*/
@@ -209,41 +258,10 @@ function convertResponse(response: RunResultResponse): AunitResult {
const methods: AunitTestMethod[] = [];
for (const tm of tc.testMethods?.testMethod || []) {
- const execTime = parseFloat(tm.executionTime || '0');
+ const execTime = Number.parseFloat(tm.executionTime || '0');
totalTime += execTime;
- const alerts: AunitAlert[] = [];
- let status: AunitTestMethod['status'] = 'pass';
-
- for (const alert of tm.alerts?.alert || []) {
- const details = (alert.details?.detail || []).map(
- (d) => d.text || '',
- );
- const stack = (alert.stack?.stackEntry || []).map((s) => ({
- uri: s.uri,
- type: s.type,
- name: s.name,
- description: s.description,
- }));
-
- alerts.push({
- kind: alert.kind || 'unknown',
- severity: alert.severity || 'unknown',
- title: alert.title || '',
- details,
- stack,
- });
-
- // Determine status from alert severity/kind
- if (
- alert.severity === 'critical' ||
- alert.kind === 'failedAssertion'
- ) {
- status = 'fail';
- } else if (alert.severity === 'fatal' || alert.kind === 'error') {
- status = 'error';
- }
- }
+ const { alerts, status } = convertAlerts(tm.alerts?.alert || []);
totalTests++;
if (status === 'pass') passCount++;
@@ -311,17 +329,27 @@ function adtLink(name: string, uri: string, systemName?: string): string {
}
/**
- * Display AUnit results in console
+ * Display a single failed/errored test method in console
*/
-function displayResults(result: AunitResult, systemName?: string): void {
- if (result.totalTests === 0) {
- console.log(`\n⚠️ No tests found`);
- return;
+function displayFailedMethod(method: AunitTestMethod): void {
+ const icon = method.status === 'fail' ? ansi.red('✗') : ansi.red('⚠');
+ console.log(` ${icon} ${method.name} (${method.executionTime}s)`);
+ for (const alert of method.alerts) {
+ console.log(` ${ansi.dim(alert.title)}`);
+ for (const detail of alert.details) {
+ const dimDetail = ansi.dim(` ${detail}`);
+ console.log(` ${dimDetail}`);
+ }
}
+}
+/**
+ * Display AUnit results summary in console
+ */
+function displaySummary(result: AunitResult): void {
const allPassed = result.failCount === 0 && result.errorCount === 0;
-
- console.log(`\n${allPassed ? '✅' : '❌'} ABAP Unit Test Results:`);
+ const statusIcon = allPassed ? '✅' : '❌';
+ console.log(`\n${statusIcon} ABAP Unit Test Results:`);
console.log(
` 📋 Total: ${result.totalTests} tests in ${result.totalTime.toFixed(3)}s`,
);
@@ -333,6 +361,18 @@ function displayResults(result: AunitResult, systemName?: string): void {
console.log(` ${ansi.red(`⚠ ${result.errorCount} errors`)}`);
if (result.skipCount > 0)
console.log(` ${ansi.yellow(`○ ${result.skipCount} skipped`)}`);
+}
+
+/**
+ * Display AUnit results in console
+ */
+function displayResults(result: AunitResult, systemName?: string): void {
+ if (result.totalTests === 0) {
+ console.log(`\n⚠️ No tests found`);
+ return;
+ }
+
+ displaySummary(result);
// Show failed tests
for (const prog of result.programs) {
@@ -350,19 +390,57 @@ function displayResults(result: AunitResult, systemName?: string): void {
console.log(`\n ${classLink}`);
for (const method of failedMethods) {
- const icon = method.status === 'fail' ? ansi.red('✗') : ansi.red('⚠');
- console.log(` ${icon} ${method.name} (${method.executionTime}s)`);
- for (const alert of method.alerts) {
- console.log(` ${ansi.dim(alert.title)}`);
- for (const detail of alert.details) {
- console.log(` ${ansi.dim(` ${detail}`)}`);
- }
- }
+ displayFailedMethod(method);
}
}
}
}
+/**
+ * Resolve target URIs and name from command options
+ */
+async function resolveTargets(options: {
+ fromFile?: string;
+ transport?: string;
+ class?: string;
+ package?: string;
+ object?: string;
+}): Promise<{ targetUris: string[]; targetName: string }> {
+ if (options.fromFile) {
+ const { readFileSync } = await import('node:fs');
+ const content = readFileSync(options.fromFile, 'utf-8');
+ const targetUris = content
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith('#'));
+ return {
+ targetUris,
+ targetName: `${targetUris.length} objects from ${options.fromFile}`,
+ };
+ }
+ if (options.transport) {
+ return {
+ targetUris: [
+ `/sap/bc/adt/cts/transportrequests/${options.transport.toUpperCase()}`,
+ ],
+ targetName: `Transport ${options.transport.toUpperCase()}`,
+ };
+ }
+ if (options.class) {
+ return {
+ targetUris: [`/sap/bc/adt/oo/classes/${options.class.toLowerCase()}`],
+ targetName: `Class ${options.class.toUpperCase()}`,
+ };
+ }
+ if (options.package) {
+ return {
+ targetUris: [`/sap/bc/adt/packages/${options.package.toUpperCase()}`],
+ targetName: `Package ${options.package.toUpperCase()}`,
+ };
+ }
+ return { targetUris: [options.object!], targetName: options.object! };
+}
+
/**
* AUnit Command Plugin
*/
@@ -453,35 +531,11 @@ export const aunitCommand: CliCommandPlugin = {
const client = (await ctx.getAdtClient()) as AdtClient;
// Determine targets
- let targetUris: string[];
- let targetName: string;
-
- if (options.fromFile) {
- const { readFileSync } = await import('fs');
- const content = readFileSync(options.fromFile, 'utf-8');
- targetUris = content
- .split('\n')
- .map((line) => line.trim())
- .filter((line) => line && !line.startsWith('#'));
- if (targetUris.length === 0) {
- ctx.logger.error(`❌ No objects found in ${options.fromFile}`);
- process.exit(1);
- }
- targetName = `${targetUris.length} objects from ${options.fromFile}`;
- } else if (options.transport) {
- targetUris = [
- `/sap/bc/adt/cts/transportrequests/${options.transport.toUpperCase()}`,
- ];
- targetName = `Transport ${options.transport.toUpperCase()}`;
- } else if (options.class) {
- targetUris = [`/sap/bc/adt/oo/classes/${options.class.toLowerCase()}`];
- targetName = `Class ${options.class.toUpperCase()}`;
- } else if (options.package) {
- targetUris = [`/sap/bc/adt/packages/${options.package.toUpperCase()}`];
- targetName = `Package ${options.package.toUpperCase()}`;
- } else {
- targetUris = [options.object!];
- targetName = options.object!;
+ const { targetUris, targetName } = await resolveTargets(options);
+
+ if (options.fromFile && targetUris.length === 0) {
+ ctx.logger.error(`❌ No objects found in ${options.fromFile}`);
+ process.exit(1);
}
ctx.logger.info(`🧪 Running ABAP Unit tests on ${targetName}...`);
diff --git a/packages/adt-aunit/src/formatters/junit.ts b/packages/adt-aunit/src/formatters/junit.ts
index 1825ce0..7f89d0f 100644
--- a/packages/adt-aunit/src/formatters/junit.ts
+++ b/packages/adt-aunit/src/formatters/junit.ts
@@ -13,7 +13,7 @@
* - testcase > system-out, system-err
*/
-import { writeFile } from 'fs/promises';
+import { writeFile } from 'node:fs/promises';
import type { AunitResult, AunitTestMethod } from '../types';
/**
@@ -21,11 +21,11 @@ import type { AunitResult, AunitTestMethod } from '../types';
*/
function escapeXml(str: string): string {
return str
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
+ .replaceAll('&', '&')
+ .replaceAll('<', '<')
+ .replaceAll('>', '>')
+ .replaceAll('"', '"')
+ .replaceAll("'", ''');
}
/**
@@ -43,7 +43,8 @@ function buildAlertMessage(method: AunitTestMethod): string {
lines.push('Stack:');
for (const entry of alert.stack) {
const desc = entry.description || entry.name || '';
- lines.push(` at ${desc}${entry.uri ? ` (${entry.uri})` : ''}`);
+ const uriPart = entry.uri ? ` (${entry.uri})` : '';
+ lines.push(` at ${desc}${uriPart}`);
}
}
return lines.join('\n');
@@ -51,13 +52,45 @@ function buildAlertMessage(method: AunitTestMethod): string {
.join('\n---\n');
}
+/**
+ * Build testcase XML lines for a single test method
+ */
+function buildTestCaseXml(
+ method: AunitTestMethod,
+ classname: string,
+): string[] {
+ const caseLines: string[] = [
+ ` `,
+ ];
+
+ if (method.status === 'fail') {
+ const message = method.alerts[0]?.title || 'Assertion failed';
+ const body = buildAlertMessage(method);
+ caseLines.push(
+ ` ${escapeXml(body)}`,
+ );
+ } else if (method.status === 'error') {
+ const message = method.alerts[0]?.title || 'Error';
+ const body = buildAlertMessage(method);
+ caseLines.push(
+ ` ${escapeXml(body)}`,
+ );
+ } else if (method.status === 'skip') {
+ caseLines.push(' ');
+ }
+
+ if (method.uri) {
+ caseLines.push(` ${escapeXml(method.uri)}`);
+ }
+
+ caseLines.push(' ');
+ return caseLines;
+}
+
/**
* Convert AUnit result to JUnit XML string
*/
export function toJunitXml(result: AunitResult): string {
- const lines: string[] = [];
- lines.push('');
-
// Collect all test suites (one per test class)
const suites: string[] = [];
@@ -78,41 +111,13 @@ export function toJunitXml(result: AunitResult): string {
0,
);
- const suiteLines: string[] = [];
- suiteLines.push(
+ const suiteLines: string[] = [
` `,
- );
+ ];
for (const method of testClass.methods) {
const classname = `${program.name}.${testClass.name}`;
- suiteLines.push(
- ` `,
- );
-
- if (method.status === 'fail') {
- const message = method.alerts[0]?.title || 'Assertion failed';
- const body = buildAlertMessage(method);
- suiteLines.push(
- ` ${escapeXml(body)}`,
- );
- } else if (method.status === 'error') {
- const message = method.alerts[0]?.title || 'Error';
- const body = buildAlertMessage(method);
- suiteLines.push(
- ` ${escapeXml(body)}`,
- );
- } else if (method.status === 'skip') {
- suiteLines.push(' ');
- }
-
- // Add system-out with ADT URI for navigation
- if (method.uri) {
- suiteLines.push(
- ` ${escapeXml(method.uri)}`,
- );
- }
-
- suiteLines.push(' ');
+ suiteLines.push(...buildTestCaseXml(method, classname));
}
suiteLines.push(' ');
@@ -120,13 +125,13 @@ export function toJunitXml(result: AunitResult): string {
}
}
- lines.push(
- ``,
- );
- lines.push(...suites);
- lines.push('');
-
- return lines.join('\n');
+ const header = ``;
+ return [
+ '',
+ header,
+ ...suites,
+ '',
+ ].join('\n');
}
/**
diff --git a/packages/adt-auth/src/plugins/service-key.ts b/packages/adt-auth/src/plugins/service-key.ts
index 89bac6c..80d73d2 100644
--- a/packages/adt-auth/src/plugins/service-key.ts
+++ b/packages/adt-auth/src/plugins/service-key.ts
@@ -1,5 +1,5 @@
-import { createServer, type Server } from 'http';
-import { parse as parseUrl } from 'url';
+import { createServer, type Server } from 'node:http';
+import { parse as parseUrl } from 'node:url';
import type { AuthPlugin, AuthPluginOptions, CookieAuthResult } from '../types';
import {
ServiceKeyParser,
@@ -123,9 +123,10 @@ async function performPkceFlow(
} = url.query;
if (error) {
- throw new Error(
- `OAuth error: ${error}${error_description ? ` - ${error_description}` : ''}`,
- );
+ const errorSuffix = error_description
+ ? ` - ${error_description}`
+ : '';
+ throw new Error(`OAuth error: ${error}${errorSuffix}`);
}
if (returnedState !== state) {
@@ -153,7 +154,10 @@ async function performPkceFlow(
);
clearTimeout(timeout);
- setTimeout(() => server.close(() => resolve(tokenData)), 500);
+ setTimeout(() => {
+ server.close();
+ resolve(tokenData);
+ }, 500);
} catch (err) {
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(
diff --git a/packages/adt-auth/src/utils/env.ts b/packages/adt-auth/src/utils/env.ts
index d42e031..a3735a5 100644
--- a/packages/adt-auth/src/utils/env.ts
+++ b/packages/adt-auth/src/utils/env.ts
@@ -1,4 +1,4 @@
-import { readFileSync, existsSync } from 'fs';
+import { readFileSync, existsSync } from 'node:fs';
import type { Destination } from '../auth-manager';
import { ServiceKeyParser, type BTPServiceKey } from '../types/service-key';
diff --git a/packages/adt-client/src/adapter.ts b/packages/adt-client/src/adapter.ts
index 4f1ccaa..19866fa 100644
--- a/packages/adt-client/src/adapter.ts
+++ b/packages/adt-client/src/adapter.ts
@@ -50,11 +50,12 @@ export function createAdtAdapter(config: AdtAdapterConfig): HttpAdapter {
// Build Authorization header:
// 1. Explicit authorizationHeader (e.g., "Bearer " for OAuth)
// 2. Basic Auth header (username/password) when not using SAML
+ const basicCredentials = `${username}:${password}`;
const authHeader =
authorizationHeader ??
(isSamlAuth
? undefined
- : `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`);
+ : `Basic ${Buffer.from(basicCredentials).toString('base64')}`);
// Create session manager for stateful sessions
const sessionManager = new SessionManager(logger);
diff --git a/packages/ts-xsd/src/codegen/generate.ts b/packages/ts-xsd/src/codegen/generate.ts
index ed48c4f..a9a8fb3 100644
--- a/packages/ts-xsd/src/codegen/generate.ts
+++ b/packages/ts-xsd/src/codegen/generate.ts
@@ -309,6 +309,17 @@ function schemaToLiteral(
return `export const ${safeName} = ${literal} ${typeAssertion};`;
}
+/**
+ * Format a string value as a single-quoted literal, unescaping JSON double quotes
+ * and escaping any single quotes.
+ */
+function toSingleQuoteLiteral(jsonStr: string): string {
+ return `'${jsonStr
+ .slice(1, -1)
+ .replaceAll('\\"', '"')
+ .replaceAll("'", String.raw`\'`)}'`;
+}
+
/**
* Convert any value to a TypeScript literal string.
*/
@@ -331,7 +342,7 @@ function objectToLiteral(
if (typeof value === 'string') {
const json = JSON.stringify(value);
if (singleQuote) {
- return `'${json.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}'`;
+ return toSingleQuoteLiteral(json);
}
return json;
}
@@ -372,9 +383,7 @@ function objectToLiteral(
let keyStr = key;
if (!isValidIdentifier(key)) {
const json = JSON.stringify(key);
- keyStr = singleQuote
- ? `'${json.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}'`
- : json;
+ keyStr = singleQuote ? toSingleQuoteLiteral(json) : json;
}
const valStr = objectToLiteral(
val,
diff --git a/packages/ts-xsd/src/codegen/ts-morph.ts b/packages/ts-xsd/src/codegen/ts-morph.ts
index be78a92..9d9c18b 100644
--- a/packages/ts-xsd/src/codegen/ts-morph.ts
+++ b/packages/ts-xsd/src/codegen/ts-morph.ts
@@ -769,6 +769,52 @@ function collectFromGroup(
return hasAny;
}
+/**
+ * Resolve the TypeScript type string for a local element.
+ */
+function resolveElementType(
+ element: LocalElement,
+ ctx: GeneratorContext,
+): string {
+ if (element.type) {
+ return resolveTypeName(element.type, ctx);
+ }
+ if (element.complexType) {
+ // Inline complex type - generate anonymous interface
+ return 'unknown'; // NOTE: could generate inline type for complex types
+ }
+ if (element.simpleType?.restriction?.enumeration) {
+ return element.simpleType.restriction.enumeration
+ .map((e: { value?: string }) => `'${e.value}'`)
+ .join(' | ');
+ }
+ if (element.simpleType?.restriction?.base) {
+ return (
+ XSD_BUILT_IN_TYPES[stripNsPrefix(element.simpleType.restriction.base)] ??
+ 'string'
+ );
+ }
+ return 'string';
+}
+
+/**
+ * Find a referenced element definition by name across the current schema and its imports.
+ */
+function findRefElement(
+ refName: string,
+ ctx: GeneratorContext,
+): LocalElement | undefined {
+ const found = ctx.schema.element?.find((e) => e.name === refName);
+ if (found) return found;
+ if (ctx.schema.$imports) {
+ for (const imported of ctx.schema.$imports) {
+ const fromImport = imported.element?.find((e) => e.name === refName);
+ if (fromImport) return fromImport;
+ }
+ }
+ return undefined;
+}
+
function addElementProperty(
element: LocalElement,
properties: Array<{ name: string; type: string; hasQuestionToken: boolean }>,
@@ -778,14 +824,7 @@ function addElementProperty(
// Handle element reference
if (element.ref) {
const refName = stripNsPrefix(element.ref);
- // Search in current schema first, then in $imports
- let refElement = ctx.schema.element?.find((e) => e.name === refName);
- if (!refElement && ctx.schema.$imports) {
- for (const imported of ctx.schema.$imports) {
- refElement = imported.element?.find((e) => e.name === refName);
- if (refElement) break;
- }
- }
+ const refElement = findRefElement(refName, ctx);
if (refElement) {
addElementProperty(
{
@@ -809,23 +848,7 @@ function addElementProperty(
const isOptional =
forceOptional || element.minOccurs === '0' || element.minOccurs === 0;
- let tsType: string;
- if (element.type) {
- tsType = resolveTypeName(element.type, ctx);
- } else if (element.complexType) {
- // Inline complex type - generate anonymous interface
- tsType = 'unknown'; // NOTE: could generate inline type for complex types
- } else if (element.simpleType?.restriction?.enumeration) {
- tsType = element.simpleType.restriction.enumeration
- .map((e: { value?: string }) => `'${e.value}'`)
- .join(' | ');
- } else if (element.simpleType?.restriction?.base) {
- tsType =
- XSD_BUILT_IN_TYPES[stripNsPrefix(element.simpleType.restriction.base)] ??
- 'string';
- } else {
- tsType = 'string';
- }
+ let tsType = resolveElementType(element, ctx);
if (isArray) {
tsType = `${tsType}[]`;
diff --git a/packages/ts-xsd/src/generators/raw-schema.ts b/packages/ts-xsd/src/generators/raw-schema.ts
index 8be1ab6..f5ce1ac 100644
--- a/packages/ts-xsd/src/generators/raw-schema.ts
+++ b/packages/ts-xsd/src/generators/raw-schema.ts
@@ -400,6 +400,17 @@ class SchemaRef {
constructor(public readonly name: string) {}
}
+/**
+ * Format a string value as a single-quoted literal, unescaping JSON double quotes
+ * and escaping any single quotes.
+ */
+function toSingleQuoteLiteral(jsonStr: string): string {
+ return `'${jsonStr
+ .slice(1, -1)
+ .replaceAll('\\"', '"')
+ .replaceAll("'", String.raw`\'`)}'`;
+}
+
function filterDeep(value: unknown, exclude: Set): unknown {
if (value instanceof SchemaRef) {
return value;
@@ -440,7 +451,7 @@ function objectToLiteral(
if (typeof value === 'string') {
const json = JSON.stringify(value);
if (singleQuote) {
- return `'${json.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}'`;
+ return toSingleQuoteLiteral(json);
}
return json;
}
@@ -477,9 +488,7 @@ function objectToLiteral(
let keyStr = key;
if (!isValidIdentifier(key)) {
const json = JSON.stringify(key);
- keyStr = singleQuote
- ? `'${json.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}'`
- : json;
+ keyStr = singleQuote ? toSingleQuoteLiteral(json) : json;
}
const valStr = objectToLiteral(
val,
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 0000000..962f9bb
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,9 @@
+# SonarQube / SonarCloud configuration
+
+# Exclude generated schema files from copy-paste detection (CPD).
+# These files are auto-generated by ts-xsd codegen from XSD sources and share
+# structurally similar patterns by design — flagging them as duplication is a
+# false positive.
+sonar.cpd.exclusions=\
+ packages/*/src/schemas/generated/**,\
+ packages/adt-plugin-abapgit/src/schemas/generated/**