diff --git a/.agents/skills/adt-rununit/SKILL.md b/.agents/skills/adt-rununit/SKILL.md new file mode 100644 index 0000000..bbdffd7 --- /dev/null +++ b/.agents/skills/adt-rununit/SKILL.md @@ -0,0 +1,30 @@ +--- +name: adt-rununit +description: Run ABAP Unit tests on the remote SAP system via ADT. +--- + +## Tool + +Use the built-in `adt_rununit` custom tool. + +### Parameters (all optional) + +| Parameter | Description | +|-----------|-------------| +| `objectType` | `CLAS` (class) or `DEVC` (package). Defaults to `DEVC` when omitted. | +| `objectName` | ABAP object name (e.g. `ZASIS_CL_INTERPRETER` or `ZASIS`). If omitted, uses `SAP_ROOT_PACKAGE` from `.env`. | + +### Examples + +- Run all tests in the root package: `adt_rununit()` (no args) +- Run tests for a specific class: `adt_rununit(objectType="CLAS", objectName="ZASIS_CL_INTERPRETER")` +- Run tests for a specific package: `adt_rununit(objectType="DEVC", objectName="ZASIS")` + +## When to use + +Only when the user explicitly requests running unit tests on SAP. Typical scenarios: +- After syncing code to SAP via `adt_gitpull`, user says "run tests" or "run unit tests" +- User wants to verify a specific class passes tests: "run tests for ZASIS_CL_INTERPRETER" +- Final verification before merging a PR + +**Never call autonomously.** Always wait for user trigger. diff --git a/.env.example b/.env.example index cde7ecb..59ea59d 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ -# SAP ADT Connection (used by .opencode/tools/adt_gitpull.ts) +# SAP ADT Connection (used by .opencode/tools/adt_gitpull.ts and adt_rununit.ts) SAP_ADT_URL=https://your-sap-system:44300 SAP_ADT_USER=DEVELOPER SAP_ADT_PASSWORD=your-password-here + +# Default package for adt_rununit (used when no target object is specified) +SAP_ROOT_PACKAGE=ZASIS diff --git a/.opencode/tools/adt_rununit.ts b/.opencode/tools/adt_rununit.ts new file mode 100644 index 0000000..acd409e --- /dev/null +++ b/.opencode/tools/adt_rununit.ts @@ -0,0 +1,95 @@ +import { tool } from "@opencode-ai/plugin" +import { readFileSync } from "node:fs" +import { resolve } from "node:path" +import { adtRunUnit } from "../../scripts/adt_rununit_core" + +function loadEnv(dir: string): Record { + const env: Record = {} + try { + const content = readFileSync(resolve(dir, ".env"), "utf-8") + for (const line of content.split("\n")) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith("#")) continue + const idx = trimmed.indexOf("=") + if (idx === -1) continue + const key = trimmed.slice(0, idx).trim() + const value = trimmed.slice(idx + 1).trim() + env[key] = value + } + } catch { + // .env not found — fall through to process.env + } + return env +} + +export default tool({ + description: + "Runs ABAP Unit tests on the remote SAP system via ADT. " + + "Optional parameters: objectType (CLAS or DEVC) and objectName. " + + "If omitted, runs tests for the package defined in SAP_ROOT_PACKAGE (.env). " + + "Returns a formatted summary with pass/fail counts and failure details.", + args: { + objectType: tool.schema.string() + .describe( + "ABAP object type: 'CLAS' (class) or 'DEVC' (package). " + + "Defaults to DEVC (package) when objectName is omitted." + ) + .optional(), + objectName: tool.schema.string() + .describe( + "ABAP object name (e.g. 'ZASIS_CL_INTERPRETER' for a class, or 'ZASIS' for a package). " + + "If omitted, uses SAP_ROOT_PACKAGE from .env." + ) + .optional(), + }, + async execute(args, context) { + const rootDir = context.worktree || context.directory + const env = loadEnv(rootDir) + const url = process.env.SAP_ADT_URL || env.SAP_ADT_URL + const user = process.env.SAP_ADT_USER || env.SAP_ADT_USER + const password = process.env.SAP_ADT_PASSWORD || env.SAP_ADT_PASSWORD + + if (!url || !user || !password) { + return ( + "ERROR: Missing SAP credentials. Ensure .env contains SAP_ADT_URL, SAP_ADT_USER, SAP_ADT_PASSWORD.\n" + + ` SAP_ADT_URL: ${url ? "set" : "MISSING"}\n` + + ` SAP_ADT_USER: ${user ? "set" : "MISSING"}\n` + + ` SAP_ADT_PASSWORD: ${password ? "set" : "MISSING"}` + ) + } + + // Resolve object type and name + let objectType: "CLAS" | "DEVC" + let objectName: string + + if (args.objectName) { + objectType = (args.objectType === "CLAS") ? "CLAS" : "DEVC" + objectName = args.objectName + } else { + // No object specified — use root package + const rootPackage = process.env.SAP_ROOT_PACKAGE || env.SAP_ROOT_PACKAGE + if (!rootPackage) { + return ( + "ERROR: No target specified and SAP_ROOT_PACKAGE is not configured.\n" + + "Either pass objectType + objectName, or set SAP_ROOT_PACKAGE in .env." + ) + } + objectType = "DEVC" + objectName = rootPackage + } + + const result = await adtRunUnit({ + url, + user, + password, + objectType, + objectName, + }) + + if (!result.ok) { + return `ERROR: ${result.error}` + } + + return result.summary + }, +}) diff --git a/docs/sessions/2026-05-30_PR-62_adt-rununit-tool.md b/docs/sessions/2026-05-30_PR-62_adt-rununit-tool.md new file mode 100644 index 0000000..b78280e --- /dev/null +++ b/docs/sessions/2026-05-30_PR-62_adt-rununit-tool.md @@ -0,0 +1,8 @@ +# 2026-05-30 PR-62 adt-rununit-tool + +**Date:** 2026-05-30 +**Title:** Add adt_rununit OpenCode tool for remote ABAP unit test execution + +## Summary + +Added a new OpenCode tool `adt_rununit` that triggers ABAP Unit tests on a remote SAP system via `abap-adt-api`. Follows the established core/wrapper split pattern from `adt_gitpull`: `scripts/adt_rununit_core.ts` contains reusable logic and `.opencode/tools/adt_rununit.ts` provides the OpenCode tool wrapper. Supports targeting specific classes (`CLAS`) or packages (`DEVC`), defaulting to the `SAP_ROOT_PACKAGE` env var when no target is specified. Returns a formatted text summary with pass/fail counts, execution time, and detailed failure information including assertion messages and stack locations. Tool errors (connection, auth) throw while test failures return successfully with the summary. All existing tests pass (44 ABAP unit tests, 0 issues from abaplint). PR #62. diff --git a/scripts/adt_rununit_core.ts b/scripts/adt_rununit_core.ts new file mode 100644 index 0000000..a2cdbbc --- /dev/null +++ b/scripts/adt_rununit_core.ts @@ -0,0 +1,160 @@ +import { ADTClient, UnitTestClass, UnitTestAlert, UnitTestAlertKind } from "abap-adt-api" + +export interface AdtRunUnitOptions { + /** SAP system base URL (e.g. https://host:44300) */ + url: string + /** SAP user */ + user: string + /** SAP password */ + password: string + /** Object type: CLAS or DEVC. Defaults to DEVC when objectName is from SAP_ROOT_PACKAGE. */ + objectType: "CLAS" | "DEVC" + /** Object name (e.g. ZASIS_CL_INTERPRETER or ZASIS) */ + objectName: string +} + +export interface AdtRunUnitSuccess { + ok: true + summary: string +} + +export interface AdtRunUnitError { + ok: false + error: string +} + +export type AdtRunUnitResult = AdtRunUnitSuccess | AdtRunUnitError + +/** Build the ADT URI for the given object type and name */ +function buildUri(objectType: "CLAS" | "DEVC", objectName: string): string { + const name = objectName.toLowerCase() + switch (objectType) { + case "CLAS": + return `/sap/bc/adt/oo/classes/${name}` + case "DEVC": + return `/sap/bc/adt/packages/${name}` + } +} + +/** Format a single alert into readable text */ +function formatAlert(alert: UnitTestAlert): string { + const lines: string[] = [] + const kindLabel = alert.kind === UnitTestAlertKind.failedAssertion + ? "Assertion Failed" + : alert.kind === UnitTestAlertKind.exception + ? "Exception" + : "Warning" + lines.push(` [${kindLabel}] ${alert.title}`) + for (const detail of alert.details) { + if (detail.trim()) { + lines.push(` ${detail}`) + } + } + if (alert.stack.length > 0) { + const top = alert.stack[0] + lines.push(` at ${top["adtcore:name"]} (${top["adtcore:uri"]})`) + } + return lines.join("\n") +} + +/** Format full unit test results into a human-readable summary */ +function formatResults(classes: UnitTestClass[], target: string): string { + let totalMethods = 0 + let passed = 0 + let failed = 0 + let totalTime = 0 + const failures: string[] = [] + + for (const cls of classes) { + // Class-level alerts + if (cls.alerts.length > 0) { + for (const alert of cls.alerts) { + if (alert.kind !== UnitTestAlertKind.warning) { + failed++ + totalMethods++ + failures.push(` ${cls["adtcore:name"]}:`) + failures.push(formatAlert(alert)) + } + } + } + + for (const method of cls.testmethods) { + totalMethods++ + totalTime += method.executionTime + if (method.alerts.length > 0) { + const hasFailure = method.alerts.some( + (a) => a.kind !== UnitTestAlertKind.warning + ) + if (hasFailure) { + failed++ + failures.push(` ${cls["adtcore:name"]}->${method["adtcore:name"]}:`) + for (const alert of method.alerts) { + failures.push(formatAlert(alert)) + } + } else { + passed++ + } + } else { + passed++ + } + } + } + + const status = failed > 0 ? "FAILED" : "PASSED" + const lines: string[] = [] + lines.push(`UNIT TESTS ${status}: ${passed}/${totalMethods} passed, ${failed} failed`) + lines.push(` Target: ${target}`) + lines.push(` Classes: ${classes.length}`) + lines.push(` Duration: ${totalTime}ms`) + + if (failures.length > 0) { + lines.push("") + lines.push(" Failures:") + lines.push(...failures) + } + + return lines.join("\n") +} + +/** + * Triggers ABAP Unit tests on a SAP system for the specified object. + */ +export async function adtRunUnit(options: AdtRunUnitOptions): Promise { + const { url, user, password, objectType, objectName } = options + + // 1. Connect to SAP system + let client: ADTClient + try { + client = new ADTClient(url, user, password) + } catch (e: any) { + return { ok: false, error: `Failed to create ADT client: ${e.message || e}` } + } + + // 2. Build target URI + const uri = buildUri(objectType, objectName) + const target = `${objectType} ${objectName.toUpperCase()} (${uri})` + + // 3. Run unit tests + let results: UnitTestClass[] + try { + results = await client.unitTestRun(uri) + } catch (e: any) { + return { + ok: false, + error: `Unit test run failed for ${target}: ${e.message || e}`, + } + } + + // 4. Format and return results + if (results.length === 0) { + return { + ok: true, + summary: `UNIT TESTS PASSED: 0/0 passed, 0 failed\n Target: ${target}\n No test classes found.`, + } + } + + return { + ok: true, + summary: formatResults(results, target), + } +}