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/**