diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..978e930 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @Monforton @thestuckster diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2a49da4 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,80 @@ +name: Create new release + +on: + workflow_dispatch: + inputs: + tag: + description: 'Tag for the release (e.g., 1.0.0)' + required: true + default: '0.0.0' + draft-release: + description: 'Should this release be a draft?' + type: boolean + required: false + default: true + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ ubuntu-latest, windows-latest, macos-latest ] + include: + - os: ubuntu-latest + graalvm-home: /usr/lib/graalvm + - os: windows-latest + graalvm-home: C:\graalvm + - os: macos-latest + graalvm-home: /Library/Java/JavaVirtualMachines/graalvm + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup GraalVM + uses: graalvm/setup-graalvm@v1 + with: + java-version: '17.0.12' + distribution: 'graalvm' + #github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build native executable + env: + VERSION_TAG: ${{ github.event.inputs.tag }} + run: ./gradlew nativeImage -Pversion="$VERSION_TAG" + + - name: Upload native executable + uses: actions/upload-artifact@v4 + with: + name: native-executable-${{ matrix.os }} + path: build/native-image/fern-junit-client* + + release: + needs: build + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Make artifacts executable + run: chmod +x artifacts/native-executable-* + + - name: Create GitHub Release + id: create_release + uses: ncipollo/release-action@v1 + with: + allowUpdates: true + draft: "${{ github.event.inputs.draft-release }}" + generateReleaseNotes: true + artifacts: artifacts/** + #token: ${{ secrets.GITHUB_TOKEN }} + tag: 'v${{ github.event.inputs.tag }}' + name: 'v${{ github.event.inputs.tag }}' + + - name: Add release URL to job summary + if: success() + run: | + echo "Release URL: ${{ steps.create_release.outputs.html_url }}" + echo "Release URL: ${{ steps.create_release.outputs.html_url }}" >> $GITHUB_STEP_SUMMARY \ No newline at end of file diff --git a/README.md b/README.md index c291901..9761dcf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fern JUnit Gradle Plugin -A Gradle plugin for publishing JUnit test results to a Fern test reporting instance. +A Gradle plugin and CLI for publishing JUnit test results to a Fern test reporting instance. ![example workflow](https://github.com/guidewire-oss/fern-junit-gradle-plugin/actions/workflows/gradle.yml/badge.svg?event=push) ![plugin](https://img.shields.io/gradle-plugin-portal/v/io.github.guidewire-oss.fern-publisher?label=Gradle%20Plugins%20Portal&color=blue) @@ -35,7 +35,7 @@ Or in `build.gradle`: ```groovy plugins { - id 'io.github.guidewire-oss.fern-publisher' version '1.0.0' + id 'io.github.guidewire-oss.fern-publisher' version '1.0.0' } ``` @@ -45,7 +45,8 @@ plugins { Newer versions of the Fern Reporter server require you to pre-register your application to receive a UUID (your project id) -1. To register your project send a `POST` request to `/api/project` with JSON body of +1. To register your project send a `POST` request to `/api/project` with JSON body of + ```json { "name": "my-project", @@ -55,6 +56,7 @@ Newer versions of the Fern Reporter server require you to pre-register your appl ``` Here is an example curl command for ease of use: + ```shell curl -X POST "https://yourFernUrl.com/api/project" \ -H "Content-Type: application/json" \ @@ -66,6 +68,7 @@ curl -X POST "https://yourFernUrl.com/api/project" \ ``` 2. You will receive a successful response that looks like: + ```json { "uuid": "59e06cf8-f390-5093-af2e-3685be593a25", @@ -79,7 +82,7 @@ curl -X POST "https://yourFernUrl.com/api/project" \ You will need to take note of the returned `ProjectID` UUID for use in configuring the plugin, as described below -### Plugin Setup +### Plugin Setup Configure the plugin in your build script: @@ -158,6 +161,45 @@ fernPublisher { - Kotlin 1.4 or later - JUnit 4 and JUnit 5 XML report formats +## Command-Line Interface (CLI) + +This project also provides a CLI tool, `fern-junit-client`, for collecting and publishing JUnit XML test reports to a Fern Reporter instance. It is designed to work the same way as the Gradle plugin, but can be used independently of Gradle and Java. + +Builds of the CLI binary are available for Linux, macOS, and Windows on the [Releases page](https://github.com/guidewire-oss/fern-junit-gradle-plugin/releases). + +### Usage + +```sh +fern-junit-client send \ + --fern-url \ + --project-name \ + --project-id \ + --file-pattern \ + [--tags ] \ + [--verbose] +``` + +#### Options + +- `--fern-url` (`-u`): Base URL of the Fern Reporter instance to send test reports to (required) +- `--project-name` (`-n`): Name of the project to associate test reports with (required) +- `--project-id` (`-i`): Project ID to associate test reports with (required) +- `--file-pattern` (`-f`): Glob pattern(s) for JUnit XML reports (can be repeated, required) +- `--tags` (`-t`): (Optional) Comma-separated tags to include on the test run +- `--verbose` (`-v`): (Optional) Enable verbose output for debugging + +### Example + +```sh +fern-junit-client send \ + --fern-url https://fern.example.com \ + --project-name my-service \ + --project-id 1234 \ + --file-pattern "build/test-results/**/*.xml" \ + --tags "ci,nightly" \ + --verbose +``` + ## Building from Source ```bash @@ -186,4 +228,14 @@ pluginManagement { mavenCentral() } } +``` +### Building the CLI +Native CLI binaries are built using GraalVM's native-image tool. This allows the CLI to run without requiring a Java runtime, making it lightweight and portable. + +For building the CLI, you will need to have GraalVM installed and set the environment variable `GRAALVM_HOME` as your GraalVM install location. + +Once that is set up, run the following command in the project root: + +```bash +./gradlew nativeImage -Pversion="1.0.0-SNAPSHOT" ``` \ No newline at end of file diff --git a/build.gradle b/build.gradle index c7fa2db..9e01b22 100644 --- a/build.gradle +++ b/build.gradle @@ -1,64 +1,164 @@ +import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform + plugins { - id 'org.jetbrains.kotlin.jvm' version '2.1.20' - id 'org.jetbrains.kotlin.plugin.serialization' version '2.1.20' - id 'java-gradle-plugin' - id 'com.gradle.plugin-publish' version '1.2.1' - id 'com.gradleup.shadow' version "8.3.6" - id 'maven-publish' // Add this plugin to use publishing block + id 'org.jetbrains.kotlin.jvm' version '2.1.20' + id 'org.jetbrains.kotlin.plugin.serialization' version '2.1.20' + id 'java-gradle-plugin' + id "org.graalvm.buildtools.native" version "0.10.6" // GraalVM plugin + id 'application' + id 'com.gradle.plugin-publish' version '1.2.1' + id 'com.gradleup.shadow' version "8.3.6" + id 'maven-publish' // Add this plugin to use publishing block } group = 'io.github.guidewire-oss' -version = '1.0.0' +version = project.hasProperty('version') ? project.version : '1.0.0' + +def os = DefaultNativePlatform.currentOperatingSystem +def arch = DefaultNativePlatform.currentArchitecture gradlePlugin { - website = "https://github.com/guidewire-oss/fern-junit-gradle-plugin" - vcsUrl = "https://github.com/guidewire-oss/fern-junit-gradle-plugin" - plugins { - create("fernPublisher") { - id = "io.github.guidewire-oss.fern-publisher" - displayName = 'fern-publisher' - description = 'This plugin simplifies the process of collecting JUnit XML test reports and publishing them to a Fern test reporting service. It parses JUnit XML reports, converts them to Fern\'s data model, and sends them to your Fern instance through its API. To learn more about Fern, check out its repository: https://github.com/guidewire-oss/fern-reporter' - tags = ['testing', 'testing-tools', 'reporter', 'fern', 'test-reporter'] - implementationClass = "io.github.guidewire.oss.plugin.FernPublisherPlugin" - } + website = "https://github.com/guidewire-oss/fern-junit-gradle-plugin" + vcsUrl = "https://github.com/guidewire-oss/fern-junit-gradle-plugin" + plugins { + create("fernPublisher") { + id = "io.github.guidewire-oss.fern-publisher" + displayName = 'fern-publisher' + description = 'This plugin simplifies the process of collecting JUnit XML test reports and publishing them to a Fern test reporting service. It parses JUnit XML reports, converts them to Fern\'s data model, and sends them to your Fern instance through its API. To learn more about Fern, check out its repository: https://github.com/guidewire-oss/fern-reporter' + tags = ['testing', 'testing-tools', 'reporter', 'fern', 'test-reporter'] + implementationClass = "io.github.guidewire.oss.plugin.FernPublisherPlugin" } + } } publishing { - repositories { - mavenLocal() - } + repositories { + mavenLocal() + } } repositories { - mavenLocal() - mavenCentral() + mavenLocal() + mavenCentral() } shadowJar { - archiveClassifier.set('') // This removes the default 'all' classifier + archiveClassifier.set('') // This removes the default 'all' classifier } dependencies { - implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0' + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0" + implementation "com.github.ajalt.clikt:clikt:5.0.1" + + testImplementation "org.jetbrains.kotlin:kotlin-test" + testImplementation "org.assertj:assertj-core:3.27.3" + testImplementation "org.junit.jupiter:junit-jupiter:5.9.2" + testImplementation "org.wiremock:wiremock:3.12.1" + testImplementation gradleTestKit() + + testRuntimeOnly "org.junit.platform:junit-platform-launcher" +} + +application { + mainClass = "io.github.guidewire.oss.MainKt" +} + +jar { + manifest { + attributes 'Main-Class': 'io.github.guidewire.oss.MainKt' + } + exclude 'META-INF/*.RSA', 'META-INF/*.SF', 'META-INF/*.DSA' // NECESSARY TO BUILD JAR + + // Include all dependencies in the jar (fat jar) + from { + configurations.runtimeClasspath.collect { + it.isDirectory() ? it : zipTree(it) + } + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +// Currently just used to pass the version. If you can find a better way of doing this, then go for it +task generateProps { + def propsFile = layout.buildDirectory.file("resources/main/app.properties") + + doLast { + file(propsFile.get().asFile).write("appVersion=$version") + } +} + +task nativeImage(type: Exec) { + group = 'build' + description = 'Builds a native image using GraalVM' + + def outputName = "fern-junit-client-${version}-${os.toFamilyName()}-${arch.name}" + def graalVmHome = System.getenv('GRAALVM_HOME') + + dependsOn("generateProps") + dependsOn tasks.jar + + doFirst { + if (graalVmHome == null) { + throw new GradleException('GRAALVM_HOME environment variable not set') + } + + def nativeImageCmd = "${graalVmHome}/bin/native-image${os.isWindows() ? '.cmd' : ''}" + if (!new File(nativeImageCmd).exists()) { + throw new GradleException("Native image command not found at: $nativeImageCmd") + } + + // Create a special jar for native image building, excluding Gradle plugin classes + copy { + from jar.outputs + into "${buildDir}/native-image" + rename {fileName -> + fileName.replace(jar.archiveFileName.get(), "native-${jar.archiveFileName.get()}") + } + } + + // Remove Gradle plugin classes from the jar + fileTree("${buildDir}/native-image").matching { + include "native-${jar.archiveFileName.get()}" + }.each {jarFile -> + ant.zip(destfile: "${buildDir}/native-image/temp.jar") { + zipfileset(src: jarFile) { + exclude(name: "org/gradle/internal/impldep/org/bouncycastle/jcajce/provider/drbg/**") + exclude(name: "com/guidewire/plugin/**") + exclude(name: "META-INF/gradle-plugins/**") + } + } + delete jarFile + file("${buildDir}/native-image/temp.jar").renameTo(jarFile) + } + } - testImplementation 'org.jetbrains.kotlin:kotlin-test' - testImplementation("org.assertj:assertj-core:3.27.3") - testImplementation "org.junit.jupiter:junit-jupiter:5.9.2" - testImplementation "org.wiremock:wiremock:3.12.1" - testImplementation(gradleTestKit()) + workingDir = "${buildDir}/native-image" + def jarName = "native-${project.name}-${project.version}-main.jar" - testRuntimeOnly "org.junit.platform:junit-platform-launcher" + commandLine = [ + "${graalVmHome}/bin/native-image${os.isWindows() ? '.cmd' : ''}", + '--no-fallback', + '--enable-url-protocols=https', + '-H:+ReportExceptionStackTraces', + '-H:ReflectionConfigurationFiles=../../src/main/resources/reflect-config.json', + '--initialize-at-build-time=kotlin', + '--initialize-at-build-time=kotlinx', + '--initialize-at-build-time=io.github.guidewire.oss', + '--initialize-at-build-time=org.slf4j', + '--initialize-at-build-time=org.gradle.internal', + '-jar', jarName, + outputName + ] } tasks.test { - useJUnitPlatform() + useJUnitPlatform() } test { - useJUnitPlatform() + useJUnitPlatform() } kotlin { - jvmToolchain(17) + jvmToolchain(17) } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 7fc6f1f..29e08e8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1 @@ -kotlin.code.style=official +kotlin.code.style=official \ No newline at end of file diff --git a/src/main/kotlin/io/github/guidewire/oss/DataSender.kt b/src/main/kotlin/io/github/guidewire/oss/DataSender.kt index dc757ae..9daa04a 100644 --- a/src/main/kotlin/io/github/guidewire/oss/DataSender.kt +++ b/src/main/kotlin/io/github/guidewire/oss/DataSender.kt @@ -39,7 +39,7 @@ fun sendTestRun(testRun: TestRun, fernUrl: String, verbose: Boolean): Result= 300) { - throw RuntimeException("Unexpected response code: ${response?.statusCode()}") + throw RuntimeException("Unexpected response code: ${response.statusCode()}") } } } diff --git a/src/main/kotlin/io/github/guidewire/oss/Main.kt b/src/main/kotlin/io/github/guidewire/oss/Main.kt new file mode 100644 index 0000000..0062414 --- /dev/null +++ b/src/main/kotlin/io/github/guidewire/oss/Main.kt @@ -0,0 +1,29 @@ +package io.github.guidewire.oss + +import com.github.ajalt.clikt.completion.completionOption +import com.github.ajalt.clikt.core.main +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.parameters.options.versionOption +import io.github.guidewire.oss.cli.FernJUnitClientCommand +import io.github.guidewire.oss.cli.SendCommand +import java.util.Properties +import kotlin.system.exitProcess + +fun main(args: Array) { + val properties = Properties() + val inputStream = object {}.javaClass.getResourceAsStream("/app.properties") + inputStream?.use { properties.load(it) } + + val version = properties.getProperty("appVersion", "0.0.0") + + try { + val command = FernJUnitClientCommand() + .subcommands(SendCommand()) + .versionOption(version) + .completionOption() + command.main(args) + } catch (e: Exception) { + System.err.println("ERROR: [${e::class.simpleName}] ${e.message}") + exitProcess(1) + } +} diff --git a/src/main/kotlin/io/github/guidewire/oss/cli/FernJUnitClientCommand.kt b/src/main/kotlin/io/github/guidewire/oss/cli/FernJUnitClientCommand.kt new file mode 100644 index 0000000..6fb303e --- /dev/null +++ b/src/main/kotlin/io/github/guidewire/oss/cli/FernJUnitClientCommand.kt @@ -0,0 +1,16 @@ +package io.github.guidewire.oss.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context + +class FernJUnitClientCommand : CliktCommand( + name = "fern-junit-client", +) { + override fun help(context: Context): String { + return "CLI tool for sending JUnit test reports to Fern" + } + + override fun run() { + // Root command doesn't do anything on its own - just prints help message + } +} diff --git a/src/main/kotlin/io/github/guidewire/oss/cli/SendCommand.kt b/src/main/kotlin/io/github/guidewire/oss/cli/SendCommand.kt new file mode 100644 index 0000000..c8ca65b --- /dev/null +++ b/src/main/kotlin/io/github/guidewire/oss/cli/SendCommand.kt @@ -0,0 +1,87 @@ +package io.github.guidewire.oss.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.multiple +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import io.github.guidewire.oss.models.TestRun +import io.github.guidewire.oss.parseReports +import io.github.guidewire.oss.sendTestRun +import kotlin.system.exitProcess + +class SendCommand : CliktCommand( + name = "send" +) { + override fun help(context: Context): String { + return "Send JUnit test reports to Fern" + } + + private val fernUrl by option("-u", "--fern-url", help = "Base URL of the Fern Reporter instance to send test reports to").required() + private val projectName by option("-n", "--project-name", help = "Name of the project to associate test reports with").required() + private val projectId by option("-i", "--project-id", help = "ID of the project to associate test reports with").required() + private val filePatterns: List by option("-f", "--file-pattern", help = "File name pattern of test reports to send to Fern").multiple(required = true) + private val tags by option("-t", "--tags", help = "Comma-separated tags to be included on runs") + private val verbose by option("-v", "--verbose", help = "Enable verbose output").flag() + + override fun run() { + try { + echo("Reading reports from: ${filePatterns.joinToString(":")}") + // Create TestRun object + val testRun = TestRun( + testProjectName = projectName, + testSeed = System.currentTimeMillis(), + testProjectId = projectId, + ) + + // Process each report path + for (reportPath in filePatterns) { + if (verbose) { + echo("Processing report path: $reportPath") + } + + // Parse reports + parseReports(testRun, reportPath, "", tags ?: "", verbose).fold( + onSuccess = { + if (verbose) { + echo("Successfully parsed reports from $reportPath") + } + }, + onFailure = { error -> + echo("Failed to parse reports from $reportPath: ${error.message}", err = true) + throw error + } + ) + } + + // Send the test run to Fern + if (testRun.suiteRuns.isEmpty()) { + echo("No test suites found in the provided report paths. Nothing to publish.") + exitProcess(0) + } + + echo("Found ${testRun.suiteRuns.size} test suites with a total of ${testRun.suiteRuns.sumOf { it.specRuns.size }} test specs") + + if (!fernUrl.startsWith("http")) { + echo("ERROR: Fern URL must start with 'http' or 'https'", err = true) + exitProcess(1) + } + + sendTestRun(testRun, fernUrl, verbose).fold( + onSuccess = { + echo("Successfully published test results to Fern") + }, + onFailure = { error -> + throw RuntimeException("Failed to publish test results to Fern", error) + } + ) + } catch (e: Exception) { + echo("ERROR: [${e::class.simpleName}] ${e.message}") + if (verbose) { + echo("Stack trace: $e", err = true) + } + exitProcess(1) + } + } +} \ No newline at end of file diff --git a/src/main/resources/reflect-config.json b/src/main/resources/reflect-config.json new file mode 100644 index 0000000..b74e9d8 --- /dev/null +++ b/src/main/resources/reflect-config.json @@ -0,0 +1,16 @@ +[ + { + "name": "io.github.guidewire.oss.cli.FernJUnitClientCommand", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true + }, + { + "name": "io.github.guidewire.oss.cli.SendCommand", + "allDeclaredConstructors": true, + "allPublicConstructors": true, + "allDeclaredMethods": true, + "allPublicMethods": true + } +] \ No newline at end of file