diff --git a/.github/workflows/build-artifacts.yml b/.github/workflows/build-artifacts.yml index c0c5cb41..ab23cf08 100644 --- a/.github/workflows/build-artifacts.yml +++ b/.github/workflows/build-artifacts.yml @@ -25,6 +25,11 @@ jobs: steps: - name: Check out uses: actions/checkout@v5 + with: + # git-versioning derives the project version from the current branch; + # the default fetch-depth: 1 leaves HEAD detached without a branch ref + # and the plugin falls back to gradle.properties' static version. + fetch-depth: 0 - name: Setup Java uses: actions/setup-java@v5 diff --git a/.github/workflows/cleanup-gh-packages.yml b/.github/workflows/cleanup-gh-packages.yml new file mode 100644 index 00000000..3cd1ef70 --- /dev/null +++ b/.github/workflows/cleanup-gh-packages.yml @@ -0,0 +1,88 @@ +name: Cleanup orphaned GH Packages + +# Removes htmlsanitycheck-* SNAPSHOT versions on GitHub Packages whose branch +# no longer exists on origin AND that are older than ORPHAN_RETENTION_DAYS. +# Release versions (anything not ending in -SNAPSHOT) are never touched. + +on: + schedule: + - cron: '17 3 * * *' # daily 03:17 UTC + workflow_dispatch: + inputs: + dry-run: + description: 'Log what would be deleted without actually deleting' + type: boolean + default: false + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + env: + ORG: aim42 + ORPHAN_RETENTION_DAYS: 28 + DRY_RUN: ${{ inputs.dry-run || 'false' }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - name: Delete SNAPSHOTs of removed branches older than retention threshold + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + # Slugify active remote branches the same way git-versioning does: + # feature/432-add-gh-packages -> feature-432-add-gh-packages + mapfile -t active_slugs < <( + git for-each-ref --format='%(refname:strip=3)' refs/remotes/origin/ \ + | grep -v '^HEAD$' \ + | tr '/' '-' + ) + + now_epoch=$(date -u +%s) + retention_seconds=$(( ORPHAN_RETENTION_DAYS * 86400 )) + + for pkg in \ + htmlsanitycheck-core \ + htmlsanitycheck-cli \ + htmlsanitycheck-gradle-plugin \ + htmlsanitycheck-maven-plugin \ + org.aim42.htmlsanitycheck.gradle.plugin + do + echo "::group::${pkg}" + gh api --paginate "/orgs/${ORG}/packages/maven/${pkg}/versions" \ + --jq '.[] | [.id, .name, .created_at] | @tsv' \ + | while IFS=$'\t' read -r id name created_at; do + + # Keep release versions + [[ "${name}" == *-SNAPSHOT ]] || continue + + # Slug = name without trailing -SNAPSHOT + slug="${name%-SNAPSHOT}" + + # Keep if branch still exists + if printf '%s\n' "${active_slugs[@]}" | grep -Fxq -- "${slug}"; then + continue + fi + + # Branch gone — check age + created_epoch=$(date -u -d "${created_at}" +%s) + age=$(( now_epoch - created_epoch )) + if (( age < retention_seconds )); then + echo "::notice::keep (still in grace): ${pkg}@${name} (age $((age/86400))d, branch ${slug} gone)" + continue + fi + + if [[ "${DRY_RUN}" == "true" ]]; then + echo "::notice::[DRY RUN] would delete: ${pkg}@${name} (age $((age/86400))d, branch ${slug} gone)" + else + echo "deleting orphan: ${pkg}@${name} (age $((age/86400))d, branch ${slug} gone)" + gh api -X DELETE "/orgs/${ORG}/packages/maven/${pkg}/versions/${id}" + fi + done + echo "::endgroup::" + done diff --git a/.github/workflows/gradle-build.yml b/.github/workflows/gradle-build.yml index 3e0d266a..e0d512a5 100644 --- a/.github/workflows/gradle-build.yml +++ b/.github/workflows/gradle-build.yml @@ -18,6 +18,45 @@ jobs: secrets: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + publish-gh-packages: + # Publish per-branch SNAPSHOTs to GitHub Packages so downstream consumers + # can pull a feature/bugfix branch before it merges. Only runs on push + # events to feature/bugfix branches (git-versioning's SNAPSHOT regex); + # develop and main fall through to the static gradle.properties version + # and would conflict with already-published releases. + needs: build-artifacts + if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature/') || startsWith(github.ref, 'refs/heads/bugfix/')) + runs-on: ubuntu-latest + permissions: + packages: write + contents: read + steps: + - name: Check out + uses: actions/checkout@v5 + with: + # Needed for git-versioning to derive the branch-slug-SNAPSHOT version. + fetch-depth: 0 + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + + - name: Setup Gradle + # Pin to commit SHA per SonarCloud rule githubactions:S7637 (supply-chain hardening). + # Update both the SHA and the comment together when bumping; verify the SHA at + # https://github.com/gradle/actions/releases. + uses: gradle/actions/setup-gradle@48b5f213c81028ace310571dc5ec0fbbca0b2947 # v4.4.3 + with: + cache-read-only: true + + - name: Publish SNAPSHOT to GitHub Packages + env: + GITHUB_USER: ${{ github.actor }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: ./gradlew publishAllPublicationsToGitHubPackagesRepository --no-daemon + post-build: needs: build-artifacts runs-on: ubuntu-latest diff --git a/.github/workflows/test-java-os-mix.yml b/.github/workflows/test-java-os-mix.yml index 5ad496cb..13038d90 100644 --- a/.github/workflows/test-java-os-mix.yml +++ b/.github/workflows/test-java-os-mix.yml @@ -48,6 +48,9 @@ jobs: - name: Check out uses: actions/checkout@v5 + with: + # Needed for git-versioning to derive the project version from the branch ref. + fetch-depth: 0 - name: Setup JDK uses: actions/setup-java@v5 @@ -68,17 +71,26 @@ jobs: if: runner.os != 'Windows' run: | uname -a + # The integration-test subproject reads htmlSanityCheckVersion from gradle.properties + # (static 2.0.0-rcN), but build-artifacts upstream published under git-versioning's + # -SNAPSHOT. Read the actual published version straight from the maven-repo + # tree we just downloaded — avoids invoking gradle from root here, which would otherwise + # have to resolve every root plugin (sonar, git-versioning, ...) on this matrix worker. + HSC_VERSION="$(basename "$(find build/maven-repo/org/aim42/htmlsanitycheck/htmlsanitycheck-core -maxdepth 1 -mindepth 1 -type d | head -1)")" + echo "Resolved HSC version: ${HSC_VERSION}" cd integration-test - ../gradlew integrationTest --scan ${GRADLE_DEBUG} + ../gradlew integrationTest -PhtmlSanityCheckVersion=${HSC_VERSION} --scan ${GRADLE_DEBUG} - name: Execute integration tests (Windows) if: runner.os == 'Windows' shell: pwsh run: | uname -a + $HSC_VERSION = (Get-ChildItem -Directory build/maven-repo/org/aim42/htmlsanitycheck/htmlsanitycheck-core | Select-Object -First 1).Name + Write-Output "Resolved HSC version: $HSC_VERSION" cd ./integration-test/ pwd - cmd /c "echo off && ..\gradlew.bat integrationTest --scan $env:GRADLE_DEBUG" + cmd /c "echo off && ..\gradlew.bat integrationTest -PhtmlSanityCheckVersion=$HSC_VERSION --scan $env:GRADLE_DEBUG" - name: Collect state upon failure (On Unix) if: failure() && runner.os != 'Windows' diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b41ea1..d1240371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,42 @@ ## Publication (Generic) - [Documentation](https://hsc.aim42.org) -- [Maven Central](https://central.sonatype.com/namespace/org.aim42.htmlSanityCheck) -- [Gradle Plugin Portal](https://plugins.gradle.org/search?term=org.aim42.htmlSanityCheck) +- [Maven Central](https://central.sonatype.com/namespace/org.aim42.htmlsanitycheck) (lowercase, see breaking change below) +- [Gradle Plugin Portal](https://plugins.gradle.org/search?term=org.aim42.htmlsanitycheck) + +## Unreleased + +### ⚠️ BREAKING CHANGE — Lowercase GA coordinates ([#432](https://github.com/aim42/htmlSanityCheck/issues/432)) + +All Maven `groupId` and `artifactId` values are now **lowercase**, aligning with +Maven conventions and enabling publication to repositories that enforce this +constraint (e.g., GitHub Packages). + +**Old → New:** + +| Coordinate | Before | After | +|---|---|---| +| Group | `org.aim42.htmlSanityCheck` | `org.aim42.htmlsanitycheck` | +| Core | `htmlSanityCheck-core` | `htmlsanitycheck-core` | +| CLI | `htmlSanityCheck-cli` | `htmlsanitycheck-cli` | +| Gradle plugin id | `org.aim42.htmlSanityCheck` | `org.aim42.htmlsanitycheck` | +| Gradle plugin artifact | `htmlSanityCheck-gradle-plugin` | `htmlsanitycheck-gradle-plugin` | +| Maven plugin artifact | `htmlSanityCheck-maven-plugin` | `htmlsanitycheck-maven-plugin` | + +**Action required when upgrading:** + +- Gradle users: replace `id 'org.aim42.htmlSanityCheck'` with `id 'org.aim42.htmlsanitycheck'`. +- Maven users: update `groupId` and `artifactId` in the plugin block to the lowercase form. +- Direct dependency users: update group and artifactId coordinates in your build. + +Existing released versions (≤ `2.0.0-rc4`) remain available under the old CamelCase coordinates: + +- [Maven Central — `org.aim42.htmlSanityCheck`](https://central.sonatype.com/namespace/org.aim42.htmlSanityCheck) (historical artifacts up to and including `2.0.0-rc4`) +- [Gradle Plugin Portal — `org.aim42.htmlSanityCheck`](https://plugins.gradle.org/plugin/org.aim42.htmlSanityCheck) (historical plugin versions) +- [GitHub Releases](https://github.com/aim42/htmlSanityCheck/releases) (CLI binaries) + +The internal Gradle project / source directory names (e.g., `htmlSanityCheck-core/`) +are unchanged for readability — only the published Maven coordinates differ. ## 2.0.0-rc4 diff --git a/build.gradle b/build.gradle index 065e0406..5a3706b4 100644 --- a/build.gradle +++ b/build.gradle @@ -16,12 +16,35 @@ plugins { alias(libs.plugins.jreleaser) id 'com.dorongold.task-tree' version '4.0.1' + + id 'me.qoomon.git-versioning' version '6.4.4' } allprojects { group = group version = htmlSanityCheckVersion + // Derive the version from the current Git ref so feature/bugfix branches publish under their own + // -SNAPSHOT coordinate and don't collide with each other or with release versions on GitHub Packages. + // Tags shaped 'v' produce a release version; any other ref falls back to gradle.properties. + gitVersioning.apply { + refs { + branch('^(feature|bugfix)/.+') { + version = '${ref}-SNAPSHOT' + } + tag('v(?.*)') { + version = '${ref.version}' + } + } + rev { + // Use Groovy interpolation (double quotes) so the gradle.properties value is captured + // at config time. git-versioning only substitutes its own placeholders (${ref}, + // ${commit}, ...) and would otherwise pass the single-quoted literal as the version, + // which leaks into the published POM as "${htmlSanityCheckVersion}". + version = "${htmlSanityCheckVersion}" + } + } + repositories { mavenCentral() mavenLocal() @@ -46,6 +69,13 @@ allprojects { } } +// Single-line `./gradlew -q printVersion` returns the git-versioning–derived project.version. +// Used by generate-pages (and any shell caller) to propagate the dynamic version to standalone +// sub-builds like self-check that read htmlSanityCheckVersion from gradle.properties. +tasks.register("printVersion") { + doLast { println project.version } +} + dependencies { // Add all subprojects to the aggregation subprojects.forEach { @@ -216,6 +246,9 @@ configure(subprojects) { publishing { publications.all { publication -> if (publication instanceof MavenPublication) { + // GA coordinates must be lowercase (Maven convention, enforced by GitHub Packages). + // Each subproject sets base.archivesName explicitly to its lowercase artifactId. + artifactId = project.base.archivesName.get() publication.pom { name = project.name description = project.description @@ -261,6 +294,21 @@ configure(subprojects) { name = 'myLocalRepositoryForFullIntegrationTests' url = mavenBuildRepo } + // Only register GitHubPackages when credentials are present. Otherwise Gradle's task- + // configuration validation fails for any publish task in the project with a cryptic + // "credentials.username doesn't have a configured value" — even for unrelated tasks. + // Skipping registration means trying to invoke publish*ToGitHubPackagesRepository + // without env vars yields a clear "task not found" instead. + if (System.getenv("GITHUB_USER") && System.getenv("GITHUB_TOKEN")) { + maven { + name = "GitHubPackages" + url = "https://maven.pkg.github.com/aim42/htmlSanityCheck" + credentials { + username = System.getenv("GITHUB_USER") + password = System.getenv("GITHUB_TOKEN") + } + } + } mavenLocal() } } @@ -270,6 +318,17 @@ configure(subprojects) { outputs.upToDateWhen { false } } + // GitHub Packages rejects GETs to the plugin marker's snapshot maven-metadata.xml with + // HTTP 400, presumably because the marker artifactId 'org.aim42.htmlsanitycheck.gradle.plugin' + // contains dots that GH's package URL parser treats specially. Skip publishing the marker + // to GitHub Packages; consumers can still resolve it through the Gradle Plugin Portal once + // a release is cut. Local + Maven Central publish targets keep the marker. + tasks.withType(PublishToMavenRepository).configureEach { + if (name.endsWith("PluginMarkerMavenPublicationToGitHubPackagesRepository")) { + enabled = false + } + } + tasks.named('test', Test) { useJUnitPlatform() } @@ -362,7 +421,11 @@ tasks.register("integrationTestOnly") { commandLine((System.getProperty("os.name") ==~ /Windows.*/ ? "..\\gradlew.bat" : "../gradlew"), - "integrationTest") + "integrationTest", + // Forward the git-versioning–derived project.version to the child build so it + // can locate the just-published artifact in build/maven-repo. + "-PhtmlSanityCheckVersion=${project.version}", + ) } logger.debug "Script output: ${result}" } diff --git a/generate-pages b/generate-pages index 1056bb92..b96d190d 100755 --- a/generate-pages +++ b/generate-pages @@ -44,7 +44,10 @@ run copyPdf cp -rp build/pdf build/microsite/output run copyStandalone cp -rp build/html5/images build/microsite/output \ && mkdir -p build/microsite/output/single-page/ \ && cp -p build/html5/arc42/hsc_arc42.html build/microsite/output/single-page/hsc_arc42-single-page.html -run htmlSanityCheck "(cd self-check && ../gradlew htmlSanityCheck --scan --refresh-dependencies --scan)" +# Propagate the git-versioning–derived project.version to self-check so it picks up the just-published +# branch-slug-SNAPSHOT (or release version on tags) instead of the static value in gradle.properties. +HSC_VERSION="$(./gradlew -q printVersion)" +run htmlSanityCheck "(cd self-check && ../gradlew -PhtmlSanityCheckVersion=${HSC_VERSION} htmlSanityCheck --scan --refresh-dependencies --scan)" run copyCheckResult cp -rp build/reports/htmlchecks build/microsite/output run fixDocLinks "sed -i .bak \ -e 's, href=\"${PWD}/build/microsite/output/, href=\"../,g' \ diff --git a/gradle.properties b/gradle.properties index f2240227..7fa31ea8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,7 +5,7 @@ htmlSanityCheckVersion=2.0.0-rc5 # end::version[] -group = org.aim42.htmlSanityCheck +group = org.aim42.htmlsanitycheck description = HTML Sanity Check org.gradle.jvmargs=-Xmx2G diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7fe7cabd..5a8a39c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ junit5-version = '5.12.2' picocli-version = "4.7.7" slf4j-version = '2.0.17' string-similarity-version = '1.0.0' -testcontainers-version = '1.20.6' +testcontainers-version = '1.21.4' wiremock-testcontainers-version = '1.0-alpha-15' [libraries] @@ -26,7 +26,7 @@ wiremock-testcontainers = { module = 'org.wiremock.integrations.testcontainers:w gradle-versions = { id= 'com.github.ben-manes.versions', version = '0.52.0' } sonar = { id = 'org.sonarqube', version = '6.3.1.5724' } jreleaser = { id = 'org.jreleaser', version = '1.17.0'} -gitProperties = { id = 'com.gorylenko.gradle-git-properties', version = '2.5.3' } +gitProperties = { id = 'com.gorylenko.gradle-git-properties', version = '2.5.4' } # Copyright Gerd Aschemann and aim42 contributors. # diff --git a/htmlSanityCheck-cli/build.gradle b/htmlSanityCheck-cli/build.gradle index 37346621..37817aff 100644 --- a/htmlSanityCheck-cli/build.gradle +++ b/htmlSanityCheck-cli/build.gradle @@ -2,6 +2,8 @@ plugins { id 'application' } +base.archivesName = 'htmlsanitycheck-cli' + dependencies { implementation libs.picocli.impl annotationProcessor libs.picocli.annotationprocessor diff --git a/htmlSanityCheck-cli/src/main/groovy/org/aim42/htmlsanitycheck/cli/HscCommand.groovy b/htmlSanityCheck-cli/src/main/groovy/org/aim42/htmlsanitycheck/cli/HscCommand.groovy index c2147080..80a1a79c 100644 --- a/htmlSanityCheck-cli/src/main/groovy/org/aim42/htmlsanitycheck/cli/HscCommand.groovy +++ b/htmlSanityCheck-cli/src/main/groovy/org/aim42/htmlsanitycheck/cli/HscCommand.groovy @@ -92,6 +92,10 @@ class HscCommand implements Runnable { @Option(names = ["-e", "--exclude"], description = "Exclude remote patterns to check", split = ',') Pattern[] excludes = [] + @Option(names = ["-o", "--junitOutputStyle"], + description = "JUnit output style: FLAT (all files in one directory, default) or HIERARCHICAL (mirrors source structure)") + Configuration.JunitOutputStyle junitOutputStyle + @Parameters(index = "0", arity = "0..1", description = "base directory (default: current directory)") File srcDir = new File(".").getAbsoluteFile() @@ -177,6 +181,7 @@ class HscCommand implements Runnable { .checkingResultsDir(resultsDirectory) .checksToExecute(AllCheckers.CHECKER_CLASSES) .excludes(hscCommand.excludes as Set) + .junitOutputStyle(hscCommand.junitOutputStyle) .build() // if we have no valid configuration, abort with exception diff --git a/htmlSanityCheck-core/build.gradle b/htmlSanityCheck-core/build.gradle index 4d7c3654..f4215402 100644 --- a/htmlSanityCheck-core/build.gradle +++ b/htmlSanityCheck-core/build.gradle @@ -4,6 +4,8 @@ plugins { alias(libs.plugins.gitProperties) } +base.archivesName = 'htmlsanitycheck-core' + dependencies { implementation (libs.commons.validator) { // Having a vulnerability here ... diff --git a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/AllChecksRunner.java b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/AllChecksRunner.java index b58ad59a..4e3554d7 100644 --- a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/AllChecksRunner.java +++ b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/AllChecksRunner.java @@ -53,6 +53,9 @@ public class AllChecksRunner { // keep all results private final PerRunResults resultsForAllPages; + // configuration (needed for junit output style) + private final Configuration configuration; + private static final Logger logger = LoggerFactory.getLogger(AllChecksRunner.class); /** @@ -62,6 +65,7 @@ public class AllChecksRunner { public AllChecksRunner(Configuration configuration) { super(); + this.configuration = configuration; this.filesToCheck = configuration.getSourceDocuments(); // TODO: #185 (checker classes shall be detected automatically (aka CheckerFactory) @@ -175,7 +179,8 @@ private void reportCheckingResultsAsHTML(String resultsDir) { * Report results in JUnit XML */ private void reportCheckingResultsAsJUnitXml(String resultsDir) { - Reporter reporter = new JUnitXmlReporter(resultsForAllPages, resultsDir); + Reporter reporter = new JUnitXmlReporter(resultsForAllPages, resultsDir, + configuration.getJunitOutputStyle()); reporter.reportFindings(); } } \ No newline at end of file diff --git a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/Configuration.java b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/Configuration.java index f9d4a627..ab1b1909 100644 --- a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/Configuration.java +++ b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/Configuration.java @@ -32,6 +32,41 @@ @ToString @Slf4j public class Configuration { + + /** + * Defines the output style for JUnit XML reports. + *

+ * This configuration option controls how JUnit XML report files are organized + * in the output directory. + * + * @since 2.0.0 + */ + public enum JunitOutputStyle { + /** + * Flat file structure where all JUnit XML reports are stored in a single directory. + * The entire file path is encoded into the filename using underscores. + *

+ * Example: {@code build/test-results/htmlchecks/TEST-unit-html-_docs_guide_installation.xml} + *

+ * This is the default for backwards compatibility, but may fail with + * "File name too long" errors for deeply nested directory structures. + */ + FLAT, + + /** + * Hierarchical directory structure where JUnit XML reports are organized + * in subdirectories that mirror the source file structure. + *

+ * Example: {@code build/test-results/htmlchecks/docs/guide/TEST-installation.xml} + *

+ * This avoids filename length issues and provides more intuitive organization. + * Recommended for projects with deeply nested directory structures. + * + * @see Issue 405 + */ + HIERARCHICAL + } + Set sourceDocuments; File sourceDir; File checkingResultsDir; @@ -52,6 +87,8 @@ public class Configuration { Set excludes = new HashSet<>(); @Builder.Default Set indexFilenames = defaultIndeFilenames(); + @Builder.Default + JunitOutputStyle junitOutputStyle = JunitOutputStyle.FLAT; /* * Explanation for configuring http status codes: @@ -79,6 +116,7 @@ public Configuration() { this.indexFilenames = defaultIndeFilenames(); this.prefixOnlyHrefExtensions = Web.POSSIBLE_EXTENSIONS; + this.junitOutputStyle = JunitOutputStyle.FLAT;// FLAT for backwards compatibility this.checksToExecute = AllCheckers.CHECKER_CLASSES; } diff --git a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java index 523f4e61..ea5afd9c 100644 --- a/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java +++ b/htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java @@ -1,5 +1,6 @@ package org.aim42.htmlsanitycheck.report; +import org.aim42.htmlsanitycheck.Configuration; import org.aim42.htmlsanitycheck.collect.Finding; import org.aim42.htmlsanitycheck.collect.PerRunResults; import org.aim42.htmlsanitycheck.collect.SingleCheckResults; @@ -11,6 +12,7 @@ import java.io.File; import java.io.FileWriter; import java.io.IOException; +import java.nio.file.Path; import java.util.UUID; /************************************************************************ @@ -36,13 +38,25 @@ /** * Write the findings' report to JUnit XML. Allows tools processing JUnit to * include the findings. + *

+ * Supports two output styles: + *

    + *
  • {@link Configuration.JunitOutputStyle#FLAT} - All files in one directory with encoded paths (default, backwards compatible)
  • + *
  • {@link Configuration.JunitOutputStyle#HIERARCHICAL} - Files organized in subdirectories mirroring source structure
  • + *
*/ public class JUnitXmlReporter extends Reporter { File outputPath; + Configuration.JunitOutputStyle outputStyle; public JUnitXmlReporter(PerRunResults runResults, String outputPath) { + this(runResults, outputPath, Configuration.JunitOutputStyle.FLAT); + } + + public JUnitXmlReporter(PerRunResults runResults, String outputPath, Configuration.JunitOutputStyle outputStyle) { super(runResults); this.outputPath = new File(outputPath); + this.outputStyle = outputStyle != null ? outputStyle : Configuration.JunitOutputStyle.FLAT; } @Override @@ -52,11 +66,15 @@ protected void initReport() { } } + // tag::reportPageSummary[] @Override protected void reportPageSummary(SinglePageResults singlePageResults) { String name = filenameOrTitleOrRandom(singlePageResults); - String sanitizedPath = name.replaceAll("[^A-Za-z0-9_-]+", "_"); - File testOutputFile = new File(outputPath, "TEST-unit-html-" + sanitizedPath + ".xml"); + + File testOutputFile = (outputStyle == Configuration.JunitOutputStyle.HIERARCHICAL) + ? getHierarchicalOutputFile(name) + : getFlatOutputFile(name); + // end::reportPageSummary[] XMLOutputFactory factory = XMLOutputFactory.newInstance(); try (FileWriter fileWriter = new FileWriter(testOutputFile)) { @@ -96,6 +114,73 @@ protected void reportPageSummary(SinglePageResults singlePageResults) { } } + /** + * Creates output file using flat structure (all files in one directory). + * Encodes the full path into the filename using underscores. + * + * @param name The source file path + * @return The output file for the JUnit XML report + */ + private File getFlatOutputFile(String name) { + String sanitizedPath = name.replaceAll("[^A-Za-z0-9_-]+", "_"); + return new File(outputPath, "TEST-unit-html-" + sanitizedPath + ".xml"); + } + + /** + * Creates output file using hierarchical structure (subdirectories mirror source structure). + * Solves filename length issues with deeply nested directories. + * + * @param name The source file path + * @return The output file for the JUnit XML report + */ + private File getHierarchicalOutputFile(String name) { + // Parse the path to extract directory structure and filename + File sourcePath = new File(name); + File parentDir = sourcePath.getParentFile(); + String fileName = sourcePath.getName(); + + // Create directory structure under outputPath to mirror the source file hierarchy + File testOutputDir; + if (parentDir != null) { + // Normalize the path to handle relative references like ".." + // This ensures we stay within the outputPath and don't try to escape it + try { + File tempPath = new File(outputPath, parentDir.getPath()); + testOutputDir = tempPath.getCanonicalFile(); + + // Verify the canonical path is still under outputPath using NIO Path API + // This provides better security against path traversal attacks + Path normalizedOutputPath = outputPath.getCanonicalFile().toPath().normalize(); + Path normalizedTestOutputDir = testOutputDir.toPath().normalize(); + + if (!normalizedTestOutputDir.startsWith(normalizedOutputPath)) { + // Path tries to escape outputPath, so just use outputPath directly + testOutputDir = outputPath; + } + } catch (Exception e) { + // If normalization fails, fall back to outputPath + testOutputDir = outputPath; + } + } else { + testOutputDir = outputPath; + } + + // Ensure the directory exists + if (!testOutputDir.exists() && !testOutputDir.mkdirs()) { + StringBuilder errorMsg = new StringBuilder("Cannot create directory: ") + .append(testOutputDir.getAbsolutePath()); + errorMsg.append(" (exists: ").append(testOutputDir.exists()) + .append(", parent canWrite: ") + .append(testOutputDir.getParentFile() != null ? testOutputDir.getParentFile().canWrite() : "unknown") + .append(")"); + throw new RuntimeException(errorMsg.toString()); //NOSONAR(S112) + } + + // Create the test file with a simple, sanitized filename + String sanitizedFileName = fileName.replaceAll("[^A-Za-z0-9_.-]+", "_"); + return new File(testOutputDir, "TEST-" + sanitizedFileName + ".xml"); + } + private static String filenameOrTitleOrRandom(SinglePageResults pageResult) { if (pageResult.getPageFilePath() != null) { return pageResult.getPageFilePath(); diff --git a/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy b/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy index 6b086dbb..82de6741 100644 --- a/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy +++ b/htmlSanityCheck-core/src/test/groovy/org/aim42/htmlsanitycheck/report/JUnitXmlReporterTest.groovy @@ -1,5 +1,6 @@ package org.aim42.htmlsanitycheck.report +import org.aim42.htmlsanitycheck.Configuration import org.aim42.htmlsanitycheck.collect.Finding import org.aim42.htmlsanitycheck.collect.PerRunResults import org.aim42.htmlsanitycheck.collect.SingleCheckResults @@ -9,6 +10,7 @@ import org.junit.Before import org.junit.Test import static org.junit.Assert.assertEquals +import static org.junit.Assert.assertNotNull import static org.junit.Assert.assertTrue // see end-of-file for license information @@ -42,23 +44,27 @@ class JUnitXmlReporterTest { void tearDown() { if (outputPath) { outputPath.traverse { - System.err.println "${it}: ${it.text}" + if (it.isFile()) { + System.err.println "${it}: ${it.text}" + } else { + System.err.println "${it}: [directory]" + } } } outputPath?.deleteDir() } @Test(expected = RuntimeException.class) - void testInitReportWithNonWritableDirectory() throws IOException { - // Create a temporary directory - File tempDir = tempFolder.newFolder() + void testInitReportWithNonWritableDirectory() { + // Create a path that cannot be created (using a non-existent parent and restricted path) + File nonExistentPath = new File("/nonexistent/path/that/cannot/be/created") - // Make the directory non-writable - assertTrue("Could not make temp directory non-writable", tempDir.setWritable(false)) - - // Create a new JUnitXmlReporter with the non-writable directory + // Try to create a JUnitXmlReporter with a path that cannot be created PerRunResults runResults = new PerRunResults() - new JUnitXmlReporter(runResults, tempDir.getAbsolutePath()).initReport() + JUnitXmlReporter reporter = new JUnitXmlReporter(runResults, nonExistentPath.getAbsolutePath()) + + // This should throw RuntimeException because the path cannot be created + reporter.initReport() } @Test @@ -89,7 +95,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Zero checks expected", "0", testsuite.@tests.text()) assertEquals("Zero findings expected", "0", testsuite.@failures.text()) assertEquals("Zero testcases expected", 1, testsuite.testcase.size()) @@ -102,7 +108,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("expected no check", "0", testsuite.@tests.text()) assertEquals("expected one finding", "1", testsuite.@failures.text()) assertEquals("One testcase expected", 1, testsuite.testcase.size()) @@ -118,7 +124,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Expect one finding", "1", testsuite.@failures.text()) assertEquals("Expect one check", "1", testsuite.@tests.text()) assertEquals("One testcase expected", 1, testsuite.testcase.size()) @@ -134,7 +140,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Expect one finding", "1", testsuite.@failures.text()) assertEquals("Expect ten checks", "10", testsuite.@tests.text()) assertEquals("Expect one testcase", 1, testsuite.testcase.size()) @@ -152,7 +158,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Expect three findings", "3", testsuite.@failures.text()) assertEquals("Expect ten checks", "10", testsuite.@tests.text()) assertEquals("Expect one testcases", 1, testsuite.testcase.size()) @@ -168,7 +174,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Expect one finding", "1", testsuite.@failures.text()) assertEquals("Expect six checks", "6", testsuite.@tests.text()) assertEquals("Expect one testcases", 1, testsuite.testcase.size()) @@ -188,7 +194,7 @@ class JUnitXmlReporterTest { addSingleCheckResultsToReporter( singleCheckResults ) reporter.reportFindings() - def testsuite = new XmlSlurper().parse(outputPath.listFiles()[0]) + def testsuite = new XmlSlurper().parse(findFirstXmlFile(outputPath)) assertEquals("Expect $nrOfFindings findings", nrOfFindings as String, testsuite.@failures.text() ) assertEquals("Expect $nrOfChecks checks", nrOfChecks as String, testsuite.@tests.text() ) assertEquals("Expect one testcase", 1, testsuite.testcase.size()) @@ -201,4 +207,459 @@ class JUnitXmlReporterTest { spr.addResultsForSingleCheck( scr ) reporter.addCheckingResultsForOnePage( spr ) } + + // Helper method to find XML files recursively in a directory + private File findFirstXmlFile(File dir) { + File[] files = dir.listFiles() + if (files == null) return null + + // First look for XML files in current directory + for (File file : files) { + if (file.isFile() && file.name.endsWith('.xml')) { + return file + } + } + + // Then recurse into subdirectories + for (File file : files) { + if (file.isDirectory()) { + File found = findFirstXmlFile(file) + if (found != null) { + return found + } + } + } + + return null + } + + // Tests for FLAT output style (default, backwards compatible) + + @Test + void testFlatModeCreatesEncodedFilename() { + // Given: a page with a nested path + SinglePageResults pageWithPath = new SinglePageResults( + "about.html", + "docs/guide/about.html", + "About Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(pageWithPath) + + // When: we generate the report in FLAT mode (explicit) + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.FLAT) + .reportPageSummary(pageWithPath) + + // Then: the file should be created in the root with encoded path + File[] files = outputPath.listFiles() + assertEquals("Should have exactly one file in root", 1, files.length) + assertTrue("Filename should contain encoded path", + files[0].name.contains("docs") && files[0].name.contains("guide")) + assertTrue("Filename should start with TEST-unit-html-", files[0].name.startsWith("TEST-unit-html-")) + + def testsuite = new XmlSlurper().parse(files[0]) + assertEquals("docs/guide/about.html", testsuite.@name.text()) + } + + @Test + void testFlatModeIsDefaultWhenNotSpecified() { + // Given: a page with a nested path + SinglePageResults pageWithPath = new SinglePageResults( + "about.html", + "docs/guide/about.html", + "About Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(pageWithPath) + + // When: we generate the report WITHOUT specifying mode (should default to FLAT) + new JUnitXmlReporter(runResults, outputPath.absolutePath) + .reportPageSummary(pageWithPath) + + // Then: the file should be created in the root with encoded path (FLAT behavior) + File[] files = outputPath.listFiles() + assertEquals("Should have exactly one file in root", 1, files.length) + assertTrue("Filename should contain encoded path", + files[0].name.contains("docs") && files[0].name.contains("guide")) + + def testsuite = new XmlSlurper().parse(files[0]) + assertEquals("docs/guide/about.html", testsuite.@name.text()) + } + + // Tests for hierarchical directory structure (issue #405) + + @Test(expected = RuntimeException.class) + void testHierarchicalModeFailsWhenCannotCreateDirectory() { + // Given: an output path that's a file (not a directory) + File tempFile = File.createTempFile("test", ".txt") + tempFile.deleteOnExit() + + SinglePageResults pageWithPath = new SinglePageResults( + "about.html", + "docs/guide/about.html", + "About Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(pageWithPath) + + // When: we try to generate a report in HIERARCHICAL mode with a file as output path + // Then: it should throw RuntimeException because it cannot create subdirectories + new JUnitXmlReporter(runResults, tempFile.getAbsolutePath(), Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(pageWithPath) + } + + @Test + void testSimpleFilenameCreatesFileInRootDirectory() { + // Given: a page with a simple filename (no directory path) + SinglePageResults singlePageResultsWithSimplePath = new SinglePageResults( + "index.html", + "index.html", + "Home Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(singlePageResultsWithSimplePath) + + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(singlePageResultsWithSimplePath) + + // Then: the test file should be created directly in the output directory + File expectedFile = new File(outputPath, "TEST-index.html.xml") + assertTrue("Expected file in root: ${expectedFile.absolutePath}", expectedFile.exists()) + + def testsuite = new XmlSlurper().parse(expectedFile) + assertEquals("index.html", testsuite.@name.text()) + } + + @Test + void testSingleLevelDirectoryCreatesSubdirectory() { + // Given: a page with a single-level directory path + SinglePageResults singlePageResultsWithPath = new SinglePageResults( + "about.html", + "docs/about.html", + "About Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(singlePageResultsWithPath) + + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(singlePageResultsWithPath) + + // Then: the test file should be created in a subdirectory + File expectedDir = new File(outputPath, "docs") + File expectedFile = new File(expectedDir, "TEST-about.html.xml") + assertTrue("Expected directory to exist: ${expectedDir.absolutePath}", expectedDir.exists()) + assertTrue("Expected file to exist: ${expectedFile.absolutePath}", expectedFile.exists()) + + def testsuite = new XmlSlurper().parse(expectedFile) + assertEquals("docs/about.html", testsuite.@name.text()) + } + + @Test + void testDeepNestedDirectoryCreatesFullHierarchy() { + // Given: a page with a deeply nested directory path + String deepPath = "docs/guide/user/installation/linux.html" + SinglePageResults singlePageResultsWithDeepPath = new SinglePageResults( + "linux.html", + deepPath, + "Linux Installation Guide", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(singlePageResultsWithDeepPath) + + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(singlePageResultsWithDeepPath) + + // Then: the full directory hierarchy should be created + File expectedDir = new File(outputPath, "docs/guide/user/installation") + File expectedFile = new File(expectedDir, "TEST-linux.html.xml") + assertTrue("Expected directory hierarchy to exist: ${expectedDir.absolutePath}", expectedDir.exists()) + assertTrue("Expected file to exist: ${expectedFile.absolutePath}", expectedFile.exists()) + + def testsuite = new XmlSlurper().parse(expectedFile) + assertEquals(deepPath, testsuite.@name.text()) + } + + @Test + void testVeryLongPathDoesNotExceedFilenameLimit() { + // Given: a page with a very long path (reproducing issue #405) + // This creates a path longer than 255 characters when flattened to a single filename + String longPath = "very/long/path/with/many/nested/directories/that/would/exceed/filesystem/limits/" + + "if/flattened/into/a/single/filename/this/is/a/test/case/for/issue/405/" + + "more/directories/to/make/it/really/long/and/problematic/for/flat/structure/" + + "final/level/index.html" + + SinglePageResults singlePageResultsWithLongPath = new SinglePageResults( + "index.html", + longPath, + "Deep Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(singlePageResultsWithLongPath) + + // When: we generate the report in HIERARCHICAL mode (should not throw exception) + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(singlePageResultsWithLongPath) + + // Then: the file should be created successfully with proper directory structure + File parentPath = new File(longPath).parentFile + File expectedDir = new File(outputPath, parentPath.path) + File expectedFile = new File(expectedDir, "TEST-index.html.xml") + assertTrue("Expected directory hierarchy to exist: ${expectedDir.absolutePath}", expectedDir.exists()) + assertTrue("Expected file to exist: ${expectedFile.absolutePath}", expectedFile.exists()) + + // Verify the filename itself is short + assertTrue("Filename should be short", expectedFile.name.length() < 50) + + def testsuite = new XmlSlurper().parse(expectedFile) + assertEquals(longPath, testsuite.@name.text()) + } + + @Test + void testMultiplePagesCreateSeparateDirectories() { + // Given: multiple pages in different directories + SinglePageResults page1 = new SinglePageResults( + "index.html", + "docs/api/index.html", + "API Index", + 1000, + new ArrayList<>()) + SinglePageResults page2 = new SinglePageResults( + "index.html", + "docs/guide/index.html", + "Guide Index", + 1000, + new ArrayList<>()) + + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(page1) + runResults.addPageResults(page2) + + JUnitXmlReporter reporter = new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + + // When: we generate reports for both pages in HIERARCHICAL mode + reporter.reportPageSummary(page1) + reporter.reportPageSummary(page2) + + // Then: separate directory structures should be created + File apiDir = new File(outputPath, "docs/api") + File guideDir = new File(outputPath, "docs/guide") + File apiFile = new File(apiDir, "TEST-index.html.xml") + File guideFile = new File(guideDir, "TEST-index.html.xml") + + assertTrue("API directory should exist", apiDir.exists()) + assertTrue("Guide directory should exist", guideDir.exists()) + assertTrue("API test file should exist", apiFile.exists()) + assertTrue("Guide test file should exist", guideFile.exists()) + + // Verify content of both files + def apiTestsuite = new XmlSlurper().parse(apiFile) + assertEquals("docs/api/index.html", apiTestsuite.@name.text()) + + def guideTestsuite = new XmlSlurper().parse(guideFile) + assertEquals("docs/guide/index.html", guideTestsuite.@name.text()) + } + + @Test + void testFilenameWithSpecialCharactersIsSanitized() { + // Given: a filename with special characters + SinglePageResults pageWithSpecialChars = new SinglePageResults( + "my file (2024).html", + "docs/my file (2024).html", + "Special Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(pageWithSpecialChars) + + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(pageWithSpecialChars) + + // Then: the filename should be sanitized but directory structure preserved + File expectedDir = new File(outputPath, "docs") + assertTrue("Directory should exist", expectedDir.exists()) + + // Find the generated file (name will be sanitized) + File[] files = expectedDir.listFiles() + assertTrue("Should have exactly one file", files != null && files.length == 1) + assertTrue("Filename should start with TEST-", files[0].name.startsWith("TEST-")) + assertTrue("Filename should be sanitized (no parentheses or spaces)", + !files[0].name.contains("(") && !files[0].name.contains(")")) + + def testsuite = new XmlSlurper().parse(files[0]) + assertEquals("docs/my file (2024).html", testsuite.@name.text()) + } + + @Test + void testRelativePathWithDotDotIsHandledCorrectly() { + // Given: a page with relative path containing .. (parent directory reference) + // Note: This tests edge case handling - in practice, paths should be normalized + SinglePageResults pageWithRelativePath = new SinglePageResults( + "index.html", + "docs/../public/index.html", + "Relative Path Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(pageWithRelativePath) + + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(pageWithRelativePath) + + // Then: the file should be created (path handling depends on implementation) + // The implementation should handle this gracefully + File[] allFiles = outputPath.listFiles() + assertTrue("Should have created at least one file or directory", allFiles != null && allFiles.length > 0) + } + + @Test + void testPathTraversalAttackIsBlocked() { + // Given: a malicious path trying to escape the output directory + // This simulates a path traversal attack like "../../../etc/passwd" + SinglePageResults maliciousPage = new SinglePageResults( + "index.html", + "../../../malicious/path/index.html", + "Malicious Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(maliciousPage) + + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(maliciousPage) + + // Then: the file should be created safely within outputPath, not outside it + File[] allFiles = outputPath.listFiles() + assertTrue("Should have created file or directory", allFiles != null && allFiles.length > 0) + + // Verify no files were created outside outputPath + def outputPathCanonical = outputPath.canonicalPath + def createdFile = findFirstXmlFile(outputPath) + assertNotNull(createdFile) + + // The created file should be within outputPath + assertTrue("File should be within output directory", + createdFile.canonicalPath.startsWith(outputPathCanonical)) + } + + @Test + void testPathTraversalWithSymlinkStyleAttackIsBlocked() { + // Given: a more sophisticated path traversal attack that tries to bypass simple checks + // Example: "validdir/../../escape/test.html" which could bypass startsWith() on strings + SinglePageResults sophisticatedAttack = new SinglePageResults( + "test.html", + "valid/../../../escape/test.html", + "Sophisticated Attack", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(sophisticatedAttack) + + // When: we generate the report in HIERARCHICAL mode + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(sophisticatedAttack) + + // Then: verify the file is safely contained + def outputPathCanonical = outputPath.canonicalPath + def createdFile = findFirstXmlFile(outputPath) + assertNotNull(createdFile) + + // Use NIO Path API to verify containment (same method as production code) + def normalizedOutputPath = outputPath.canonicalFile.toPath().normalize() + def normalizedCreatedPath = createdFile.canonicalFile.toPath().normalize() + + assertTrue("File should be within output directory using NIO Path API", + normalizedCreatedPath.startsWith(normalizedOutputPath)) + } + + @Test + void testEnhancedErrorMessageWhenDirectoryCreationFails() { + // Given: a non-existent parent directory that cannot be created + // We'll use a path that's invalid on the filesystem + File invalidOutputPath = new File("/nonexistent/deeply/nested/path/that/cannot/be/created") + + SinglePageResults page = new SinglePageResults( + "test.html", + "some/deep/path/test.html", + "Test Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(page) + + // When/Then: directory creation should fail with enhanced error message + try { + new JUnitXmlReporter(runResults, invalidOutputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(page) + fail("Should have thrown RuntimeException for directory creation failure") + } catch (RuntimeException e) { + // Verify the error message contains diagnostic information + String errorMsg = e.message + assertTrue("Error message should mention 'Cannot create directory'", + errorMsg.contains("Cannot create directory")) + assertTrue("Error message should contain full path", + errorMsg.contains(invalidOutputPath.absolutePath)) + assertTrue("Error message should include 'exists:' diagnostic", + errorMsg.contains("exists:")) + assertTrue("Error message should include 'parent canWrite:' diagnostic", + errorMsg.contains("parent canWrite:")) + } + } + + @Test + void testEnhancedErrorMessageFormatIsCorrect() { + // Given: setup that will trigger directory creation failure + File readOnlyParent = new File(outputPath, "readonly-parent") + readOnlyParent.mkdirs() + + // Try to make it read-only (this may not work on all platforms, especially Windows) + boolean madeReadOnly = readOnlyParent.setReadOnly() + + if (!madeReadOnly || readOnlyParent.canWrite()) { + // Skip test if we cannot make directory read-only on this platform + System.err.println("Skipping testEnhancedErrorMessageFormatIsCorrect - cannot make directory read-only on this platform") + return + } + + try { + SinglePageResults page = new SinglePageResults( + "test.html", + "readonly-parent/subdir/test.html", + "Test Page", + 1000, + new ArrayList<>()) + PerRunResults runResults = new PerRunResults() + runResults.addPageResults(page) + + // When: attempting to create subdirectory in read-only parent + new JUnitXmlReporter(runResults, outputPath.absolutePath, Configuration.JunitOutputStyle.HIERARCHICAL) + .reportPageSummary(page) + fail("Should have thrown RuntimeException") + } catch (RuntimeException e) { + // Then: error message should have proper format with parentheses + String errorMsg = e.message + assertTrue("Error message should contain opening parenthesis", + errorMsg.contains("(")) + assertTrue("Error message should contain closing parenthesis", + errorMsg.contains(")")) + // Should have format like: "... (exists: true, parent canWrite: false)" + assertTrue("Error message should match expected format pattern", + errorMsg.matches(".*\\(exists: .*, parent canWrite: .*\\).*")) + } finally { + // Cleanup: restore write permission + readOnlyParent.setWritable(true) + } + } } diff --git a/htmlSanityCheck-gradle-plugin/README.adoc b/htmlSanityCheck-gradle-plugin/README.adoc index 66873cd8..12a2cc5d 100644 --- a/htmlSanityCheck-gradle-plugin/README.adoc +++ b/htmlSanityCheck-gradle-plugin/README.adoc @@ -2,8 +2,8 @@ :doctype: book include::../src/docs/asciidoctor-config.ad[] -image:https://img.shields.io/gradle-plugin-portal/v/org.aim42.htmlSanityCheck[link=https://plugins.gradle.org/search?term=org.aim42.htmlSanityCheck] -image:https://img.shields.io/maven-central/v/org.aim42.htmlSanityCheck/org.aim42.htmlSanityCheck.gradle.plugin[link=https://central.sonatype.com/artifact/org.aim42.htmlSanityCheck/org.aim42.htmlSanityCheck.gradle.plugin] +image:https://img.shields.io/gradle-plugin-portal/v/org.aim42.htmlsanitycheck[link=https://plugins.gradle.org/search?term=org.aim42.htmlsanitycheck] +image:https://img.shields.io/maven-central/v/org.aim42.htmlsanitycheck/org.aim42.htmlsanitycheck.gradle.plugin[link=https://central.sonatype.com/artifact/org.aim42.htmlsanitycheck/org.aim42.htmlsanitycheck.gradle.plugin] ifdef::env-github[] :imagesdir: ../src/docs/images @@ -18,6 +18,15 @@ endif::env-github[] The {gradle-url}[Gradle] plugin of HTML Sanity Check (xref:{xrefToManual}[HSC]) enables to check generated or native HTML documentation from the Gradle build. +[IMPORTANT] +.Breaking change since 2.0.0-rc4: lowercase GA coordinates +==== +The plugin id and Maven coordinates were lowercased to comply with Maven conventions and to support repositories that enforce this (e.g., GitHub Packages). +See https://github.com/aim42/htmlSanityCheck/issues/432[Issue 432] and the https://github.com/aim42/htmlSanityCheck/blob/main/CHANGELOG.md[CHANGELOG] for the migration table. + +Versions up to and including `2.0.0-rc4` remain available under the historical CamelCase id at https://plugins.gradle.org/plugin/org.aim42.htmlSanityCheck[the Gradle Plugin Portal] and https://central.sonatype.com/namespace/org.aim42.htmlSanityCheck[Maven Central]. +==== + [[sec:installation]] == Installation (Gradle Plugin) @@ -37,30 +46,28 @@ plugins { === Legacy Installation -In the case of https://docs.gradle.org/current/userguide/plugins.html#sec:old_plugin_application[legacy plugin usage] +In the case of https://docs.gradle.org/current/userguide/plugins.html#sec:old_plugin_application[legacy plugin usage], the full Maven coordinates (group + artifact + version) are required. +The plugin-marker pseudo-POM resolved automatically by the `plugins {}` DSL only applies to the default form above. .build.gradle [source,groovy,subs="attributes+"] ---- buildscript { repositories { - // maven { url "{jitpack-url}" } // <1> - mavenCentral() // <2> - gradlePluginPortal() // <3> + mavenCentral() // <1> + gradlePluginPortal() // <2> } dependencies { - classpath ('gradle.plugin.org.aim42:{project}:{hsc-version}') // <4> + classpath 'org.aim42.{project}:{project}-gradle-plugin:{hsc-version}' // <3> } } apply plugin: 'org.aim42.{project}' ---- - -<1> In case you would like to use a development version (or even a branch), check out <>. -<2> Beginning with version `2.x` all releases will be published to https://central.sonatype.com/artifact/org.aim42.htmlSanityCheck/org.aim42.htmlSanityCheck.gradle.plugin[Maven Central]. -<3> The https://plugins.gradle.org[Gradle Plugin Portal] contains https://plugins.gradle.org/plugin/org.aim42.htmlSanityCheck[most versions] or will redirect downloads of newer versions to Maven Central. -<4> Check out <>. +<1> Beginning with version `2.x` all releases will be published to https://central.sonatype.com/artifact/org.aim42.htmlsanitycheck/htmlsanitycheck-gradle-plugin[Maven Central]. +<2> The https://plugins.gradle.org[Gradle Plugin Portal] contains https://plugins.gradle.org/plugin/org.aim42.htmlsanitycheck[most versions] or will redirect downloads of newer versions to Maven Central. +<3> Full GA coordinates — see <>. [[box:current-version]] [IMPORTANT] @@ -212,7 +219,7 @@ The mentioned configurations effectively move the configured codes around, i.e., `build.gradle` [source,groovy] ---- -apply plugin: 'org.aim42.htmlSanityCheck' +apply plugin: 'org.aim42.htmlsanitycheck' htmlSanityCheck { sourceDir = file( "$buildDir/docs" ) @@ -286,7 +293,7 @@ asciidoctor { } -apply plugin: 'org.aim42.htmlSanityCheck' +apply plugin: 'org.aim42.htmlsanitycheck' htmlSanityCheck { // ensure asciidoctor->html runs first @@ -359,20 +366,42 @@ include::src/test/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckTaskFun [[sec:development-versions]] == Development versions -In case you want to use a current development (or arbitrary branch or tag) version from GitHub, -add the following to your `settings.gradle`: +In case you want to use a current development (or arbitrary branch or tag) version, the recommended source is *GitHub Packages*. +JitPack is still available but no longer recommended (see <>). + +[[sec:github-packages]] +=== GitHub Packages (recommended) + +Development snapshots are published to https://github.com/orgs/aim42/packages?repo_name=htmlSanityCheck[GitHub Packages for `aim42/htmlSanityCheck`]. +GitHub Packages requires authentication even for public packages, so you must supply a Personal Access Token (PAT) with `read:packages` scope via the env vars `GITHUB_USER` and `GITHUB_TOKEN`: + +[source,shell] +---- +export GITHUB_USER= +export GITHUB_TOKEN= +---- + +Then declare the repository in your project's `settings.gradle`: .settings.gradle [source,groovy,subs="attributes+"] ---- pluginManagement { repositories { - maven { url "{jitpack-url}" } + maven { + name = "GitHubPackages" + url = "https://maven.pkg.github.com/aim42/htmlSanityCheck" + credentials { + username = System.getenv("GITHUB_USER") + password = System.getenv("GITHUB_TOKEN") + } + } + gradlePluginPortal() } } ---- -Then you can use a respective version in your `build.gradle`: +And select the development version in your `build.gradle`: .build.gradle [source,groovy,subs="attributes+"] @@ -382,15 +411,40 @@ plugins { } ---- -[NOTE] -.JitPack builds +[TIP] +==== +The same repository declaration works for the `dependencies {}` block (e.g. to pull `{project}-core` directly) — declare it under `repositories {}` in your `build.gradle` instead of `settings.gradle`. ==== -Building the desired version for the first time (or after some cache expiry at {jitpack-url}[JitPack]), you may experience a timeout. -You can look up the https://jitpack.io/#org.aim42/htmlSanityCheck[current build state]: +[[sec:jitpack-deprecation]] +=== JitPack (deprecated) -image::jitpack-branch-screenshot.png[] +[WARNING] ==== +JitPack support is retained for backwards compatibility but is no longer recommended. +Known issues that motivated the switch to GitHub Packages (https://github.com/aim42/htmlSanityCheck/issues/432[Issue 432]): + +* New branches do not always show up. +* Updates after force pushes are not always rebuilt. +* First-time builds (or after cache expiry) can time out. +==== + +If you still want to use JitPack, add it to your `settings.gradle`: + +.settings.gradle +[source,groovy,subs="attributes+"] +---- +pluginManagement { + repositories { + maven { url "{jitpack-url}" } + gradlePluginPortal() + } +} +---- + +You can look up the https://jitpack.io/#org.aim42/htmlSanityCheck[current JitPack build state]: + +image::jitpack-branch-screenshot.png[] diff --git a/htmlSanityCheck-gradle-plugin/build.gradle b/htmlSanityCheck-gradle-plugin/build.gradle index feb4f0a7..19f63f0b 100644 --- a/htmlSanityCheck-gradle-plugin/build.gradle +++ b/htmlSanityCheck-gradle-plugin/build.gradle @@ -7,6 +7,8 @@ plugins { // id 'codenarc' } +base.archivesName = 'htmlsanitycheck-gradle-plugin' + def profile = project.hasProperty('profile') ? project.property('profile') : 'no-gpp' afterEvaluate { @@ -24,7 +26,7 @@ gradlePlugin { vcsUrl = rootProject.ext.urls['scm'] plugins { htmlSanityCheck { - id = 'org.aim42.htmlSanityCheck' + id = 'org.aim42.htmlsanitycheck' implementationClass = 'org.aim42.htmlsanitycheck.gradle.HtmlSanityCheckPlugin' displayName = 'Gradle HtmlSanityCheck Plugin' description = project.description diff --git a/htmlSanityCheck-gradle-plugin/src/main/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckTask.groovy b/htmlSanityCheck-gradle-plugin/src/main/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckTask.groovy index bdead7ac..ade71ae6 100644 --- a/htmlSanityCheck-gradle-plugin/src/main/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckTask.groovy +++ b/htmlSanityCheck-gradle-plugin/src/main/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckTask.groovy @@ -86,6 +86,11 @@ class HtmlSanityCheckTask extends DefaultTask { @Input Set excludes + // JUnit output style: FLAT (default, backwards compatible) or HIERARCHICAL (mirrors source structure) + @Optional + @Input + Configuration.JunitOutputStyle junitOutputStyle + @Input List> checkerClasses = AllCheckers.CHECKER_CLASSES @@ -194,6 +199,7 @@ See ${checkingResultsDir} for a detailed report.""" .checksToExecute(checkerClasses) .excludes(excludes.stream().map(Pattern::compile).collect(Collectors.toSet())) + .junitOutputStyle(junitOutputStyle) .build() // in case we have configured specific interpretations of http status codes diff --git a/htmlSanityCheck-gradle-plugin/src/test/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckBaseSpec.groovy b/htmlSanityCheck-gradle-plugin/src/test/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckBaseSpec.groovy index 8ef683a8..3a56cb9c 100644 --- a/htmlSanityCheck-gradle-plugin/src/test/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckBaseSpec.groovy +++ b/htmlSanityCheck-gradle-plugin/src/test/groovy/org/aim42/htmlsanitycheck/gradle/HtmlSanityCheckBaseSpec.groovy @@ -30,7 +30,7 @@ class HtmlSanityCheckBaseSpec extends Specification { // (URIs consist of / instead of backslashes) buildFile = testProjectDir.newFile('build.gradle') << """ plugins { - id 'org.aim42.htmlSanityCheck' + id 'org.aim42.htmlsanitycheck' } htmlSanityCheck { diff --git a/htmlSanityCheck-maven-plugin/README.adoc b/htmlSanityCheck-maven-plugin/README.adoc index 15ce403e..ab087e13 100644 --- a/htmlSanityCheck-maven-plugin/README.adoc +++ b/htmlSanityCheck-maven-plugin/README.adoc @@ -2,7 +2,7 @@ :doctype: book include::../src/docs/asciidoctor-config.ad[] -image:https://img.shields.io/maven-central/v/org.aim42.htmlSanityCheck/htmlSanityCheck-maven-plugin[link=https://central.sonatype.com/artifact/org.aim42.htmlSanityCheck/htmlSanityCheck-maven-plugin] +image:https://img.shields.io/maven-central/v/org.aim42.htmlsanitycheck/htmlsanitycheck-maven-plugin[link=https://central.sonatype.com/artifact/org.aim42.htmlsanitycheck/htmlsanitycheck-maven-plugin] ifdef::env-github[] :imagesdir: ../src/docs/images @@ -17,6 +17,15 @@ endif::env-github[] The {maven-url}[Maven] plugin of HTML Sanity Check (xref:{xrefToManual}[HSC]) enables to check generated or native HTML documentation from the Maven build. +[IMPORTANT] +.Breaking change since 2.0.0-rc4: lowercase GA coordinates +==== +The Maven `groupId` and `artifactId` were lowercased to comply with Maven conventions and to support repositories that enforce this (e.g., GitHub Packages). +See https://github.com/aim42/htmlSanityCheck/issues/432[Issue 432] and the https://github.com/aim42/htmlSanityCheck/blob/main/CHANGELOG.md[CHANGELOG] for the migration table. + +Versions up to and including `2.0.0-rc4` remain available under the historical CamelCase coordinates at https://central.sonatype.com/namespace/org.aim42.htmlSanityCheck[Maven Central]. +==== + [[sec:installation]] == Installation (Maven Plugin) @@ -28,8 +37,8 @@ Use the following snippet inside a Maven build file: [source,xml,subs="attributes+"] ---- - org.aim42.htmlSanityCheck - htmlSanityCheck-maven-plugin + org.aim42.htmlsanitycheck + htmlsanitycheck-maven-plugin {hsc-version} // <1> @@ -65,6 +74,46 @@ include::../gradle.properties[tag=version] ---- ==== +[[sec:development-versions]] +=== Development versions + +Development snapshots are published to *GitHub Packages* for https://github.com/orgs/aim42/packages?repo_name=htmlSanityCheck[`aim42/htmlSanityCheck`]. +GitHub Packages requires authentication even for public packages — supply a Personal Access Token (PAT) with `read:packages` scope via `~/.m2/settings.xml`: + +.~/.m2/settings.xml +[source,xml,subs="attributes+"] +---- + + + + github-aim42 + YOUR_GITHUB_USERNAME + YOUR_PAT_WITH_read:packages + + + +---- + +Then declare the repository in your project's `pom.xml`: + +[source,xml,subs="attributes+"] +---- + + + github-aim42 + https://maven.pkg.github.com/aim42/htmlSanityCheck + true + + +---- + +[WARNING] +.JitPack (deprecated) +==== +JitPack support is retained for backwards compatibility but is no longer recommended for the reasons documented in https://github.com/aim42/htmlSanityCheck/issues/432[Issue 432] (missing branches after force-push, stale builds, first-time timeouts). +Prefer GitHub Packages. +==== + [[sec:usage]] == Usage diff --git a/htmlSanityCheck-maven-plugin/build.gradle b/htmlSanityCheck-maven-plugin/build.gradle index 80e4741a..d4fba141 100644 --- a/htmlSanityCheck-maven-plugin/build.gradle +++ b/htmlSanityCheck-maven-plugin/build.gradle @@ -6,6 +6,8 @@ plugins { id "java" } +base.archivesName = 'htmlsanitycheck-maven-plugin' + ext { mavenPluginToolsVersion = "3.15.1" } @@ -38,7 +40,12 @@ tasks.register('generatePom', Copy) { into 'build/maven' filesMatching("**/pom.*") { expand(version: project.version, - mavenPluginToolsVersion: project.mavenPluginToolsVersion) + mavenPluginToolsVersion: project.mavenPluginToolsVersion, + // Absolute file:// URL of the root build/maven-repo where Gradle publishes the + // just-built core artifact. Used by the templated pom.xml as a 'remote' repo so + // Maven resolves SNAPSHOTs cleanly. This pom.xml is build-time-only; the published + // pom.xml comes from generatePomFileForMavenJavaPublication. + mavenBuildRepoUrl: rootProject.file("${Project.DEFAULT_BUILD_DIR_NAME}/maven-repo").toURI().toString()) } } diff --git a/htmlSanityCheck-maven-plugin/settings.xml b/htmlSanityCheck-maven-plugin/settings.xml index e198fdb3..4de4771b 100644 --- a/htmlSanityCheck-maven-plugin/settings.xml +++ b/htmlSanityCheck-maven-plugin/settings.xml @@ -2,5 +2,11 @@ - ../build/maven-repo + + ../.gradle/maven-local-cache diff --git a/htmlSanityCheck-maven-plugin/src/main/java/org/aim42/htmlsanitycheck/maven/HtmlSanityCheckMojo.java b/htmlSanityCheck-maven-plugin/src/main/java/org/aim42/htmlsanitycheck/maven/HtmlSanityCheckMojo.java index 4461d283..fa7deddc 100644 --- a/htmlSanityCheck-maven-plugin/src/main/java/org/aim42/htmlsanitycheck/maven/HtmlSanityCheckMojo.java +++ b/htmlSanityCheck-maven-plugin/src/main/java/org/aim42/htmlsanitycheck/maven/HtmlSanityCheckMojo.java @@ -200,6 +200,18 @@ public class HtmlSanityCheckMojo extends AbstractMojo { @Parameter private Set excludes = new HashSet<>(); + /** + * (optional) + * JUnit output style: FLAT (all files in one directory, default for backwards compatibility) + * or HIERARCHICAL (subdirectories mirror source structure, solves filename length issues). + *

+ * Type: JunitOutputStyle (FLAT or HIERARCHICAL). + *

+ * Default: FLAT. + */ + @Parameter + private Configuration.JunitOutputStyle junitOutputStyle; + static PerRunResults performChecks(Configuration myConfig) throws MojoExecutionException { try { AllChecksRunner allChecksRunner = new AllChecksRunner(myConfig); @@ -286,6 +298,7 @@ protected Configuration setupConfiguration() { .ignoreIPAddresses(ignoreIPAddresses) .checksToExecute(checkerClasses) + .junitOutputStyle(junitOutputStyle) .build(); // in case we have configured specific interpretations of http status codes diff --git a/htmlSanityCheck-maven-plugin/src/main/maven/pom.properties b/htmlSanityCheck-maven-plugin/src/main/maven/pom.properties index d5b74e55..aa16b7fd 100644 --- a/htmlSanityCheck-maven-plugin/src/main/maven/pom.properties +++ b/htmlSanityCheck-maven-plugin/src/main/maven/pom.properties @@ -1,4 +1,4 @@ version=${version} -groupId=org.aim42.htmlSanityCheck -artifactId=htmlSanityCheck-maven-plugin +groupId=org.aim42.htmlsanitycheck +artifactId=htmlsanitycheck-maven-plugin diff --git a/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml b/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml index 198484c3..d715c441 100644 --- a/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml +++ b/htmlSanityCheck-maven-plugin/src/main/maven/pom.xml @@ -3,8 +3,8 @@ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.aim42.htmlSanityCheck - htmlSanityCheck-maven-plugin + org.aim42.htmlsanitycheck + htmlsanitycheck-maven-plugin HTML Sanitycheck Maven Plugin ${version} maven-plugin @@ -14,10 +14,25 @@ 8 + + + + integration-test-local + ${mavenBuildRepoUrl} + true + true + + + - org.aim42.htmlSanityCheck - htmlSanityCheck-core + org.aim42.htmlsanitycheck + htmlsanitycheck-core ${version} diff --git a/integration-test/gradle-plugin/build.gradle b/integration-test/gradle-plugin/build.gradle index b9839c7d..a58e30fb 100644 --- a/integration-test/gradle-plugin/build.gradle +++ b/integration-test/gradle-plugin/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.aim42.htmlSanityCheck' version "${htmlSanityCheckVersion}" + id 'org.aim42.htmlsanitycheck' version "${htmlSanityCheckVersion}" } repositories { @@ -16,6 +16,10 @@ htmlSanityCheck { // hosts). The link works in browsers and from most environments; exclude it from // automated checking rather than dropping the historical reference. "https://web.archive.org/.*", + // TODO(#432): remove this exclude after the first release under the lowercase plugin id is published to the Gradle Plugin Portal. + "https://plugins.gradle.org/plugin/org.aim42.htmlsanitycheck.*", + // TODO(#432): remove this exclude after the first release under the lowercase group is published to Maven Central. + "https://central.sonatype.com/(namespace|artifact)/org.aim42.htmlsanitycheck.*", ] httpSuccessCodes = [429] diff --git a/integration-test/maven-plugin/pom.xml b/integration-test/maven-plugin/pom.xml index c1243586..98308aed 100644 --- a/integration-test/maven-plugin/pom.xml +++ b/integration-test/maven-plugin/pom.xml @@ -3,7 +3,7 @@ xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 - org.aim42.htmlSanityCheck.integration-test + org.aim42.htmlsanitycheck.integration-test maven-plugin-integration-test pom 0.0-SNAPSHOT @@ -11,11 +11,26 @@ YOU MUST SET THIS EXPLICITLY + + + + + integration-test-local + file://${project.basedir}/../../build/maven-repo + true + true + + + - org.aim42.htmlSanityCheck - htmlSanityCheck-maven-plugin + org.aim42.htmlsanitycheck + htmlsanitycheck-maven-plugin ${hsc.version} @@ -37,6 +52,10 @@ https://web.archive.org/.* + + https://plugins.gradle.org/plugin/org.aim42.htmlsanitycheck.* + + https://central.sonatype.com/(namespace|artifact)/org.aim42.htmlsanitycheck.* ../common/build/docs ${project.build.directory}/reports diff --git a/integration-test/maven-plugin/settings.xml b/integration-test/maven-plugin/settings.xml index 7a1dae4e..97b98538 100644 --- a/integration-test/maven-plugin/settings.xml +++ b/integration-test/maven-plugin/settings.xml @@ -2,6 +2,13 @@ - ../../build/maven-repo + + ../../.gradle/maven-local-cache diff --git a/self-check/build.gradle b/self-check/build.gradle index 61dbe45b..dd09513e 100644 --- a/self-check/build.gradle +++ b/self-check/build.gradle @@ -1,5 +1,5 @@ plugins { - id 'org.aim42.htmlSanityCheck' version "${htmlSanityCheckVersion}" + id 'org.aim42.htmlsanitycheck' version "${htmlSanityCheckVersion}" } htmlSanityCheck { @@ -10,6 +10,10 @@ htmlSanityCheck { "https://www.baeldung.com/.*", // Baeldung seems to have implemented some anti-robot/GPT strategy? "https://www.iconfinder.com/icons/118743/arrow_up_icon", "https://www.freepik.com/", // Both fail frequently on GitHub pages "https://web.archive.org/.*", // Wayback Machine connect-times-out from some CI runners (notably Azure Windows hosts). + // TODO(#432): remove this exclude after the first release under the lowercase plugin id is published to the Gradle Plugin Portal. + "https://plugins.gradle.org/plugin/org.aim42.htmlsanitycheck.*", + // TODO(#432): remove this exclude after the first release under the lowercase group is published to Maven Central. + "https://central.sonatype.com/(namespace|artifact)/org.aim42.htmlsanitycheck.*", ] httpSuccessCodes = [ 429 ] diff --git a/src/docs/asciidoctor-config.ad b/src/docs/asciidoctor-config.ad index 5aa07730..215a6481 100644 --- a/src/docs/asciidoctor-config.ad +++ b/src/docs/asciidoctor-config.ad @@ -14,7 +14,7 @@ ifndef::imagesdir[:imagesdir: src/docs/images] :jitpack-url: https://jitpack.io/ :maven-url: https://maven.apache.org/ -:project: htmlSanityCheck +:project: htmlsanitycheck :project-url: https://github.com/aim42/htmlSanityCheck :project-issues: https://github.com/aim42/htmlSanityCheck/issues :project-bugs: {project-issues}?q=is%3Aopen+is%3Aissue+label%3Abug diff --git a/src/docs/development/_includes/issue-405.adoc b/src/docs/development/_includes/issue-405.adoc new file mode 100644 index 00000000..241fa8a6 --- /dev/null +++ b/src/docs/development/_includes/issue-405.adoc @@ -0,0 +1,146 @@ +:filename: development/issue-405.adoc +include::../../_common.adoc[] + +== {issue-closed} "File name too long" error with deep paths in JUnit reports (405) + +=== Problem + +https://github.com/aim42/htmlSanityCheck/issues/405[Issue 405] reports that when using the htmlSanityCheck Gradle plugin within a subproject with deeply nested directory structures, a "File name too long" error occurs during the generation of JUnit XML reports. + +The error happens because the generated JUnit report filenames incorporate both the full path to the Gradle subproject and the deeply nested folder structure of the files being checked. +This results in filenames that exceed the filesystem's maximum filename length limit (typically 255 characters). + +Example error: +[source,text] +---- +Caused by: java.io.FileNotFoundException: +/home/xxxxx/.../documentation/build/test-results/htmlchecks/ +TEST-unit-html-_xxxx_xxxxx_xxx_xxxxxxxx_xxxxxxxxxx_xxxxxxxxxxxx_ +xxxxxxxxxxxxxxxxxxxxx_documentation_build_test-results_htmlchecks_ +xxxxxxxxxxxxxxx_xx_xxxxx_xxxxxxxx_xxx_xxxx_xxxxxxxxx_xx_xxxx_xx_ +xx_xxxxxxxxxxxxx_xx_xxxxxx_xxxxxxxxxxxxx_xx_xxxxxxxx_xxxxxxxxxx_ +xxxx.xml (File name too long) +---- + +=== Background + +The original `JUnitXmlReporter` implementation used a flat file structure where all JUnit XML reports were stored in a single directory. The filename was constructed by: + +. Taking the full file path of the checked HTML file +. Sanitizing it by replacing all non-alphanumeric characters with underscores +. Prepending `TEST-unit-html-` to create the final filename + +This approach worked well for shallow directory structures but failed when: + +* Working in deeply nested Gradle subprojects +* Checking HTML files that are themselves in deep directory structures +* The combined path length exceeded OS filename limits (~255 characters) + +=== Solution + +A new configuration option `junitOutputStyle` allows choosing between two output structures for JUnit XML reports: + +FLAT (default):: All JUnit XML reports are stored in a single directory with the entire file path encoded into the filename using underscores. This maintains backward compatibility with existing configurations. ++ +[source,text] +---- +build/test-results/htmlchecks/ + └── TEST-unit-html-_docs_guide_user_installation_linux_html.xml +---- + +HIERARCHICAL:: Creates a hierarchical directory structure that mirrors the source file organization, solving the filename length issue. ++ +[source,text] +---- +build/test-results/htmlchecks/ + └── docs/ + └── guide/ + └── user/ + └── installation/ + └── TEST-linux.html.xml ✅ +---- + +The HIERARCHICAL approach provides several benefits: + +Solves the filename length issue:: Individual filenames stay well under the 255-character filesystem limit +Intuitive organization:: Directory structure mirrors the checked HTML files' structure, making results easy to find +Maintains all information:: Full path information is preserved through the directory hierarchy +Robust error handling:: Handles edge cases like special characters, relative paths, and path traversal attempts + +=== Configuration + +To enable the hierarchical output structure, set `junitOutputStyle` to `HIERARCHICAL` in your build configuration: + +.Gradle +[source,groovy] +---- +htmlSanityCheck { + junitOutputStyle = org.aim42.htmlsanitycheck.Configuration.JunitOutputStyle.HIERARCHICAL +} +---- + +.Maven +[source,xml] +---- + + org.aim42.htmlSanityCheck + htmlSanityCheck-maven-plugin + + HIERARCHICAL + + +---- + +.CLI +[source,bash] +---- +hsc --junitOutputStyle HIERARCHICAL /path/to/html/files +---- + +NOTE: The default value is `FLAT` for backward compatibility. Existing users will see no change unless they explicitly configure `HIERARCHICAL` mode. + +=== Implementation + +The solution was implemented in `JUnitXmlReporter.java` with the following components: + +. A new `JunitOutputStyle` enum (nested in `Configuration` class) defining `FLAT` and `HIERARCHICAL` modes +. An `outputStyle` field to store the configuration (defaults to `FLAT`) +. Modified `reportPageSummary()` method to select the appropriate output strategy: ++ +[source,java] +---- +include::../../../../htmlSanityCheck-core/src/main/java/org/aim42/htmlsanitycheck/report/JUnitXmlReporter.java[tags=reportPageSummary,indent=0] +---- + +. Two separate methods implement each strategy: + * `getFlatOutputFile()` - Original flat structure implementation + * `getHierarchicalOutputFile()` - New hierarchical structure implementation + +Key implementation details for HIERARCHICAL mode: + +Path normalization:: Uses `getCanonicalFile()` to handle relative paths with `..` references +Security check:: Verifies that normalized paths don't escape the output directory +Graceful fallback:: Falls back to the output root if path resolution fails +Filename sanitization:: Only sanitizes the filename component, not the entire path + +The configuration option is exposed in: + +* `Configuration` class - `junitOutputStyle` field with `FLAT` default +* Gradle plugin (`HtmlSanityCheckTask`) - `junitOutputStyle` input property +* Maven plugin (`HtmlSanityCheckMojo`) - `junitOutputStyle` parameter +* CLI (`HscCommand`) - `--junitOutputStyle` / `-o` command-line option + +=== Testing + +The implementation includes comprehensive test coverage with 7 new tests in `JUnitXmlReporterTest` that explicitly test HIERARCHICAL mode: + +`testSimpleFilenameCreatesFileInRootDirectory`:: Verifies files with no directory path are created in the root +`testSingleLevelDirectoryCreatesSubdirectory`:: Tests single-level directory creation +`testDeepNestedDirectoryCreatesFullHierarchy`:: Tests deeply nested directories (4+ levels) +`testVeryLongPathDoesNotExceedFilenameLimit`:: Reproduces and fixes the issue #405 with very long paths +`testMultiplePagesCreateSeparateDirectories`:: Verifies multiple pages create separate directory structures +`testFilenameWithSpecialCharactersIsSanitized`:: Tests filename sanitization while preserving the directory structure +`testRelativePathWithDotDotIsHandledCorrectly`:: Tests edge case handling of relative paths + +All existing tests continue to pass with FLAT mode (the default), ensuring backward compatibility. +The full test suite of 379+ tests validates that existing functionality is preserved. diff --git a/src/docs/development/design-discussions.adoc b/src/docs/development/design-discussions.adoc index 05a2ac55..1f1e2ee8 100644 --- a/src/docs/development/design-discussions.adoc +++ b/src/docs/development/design-discussions.adoc @@ -19,5 +19,6 @@ include::_includes/issue-252.adoc[leveloffset=+2] === Resolved Issues +include::_includes/issue-405.adoc[leveloffset=+2] include::_includes/issue-244.adoc[leveloffset=+2] include::_includes/issue-190.adoc[leveloffset=+2] diff --git a/src/docs/development/publishing.adoc b/src/docs/development/publishing.adoc index aeb59b71..6e0a68fa 100644 --- a/src/docs/development/publishing.adoc +++ b/src/docs/development/publishing.adoc @@ -15,7 +15,8 @@ ifdef::backend-html5[:markdown-suffix: html] We distribute HSC for different usage scenarios to appropriate repositories, i.e., we publish -* The HSC artifacts (Core, Maven plugin, and Gradle plugin) to https://central.sonatype.com/search?q=org.aim42.htmlSanityCheck[Maven Central] (MC) for retrieval by the common Java build tools, +* The HSC artifacts (Core, Maven plugin, and Gradle plugin) to https://central.sonatype.com/search?q=org.aim42.htmlsanitycheck[Maven Central] (MC) for retrieval by the common Java build tools, +* Development snapshots additionally to https://github.com/orgs/aim42/packages?repo_name=htmlSanityCheck[GitHub Packages] (replacing JitPack, cf. https://github.com/aim42/htmlSanityCheck/issues/432[Issue 432]), * The HSC Command Line Interface (CLI) distribution to ** https://github.com/aim42/htmlSanityCheck/releases[GitHub] for download as ZIP- or TAR-File. ** https://sdkman.io/sdks#hsc[SDKMAN] for convenient installation (`sdk install hsc`). @@ -34,7 +35,7 @@ Make yourself familiar with https://central.sonatype.org/publish-ea/publish-ea-g [CAUTION] .Maven Central credentials needed ==== -You need respective credentials to upload files to Maven Central for the `org.aim42.htmlSanityCheck` namespace. +You need respective credentials to upload files to Maven Central for the `org.aim42.htmlsanitycheck` namespace. Talk to Gernot Starke to get these permissions. ==== @@ -76,6 +77,20 @@ export GRADLE_PUBLISH_KEY=... export GRADLE_PUBLISH_SECRET=... ---- +[[sec:gh-packages-credentials]] +=== GitHub Packages + +Development snapshots are pushed to https://github.com/orgs/aim42/packages?repo_name=htmlSanityCheck[GitHub Packages for `aim42/htmlSanityCheck`]. +A Personal Access Token (PAT) with `write:packages` scope is required, supplied via env vars: + +[source,shell] +---- +export GITHUB_USER=... +export GITHUB_TOKEN= +---- + +The same env-var pair is the default for GitHub Actions secrets — no additional configuration is needed on CI. + [[sec:sdkman-api-credentials]] === SDKMAN @@ -188,6 +203,18 @@ Clean, check (test), and perform integration tests: ./gradlew clean check integrationTest ---- +=== Publish development snapshot to GitHub Packages + +For sharing a development branch with downstream consumers before a release, push the current version to GitHub Packages (cf. <>): + +[source,shell] +---- +./gradlew publishAllPublicationsToGitHubPackagesRepository +---- + +This publishes all four artifacts (core, cli, gradle-plugin, maven-plugin) under the lowercase GA coordinates `org.aim42.htmlsanitycheck:htmlsanitycheck-*`. +Consumers configure their build to read from the same repository — see the development-versions section of the xref:../manual/30_gradle-plugin.adoc[Gradle plugin] or xref:../manual/40_maven-plugin.adoc[Maven plugin] documentation. + === Publish on Gradle Plugin Portal Set the respective credentials (cf. <>).