Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/**
Expand All @@ -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)
Expand Down Expand Up @@ -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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,41 @@
@ToString
@Slf4j
public class Configuration {

/**
* Defines the output style for JUnit XML reports.
* <p>
* 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.
* <p>
* Example: {@code build/test-results/htmlchecks/TEST-unit-html-_docs_guide_installation.xml}
* <p>
* 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.
* <p>
* Example: {@code build/test-results/htmlchecks/docs/guide/TEST-installation.xml}
* <p>
* This avoids filename length issues and provides more intuitive organization.
* Recommended for projects with deeply nested directory structures.
*
* @see <a href="https://github.com/aim42/htmlSanityCheck/issues/405">Issue 405</a>
*/
HIERARCHICAL
}

Set<File> sourceDocuments;
File sourceDir;
File checkingResultsDir;
Expand All @@ -52,6 +87,8 @@ public class Configuration {
Set<Pattern> excludes = new HashSet<>();
@Builder.Default
Set<String> indexFilenames = defaultIndeFilenames();
@Builder.Default
JunitOutputStyle junitOutputStyle = JunitOutputStyle.FLAT;

/*
* Explanation for configuring http status codes:
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -36,13 +37,25 @@
/**
* Write the findings' report to JUnit XML. Allows tools processing JUnit to
* include the findings.
* <p>
* Supports two output styles:
* <ul>
* <li>{@link Configuration.JunitOutputStyle#FLAT} - All files in one directory with encoded paths (default, backwards compatible)</li>
* <li>{@link Configuration.JunitOutputStyle#HIERARCHICAL} - Files organized in subdirectories mirroring source structure</li>
* </ul>
*/
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
Expand All @@ -52,11 +65,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)) {
Expand Down Expand Up @@ -96,6 +113,63 @@ 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
if (!testOutputDir.getAbsolutePath().startsWith(outputPath.getCanonicalPath())) {
// 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()) {
throw new RuntimeException("Cannot create directory " + testOutputDir); //NOSONAR(S112)
Copy link

Copilot AI Oct 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message should include more context about why directory creation failed. Consider including the original IOException details or system-specific error information to help with debugging.

Suggested change
throw new RuntimeException("Cannot create directory " + testOutputDir); //NOSONAR(S112)
StringBuilder errorMsg = new StringBuilder("Cannot create directory: ")
.append(testOutputDir.getAbsolutePath());
errorMsg.append(" (exists: ").append(testOutputDir.exists())
.append(", canWrite: ").append(testOutputDir.getParentFile() != null ? testOutputDir.getParentFile().canWrite() : "unknown")
.append(")");
throw new RuntimeException(errorMsg.toString()); //NOSONAR(S112)

Copilot uses AI. Check for mistakes.
}

// 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();
Expand Down
Loading
Loading