diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..672519fd9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# Project Guide + +Use [Allure Agent Mode](docs/allure-agent-mode.md) for all test-related work in this repository. + +- Read `docs/allure-agent-mode.md` before designing, writing, reviewing, validating, debugging, or enriching tests. +- If a command executes tests and its result will be used for smoke checking, reasoning, review, coverage analysis, debugging, or any user-facing conclusion, run it through `allure agent`. It preserves the original console logs and adds agent-mode artifacts without inheriting the normal report or export plugins from the project config. +- Use `allure agent` for smoke checks too, even when the change is small or mechanical. +- Only skip agent mode when it is impossible or when you are debugging agent mode itself. +- If agent-mode output is missing or incomplete, debug that first rather than silently falling back to console-only review. +- Use Allure agent-mode when adding tests for features or fixes so expectations, evidence quality, and scope review are part of the loop. diff --git a/allure-commandline/build.gradle.kts b/allure-commandline/build.gradle.kts index 92c326cc3..4cedec9c9 100644 --- a/allure-commandline/build.gradle.kts +++ b/allure-commandline/build.gradle.kts @@ -5,10 +5,21 @@ import org.gradle.kotlin.dsl.support.unzipTo plugins { application id("com.netflix.nebula.ospackage") + id("io.qameta.allure") } description = "Allure Commandline" +allure { + version.set("2.34.0") + adapter { + allureJavaVersion.set("2.34.0") + aspectjVersion.set("1.9.25.1") + autoconfigure.set(false) + aspectjWeaver.set(true) + } +} + application { mainClass.set("io.qameta.allure.CommandLine") } @@ -132,6 +143,7 @@ dependencies { implementation("com.fasterxml.jackson.dataformat:jackson-dataformat-yaml") implementation("commons-io:commons-io") implementation(project(":allure-generator")) + testImplementation("io.qameta.allure:allure-assertj") testImplementation("io.qameta.allure:allure-junit-platform") testImplementation("org.apache.commons:commons-lang3") testImplementation("org.assertj:assertj-core") diff --git a/allure-commandline/src/test/java/io/qameta/allure/CommandLineTest.java b/allure-commandline/src/test/java/io/qameta/allure/CommandLineTest.java index b955164d1..815c175e0 100644 --- a/allure-commandline/src/test/java/io/qameta/allure/CommandLineTest.java +++ b/allure-commandline/src/test/java/io/qameta/allure/CommandLineTest.java @@ -15,9 +15,11 @@ */ package io.qameta.allure; +import io.qameta.allure.command.MainCommand; import io.qameta.allure.option.ConfigOptions; import io.qameta.allure.option.ReportLanguageOptions; import io.qameta.allure.option.ReportNameOptions; +import io.qameta.allure.option.VerboseOptions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -60,17 +62,26 @@ void setUp() { this.commandLine = new CommandLine(commands); } + /** + * Verifies that invoking the parser without arguments is rejected. + * The test checks the command line reports an argument parsing error. + */ + @Description @Test void shouldParseEmptyArguments() { - final Optional parse = commandLine.parse(); + final Optional parse = parse(); assertThat(parse) .hasValue(ExitCode.ARGUMENT_PARSING_ERROR); } + /** + * Verifies parsing the verbose flag with help output. + * The test checks verbose options are populated and the command exits successfully. + */ + @Description @Test void shouldParseVerboseFlag() { - final Optional parse = commandLine - .parse("-v", "--help"); + final Optional parse = parse("-v", "--help"); assertThat(parse) .isEmpty(); @@ -80,15 +91,19 @@ void shouldParseVerboseFlag() { .hasFieldOrPropertyWithValue("verbose", true) .hasFieldOrPropertyWithValue("quiet", false); - final ExitCode exitCode = commandLine.run(); + final ExitCode exitCode = runCommand(); assertThat(exitCode) .isEqualTo(NO_ERROR); } + /** + * Verifies parsing the quiet flag with help output. + * The test checks quiet options are populated and the command exits successfully. + */ + @Description @Test void shouldParseQuietFlag() { - final Optional parse = commandLine - .parse("-q", "--help"); + final Optional parse = parse("-q", "--help"); assertThat(parse) .isEmpty(); @@ -98,18 +113,28 @@ void shouldParseQuietFlag() { .hasFieldOrPropertyWithValue("verbose", false) .hasFieldOrPropertyWithValue("quiet", true); - final ExitCode exitCode = commandLine.run(); + final ExitCode exitCode = runCommand(); assertThat(exitCode) .isEqualTo(NO_ERROR); } + /** + * Verifies the generate command accepts result directories that do not exist yet. + * The test checks parsing succeeds for arbitrary result path arguments. + */ + @Description @Test void shouldAllowResultsDirectoriesThatNotExists() { - final Optional exitCode = commandLine.parse(GENERATE_COMMAND, randomString(), randomString()); + final Optional exitCode = parse(GENERATE_COMMAND, randomString(), randomString()); assertThat(exitCode) .isEmpty(); } + /** + * Verifies running the generate command delegates to the command service. + * The test checks parsed result directories, output directory, and default flags are passed through. + */ + @Description @Test void shouldRunGenerate(@TempDir final Path temp) throws IOException { final Path report = Files.createDirectories(temp.resolve("report")); @@ -126,14 +151,14 @@ void shouldRunGenerate(@TempDir final Path temp) throws IOException { ) ).thenReturn(NO_ERROR); - final Optional exitCode = commandLine.parse( + final Optional exitCode = parse( GENERATE_COMMAND, firstResult.toString(), secondResult.toString(), "--output", report.toString() ); assertThat(exitCode) .isEmpty(); - final ExitCode code = commandLine.run(); + final ExitCode code = runCommand(); verify(commands, times(1)) .generate( eq(report), eq(results), @@ -145,6 +170,11 @@ void shouldRunGenerate(@TempDir final Path temp) throws IOException { .isEqualTo(NO_ERROR); } + /** + * Verifies the generate command captures a custom report name. + * The test checks the delegated report-name options contain the parsed name. + */ + @Description @Test void shouldRunGenerateWithReportName(@TempDir final Path temp) throws IOException { final Path report = Files.createDirectories(temp.resolve("report")); @@ -162,7 +192,7 @@ void shouldRunGenerateWithReportName(@TempDir final Path temp) throws IOExceptio ) ).thenReturn(NO_ERROR); - final Optional exitCode = commandLine.parse( + final Optional exitCode = parse( GENERATE_COMMAND, firstResult.toString(), secondResult.toString(), "--output", report.toString(), "--name", reportName @@ -171,7 +201,7 @@ void shouldRunGenerateWithReportName(@TempDir final Path temp) throws IOExceptio .isEmpty(); final ArgumentCaptor captor = ArgumentCaptor.captor(); - final ExitCode code = commandLine.run(); + final ExitCode code = runCommand(); verify(commands, times(1)) .generate( eq(report), eq(results), eq(false), eq(false), @@ -185,6 +215,11 @@ void shouldRunGenerateWithReportName(@TempDir final Path temp) throws IOExceptio .isEqualTo(reportName); } + /** + * Verifies the generate command captures a custom report language. + * The test checks the delegated language options contain the parsed language code. + */ + @Description @Test void shouldRunGenerateWithReportLanguage(@TempDir final Path temp) throws IOException { final Path report = Files.createDirectories(temp.resolve("report")); @@ -202,7 +237,7 @@ void shouldRunGenerateWithReportLanguage(@TempDir final Path temp) throws IOExce ) ).thenReturn(NO_ERROR); - final Optional exitCode = commandLine.parse( + final Optional exitCode = parse( GENERATE_COMMAND, firstResult.toString(), secondResult.toString(), "--output", report.toString(), "--lang", lang @@ -211,7 +246,7 @@ void shouldRunGenerateWithReportLanguage(@TempDir final Path temp) throws IOExce .isEmpty(); final ArgumentCaptor captor = ArgumentCaptor.captor(); - final ExitCode code = commandLine.run(); + final ExitCode code = runCommand(); verify(commands, times(1)) .generate( eq(report), eq(results), eq(false), eq(false), @@ -225,30 +260,40 @@ void shouldRunGenerateWithReportLanguage(@TempDir final Path temp) throws IOExce .isEqualTo(lang); } + /** + * Verifies running the open command delegates to the command service. + * The test checks the parsed report directory and port are passed through. + */ + @Description @Test void shouldRunOpen(@TempDir final Path report) { final int port = randomPort(); when(commands.open(report, null, port)) .thenReturn(NO_ERROR); - final Optional exitCode = commandLine.parse( + final Optional exitCode = parse( OPEN_COMMAND, "--port", String.valueOf(port), report.toString() ); assertThat(exitCode) .isEmpty(); - final ExitCode code = commandLine.run(); + final ExitCode code = runCommand(); verify(commands, times(1)).open(report, null, port); assertThat(code) .isEqualTo(NO_ERROR); } + /** + * Verifies the open command rejects multiple report directories. + * The test checks parsing returns an argument parsing error for ambiguous input. + */ + @Description @Test void shouldNotLetToSpecifyFewReportDirectories(@TempDir final Path temp) throws IOException { final Path first = Files.createDirectories(temp.resolve("first")); final Path second = Files.createDirectories(temp.resolve("second")); - final Optional exitCode = commandLine.parse( + final Optional exitCode = parse( OPEN_COMMAND, first.toString(), second.toString() ); assertThat(exitCode) @@ -256,32 +301,47 @@ void shouldNotLetToSpecifyFewReportDirectories(@TempDir final Path temp) throws .hasValue(ExitCode.ARGUMENT_PARSING_ERROR); } + /** + * Verifies command-specific help can be parsed and run. + * The test checks help for the open command exits successfully. + */ + @Description @Test void shouldRunHelpForCommand() { - final Optional exitCode = commandLine.parse( + final Optional exitCode = parse( "--help", OPEN_COMMAND ); assertThat(exitCode) .isEmpty(); - final ExitCode run = commandLine.run(); + final ExitCode run = runCommand(); assertThat(run) .isEqualTo(NO_ERROR); } + /** + * Verifies the version flag can be parsed and run. + * The test checks version output exits successfully without a subcommand. + */ + @Description @Test void shouldPrintVersion() { - final Optional exitCode = commandLine.parse( + final Optional exitCode = parse( "--version" ); assertThat(exitCode) .isEmpty(); - final ExitCode run = commandLine.run(); + final ExitCode run = runCommand(); assertThat(run) .isEqualTo(NO_ERROR); } + /** + * Verifies the serve command parses host, port, profile, and result directories. + * The test checks the delegated config options include the requested profile. + */ + @Description @Test void shouldParseServeCommand(@TempDir final Path temp) throws IOException { final int port = randomPort(); @@ -289,7 +349,7 @@ void shouldParseServeCommand(@TempDir final Path temp) throws IOException { final String profile = randomString(); final Path first = Files.createDirectories(temp.resolve("first")); final Path second = Files.createDirectories(temp.resolve("second")); - final Optional code = commandLine.parse( + final Optional code = parse( SERVE_COMMAND, "--port", String.valueOf(port), "--host", host, @@ -307,7 +367,7 @@ void shouldParseServeCommand(@TempDir final Path temp) throws IOException { captor.capture(), any(ReportNameOptions.class), any(ReportLanguageOptions.class)) ) .thenReturn(NO_ERROR); - final ExitCode run = commandLine.run(); + final ExitCode run = runCommand(); assertThat(run) .isEqualTo(NO_ERROR); @@ -317,6 +377,11 @@ void shouldParseServeCommand(@TempDir final Path temp) throws IOException { .containsExactly(profile); } + /** + * Verifies the serve command parses a custom report name. + * The test checks the delegated report-name options contain the requested name. + */ + @Description @Test void shouldParseServeCommandWithReportName(@TempDir final Path temp) throws IOException { final int port = randomPort(); @@ -325,7 +390,7 @@ void shouldParseServeCommandWithReportName(@TempDir final Path temp) throws IOEx final String reportName = randomString(); final Path first = Files.createDirectories(temp.resolve("first")); final Path second = Files.createDirectories(temp.resolve("second")); - final Optional code = commandLine.parse( + final Optional code = parse( SERVE_COMMAND, "--port", String.valueOf(port), "--host", host, @@ -345,7 +410,7 @@ void shouldParseServeCommandWithReportName(@TempDir final Path temp) throws IOEx captorConfig.capture(), captorReportName.capture(), any(ReportLanguageOptions.class) )) .thenReturn(NO_ERROR); - final ExitCode run = commandLine.run(); + final ExitCode run = runCommand(); assertThat(run) .isEqualTo(NO_ERROR); @@ -359,6 +424,11 @@ void shouldParseServeCommandWithReportName(@TempDir final Path temp) throws IOEx .isEqualTo(reportName); } + /** + * Verifies the serve command parses a custom report language. + * The test checks the delegated language options contain the requested language code. + */ + @Description @Test void shouldParseServeCommandWithReportLanguage(@TempDir final Path temp) throws IOException { final int port = randomPort(); @@ -367,7 +437,7 @@ void shouldParseServeCommandWithReportLanguage(@TempDir final Path temp) throws final String lang = "de"; final Path first = Files.createDirectories(temp.resolve("first")); final Path second = Files.createDirectories(temp.resolve("second")); - final Optional code = commandLine.parse( + final Optional code = parse( SERVE_COMMAND, "--port", String.valueOf(port), "--host", host, @@ -387,7 +457,7 @@ void shouldParseServeCommandWithReportLanguage(@TempDir final Path temp) throws captorConfig.capture(), any(ReportNameOptions.class), captorReportLang.capture() )) .thenReturn(NO_ERROR); - final ExitCode run = commandLine.run(); + final ExitCode run = runCommand(); assertThat(run) .isEqualTo(NO_ERROR); @@ -401,44 +471,109 @@ void shouldParseServeCommandWithReportLanguage(@TempDir final Path temp) throws .isEqualTo(lang); } + /** + * Verifies serve command language validation. + * The test checks an unsupported language value is rejected during parsing. + */ + @Description @Test void shouldValidateLanguageValue() { - final Optional exitCode = commandLine.parse(SERVE_COMMAND, "--lang", "invalid"); + final Optional exitCode = parse(SERVE_COMMAND, "--lang", "invalid"); assertThat(exitCode) .isPresent() .hasValue(ARGUMENT_PARSING_ERROR); } + /** + * Verifies serve command port validation. + * The test checks an out-of-range port value is rejected during parsing. + */ + @Description @Test void shouldValidatePortValue() { - final Optional exitCode = commandLine.parse(SERVE_COMMAND, "--port", "213123"); + final Optional exitCode = parse(SERVE_COMMAND, "--port", "213123"); assertThat(exitCode) .isPresent() .hasValue(ARGUMENT_PARSING_ERROR); } + /** + * Verifies the plugin command lists configured plugins. + * The test checks plugin listing is delegated and exits successfully. + */ + @Description @Test void shouldPrintPluginList() { - final Optional exitCode = commandLine.parse(PLUGIN_COMMAND); + final Optional exitCode = parse(PLUGIN_COMMAND); assertThat(exitCode) .isEmpty(); when(commands.listPlugins(any(ConfigOptions.class))).thenReturn(NO_ERROR); - final ExitCode run = commandLine.run(); + final ExitCode run = runCommand(); assertThat(run) .isEqualTo(NO_ERROR); } + /** + * Verifies verbose options can be parsed without a command while execution still fails. + * The test checks parsing accepts the option and running returns an argument parsing error. + */ + @Description @Test void shouldHandleVerboseOptionsWithoutArgs() { final String verboseOption = "-q"; - final Optional exitCode = commandLine.parse(verboseOption); + final Optional exitCode = parse(verboseOption); assertThat(exitCode) .isEmpty(); - final ExitCode run = commandLine.run(); + final ExitCode run = runCommand(); assertThat(run) .isEqualTo(ARGUMENT_PARSING_ERROR); } + + @Step("Parse command line arguments") + private Optional parse(final String... arguments) { + final Optional result = commandLine.parse(arguments); + attachCommandParse(arguments, result); + return result; + } + + @Step("Run parsed command") + private ExitCode runCommand() { + final ExitCode result = commandLine.run(); + Allure.addAttachment( + "command-run.txt", + "text/plain", + String.format("runResult=%s%n%s", result, describeMainCommand()), + ".txt" + ); + return result; + } + + private void attachCommandParse(final String[] arguments, final Optional result) { + Allure.addAttachment( + "command-parse.txt", + "text/plain", + String.format( + "arguments=%s%nparseResult=%s%n%s", + Arrays.toString(arguments), + result.map(ExitCode::name).orElse(""), + describeMainCommand() + ), + ".txt" + ); + } + + private String describeMainCommand() { + final MainCommand mainCommand = commandLine.getMainCommand(); + final VerboseOptions verboseOptions = mainCommand.getVerboseOptions(); + return String.format( + "help=%s%nversion=%s%nverbose=%s%nquiet=%s%n", + mainCommand.isHelp(), + mainCommand.isVersion(), + verboseOptions.isVerbose(), + verboseOptions.isQuiet() + ); + } } diff --git a/allure-commandline/src/test/java/io/qameta/allure/CommandsTest.java b/allure-commandline/src/test/java/io/qameta/allure/CommandsTest.java index 76909d2dd..b4fd2a22f 100644 --- a/allure-commandline/src/test/java/io/qameta/allure/CommandsTest.java +++ b/allure-commandline/src/test/java/io/qameta/allure/CommandsTest.java @@ -42,6 +42,11 @@ */ class CommandsTest { + /** + * Verifies listing plugins succeeds even when the selected profile has no config file. + * The test checks the command returns the no-error exit code. + */ + @Description @Test void shouldNotFailWhenListPluginsWithoutConfig(@TempDir final Path home) { final Commands commands = new Commands(home); @@ -53,6 +58,11 @@ void shouldNotFailWhenListPluginsWithoutConfig(@TempDir final Path home) { .isEqualTo(ExitCode.NO_ERROR); } + /** + * Verifies report generation refuses to overwrite a non-empty report directory by default. + * The test checks the command returns a generic error when the output directory already has content. + */ + @Description @Test void shouldFailIfDirectoryExists(@TempDir final Path temp) throws Exception { final Path home = Files.createDirectories(temp.resolve("home")); @@ -68,6 +78,11 @@ void shouldFailIfDirectoryExists(@TempDir final Path temp) throws Exception { .isEqualTo(ExitCode.GENERIC_ERROR); } + /** + * Verifies plugin listing reads the selected commandline config profile. + * The test checks a valid profile-backed config lets the command finish successfully. + */ + @Description @Test void shouldListPlugins(@TempDir final Path home) throws Exception { createConfig(home, "allure-test.yml"); @@ -81,6 +96,11 @@ void shouldListPlugins(@TempDir final Path home) throws Exception { .isEqualTo(ExitCode.NO_ERROR); } + /** + * Verifies commandline configuration loading from the selected profile. + * The test checks the expected plugin list is parsed from the profile config file. + */ + @Description @Test void shouldLoadConfig(@TempDir final Path home) throws Exception { createConfig(home, "allure-test.yml"); @@ -90,6 +110,12 @@ void shouldLoadConfig(@TempDir final Path home) throws Exception { final Commands commands = new Commands(home); final CommandlineConfig config = commands.getConfig(options); + Allure.addAttachment( + "loaded-config.txt", + "text/plain", + String.format("profile=test%nplugins=%s%n", config.getPlugins()), + ".txt" + ); assertThat(config) .isNotNull(); @@ -98,6 +124,11 @@ void shouldLoadConfig(@TempDir final Path home) throws Exception { .containsExactly("a", "b", "c"); } + /** + * Verifies report generation allows an empty existing output directory. + * The test checks generation exits successfully when there are no input results and no existing report files. + */ + @Description @Test void shouldAllowEmptyReportDirectory(@TempDir final Path temp) throws Exception { final Path home = Files.createDirectories(temp.resolve("home")); @@ -118,6 +149,11 @@ void shouldAllowEmptyReportDirectory(@TempDir final Path temp) throws Exception .isEqualTo(ExitCode.NO_ERROR); } + /** + * Verifies the report server normalizes report-directory paths before serving files. + * The test checks a regular JavaScript file is served with the expected content type and body. + */ + @Description @Test void shouldServeRegularFileWhenReportDirectoryIsNotNormalized(@TempDir final Path temp) throws Exception { final Path home = Files.createDirectories(temp.resolve("home")); @@ -141,6 +177,11 @@ void shouldServeRegularFileWhenReportDirectoryIsNotNormalized(@TempDir final Pat } } + /** + * Verifies the report server preserves the Allure image-diff attachment content type. + * The test checks an imagediff file is served with the expected media type and body. + */ + @Description @Test void shouldServeImageDiffAttachment(@TempDir final Path temp) throws Exception { final Path home = Files.createDirectories(temp.resolve("home")); @@ -165,6 +206,11 @@ void shouldServeImageDiffAttachment(@TempDir final Path temp) throws Exception { } } + /** + * Verifies unknown report files are served as generic binary attachments. + * The test checks an unrecognized extension receives octet-stream content type and the original body. + */ + @Description @Test void shouldServeUnknownFileAsOctetStream(@TempDir final Path temp) throws Exception { final Path home = Files.createDirectories(temp.resolve("home")); @@ -189,6 +235,11 @@ void shouldServeUnknownFileAsOctetStream(@TempDir final Path temp) throws Except } } + /** + * Verifies directory requests serve nested index files from inside the report directory. + * The test checks the nested index response has a success status and expected body. + */ + @Description @Test void shouldServeDirectoryIndexWhenServingReport(@TempDir final Path temp) throws Exception { final Path home = Files.createDirectories(temp.resolve("home")); @@ -211,6 +262,11 @@ void shouldServeDirectoryIndexWhenServingReport(@TempDir final Path temp) throws } } + /** + * Verifies encoded traversal attempts are rejected by the report server. + * The test checks a normalized outside path returns a 404 response. + */ + @Description @Test void shouldReturnNotFoundForInvalidRequestPathWhenServingReport(@TempDir final Path temp) throws Exception { final Path home = Files.createDirectories(temp.resolve("home")); @@ -234,6 +290,11 @@ void shouldReturnNotFoundForInvalidRequestPathWhenServingReport(@TempDir final P } } + /** + * Verifies explicit path-boundary checks reject paths outside the normalized report directory. + * The test checks a normalized parent traversal target is not considered inside the report directory. + */ + @Description @Test void shouldDetectResolvedPathOutsideNormalizedReportDirectory(@TempDir final Path temp) throws Exception { final Path normalizedReportDirectory = Files.createDirectories(temp.resolve("report")).normalize(); @@ -243,6 +304,11 @@ void shouldDetectResolvedPathOutsideNormalizedReportDirectory(@TempDir final Pat .isFalse(); } + /** + * Verifies missing report files return a not-found response. + * The test checks the server returns both 404 status and the standard not-found body. + */ + @Description @Test void shouldReturnNotFoundForMissingFileWhenServingReport(@TempDir final Path temp) throws Exception { final Path home = Files.createDirectories(temp.resolve("home")); @@ -263,6 +329,11 @@ void shouldReturnNotFoundForMissingFileWhenServingReport(@TempDir final Path tem } } + /** + * Verifies directory indexes that resolve through symbolic links are not served. + * The test checks a symlinked nested index returns a not-found response. + */ + @Description @Test void shouldReturnNotFoundForDirectoryWithoutServableIndexWhenServingReport(@TempDir final Path temp) throws Exception { final Path home = Files.createDirectories(temp.resolve("home")); @@ -286,6 +357,11 @@ void shouldReturnNotFoundForDirectoryWithoutServableIndexWhenServingReport(@Temp } } + /** + * Verifies symlinked files inside the report directory are not served. + * The test checks a symlinked attachment path returns a not-found response. + */ + @Description @Test void shouldReturnNotFoundForSymbolicLinkFilesWhenServingReport(@TempDir final Path temp) throws Exception { final Path home = Files.createDirectories(temp.resolve("home")); @@ -328,11 +404,25 @@ private HttpURLConnection openConnection(final int port, final String path) thro return (HttpURLConnection) new URL("http://127.0.0.1:" + port + path).openConnection(); } + @Step("Read HTTP response") private String readResponse(final HttpURLConnection connection) throws IOException { try (InputStream response = Objects.nonNull(connection.getErrorStream()) ? connection.getErrorStream() : connection.getInputStream()) { - return new String(response.readAllBytes(), StandardCharsets.UTF_8); + final String body = new String(response.readAllBytes(), StandardCharsets.UTF_8); + Allure.addAttachment( + "http-response.txt", + "text/plain", + String.format( + "url=%s%nstatus=%s%ncontentType=%s%nbody=%s%n", + connection.getURL(), + connection.getResponseCode(), + connection.getHeaderField("Content-Type"), + body + ), + ".txt" + ); + return body; } } } diff --git a/allure-commandline/src/test/java/io/qameta/allure/plugin/DirectoryPluginLoaderTest.java b/allure-commandline/src/test/java/io/qameta/allure/plugin/DirectoryPluginLoaderTest.java index 92e940575..2bfc1ee0b 100644 --- a/allure-commandline/src/test/java/io/qameta/allure/plugin/DirectoryPluginLoaderTest.java +++ b/allure-commandline/src/test/java/io/qameta/allure/plugin/DirectoryPluginLoaderTest.java @@ -16,6 +16,8 @@ package io.qameta.allure.plugin; import io.qameta.allure.Aggregator; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.Extension; import io.qameta.allure.core.Plugin; import org.junit.jupiter.api.BeforeEach; @@ -24,11 +26,13 @@ import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; @@ -41,30 +45,45 @@ void setUp() { pluginLoader = new DefaultPluginLoader(); } + /** + * Verifies loading a plugin from a missing directory is treated as absent. + * The test checks the loader returns an empty optional instead of failing. + */ + @Description @Test void shouldNotFailWhenPluginDirectoryNotExists(@TempDir final Path temp) { final Path pluginFolder = temp.resolve("plugin"); - final Optional plugin = pluginLoader.loadPlugin(getClass().getClassLoader(), pluginFolder); + final Optional plugin = loadPlugin(pluginFolder); assertThat(plugin) .isEmpty(); } + /** + * Verifies an empty plugin directory does not produce a plugin. + * The test checks the loader returns an empty optional when no descriptor is present. + */ + @Description @Test void shouldLoadEmptyPlugin(@TempDir final Path pluginDirectory) { - final Optional plugin = pluginLoader.loadPlugin(getClass().getClassLoader(), pluginDirectory); + final Optional plugin = loadPlugin(pluginDirectory); assertThat(plugin) .isEmpty(); } + /** + * Verifies loading a plugin descriptor and extension jar from the plugin root. + * The test checks plugin metadata and the aggregator extension class are discovered. + */ @SuppressWarnings("deprecation") + @Description @Test void shouldLoadPluginExtensions(@TempDir final Path pluginFolder) throws Exception { add(pluginFolder, "plugin.jar", "plugin.jar"); add(pluginFolder, "dummy-plugin.yml", "allure-plugin.yml"); - final Optional loaded = pluginLoader.loadPlugin(getClass().getClassLoader(), pluginFolder); + final Optional loaded = loadPlugin(pluginFolder); assertThat(loaded) .isPresent(); @@ -90,13 +109,18 @@ void shouldLoadPluginExtensions(@TempDir final Path pluginFolder) throws Excepti } + /** + * Verifies a plugin with only static files is loaded without extensions. + * The test checks metadata and copied static file content are available from the plugin model. + */ + @Description @Test void shouldLoadStaticOnlyPlugin(@TempDir final Path temp) throws Exception { final Path pluginFolder = Files.createDirectories(temp.resolve("plugins")); add(pluginFolder, "static-file.txt", "static/some-file"); add(pluginFolder, "dummy-plugin2.yml", "allure-plugin.yml"); - final Optional loaded = pluginLoader.loadPlugin(getClass().getClassLoader(), pluginFolder); + final Optional loaded = loadPlugin(pluginFolder); assertThat(loaded) .isPresent(); @@ -119,13 +143,18 @@ void shouldLoadStaticOnlyPlugin(@TempDir final Path temp) throws Exception { .hasContent("ho-ho-ho"); } + /** + * Verifies extension jars are discovered from a plugin lib directory. + * The test checks plugin metadata and the aggregator extension class are loaded from lib. + */ @SuppressWarnings("deprecation") + @Description @Test void shouldLoadJarsInLibDirectory(@TempDir final Path pluginFolder) throws Exception { add(pluginFolder, "plugin.jar", "lib/plugin.jar"); add(pluginFolder, "dummy-plugin.yml", "allure-plugin.yml"); - final Optional loaded = pluginLoader.loadPlugin(getClass().getClassLoader(), pluginFolder); + final Optional loaded = loadPlugin(pluginFolder); assertThat(loaded) .isPresent(); @@ -150,21 +179,88 @@ void shouldLoadJarsInLibDirectory(@TempDir final Path pluginFolder) throws Excep .isEqualTo("io.qameta.allure.packages.PackagesPlugin"); } + /** + * Verifies invalid plugin descriptors are ignored. + * The test checks a malformed descriptor yields an empty plugin result. + */ + @Description @Test void shouldProcessInvalidConfigFile(@TempDir final Path pluginFolder) throws Exception { add(pluginFolder, "static-file.txt", "allure-plugin.yml"); - final Optional loaded = pluginLoader.loadPlugin(getClass().getClassLoader(), pluginFolder); + final Optional loaded = loadPlugin(pluginFolder); assertThat(loaded) .isEmpty(); } + private Optional loadPlugin(final Path pluginFolder) { + return Allure.step("Load plugin from directory", () -> { + final Optional plugin = pluginLoader.loadPlugin(getClass().getClassLoader(), pluginFolder); + Allure.addAttachment("loaded-plugin.txt", "text/plain", describePlugin(plugin)); + return plugin; + }); + } + private void add(final Path pluginDirectory, final String resourceName, final String dest) throws IOException { - final Path destFile = pluginDirectory.resolve(dest); - Files.createDirectories(destFile.getParent()); - try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { - Files.copy(Objects.requireNonNull(is), destFile); + Allure.step("Copy plugin resource " + resourceName + " as " + dest, () -> { + final Path destFile = pluginDirectory.resolve(dest); + Files.createDirectories(destFile.getParent()); + final byte[] content; + try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { + content = Objects.requireNonNull(is).readAllBytes(); + } + Files.write(destFile, content); + attachCopiedResource(dest, content); + }); + } + + private void attachCopiedResource(final String fileName, final byte[] content) { + if (isText(fileName)) { + Allure.addAttachment( + Path.of(fileName).getFileName().toString(), + contentType(fileName), + new String(content, StandardCharsets.UTF_8), + extension(fileName) + ); + return; + } + Allure.addAttachment( + Path.of(fileName).getFileName() + ".metadata.txt", + "text/plain", + "fileName=" + Path.of(fileName).getFileName() + System.lineSeparator() + + "size=" + content.length + ); + } + + private String describePlugin(final Optional plugin) { + if (plugin.isEmpty()) { + return "plugin="; } + final Plugin value = plugin.get(); + return String.format( + "id=%s%nname=%s%ndescription=%s%nextensions=%s%npluginFiles=%s", + value.getConfig().getId(), + value.getConfig().getName(), + value.getConfig().getDescription(), + value.getExtensions().stream() + .map(extension -> extension.getClass().getCanonicalName()) + .collect(Collectors.joining(", ")), + value.getPluginFiles().keySet().stream() + .sorted() + .collect(Collectors.joining(", ")) + ); + } + + private boolean isText(final String fileName) { + return fileName.endsWith(".yml") || fileName.endsWith(".txt") || !fileName.contains("."); + } + + private String contentType(final String fileName) { + return fileName.endsWith(".yml") ? "application/yaml" : "text/plain"; + } + + private String extension(final String fileName) { + return fileName.endsWith(".yml") ? ".yml" : ".txt"; } } diff --git a/allure-generator/build.gradle.kts b/allure-generator/build.gradle.kts index 9985d6a0e..354c5e7e9 100644 --- a/allure-generator/build.gradle.kts +++ b/allure-generator/build.gradle.kts @@ -3,6 +3,7 @@ import com.github.gradle.node.npm.task.NpmTask plugins { `java-library` id("com.github.node-gradle.node") + id("io.qameta.allure") } description = "Allure Report Generator" @@ -110,6 +111,16 @@ tasks.test { dependsOn(testWeb) } +allure { + version.set("2.34.0") + adapter { + allureJavaVersion.set("2.34.0") + aspectjVersion.set("1.9.25.1") + autoconfigure.set(false) + aspectjWeaver.set(true) + } +} + val allurePlugin by configurations.existing dependencies { @@ -129,6 +140,7 @@ dependencies { implementation("org.apache.httpcomponents:httpclient") implementation("org.freemarker:freemarker") implementation("org.jsoup:jsoup") + testImplementation("io.qameta.allure:allure-assertj") testImplementation("io.qameta.allure:allure-java-commons") testImplementation("io.qameta.allure:allure-junit-platform") testImplementation("org.apache.commons:commons-lang3") diff --git a/allure-generator/src/test/java/io/qameta/allure/DefaultResultsVisitorTest.java b/allure-generator/src/test/java/io/qameta/allure/DefaultResultsVisitorTest.java index dc36cc431..5b3135ff5 100644 --- a/allure-generator/src/test/java/io/qameta/allure/DefaultResultsVisitorTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/DefaultResultsVisitorTest.java @@ -27,6 +27,10 @@ class DefaultResultsVisitorTest { + /** + * Verifies falling back to application/octet-stream for unknown attachment types. + */ + @Description @Test void shouldFallbackToOctetStreamForUnknownAttachmentTypes(@TempDir final Path temp) throws Exception { final Path attachmentFile = temp.resolve("custom-attachment.foobar"); @@ -35,7 +39,18 @@ void shouldFallbackToOctetStreamForUnknownAttachmentTypes(@TempDir final Path te final Configuration configuration = ConfigurationBuilder.empty().build(); final DefaultResultsVisitor visitor = new DefaultResultsVisitor(configuration); - final Attachment attachment = visitor.visitAttachmentFile(attachmentFile); + final Attachment attachment = Allure.step( + "Visit attachment file with an unknown extension", + () -> visitor.visitAttachmentFile(attachmentFile) + ); + Allure.addAttachment(attachment.getName(), "text/plain", Files.readString(attachmentFile)); + Allure.addAttachment("Visited attachment metadata", "text/plain", String.format( + "name=%s%ntype=%s%nsource=%s%ncontent=%s%n", + attachment.getName(), + attachment.getType(), + attachment.getSource(), + Files.readString(attachmentFile) + )); assertThat(attachment.getName()).isEqualTo("custom-attachment.foobar"); assertThat(attachment.getType()).isEqualTo(DefaultResultsVisitor.APPLICATION_OCTET_STREAM); diff --git a/allure-generator/src/test/java/io/qameta/allure/EmptyResultsTest.java b/allure-generator/src/test/java/io/qameta/allure/EmptyResultsTest.java index c429f1d6f..89cfefd6d 100644 --- a/allure-generator/src/test/java/io/qameta/allure/EmptyResultsTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/EmptyResultsTest.java @@ -19,14 +19,21 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.stream.Collectors; +import java.util.stream.Stream; /** * @author charlie (Dmitry Baev). */ class EmptyResultsTest { + /** + * Verifies allowing empty results directory for empty results handling. + */ + @Description @Test void shouldAllowEmptyResultsDirectory(@TempDir final Path temp) throws Exception { final Path resultsDirectory = Files.createDirectories(temp.resolve("results")); @@ -34,9 +41,13 @@ void shouldAllowEmptyResultsDirectory(@TempDir final Path temp) throws Exception final Configuration configuration = ConfigurationBuilder.bundled().build(); final ReportGenerator generator = new ReportGenerator(configuration); - generator.generate(outputDirectory, resultsDirectory); + generateReport(generator, outputDirectory, resultsDirectory, "empty directory"); } + /** + * Verifies allowing a missing results directory for empty results handling. + */ + @Description @Test void shouldAllowNonExistsResultsDirectory(@TempDir final Path temp) throws Exception { final Path resultsDirectory = temp.resolve("results"); @@ -44,9 +55,13 @@ void shouldAllowNonExistsResultsDirectory(@TempDir final Path temp) throws Excep final Configuration configuration = ConfigurationBuilder.bundled().build(); final ReportGenerator generator = new ReportGenerator(configuration); - generator.generate(outputDirectory, resultsDirectory); + generateReport(generator, outputDirectory, resultsDirectory, "missing directory"); } + /** + * Verifies allowing regular file as results directory for empty results handling. + */ + @Description @Test void shouldAllowRegularFileAsResultsDirectory(@TempDir final Path temp) throws Exception { final Path resultsDirectory = Files.createTempFile(temp, "a", ".txt"); @@ -54,6 +69,30 @@ void shouldAllowRegularFileAsResultsDirectory(@TempDir final Path temp) throws E final Configuration configuration = ConfigurationBuilder.bundled().build(); final ReportGenerator generator = new ReportGenerator(configuration); - generator.generate(outputDirectory, resultsDirectory); + generateReport(generator, outputDirectory, resultsDirectory, "regular file"); + } + + private void generateReport( + final ReportGenerator generator, + final Path outputDirectory, + final Path resultsDirectory, + final String resultsDirectoryKind + ) { + Allure.parameter("resultsDirectoryKind", resultsDirectoryKind); + Allure.step("Generate report when results path is " + resultsDirectoryKind, () -> { + generator.generate(outputDirectory, resultsDirectory); + Allure.addAttachment("Generated report files", "text/plain", listRelativeFiles(outputDirectory)); + }); + } + + private String listRelativeFiles(final Path directory) throws IOException { + try (Stream files = Files.walk(directory)) { + return files + .filter(Files::isRegularFile) + .map(directory::relativize) + .map(Path::toString) + .sorted() + .collect(Collectors.joining(System.lineSeparator())); + } } } diff --git a/allure-generator/src/test/java/io/qameta/allure/ReportGeneratorTest.java b/allure-generator/src/test/java/io/qameta/allure/ReportGeneratorTest.java index dc560f6cd..fbbb1a723 100644 --- a/allure-generator/src/test/java/io/qameta/allure/ReportGeneratorTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/ReportGeneratorTest.java @@ -41,19 +41,35 @@ static void setUp(@TempDir final Path temp) throws Exception { final ReportGenerator generator = new ReportGenerator(configuration); output = temp.resolve("report"); final Path resultsDirectory = Files.createDirectories(temp.resolve("results")); - allure1data().forEach(resource -> unpackFile( - "allure1data/" + resource, - resultsDirectory.resolve(resource) - )); - generator.generate(output, resultsDirectory); + Allure.step("Prepare Allure 1 fixture dataset", () -> { + allure1data().forEach(resource -> unpackFile( + "allure1data/" + resource, + resultsDirectory.resolve(resource) + )); + }); + Allure.step("Generate report from Allure 1 fixture dataset", () -> { + generator.generate(output, resultsDirectory); + Allure.addAttachment("Generated report files", "text/plain", String.join( + System.lineSeparator(), + listRelativeFiles(output) + )); + }); } + /** + * Verifies generating index HTML for report generation. + */ + @Description @Test void shouldGenerateIndexHtml() { assertThat(output.resolve("index.html")) .isRegularFile(); } + /** + * Verifies writing report static for report generation. + */ + @Description @Test void shouldWriteReportStatic() throws Exception { final Path assetsDirectory = output.resolve("assets"); @@ -65,102 +81,170 @@ void shouldWriteReportStatic() throws Exception { .anySatisfy(file -> assertThat(file).endsWith(".ico")); } + /** + * Verifies generating categories JSON for report generation. + */ + @Description @Test void shouldGenerateCategoriesJson() { assertThat(output.resolve("data/categories.json")) .isRegularFile(); } + /** + * Verifies generating xUnit JSON for report generation. + */ + @Description @Test void shouldGenerateXunitJson() { assertThat(output.resolve("data/suites.json")) .isRegularFile(); } + /** + * Verifies generating timeline JSON for report generation. + */ + @Description @Test void shouldGenerateTimelineJson() { assertThat(output.resolve("data/timeline.json")) .isRegularFile(); } + /** + * Verifies generating widget categories JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetCategoriesJson() { assertThat(output.resolve("widgets/categories.json")) .isRegularFile(); } + /** + * Verifies generating widget categories trend JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetCategoriesTrendJson() { assertThat(output.resolve("widgets/categories-trend.json")) .isRegularFile(); } + /** + * Verifies generating widget duration JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetDurationJson() { assertThat(output.resolve("widgets/duration.json")) .isRegularFile(); } + /** + * Verifies generating widget duration trend JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetDurationTrendJson() { assertThat(output.resolve("widgets/duration-trend.json")) .isRegularFile(); } + /** + * Verifies generating widget retry trend JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetRetryTrendJson() { assertThat(output.resolve("widgets/retry-trend.json")) .isRegularFile(); } + /** + * Verifies generating widget environment JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetEnvironmentJson() { assertThat(output.resolve("widgets/environment.json")) .isRegularFile(); } + /** + * Verifies generating widget executors JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetExecutorsJson() { assertThat(output.resolve("widgets/executors.json")) .isRegularFile(); } + /** + * Verifies generating widget history trend JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetHistoryTrendJson() { assertThat(output.resolve("widgets/history-trend.json")) .isRegularFile(); } + /** + * Verifies generating widget launch JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetLaunchJson() { assertThat(output.resolve("widgets/launch.json")) .isRegularFile(); } + /** + * Verifies generating widget severity JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetSeverityJson() { assertThat(output.resolve("widgets/severity.json")) .isRegularFile(); } + /** + * Verifies generating widget status JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetStatusJson() { assertThat(output.resolve("widgets/status-chart.json")) .isRegularFile(); } + /** + * Verifies generating widget suites JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetSuitesJson() { assertThat(output.resolve("widgets/suites.json")) .isRegularFile(); } + /** + * Verifies generating widget summary JSON for report generation. + */ + @Description @Test void shouldGenerateWidgetSummaryJson() { assertThat(output.resolve("widgets/summary.json")) .isRegularFile(); } + /** + * Verifies generating attachments for report generation. + */ + @Description @Test void shouldGenerateAttachments() throws Exception { final Path attachmentsFolder = output.resolve("data/attachments"); @@ -170,6 +254,10 @@ void shouldGenerateAttachments() throws Exception { .hasSize(13); } + /** + * Verifies generating test cases for report generation. + */ + @Description @Test void shouldGenerateTestCases() throws Exception { final Path testCasesFolder = output.resolve("data/test-cases"); @@ -179,12 +267,20 @@ void shouldGenerateTestCases() throws Exception { .hasSize(20); } + /** + * Verifies generating history for report generation. + */ + @Description @Test void shouldGenerateHistory() { assertThat(output.resolve("history/history.json")) .isRegularFile(); } + /** + * Verifies generating mail for report generation. + */ + @Description @Test void shouldGenerateMail() { assertThat(output.resolve("export/mail.html")) diff --git a/allure-generator/src/test/java/io/qameta/allure/allure1/Allure1PluginTest.java b/allure-generator/src/test/java/io/qameta/allure/allure1/Allure1PluginTest.java index 27a75a900..ea5f2b392 100644 --- a/allure-generator/src/test/java/io/qameta/allure/allure1/Allure1PluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/allure1/Allure1PluginTest.java @@ -15,8 +15,10 @@ */ package io.qameta.allure.allure1; +import io.qameta.allure.Allure; import io.qameta.allure.ConfigurationBuilder; import io.qameta.allure.DefaultResultsVisitor; +import io.qameta.allure.Description; import io.qameta.allure.Issue; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.LaunchResults; @@ -53,6 +55,9 @@ import static io.qameta.allure.entity.Status.FAILED; import static io.qameta.allure.entity.Status.PASSED; import static io.qameta.allure.entity.Status.UNKNOWN; +import static io.qameta.allure.testdata.TestData.attachFileContent; +import static io.qameta.allure.testdata.TestData.attachLaunchResults; +import static io.qameta.allure.testdata.TestData.toHex; import static org.allurefw.allure1.AllureUtils.generateTestSuiteJsonName; import static org.allurefw.allure1.AllureUtils.generateTestSuiteXmlName; import static org.assertj.core.api.Assertions.assertThat; @@ -60,6 +65,8 @@ class Allure1PluginTest { + private static final String TEXT_PLAIN = "text/plain"; + private Path directory; @BeforeEach @@ -67,6 +74,10 @@ void setUp(@TempDir final Path directory) { this.directory = directory; } + /** + * Verifies processing empty or null status for Allure 1 parsing. + */ + @Description @Test void shouldProcessEmptyOrNullStatus() throws Exception { Set testResults = process( @@ -83,6 +94,10 @@ void shouldProcessEmptyOrNullStatus() throws Exception { ); } + /** + * Verifies reading test suite XML for Allure 1 parsing. + */ + @Description @Test void shouldReadTestSuiteXml() throws Exception { Set testResults = process( @@ -92,6 +107,10 @@ void shouldReadTestSuiteXml() throws Exception { .hasSize(4); } + /** + * Verifies sanitizing description HTML for Allure 1 parsing. + */ + @Description @Test void shouldSanitizeDescriptionHtml() throws Exception { final Set testResults = process( @@ -106,6 +125,10 @@ void shouldSanitizeDescriptionHtml() throws Exception { .doesNotContain("alert("); } + /** + * Verifies excluding duplicated parameters for Allure 1 parsing. + */ + @Description @SuppressWarnings("unchecked") @Test void shouldExcludeDuplicatedParams() throws Exception { @@ -125,6 +148,10 @@ void shouldExcludeDuplicatedParams() throws Exception { ); } + /** + * Verifies reading test suite JSON for Allure 1 parsing. + */ + @Description @Test void shouldReadTestSuiteJson() throws Exception { Set testResults = process( @@ -134,6 +161,10 @@ void shouldReadTestSuiteJson() throws Exception { .hasSize(1); } + /** + * Verifies reading attachments for Allure 1 parsing. + */ + @Description @Test void shouldReadAttachments() throws Exception { final LaunchResults launchResults = process( @@ -178,6 +209,10 @@ private Stream extractAttachments(Step step) { return Stream.concat(fromSteps, fromAttachments); } + /** + * Verifies that a missing results directory does not fail parsing. + */ + @Description @Test void shouldNotFailIfNoResultsDirectory() throws Exception { Set testResults = process().getResults(); @@ -185,6 +220,10 @@ void shouldNotFailIfNoResultsDirectory() throws Exception { .isEmpty(); } + /** + * Verifies resolving suite title if exists for Allure 1 parsing. + */ + @Description @Test void shouldGetSuiteTitleIfExists() throws Exception { Set testCases = process( @@ -197,6 +236,10 @@ void shouldGetSuiteTitleIfExists() throws Exception { .containsExactly("Passing test"); } + /** + * Verifies suite label fallback when an Allure 1 suite title is missing. + */ + @Description @Test void shouldNotFailIfSuiteTitleNotExists() throws Exception { Set testCases = process( @@ -210,6 +253,10 @@ void shouldNotFailIfSuiteTitleNotExists() throws Exception { .containsExactly("my.company.AlwaysPassingTest"); } + /** + * Verifies copying labels from suite for Allure 1 parsing. + */ + @Description @Test void shouldCopyLabelsFromSuite() throws Exception { Set testCases = process( @@ -224,6 +271,10 @@ void shouldCopyLabelsFromSuite() throws Exception { .containsExactlyInAnyOrder("SuccessStory", "OtherStory"); } + /** + * Verifies deriving the flaky flag from an Allure 1 label. + */ + @Description @Test void shouldSetFlakyFromLabel() throws Exception { Set testCases = process( @@ -235,6 +286,10 @@ void shouldSetFlakyFromLabel() throws Exception { .containsExactly(true); } + /** + * Verifies deriving the package from the Allure 1 test class label. + */ + @Description @Test void shouldUseTestClassLabelForPackage() throws Exception { Set testResults = process( @@ -247,6 +302,10 @@ void shouldUseTestClassLabelForPackage() throws Exception { .containsExactly("my.company.package.subpackage.MyClass"); } + /** + * Verifies deriving the full name from the Allure 1 test class label. + */ + @Description @Test void shouldUseTestClassLabelForFullName() throws Exception { Set testResults = process( @@ -259,6 +318,10 @@ void shouldUseTestClassLabelForFullName() throws Exception { .containsExactly("my.company.package.subpackage.MyClass.testThree"); } + /** + * Verifies adding the test result format label for Allure 1 parsing. + */ + @Description @Test void shouldAddTestResultFormatLabel() throws Exception { Set testResults = process( @@ -271,6 +334,10 @@ void shouldAddTestResultFormatLabel() throws Exception { .containsOnly(Allure1Plugin.ALLURE1_RESULTS_FORMAT); } + /** + * Verifies generating different history id for parameterized tests for Allure 1 parsing. + */ + @Description @Test void shouldGenerateDifferentHistoryIdForParameterizedTests() throws Exception { final String historyId1 = "56f15d234f8ad63b493afb25f7c26556"; @@ -285,6 +352,10 @@ void shouldGenerateDifferentHistoryIdForParameterizedTests() throws Exception { .containsExactlyInAnyOrder(historyId1, historyId2); } + /** + * Verifies reading properties file for Allure 1 parsing. + */ + @Description @Test void shouldReadPropertiesFile() throws Exception { final String testName = "testFour"; @@ -304,6 +375,10 @@ void shouldReadPropertiesFile() throws Exception { .containsExactlyInAnyOrder(link1, link2, link3); } + /** + * Verifies processing null parameters for Allure 1 parsing. + */ + @Description @Test void shouldProcessNullParameters() throws Exception { final Set results = process( @@ -323,6 +398,10 @@ void shouldProcessNullParameters() throws Exception { ); } + /** + * Verifies that an Allure 1 history-id label overrides generated history ids. + */ + @Description @Test void shouldBeAbleToSpecifyHistoryIdViaLabel() throws Exception { final Set results = process( @@ -340,6 +419,10 @@ void shouldBeAbleToSpecifyHistoryIdViaLabel() throws Exception { .containsNull(); } + /** + * Verifies processing empty lists for Allure 1 parsing. + */ + @Description @Issue("629") @Test void shouldProcessEmptyLists() throws Exception { @@ -351,6 +434,10 @@ void shouldProcessEmptyLists() throws Exception { .hasSize(1); } + /** + * Verifies preserving content type from attachment for Allure 1 parsing. + */ + @Description @Test void shouldPreserveContentTypeFromAttachment() throws IOException { final LaunchResults results = process( @@ -372,6 +459,10 @@ void shouldPreserveContentTypeFromAttachment() throws IOException { assertThat(attachments.get(0).getSource()).endsWith(".txt"); } + /** + * Verifies resolving attachments with relative results path for Allure 1 parsing. + */ + @Description @Test void shouldResolveAttachmentsWithRelativeResultsPath() throws IOException { final Path allureResults = directory.resolve("allure-results"); @@ -381,10 +472,8 @@ void shouldResolveAttachmentsWithRelativeResultsPath() throws IOException { copyFile(allureResults, "allure1/sample-attachment.txt", "link.txt"); final Allure1Plugin reader = new Allure1Plugin(); final Configuration configuration = ConfigurationBuilder.bundled().build(); - final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); final Path relative = allureResults.resolve("..").resolve("allure-results"); - reader.readResults(configuration, resultsVisitor, relative); - final LaunchResults results = resultsVisitor.getLaunchResults(); + final LaunchResults results = readResults(reader, configuration, relative); assertThat(results.getResults()) .hasSize(1); @@ -400,12 +489,14 @@ void shouldResolveAttachmentsWithRelativeResultsPath() throws IOException { assertThat(attachments.get(0).getSource()).endsWith(".txt"); } - + /** + * Verifies reading environment properties UTF-8 for Allure 1 parsing. + */ + @Description @SuppressWarnings("unchecked") @Test void shouldReadEnvironmentPropertiesUtf8() throws Exception { - writeBytes( - "test_executor=测试人员 A\n".getBytes(StandardCharsets.UTF_8)); + writeBytes("test_executor=测试人员 A\n".getBytes(StandardCharsets.UTF_8)); final LaunchResults launchResults = process(); final Map env = launchResults.getExtra( @@ -416,6 +507,10 @@ void shouldReadEnvironmentPropertiesUtf8() throws Exception { assertThat(env).containsEntry("test_executor", "测试人员 A"); } + /** + * Verifies reading environment properties UTF-8 with BOM for Allure 1 parsing. + */ + @Description @SuppressWarnings("unchecked") @Test void shouldReadEnvironmentPropertiesUtf8WithBom() throws Exception { @@ -437,6 +532,10 @@ void shouldReadEnvironmentPropertiesUtf8WithBom() throws Exception { assertThat(env).doesNotContainKey("\uFEFFexecutor"); } + /** + * Verifies falling back to ISO-8859-1 when UTF-8 environment decoding fails. + */ + @Description @SuppressWarnings("unchecked") @Test void shouldFallbackToIso88591WhenUtf8DecodingFails() throws Exception { @@ -452,6 +551,10 @@ void shouldFallbackToIso88591WhenUtf8DecodingFails() throws Exception { assertThat(env).containsEntry("name", "café"); } + /** + * Verifies rejecting attachment sources with invalid characters. + */ + @Description @Test void shouldNotAllowInvalidCharactersInAttachmentSource() throws IOException { final LaunchResults results = process( @@ -464,6 +567,10 @@ void shouldNotAllowInvalidCharactersInAttachmentSource() throws IOException { } + /** + * Verifies rejecting attachment source path traversal attempts. + */ + @Description @Test void shouldNotAllowAttachmentSourcePathTraversal() throws IOException { final Path allureResultsDir = directory.resolve("allure-results"); @@ -474,16 +581,17 @@ void shouldNotAllowAttachmentSourcePathTraversal() throws IOException { final Allure1Plugin reader = new Allure1Plugin(); final Configuration configuration = ConfigurationBuilder.bundled().build(); - final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); - reader.readResults(configuration, resultsVisitor, allureResultsDir); - - final LaunchResults results = resultsVisitor.getLaunchResults(); + final LaunchResults results = readResults(reader, configuration, allureResultsDir); assertThat(results.getAttachments()) .isEmpty(); } + /** + * Verifies rejecting attachment sources that resolve through symbolic links. + */ + @Description @Test void shouldNotAllowAttachmentSourceSymbolicLink() throws IOException { final Path allureResultsDir = directory.resolve("allure-results"); @@ -496,10 +604,7 @@ void shouldNotAllowAttachmentSourceSymbolicLink() throws IOException { final Allure1Plugin reader = new Allure1Plugin(); final Configuration configuration = ConfigurationBuilder.bundled().build(); - final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); - reader.readResults(configuration, resultsVisitor, allureResultsDir); - - final LaunchResults results = resultsVisitor.getLaunchResults(); + final LaunchResults results = readResults(reader, configuration, allureResultsDir); assertThat(results.getAttachments()) .isEmpty(); @@ -507,29 +612,62 @@ void shouldNotAllowAttachmentSourceSymbolicLink() throws IOException { } private LaunchResults process(String... strings) throws IOException { - Iterator iterator = Arrays.asList(strings).iterator(); - while (iterator.hasNext()) { - String first = iterator.next(); - String second = iterator.next(); - copyFile(directory, first, second); - } - Allure1Plugin reader = new Allure1Plugin(); - final Configuration configuration = ConfigurationBuilder.bundled().build(); - final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); - reader.readResults(configuration, resultsVisitor, directory); - return resultsVisitor.getLaunchResults(); + return Allure.step( + "Read Allure 1 launch from " + strings.length / 2 + " fixture file(s)", + () -> { + Iterator iterator = Arrays.asList(strings).iterator(); + while (iterator.hasNext()) { + String first = iterator.next(); + String second = iterator.next(); + copyFile(directory, first, second); + } + final Allure1Plugin reader = new Allure1Plugin(); + final Configuration configuration = ConfigurationBuilder.bundled().build(); + return readResults(reader, configuration, directory); + } + ); } private void writeBytes(final byte[] bytes) throws IOException { - Files.write(directory.resolve("environment.properties"), bytes); + Allure.step("Write environment.properties with " + bytes.length + " byte(s)", () -> { + final Path output = directory.resolve("environment.properties"); + Files.write(output, bytes); + Allure.addAttachment("environment.properties", TEXT_PLAIN, describeEnvironmentProperties(bytes)); + }); } private void copyFile(Path dir, String resourceName, String fileName) throws IOException { - try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { - Files.copy( - Objects.requireNonNull(is, "resource " + resourceName + " not found"), - dir.resolve(fileName) - ); - } + Allure.step("Copy fixture " + resourceName + " as " + fileName, () -> { + final Path output = dir.resolve(fileName); + final byte[] content; + try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { + content = Objects.requireNonNull(is, "resource " + resourceName + " not found").readAllBytes(); + Files.write(output, content); + } + attachFileContent(fileName, content); + }); + } + + private LaunchResults readResults( + final Allure1Plugin reader, + final Configuration configuration, + final Path resultsDirectory + ) { + return Allure.step("Parse Allure 1 results from " + resultsDirectory, () -> { + final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); + reader.readResults(configuration, resultsVisitor, resultsDirectory); + final LaunchResults results = resultsVisitor.getLaunchResults(); + attachLaunchResults("Attach parsed Allure 1 launch artifacts", results); + return results; + }); + } + + private String describeEnvironmentProperties(final byte[] bytes) { + return String.format( + "utf8=%s%niso88591=%s%nhex=%s%n", + new String(bytes, StandardCharsets.UTF_8), + new String(bytes, StandardCharsets.ISO_8859_1), + toHex(bytes) + ); } } diff --git a/allure-generator/src/test/java/io/qameta/allure/allure2/Allure2PluginTest.java b/allure-generator/src/test/java/io/qameta/allure/allure2/Allure2PluginTest.java index ca5d739b2..116ea177b 100644 --- a/allure-generator/src/test/java/io/qameta/allure/allure2/Allure2PluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/allure2/Allure2PluginTest.java @@ -15,8 +15,10 @@ */ package io.qameta.allure.allure2; +import io.qameta.allure.Allure; import io.qameta.allure.ConfigurationBuilder; import io.qameta.allure.DefaultResultsVisitor; +import io.qameta.allure.Description; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.LaunchResults; import io.qameta.allure.entity.Attachment; @@ -42,6 +44,8 @@ import java.util.UUID; import static io.qameta.allure.entity.Status.UNKNOWN; +import static io.qameta.allure.testdata.TestData.attachFileContent; +import static io.qameta.allure.testdata.TestData.attachLaunchResults; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.tuple; @@ -54,6 +58,10 @@ void setUp(@TempDir final Path directory) { this.directory = directory; } + /** + * Verifies reading befores from groups for Allure 2 parsing. + */ + @Description @Test void shouldReadBeforesFromGroups() throws Exception { Set testResults = process( @@ -70,6 +78,10 @@ void shouldReadBeforesFromGroups() throws Exception { .containsExactlyInAnyOrder("mockAuthorization", "loadTestConfiguration"); } + /** + * Verifies reading afters from groups for Allure 2 parsing. + */ + @Description @Test void shouldReadAftersFromGroups() throws Exception { Set testResults = process( @@ -86,6 +98,10 @@ void shouldReadAftersFromGroups() throws Exception { .containsExactlyInAnyOrder("unloadTestConfiguration", "cleanUpContext"); } + /** + * Verifies excluding duplicated parameters for Allure 2 parsing. + */ + @Description @Test void shouldExcludeDuplicatedParams() throws Exception { Set testResults = process( @@ -103,6 +119,10 @@ void shouldExcludeDuplicatedParams() throws Exception { ); } + /** + * Verifies picking up attachments for test case for Allure 2 parsing. + */ + @Description @Test void shouldPickUpAttachmentsForTestCase() throws IOException { Set testResults = process( @@ -126,6 +146,10 @@ void shouldPickUpAttachmentsForTestCase() throws IOException { .containsExactly("String attachment in test"); } + /** + * Verifies picking up attachments for afters for Allure 2 parsing. + */ + @Description @Test void shouldPickUpAttachmentsForAfters() throws IOException { Set testResults = process( @@ -149,6 +173,10 @@ void shouldPickUpAttachmentsForAfters() throws IOException { .containsExactly("String attachment in after"); } + /** + * Verifies that group attachments are not overwritten for Allure 2 parsing. + */ + @Description @Test void shouldDoNotOverrideAttachmentsForGroups() throws IOException { Set testResults = process( @@ -171,6 +199,10 @@ void shouldDoNotOverrideAttachmentsForGroups() throws IOException { } + /** + * Verifies processing empty status for Allure 2 parsing. + */ + @Description @Test void shouldProcessEmptyStatus() throws Exception { Set testResults = process( @@ -183,6 +215,10 @@ void shouldProcessEmptyStatus() throws Exception { .containsExactly(UNKNOWN); } + /** + * Verifies processing null status for Allure 2 parsing. + */ + @Description @Test void shouldProcessNullStatus() throws Exception { Set testResults = process( @@ -195,6 +231,10 @@ void shouldProcessNullStatus() throws Exception { .containsExactly(UNKNOWN); } + /** + * Verifies processing invalid status for Allure 2 parsing. + */ + @Description @Test void shouldProcessInvalidStatus() throws Exception { Set testResults = process( @@ -207,6 +247,10 @@ void shouldProcessInvalidStatus() throws Exception { .containsExactly(UNKNOWN); } + /** + * Verifies processing null stage time for Allure 2 parsing. + */ + @Description @Test void shouldProcessNullStageTime() throws Exception { Set testResults = process( @@ -218,6 +262,10 @@ void shouldProcessNullStageTime() throws Exception { .hasSize(1); } + /** + * Verifies adding the test result format label for Allure 2 parsing. + */ + @Description @Test void shouldAddTestResultFormatLabel() throws Exception { Set testResults = process( @@ -232,6 +280,10 @@ void shouldAddTestResultFormatLabel() throws Exception { .containsOnly(Allure2Plugin.ALLURE2_RESULTS_FORMAT); } + /** + * Verifies processing parameters for Allure 2 parsing. + */ + @Description @Test void shouldProcessParameters() throws Exception { Set testResults = process( @@ -251,6 +303,10 @@ void shouldProcessParameters() throws Exception { ); } + /** + * Verifies processing step parameters for Allure 2 parsing. + */ + @Description @Test void shouldProcessStepParameters() throws Exception { Set testResults = process( @@ -272,6 +328,10 @@ void shouldProcessStepParameters() throws Exception { ); } + /** + * Verifies ordering fixtures by start date for Allure 2 parsing. + */ + @Description @Test void shouldOrderFixturesByStartDate() throws Exception { Set testResults = process( @@ -303,6 +363,10 @@ void shouldOrderFixturesByStartDate() throws Exception { ); } + /** + * Verifies deriving the flaky flag from Allure 2 result data. + */ + @Description @Test void shouldSetFlakyFromResults() throws IOException { final LaunchResults results = process( @@ -320,6 +384,10 @@ void shouldSetFlakyFromResults() throws IOException { ); } + /** + * Verifies sanitizing description HTML for Allure 2 parsing. + */ + @Description @Test void shouldSanitizeDescriptionHtml() throws Exception { final LaunchResults results = process( @@ -338,6 +406,10 @@ void shouldSanitizeDescriptionHtml() throws Exception { .doesNotContain("javascript:"); } + /** + * Verifies script tags are stripped from Allure 2 HTML descriptions. + */ + @Description @Test void shouldStripScriptTagsFromDescriptionHtml() throws Exception { final LaunchResults results = process( @@ -355,6 +427,10 @@ void shouldStripScriptTagsFromDescriptionHtml() throws Exception { .doesNotContain("alert("); } + /** + * Verifies preserving content type from attachment for Allure 2 parsing. + */ + @Description @Test void shouldPreserveContentTypeFromAttachment() throws IOException { final LaunchResults results = process( @@ -376,6 +452,10 @@ void shouldPreserveContentTypeFromAttachment() throws IOException { assertThat(attachments.get(0).getSource()).endsWith(".txt"); } + /** + * Verifies rejecting attachment sources with invalid characters. + */ + @Description @Test void shouldNotAllowInvalidCharactersInAttachmentSource() throws IOException { final LaunchResults results = process( @@ -388,6 +468,10 @@ void shouldNotAllowInvalidCharactersInAttachmentSource() throws IOException { } + /** + * Verifies rejecting attachment source path traversal attempts. + */ + @Description @Test void shouldNotAllowAttachmentSourcePathTraversal() throws IOException { final Path allureResultsDir = directory.resolve("allure-results"); @@ -398,16 +482,17 @@ void shouldNotAllowAttachmentSourcePathTraversal() throws IOException { final Allure2Plugin reader = new Allure2Plugin(); final Configuration configuration = ConfigurationBuilder.bundled().build(); - final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); - reader.readResults(configuration, resultsVisitor, allureResultsDir); - - final LaunchResults results = resultsVisitor.getLaunchResults(); + final LaunchResults results = readResults(reader, configuration, allureResultsDir); assertThat(results.getAttachments()) .isEmpty(); } + /** + * Verifies rejecting attachment sources that resolve through symbolic links. + */ + @Description @Test void shouldNotAllowAttachmentSourceSymbolicLink() throws IOException { final Path allureResultsDir = directory.resolve("allure-results"); @@ -420,16 +505,17 @@ void shouldNotAllowAttachmentSourceSymbolicLink() throws IOException { final Allure2Plugin reader = new Allure2Plugin(); final Configuration configuration = ConfigurationBuilder.bundled().build(); - final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); - reader.readResults(configuration, resultsVisitor, allureResultsDir); - - final LaunchResults results = resultsVisitor.getLaunchResults(); + final LaunchResults results = readResults(reader, configuration, allureResultsDir); assertThat(results.getAttachments()) .isEmpty(); } + /** + * Verifies resolving attachments with relative results path for Allure 2 parsing. + */ + @Description @Test void shouldResolveAttachmentsWithRelativeResultsPath() throws IOException { final Path allureResults = directory.resolve("allure-results"); @@ -439,10 +525,8 @@ void shouldResolveAttachmentsWithRelativeResultsPath() throws IOException { final Allure2Plugin reader = new Allure2Plugin(); final Configuration configuration = ConfigurationBuilder.bundled().build(); - final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); final Path relative = allureResults.resolve("..").resolve("allure-results"); - reader.readResults(configuration, resultsVisitor, relative); - final LaunchResults results = resultsVisitor.getLaunchResults(); + final LaunchResults results = readResults(reader, configuration, relative); assertThat(results.getResults()) .hasSize(1); @@ -459,23 +543,46 @@ void shouldResolveAttachmentsWithRelativeResultsPath() throws IOException { } private LaunchResults process(String... strings) throws IOException { - Iterator iterator = Arrays.asList(strings).iterator(); - while (iterator.hasNext()) { - String first = iterator.next(); - String second = iterator.next(); - copyFile(directory, first, second); - } - Allure2Plugin reader = new Allure2Plugin(); - final Configuration configuration = ConfigurationBuilder.bundled().build(); - final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); - reader.readResults(configuration, resultsVisitor, directory); - return resultsVisitor.getLaunchResults(); + return Allure.step( + "Read Allure 2 launch from " + strings.length / 2 + " fixture file(s)", + () -> { + Iterator iterator = Arrays.asList(strings).iterator(); + while (iterator.hasNext()) { + String first = iterator.next(); + String second = iterator.next(); + copyFile(directory, first, second); + } + final Allure2Plugin reader = new Allure2Plugin(); + final Configuration configuration = ConfigurationBuilder.bundled().build(); + return readResults(reader, configuration, directory); + } + ); } private void copyFile(Path dir, String resourceName, String fileName) throws IOException { - try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { - Files.copy(Objects.requireNonNull(is), dir.resolve(fileName)); - } + Allure.step("Copy fixture " + resourceName + " as " + fileName, () -> { + final Path output = dir.resolve(fileName); + final byte[] content; + try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { + content = Objects.requireNonNull(is).readAllBytes(); + Files.write(output, content); + } + attachFileContent(fileName, content); + }); + } + + private LaunchResults readResults( + final Allure2Plugin reader, + final Configuration configuration, + final Path resultsDirectory + ) { + return Allure.step("Parse Allure 2 results from " + resultsDirectory, () -> { + final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); + reader.readResults(configuration, resultsVisitor, resultsDirectory); + final LaunchResults results = resultsVisitor.getLaunchResults(); + attachLaunchResults("Attach parsed Allure 2 launch artifacts", results); + return results; + }); } private static String generateTestResultName() { diff --git a/allure-generator/src/test/java/io/qameta/allure/category/CategoriesPluginTest.java b/allure-generator/src/test/java/io/qameta/allure/category/CategoriesPluginTest.java index 5afc3f9c3..e6ee7b0e9 100644 --- a/allure-generator/src/test/java/io/qameta/allure/category/CategoriesPluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/category/CategoriesPluginTest.java @@ -15,8 +15,10 @@ */ package io.qameta.allure.category; +import io.qameta.allure.Allure; import io.qameta.allure.ConfigurationBuilder; import io.qameta.allure.DefaultResultsVisitor; +import io.qameta.allure.Description; import io.qameta.allure.Issue; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.InMemoryReportStorage; @@ -30,9 +32,11 @@ import org.junit.jupiter.api.io.TempDir; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Base64; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -56,6 +60,10 @@ class CategoriesPluginTest { private static final String CATEGORY_NAME = "Category"; + /** + * Verifies defaulting categories to results for category aggregation. + */ + @Description @Test void shouldDefaultCategoriesToResults() { final TestResult first = new TestResult() @@ -81,6 +89,10 @@ void shouldDefaultCategoriesToResults() { } + /** + * Verifies setting custom categories to results for category aggregation. + */ + @Description @Test void shouldSetCustomCategoriesToResults() { final String categoryName = "Some category"; @@ -114,6 +126,10 @@ void shouldSetCustomCategoriesToResults() { .containsExactlyInAnyOrder(categoryName); } + /** + * Verifies creating tree for category aggregation. + */ + @Description @Test void shouldCreateTree() { final TestResult first = new TestResult() @@ -160,6 +176,10 @@ void shouldCreateTree() { .containsExactlyInAnyOrder("first", "third"); } + /** + * Verifies the main category aggregation workflow. + */ + @Description @Test void shouldWork() { final Configuration configuration = ConfigurationBuilder.bundled().build(); @@ -179,7 +199,7 @@ meta, createTestResult("asd\n", Status.BROKEN) CategoriesPlugin plugin = new CategoriesPlugin(); final InMemoryReportStorage storage = new InMemoryReportStorage(); - plugin.aggregate(configuration, launchResultsList, storage); + aggregateCategories(plugin, configuration, launchResultsList, storage); Set results = launchResultsList.get(0).getAllResults(); List categories = results.toArray(new TestResult[]{})[0] @@ -196,6 +216,10 @@ meta, createTestResult("asd\n", Status.BROKEN) .containsKey("data/" + CSV_FILE_NAME); } + /** + * Verifies custom categories can match flaky test results. + */ + @Description @Test void flakyTestsCanBeAddedToCategory() { final Configuration configuration = ConfigurationBuilder.bundled().build(); @@ -215,7 +239,7 @@ meta, createTestResult("asd\n", Status.FAILED, true) CategoriesPlugin plugin = new CategoriesPlugin(); final InMemoryReportStorage storage = new InMemoryReportStorage(); - plugin.aggregate(configuration, launchResultsList, storage); + aggregateCategories(plugin, configuration, launchResultsList, storage); Set results = launchResultsList.get(0).getAllResults(); List categories = results.toArray(new TestResult[]{})[0] @@ -229,6 +253,10 @@ meta, createTestResult("asd\n", Status.FAILED, true) .containsKey("data/" + JSON_FILE_NAME); } + /** + * Verifies default category matching includes flaky test results. + */ + @Description @Test void flakyTestsShouldBeMatchedByDefault() { final Configuration configuration = ConfigurationBuilder.bundled().build(); @@ -247,7 +275,7 @@ meta, createTestResult("asd\n", Status.FAILED, true) final CategoriesPlugin plugin = new CategoriesPlugin(); final InMemoryReportStorage storage = new InMemoryReportStorage(); - plugin.aggregate(configuration, launchResultsList, storage); + aggregateCategories(plugin, configuration, launchResultsList, storage); final Set results = launchResultsList.get(0).getAllResults(); List categories = results.toArray(new TestResult[]{})[0] @@ -261,6 +289,10 @@ meta, createTestResult("asd\n", Status.FAILED, true) .containsKey("data/" + JSON_FILE_NAME); } + /** + * Verifies sorting category results by ascending start time. + */ + @Description @Issue("587") @Issue("572") @Test @@ -304,26 +336,42 @@ private TestResult createTestResult(final String message, final Status status, f .setFlaky(flaky); } + /** + * Verifies removing simple ANSI code for category aggregation. + */ + @Description @Test void shouldRemoveSimpleAnsiCode() { String input = "\u001B[31mAnsi text\u001B[0m"; String expected = "Ansi text"; - assertThat(stripAnsi(input)).isEqualTo(expected); + assertAnsiStripped(input, expected); } + /** + * Verifies removing multiple ANSI codes for category aggregation. + */ + @Description @Test void shouldRemoveMultipleAnsiCodes() { String input = "Timed out 5000ms waiting for expect(locator).toBeVisible()"; String expected = "Timed out 5000ms waiting for expect(locator).toBeVisible()"; - assertThat(stripAnsi(input)).isEqualTo(expected); + assertAnsiStripped(input, expected); } + /** + * Verifies leaving clean category text unchanged when no ANSI codes are present. + */ + @Description @Test void shouldReturnUnchangedIfNoAnsi() { String input = "Clean text"; - assertThat(stripAnsi(input)).isEqualTo("Clean text"); + assertAnsiStripped(input, "Clean text"); } + /** + * Verifies sanitizing category description HTML for category aggregation. + */ + @Description @Test void shouldSanitizeCategoryDescriptionHtml(@TempDir final Path directory) throws IOException { final String categoriesJson = "[{\"name\":\"xss\",\"descriptionHtml\":\"

safe

\"}]"; @@ -332,7 +380,10 @@ void shouldSanitizeCategoryDescriptionHtml(@TempDir final Path directory) throws final Configuration configuration = ConfigurationBuilder.bundled().build(); final DefaultResultsVisitor visitor = new DefaultResultsVisitor(configuration); final CategoriesPlugin plugin = new CategoriesPlugin(); - plugin.readResults(configuration, visitor, directory); + Allure.step( + "Read category definitions from " + directory, + () -> plugin.readResults(configuration, visitor, directory) + ); final LaunchResults launchResults = visitor.getLaunchResults(); final List categories = launchResults.getExtra(CATEGORIES, ArrayList::new); @@ -342,4 +393,41 @@ void shouldSanitizeCategoryDescriptionHtml(@TempDir final Path directory) throws .doesNotContain(" launchResults, + final InMemoryReportStorage storage + ) { + Allure.step("Aggregate categories for " + launchResults.size() + " launch(es)", () -> { + plugin.aggregate(configuration, launchResults, storage); + attachStorageFiles(storage); + }); + } + + private void assertAnsiStripped(final String input, final String expected) { + Allure.step("Strip ANSI escapes from category text", () -> { + final String actual = stripAnsi(input); + Allure.addAttachment( + "ANSI stripping sample", + "text/plain", + String.format("input=%s%nexpected=%s%nactual=%s%n", input, expected, actual) + ); + assertThat(actual).isEqualTo(expected); + }); + } + + private void attachStorageFiles(final InMemoryReportStorage storage) { + Allure.step("Attach in-memory storage contents", () -> storage.getReportDataFiles().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> Allure.addAttachment( + entry.getKey(), + "text/plain", + new String( + Base64.getDecoder().decode(entry.getValue()), + StandardCharsets.UTF_8 + ) + ))); + } } diff --git a/allure-generator/src/test/java/io/qameta/allure/core/ReportWebGeneratorTest.java b/allure-generator/src/test/java/io/qameta/allure/core/ReportWebGeneratorTest.java index dbc0f2a6e..80ed9db48 100644 --- a/allure-generator/src/test/java/io/qameta/allure/core/ReportWebGeneratorTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/core/ReportWebGeneratorTest.java @@ -15,12 +15,16 @@ */ package io.qameta.allure.core; +import io.qameta.allure.Allure; import io.qameta.allure.ConfigurationBuilder; +import io.qameta.allure.Description; +import io.qameta.allure.ReportStorage; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junitpioneer.jupiter.SetEnvironmentVariable; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -30,16 +34,15 @@ */ class ReportWebGeneratorTest { + /** + * Verifies referencing hashed directory assets for web report generation. + */ + @Description @Test void shouldReferenceHashedDirectoryAssets(@TempDir final Path tempDirectory) { final Configuration configuration = ConfigurationBuilder.empty().build(); - new ReportWebGenerator() - .generate( - configuration, - new FileSystemReportStorage(tempDirectory), - tempDirectory - ); + generateReport(configuration, new FileSystemReportStorage(tempDirectory), tempDirectory); final Path indexHtml = tempDirectory.resolve("index.html"); @@ -54,17 +57,16 @@ void shouldReferenceHashedDirectoryAssets(@TempDir final Path tempDirectory) { .doesNotContain("styles.css"); } + /** + * Verifies disabling analytics for web report generation. + */ + @Description @SetEnvironmentVariable(key = "ALLURE_NO_ANALYTICS", value = "true") @Test void shouldDisableAnalytics(@TempDir final Path tempDirectory) { final Configuration configuration = ConfigurationBuilder.empty().build(); final InMemoryReportStorage reportStorage = new InMemoryReportStorage(); - new ReportWebGenerator() - .generate( - configuration, - reportStorage, - tempDirectory - ); + generateReport(configuration, reportStorage, tempDirectory); final Path indexHtml = tempDirectory.resolve("index.html"); @@ -74,18 +76,17 @@ void shouldDisableAnalytics(@TempDir final Path tempDirectory) { .doesNotContain("googletagmanager"); } + /** + * Verifies setting language for web report generation. + */ + @Description @Test void shouldSetLanguage(@TempDir final Path tempDirectory) { final Configuration configuration = ConfigurationBuilder.empty() .withReportLanguage("xyz") .build(); final InMemoryReportStorage reportStorage = new InMemoryReportStorage(); - new ReportWebGenerator() - .generate( - configuration, - reportStorage, - tempDirectory - ); + generateReport(configuration, reportStorage, tempDirectory); final Path indexHtml = tempDirectory.resolve("index.html"); @@ -95,17 +96,16 @@ void shouldSetLanguage(@TempDir final Path tempDirectory) { .contains("lang=\"xyz\""); } + /** + * Verifies setting default language if not provided for web report generation. + */ + @Description @Test void shouldSetDefaultLanguageIfNotProvided(@TempDir final Path tempDirectory) { final Configuration configuration = ConfigurationBuilder.empty() .build(); final InMemoryReportStorage reportStorage = new InMemoryReportStorage(); - new ReportWebGenerator() - .generate( - configuration, - reportStorage, - tempDirectory - ); + generateReport(configuration, reportStorage, tempDirectory); final Path indexHtml = tempDirectory.resolve("index.html"); @@ -115,17 +115,16 @@ void shouldSetDefaultLanguageIfNotProvided(@TempDir final Path tempDirectory) { .contains("lang=\"en\""); } + /** + * Verifies inlining hashed scripts in single file mode for web report generation. + */ + @Description @Test void shouldInlineHashedScriptsInSingleFileMode(@TempDir final Path tempDirectory) { final Configuration configuration = ConfigurationBuilder.empty().build(); final InMemoryReportStorage reportStorage = new InMemoryReportStorage(); - new ReportWebGenerator() - .generate( - configuration, - reportStorage, - tempDirectory - ); + generateReport(configuration, reportStorage, tempDirectory); final Path indexHtml = tempDirectory.resolve("index.html"); @@ -137,4 +136,20 @@ void shouldInlineHashedScriptsInSingleFileMode(@TempDir final Path tempDirectory .doesNotContain("type=\"module\"") .doesNotContain("type=\"importmap\""); } + + private void generateReport( + final Configuration configuration, + final ReportStorage reportStorage, + final Path outputDirectory + ) { + Allure.step("Generate report web assets into " + outputDirectory, () -> { + new ReportWebGenerator().generate(configuration, reportStorage, outputDirectory); + final Path indexHtml = outputDirectory.resolve("index.html"); + Allure.addAttachment( + "Generated index.html", + "text/html", + Files.readString(indexHtml, StandardCharsets.UTF_8) + ); + }); + } } diff --git a/allure-generator/src/test/java/io/qameta/allure/detect/MagicBytesContentTypeDetectorTest.java b/allure-generator/src/test/java/io/qameta/allure/detect/MagicBytesContentTypeDetectorTest.java index 4f7caa945..17c62cdbd 100644 --- a/allure-generator/src/test/java/io/qameta/allure/detect/MagicBytesContentTypeDetectorTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/detect/MagicBytesContentTypeDetectorTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.detect; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import org.apache.commons.io.IOUtils; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -26,6 +28,7 @@ import java.util.Objects; import java.util.stream.Stream; +import static io.qameta.allure.testdata.TestData.toHex; import static org.assertj.core.api.Assertions.assertThat; /** @@ -45,21 +48,40 @@ static Stream data() { ); } + /** + * Verifies detecting content type for magic byte content detection. + */ + @Description @ParameterizedTest @MethodSource("data") void shouldDetectContentType(final String resourceName, final String expectedContentType) throws IOException { - final ByteArrayOutputStream bos = new ByteArrayOutputStream(); + Allure.parameter("resourceName", resourceName); + Allure.parameter("expectedContentType", expectedContentType); final String resource = "sample-files-to-detect/" + resourceName; - try (InputStream stream = getClass().getClassLoader() - .getResourceAsStream(resource)) { - IOUtils.copy(Objects.requireNonNull(stream, "no resource found:" + resource), bos); - } - byte[] bytes = bos.toByteArray(); - final String detectedContentType = MagicBytesContentTypeDetector.detectContentType(bytes); + byte[] bytes = Allure.step("Read sample file " + resource, () -> { + final ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try (InputStream stream = getClass().getClassLoader() + .getResourceAsStream(resource)) { + IOUtils.copy(Objects.requireNonNull(stream, "no resource found:" + resource), bos); + } + final byte[] content = bos.toByteArray(); + Allure.addAttachment(resourceName, "text/plain", describeMagicBytes(content)); + return content; + }); + final String detectedContentType = Allure.step( + "Detect content type from magic bytes", + () -> MagicBytesContentTypeDetector.detectContentType(bytes) + ); assertThat(detectedContentType) .isEqualTo(expectedContentType); } - + private String describeMagicBytes(final byte[] content) { + return String.format( + "length=%d%nhexPreview=%s%n", + content.length, + toHex(content, 32) + ); + } } diff --git a/allure-generator/src/test/java/io/qameta/allure/detect/WellKnownFileExtensionsUtilsTest.java b/allure-generator/src/test/java/io/qameta/allure/detect/WellKnownFileExtensionsUtilsTest.java index c9edb99a0..2d784cb45 100644 --- a/allure-generator/src/test/java/io/qameta/allure/detect/WellKnownFileExtensionsUtilsTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/detect/WellKnownFileExtensionsUtilsTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.detect; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -43,10 +45,19 @@ static Stream expectedContentTypes() { ); } + /** + * Verifies detecting content type for extension-based content detection. + */ + @Description @ParameterizedTest @MethodSource("expectedContentTypes") void shouldDetectContentType(final String resourceName, final String expectedContentType) { - final String detectedContentType = WellKnownFileExtensionsUtils.lookup(resourceName); + Allure.parameter("resourceName", resourceName); + Allure.parameter("expectedContentType", expectedContentType); + final String detectedContentType = Allure.step( + "Resolve content type from file name", + () -> WellKnownFileExtensionsUtils.lookup(resourceName) + ); assertThat(detectedContentType) .isEqualTo(expectedContentType); @@ -68,10 +79,19 @@ static Stream expectedExtensions() { ); } + /** + * Verifies returning extension by content type for extension-based content detection. + */ + @Description @ParameterizedTest @MethodSource("expectedExtensions") void shouldReturnExtensionByContentType(final String contentType, final String expectedExtension) { - final String detectedContentType = WellKnownFileExtensionsUtils.getExtensionByMimeType(contentType); + Allure.parameter("contentType", contentType); + Allure.parameter("expectedExtension", expectedExtension); + final String detectedContentType = Allure.step( + "Resolve file extension from content type", + () -> WellKnownFileExtensionsUtils.getExtensionByMimeType(contentType) + ); assertThat(detectedContentType) .isEqualTo(expectedExtension); diff --git a/allure-generator/src/test/java/io/qameta/allure/environment/Allure1EnvironmentPluginTest.java b/allure-generator/src/test/java/io/qameta/allure/environment/Allure1EnvironmentPluginTest.java index 62eae0fa7..5c1c2d3c3 100644 --- a/allure-generator/src/test/java/io/qameta/allure/environment/Allure1EnvironmentPluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/environment/Allure1EnvironmentPluginTest.java @@ -15,8 +15,10 @@ */ package io.qameta.allure.environment; +import io.qameta.allure.Allure; import io.qameta.allure.ConfigurationBuilder; import io.qameta.allure.DefaultResultsVisitor; +import io.qameta.allure.Description; import io.qameta.allure.allure1.Allure1Plugin; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.LaunchResults; @@ -34,6 +36,8 @@ import java.util.List; import java.util.Objects; +import static io.qameta.allure.testdata.TestData.attachFileContent; +import static io.qameta.allure.testdata.TestData.attachLaunchResults; import static java.util.Arrays.asList; import static java.util.Collections.singletonList; import static org.allurefw.allure1.AllureUtils.generateTestSuiteJsonName; @@ -45,6 +49,8 @@ */ class Allure1EnvironmentPluginTest { + private static final String TEXT_PLAIN = "text/plain"; + private Path temp; @BeforeEach @@ -52,6 +58,10 @@ void setUp(@TempDir final Path temp) { this.temp = temp; } + /** + * Verifies reading environment properties for Allure 1 environment aggregation. + */ + @Description @Test void shouldReadEnvironmentProperties() throws Exception { EnvironmentItem[] expected = new EnvironmentItem[]{ @@ -72,6 +82,10 @@ void shouldReadEnvironmentProperties() throws Exception { .containsExactlyInAnyOrder(expected); } + /** + * Verifies reading environment XML for Allure 1 environment aggregation. + */ + @Description @Test void shouldReadEnvironmentXml() throws Exception { EnvironmentItem[] expected = new EnvironmentItem[]{ @@ -92,6 +106,10 @@ void shouldReadEnvironmentXml() throws Exception { .containsExactlyInAnyOrder(expected); } + /** + * Verifies stacking parameter values for Allure 1 environment aggregation. + */ + @Description @Test void shouldStackParameterValues() throws Exception { EnvironmentItem[] expected = new EnvironmentItem[]{ @@ -122,28 +140,57 @@ void shouldStackParameterValues() throws Exception { @SafeVarargs private List process(List... results) throws IOException { - List launches = new ArrayList<>(); - final Configuration configuration = ConfigurationBuilder.bundled().build(); - Allure1Plugin reader = new Allure1Plugin(); - for (List result : results) { - Path resultsDirectory = Files.createTempDirectory(temp, "results"); - Iterator iterator = result.iterator(); - while (iterator.hasNext()) { - String first = iterator.next(); - String second = iterator.next(); - copyFile(resultsDirectory, first, second); + return Allure.step("Read environment data from " + results.length + " Allure 1 launch(es)", () -> { + List launches = new ArrayList<>(); + final Configuration configuration = ConfigurationBuilder.bundled().build(); + Allure1Plugin reader = new Allure1Plugin(); + for (List result : results) { + Path resultsDirectory = Files.createTempDirectory(temp, "results"); + Iterator iterator = result.iterator(); + while (iterator.hasNext()) { + String first = iterator.next(); + String second = iterator.next(); + copyFile(resultsDirectory, first, second); + } + final LaunchResults launchResults = Allure.step("Parse Allure 1 launch at " + resultsDirectory, () -> { + final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); + reader.readResults(configuration, resultsVisitor, resultsDirectory); + final LaunchResults parsed = resultsVisitor.getLaunchResults(); + attachLaunchResults("Attach parsed Allure 1 launch artifacts", parsed); + return parsed; + }); + launches.add(launchResults); } - final DefaultResultsVisitor resultsVisitor = new DefaultResultsVisitor(configuration); - reader.readResults(configuration, resultsVisitor, resultsDirectory); - launches.add(resultsVisitor.getLaunchResults()); - } - Allure1EnvironmentPlugin envPlugin = new Allure1EnvironmentPlugin(); - return envPlugin.getData(launches); + Allure1EnvironmentPlugin envPlugin = new Allure1EnvironmentPlugin(); + final List environment = envPlugin.getData(launches); + Allure.addAttachment("Environment items", TEXT_PLAIN, describeEnvironment(environment)); + return environment; + }); } private void copyFile(Path dir, String resourceName, String fileName) throws IOException { - try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { - Files.copy(Objects.requireNonNull(is), dir.resolve(fileName)); + Allure.step("Copy fixture " + resourceName + " as " + fileName, () -> { + final Path output = dir.resolve(fileName); + final byte[] content; + try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { + content = Objects.requireNonNull(is).readAllBytes(); + Files.write(output, content); + } + attachFileContent(fileName, content); + }); + } + + private String describeEnvironment(final List environment) { + final StringBuilder builder = new StringBuilder(); + for (EnvironmentItem item : environment) { + if (builder.length() > 0) { + builder.append(System.lineSeparator()); + } + builder + .append(item.getName()) + .append('=') + .append(item.getValues()); } + return builder.toString(); } } diff --git a/allure-generator/src/test/java/io/qameta/allure/ga/GaPluginTest.java b/allure-generator/src/test/java/io/qameta/allure/ga/GaPluginTest.java index 446a0ab79..2bc1d7747 100644 --- a/allure-generator/src/test/java/io/qameta/allure/ga/GaPluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/ga/GaPluginTest.java @@ -15,7 +15,9 @@ */ package io.qameta.allure.ga; +import io.qameta.allure.Allure; import io.qameta.allure.DefaultLaunchResults; +import io.qameta.allure.Description; import io.qameta.allure.entity.ExecutorInfo; import io.qameta.allure.executor.ExecutorPlugin; import org.junit.jupiter.api.Test; @@ -31,46 +33,56 @@ */ class GaPluginTest { + /** + * Verifies resolving default executor for analytics executor detection. + */ + @Description @Test void shouldGetDefaultExecutor() { - final String executorType = GaPlugin.getExecutorType( - List.of( - new DefaultLaunchResults(Set.of(), Map.of(), Map.of()) - ) - ); + Allure.parameter("executorMetadata", "missing"); + final String executorType = getExecutorType(new DefaultLaunchResults(Set.of(), Map.of(), Map.of())); assertThat(executorType) .isEqualTo("local"); } + /** + * Verifies processing null executor for analytics executor detection. + */ + @Description @Test void shouldProcessNullExecutor() { - final String executorType = GaPlugin.getExecutorType( - List.of( - new DefaultLaunchResults(Set.of(), Map.of(), Map.of( - ExecutorPlugin.EXECUTORS_BLOCK_NAME, - new ExecutorInfo() - )) - ) - ); + Allure.parameter("executorMetadata", "present without type"); + final String executorType = getExecutorType(new DefaultLaunchResults(Set.of(), Map.of(), Map.of( + ExecutorPlugin.EXECUTORS_BLOCK_NAME, + new ExecutorInfo() + ))); assertThat(executorType) .isEqualTo("local"); } + /** + * Verifies processing executor for analytics executor detection. + */ + @Description @Test void shouldProcessExecutor() { - final String executorType = GaPlugin.getExecutorType( - List.of( - new DefaultLaunchResults(Set.of(), Map.of(), Map.of( - ExecutorPlugin.EXECUTORS_BLOCK_NAME, - new ExecutorInfo() - .setType("some executor type") - )) - ) - ); + Allure.parameter("executorMetadata", "present with type"); + final String executorType = getExecutorType(new DefaultLaunchResults(Set.of(), Map.of(), Map.of( + ExecutorPlugin.EXECUTORS_BLOCK_NAME, + new ExecutorInfo() + .setType("some executor type") + ))); assertThat(executorType) .isEqualTo("some executor type"); } + + private String getExecutorType(final DefaultLaunchResults launchResults) { + return Allure.step( + "Resolve Google Analytics executor type", + () -> GaPlugin.getExecutorType(List.of(launchResults)) + ); + } } diff --git a/allure-generator/src/test/java/io/qameta/allure/history/HistoryPluginTest.java b/allure-generator/src/test/java/io/qameta/allure/history/HistoryPluginTest.java index a3dfac951..50763b2eb 100644 --- a/allure-generator/src/test/java/io/qameta/allure/history/HistoryPluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/history/HistoryPluginTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.history; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.entity.Status; import io.qameta.allure.entity.TestResult; import io.qameta.allure.entity.Time; @@ -37,6 +39,10 @@ class HistoryPluginTest { private static final String HISTORY_BLOCK_NAME = "history"; + /** + * Verifies detecting the new failed mark for history aggregation. + */ + @Description @Test void shouldHasNewFailedMark() { String historyId = UUID.randomUUID().toString(); @@ -48,15 +54,17 @@ void shouldHasNewFailedMark() { extra.put(HISTORY_BLOCK_NAME, historyDataMap); TestResult testResult = createTestResult(FAILED, historyId, 100, 101); - new HistoryPlugin().getData(singletonList( - createLaunchResults(extra, testResult) - )); + getHistoryData(extra, testResult); assertThat(testResult.isNewFailed()).isTrue(); assertThat(testResult.isFlaky()).isFalse(); assertThat(testResult.isNewPassed()).isFalse(); assertThat(testResult.isNewBroken()).isFalse(); } + /** + * Verifies detecting the new broken mark for history aggregation. + */ + @Description @Test void shouldHasNewBrokenMark() { String historyId = UUID.randomUUID().toString(); @@ -68,15 +76,17 @@ void shouldHasNewBrokenMark() { extra.put(HISTORY_BLOCK_NAME, historyDataMap); TestResult testResult = createTestResult(Status.BROKEN, historyId, 100, 101); - new HistoryPlugin().getData(singletonList( - createLaunchResults(extra, testResult) - )); + getHistoryData(extra, testResult); assertThat(testResult.isNewFailed()).isFalse(); assertThat(testResult.isFlaky()).isFalse(); assertThat(testResult.isNewPassed()).isFalse(); assertThat(testResult.isNewBroken()).isTrue(); } + /** + * Verifies detecting the flaky mark for history aggregation. + */ + @Description @Test void shouldHasFlakyMark() { String historyId = UUID.randomUUID().toString(); @@ -89,15 +99,17 @@ void shouldHasFlakyMark() { extra.put(HISTORY_BLOCK_NAME, historyDataMap); TestResult testResult = createTestResult(FAILED, historyId, 100, 101); - new HistoryPlugin().getData(singletonList( - createLaunchResults(extra, testResult) - )); + getHistoryData(extra, testResult); assertThat(testResult.isNewFailed()).isTrue(); assertThat(testResult.isFlaky()).isTrue(); assertThat(testResult.isNewPassed()).isFalse(); assertThat(testResult.isNewBroken()).isFalse(); } + /** + * Verifies detecting the new passed mark for history aggregation. + */ + @Description @Test void shouldHasNewPassedMark() { String historyId = UUID.randomUUID().toString(); @@ -109,15 +121,17 @@ void shouldHasNewPassedMark() { extra.put(HISTORY_BLOCK_NAME, historyDataMap); TestResult testResult = createTestResult(Status.PASSED, historyId, 100, 101); - new HistoryPlugin().getData(singletonList( - createLaunchResults(extra, testResult) - )); + getHistoryData(extra, testResult); assertThat(testResult.isNewFailed()).isFalse(); assertThat(testResult.isFlaky()).isFalse(); assertThat(testResult.isNewPassed()).isTrue(); assertThat(testResult.isNewBroken()).isFalse(); } + /** + * Verifies reducing history data across multiple launches for history aggregation. + */ + @Description @Test void shouldReduceHistoryResults() { String historyId1 = UUID.randomUUID().toString(); @@ -132,10 +146,13 @@ void shouldReduceHistoryResults() { extra2.put(HISTORY_BLOCK_NAME, copyHistoryData(historyDataMap)); - Map data = new HistoryPlugin().getData(asList( - createLaunchResults(extra1, createTestResult(PASSED, historyId1, 3, 4)), - createLaunchResults(extra2, createTestResult(PASSED, historyId2, 5, 6)) - )); + Map data = Allure.step( + "Reduce history entries across two launches", + () -> new HistoryPlugin().getData(asList( + createLaunchResults(extra1, createTestResult(PASSED, historyId1, 3, 4)), + createLaunchResults(extra2, createTestResult(PASSED, historyId2, 5, 6)) + )) + ); assertThat(data).containsKeys(historyId1, historyId2); assertThat(data.get(historyId1).getItems()).hasSize(2); @@ -166,4 +183,11 @@ private HistoryItem createHistoryItem(Status status, long start, long stop) { .setTime(new Time().setStart(start).setStop(stop)); } + private Map getHistoryData(final Map extra, final TestResult testResult) { + return Allure.step( + "Calculate history marks for result " + testResult.getName(), + () -> new HistoryPlugin().getData(singletonList(createLaunchResults(extra, testResult))) + ); + } + } diff --git a/allure-generator/src/test/java/io/qameta/allure/history/HistoryTrendPluginTest.java b/allure-generator/src/test/java/io/qameta/allure/history/HistoryTrendPluginTest.java index 77a035fd2..ebd2e6e91 100644 --- a/allure-generator/src/test/java/io/qameta/allure/history/HistoryTrendPluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/history/HistoryTrendPluginTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.history; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.ReportStorage; import io.qameta.allure.context.JacksonContext; import io.qameta.allure.core.Configuration; @@ -57,6 +59,10 @@ */ class HistoryTrendPluginTest { + /** + * Verifies reading old data for history trend aggregation. + */ + @Description @SuppressWarnings("unchecked") @Test void shouldReadOldData(@TempDir final Path resultsDirectory) throws Exception { @@ -71,7 +77,11 @@ void shouldReadOldData(@TempDir final Path resultsDirectory) throws Exception { final ResultsVisitor visitor = mock(ResultsVisitor.class); final HistoryTrendPlugin plugin = new HistoryTrendPlugin(); - plugin.readResults(configuration, visitor, resultsDirectory); + Allure.step("Read old history trend data from " + trend, () -> plugin.readResults( + configuration, + visitor, + resultsDirectory + )); final ArgumentCaptor> captor = ArgumentCaptor.captor(); verify(visitor, times(1)) @@ -84,6 +94,10 @@ void shouldReadOldData(@TempDir final Path resultsDirectory) throws Exception { .containsExactly(20L, 12L, 12L, 1L); } + /** + * Verifies reading new data for history trend aggregation. + */ + @Description @SuppressWarnings("unchecked") @Test void shouldReadNewData(@TempDir final Path resultsDirectory) throws Exception { @@ -98,7 +112,11 @@ void shouldReadNewData(@TempDir final Path resultsDirectory) throws Exception { final ResultsVisitor visitor = mock(ResultsVisitor.class); final HistoryTrendPlugin plugin = new HistoryTrendPlugin(); - plugin.readResults(configuration, visitor, resultsDirectory); + Allure.step("Read current history trend data from " + trend, () -> plugin.readResults( + configuration, + visitor, + resultsDirectory + )); final ArgumentCaptor> captor = ArgumentCaptor.captor(); verify(visitor, times(1)) @@ -122,11 +140,16 @@ void shouldReadNewData(@TempDir final Path resultsDirectory) throws Exception { ); } + /** + * Verifies processing corrupted data for history trend aggregation. + */ + @Description @SuppressWarnings("unchecked") @Test void shouldProcessCorruptedData(@TempDir final Path resultsDirectory) throws Exception { final Path history = Files.createDirectories(resultsDirectory.resolve("history")); - Files.createFile(history.resolve("history-trend.json")); + final Path trend = history.resolve("history-trend.json"); + Allure.step("Create empty history trend file", () -> Files.createFile(trend)); final Configuration configuration = mock(Configuration.class); when(configuration.requireContext(JacksonContext.class)) @@ -135,7 +158,11 @@ void shouldProcessCorruptedData(@TempDir final Path resultsDirectory) throws Exc final ResultsVisitor visitor = mock(ResultsVisitor.class); final HistoryTrendPlugin plugin = new HistoryTrendPlugin(); - plugin.readResults(configuration, visitor, resultsDirectory); + Allure.step("Read corrupted history trend data from " + trend, () -> plugin.readResults( + configuration, + visitor, + resultsDirectory + )); final ArgumentCaptor> captor = ArgumentCaptor.captor(); verify(visitor, times(1)) @@ -144,13 +171,20 @@ void shouldProcessCorruptedData(@TempDir final Path resultsDirectory) throws Exc assertThat(captor.getValue()).hasSize(0); } + /** + * Verifies aggregating for empty report for history trend aggregation. + */ + @Description @Test void shouldAggregateForEmptyReport() { final Configuration configuration = mock(Configuration.class); final HistoryTrendPlugin.JsonAggregator aggregator = new HistoryTrendPlugin.JsonAggregator(); final ReportStorage reportStorage = mock(); - aggregator.aggregate(configuration, Collections.emptyList(), reportStorage); + Allure.step( + "Aggregate history trend widget for an empty report", + () -> aggregator.aggregate(configuration, Collections.emptyList(), reportStorage) + ); final ArgumentCaptor> captor = ArgumentCaptor.captor(); @@ -170,15 +204,22 @@ void shouldAggregateForEmptyReport() { } + /** + * Verifies resolving data for history trend aggregation. + */ + @Description @Test void shouldGetData() { final List history = randomHistoryTrendItems(); - final List data = HistoryTrendPlugin.getData(createSingleLaunchResults( - singletonMap(HISTORY_TREND_BLOCK_NAME, history), - randomTestResult().setStatus(Status.PASSED), - randomTestResult().setStatus(Status.FAILED), - randomTestResult().setStatus(Status.FAILED) - )); + final List data = Allure.step( + "Build history trend data with previous trend entries and three current results", + () -> HistoryTrendPlugin.getData(createSingleLaunchResults( + singletonMap(HISTORY_TREND_BLOCK_NAME, history), + randomTestResult().setStatus(Status.PASSED), + randomTestResult().setStatus(Status.FAILED), + randomTestResult().setStatus(Status.FAILED) + )) + ); assertThat(data) .hasSize(1 + history.size()) @@ -194,6 +235,10 @@ void shouldGetData() { } + /** + * Verifies finding latest executor for history trend aggregation. + */ + @Description @Test void shouldFindLatestExecutor() { final Map extra1 = new HashMap<>(); @@ -218,7 +263,10 @@ void shouldFindLatestExecutor() { ) ); - final List data = HistoryTrendPlugin.getData(launchResults); + final List data = Allure.step( + "Build history trend data and choose the latest executor", + () -> HistoryTrendPlugin.getData(launchResults) + ); assertThat(data) .hasSize(1 + history1.size() + history2.size()); @@ -229,6 +277,10 @@ void shouldFindLatestExecutor() { .hasFieldOrPropertyWithValue("buildOrder", 7L); } + /** + * Verifies processing null build order for history trend aggregation. + */ + @Description @Test void shouldProcessNullBuildOrder() { final List history = randomHistoryTrendItems(); @@ -248,7 +300,10 @@ void shouldProcessNullBuildOrder() { randomTestResult().setStatus(Status.FAILED) ) ); - final List data = HistoryTrendPlugin.getData(launchResults); + final List data = Allure.step( + "Build history trend data with null build order", + () -> HistoryTrendPlugin.getData(launchResults) + ); assertThat(data) .hasSize(1 + 2 * history.size()); diff --git a/allure-generator/src/test/java/io/qameta/allure/idea/IdeaLinksPluginTest.java b/allure-generator/src/test/java/io/qameta/allure/idea/IdeaLinksPluginTest.java index 0818bd176..2c0841de5 100644 --- a/allure-generator/src/test/java/io/qameta/allure/idea/IdeaLinksPluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/idea/IdeaLinksPluginTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.idea; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.InMemoryReportStorage; import io.qameta.allure.core.LaunchResults; @@ -35,6 +37,10 @@ class IdeaLinksPluginTest { private static final String TEST_CLASS = "io.qameta.allure.AllureTest"; + /** + * Verifies exporting test result to Jira for IDEA link aggregation. + */ + @Description @Test void shouldExportTestResultToJira() { final LaunchResults launchResults = mock(LaunchResults.class); @@ -46,10 +52,13 @@ void shouldExportTestResultToJira() { final IdeaLinksPlugin jiraTestResultExportPlugin = new IdeaLinksPlugin(true, 63342); - jiraTestResultExportPlugin.aggregate( - mock(Configuration.class), - Collections.singletonList(launchResults), - new InMemoryReportStorage() + Allure.step( + "Aggregate IDEA links for one test result", + () -> jiraTestResultExportPlugin.aggregate( + mock(Configuration.class), + Collections.singletonList(launchResults), + new InMemoryReportStorage() + ) ); assertThat(testResult.getLinks()).hasSize(1); diff --git a/allure-generator/src/test/java/io/qameta/allure/markdown/MarkdownAggregatorTest.java b/allure-generator/src/test/java/io/qameta/allure/markdown/MarkdownAggregatorTest.java index ffa41fb3d..ff43660c7 100644 --- a/allure-generator/src/test/java/io/qameta/allure/markdown/MarkdownAggregatorTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/markdown/MarkdownAggregatorTest.java @@ -15,28 +15,40 @@ */ package io.qameta.allure.markdown; +import io.qameta.allure.Allure; import io.qameta.allure.ConfigurationBuilder; import io.qameta.allure.DefaultLaunchResults; +import io.qameta.allure.Description; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.InMemoryReportStorage; +import io.qameta.allure.core.LaunchResults; import io.qameta.allure.core.MarkdownDescriptionsPlugin; import io.qameta.allure.entity.TestResult; import org.junit.jupiter.api.Test; import java.util.Collections; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; class MarkdownAggregatorTest { + /** + * Verifies markdown aggregation with an empty launch list. + */ + @Description @Test void shouldNotFailIfEmptyResults() { final Configuration configuration = ConfigurationBuilder.bundled().build(); final MarkdownDescriptionsPlugin aggregator = new MarkdownDescriptionsPlugin(); - aggregator.aggregate(configuration, Collections.emptyList(), new InMemoryReportStorage()); + aggregateMarkdownDescriptions(aggregator, configuration, Collections.emptyList()); } + /** + * Verifies markdown aggregation leaves empty descriptions unchanged. + */ + @Description @Test void shouldSkipResultsWithEmptyDescription() { final Configuration configuration = ConfigurationBuilder.bundled().build(); @@ -49,12 +61,16 @@ void shouldSkipResultsWithEmptyDescription() { Collections.emptyMap(), Collections.emptyMap() ); - aggregator.aggregate(configuration, Collections.singletonList(launchResults), new InMemoryReportStorage()); + aggregateMarkdownDescriptions(aggregator, configuration, Collections.singletonList(launchResults)); assertThat(result) .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) .containsExactly(null, null); } + /** + * Verifies markdown aggregation keeps existing HTML descriptions unchanged. + */ + @Description @Test void shouldSkipResultsWithNonEmptyDescriptionHtml() { final Configuration configuration = ConfigurationBuilder.bundled().build(); @@ -70,12 +86,16 @@ void shouldSkipResultsWithNonEmptyDescriptionHtml() { Collections.emptyMap(), Collections.emptyMap() ); - aggregator.aggregate(configuration, Collections.singletonList(launchResults), new InMemoryReportStorage()); + aggregateMarkdownDescriptions(aggregator, configuration, Collections.singletonList(launchResults)); assertThat(result) .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) .containsExactly("desc", "descHtml"); } + /** + * Verifies processing description for markdown description aggregation. + */ + @Description @Test void shouldProcessDescription() { final Configuration configuration = ConfigurationBuilder.bundled().build(); @@ -90,9 +110,20 @@ void shouldProcessDescription() { Collections.emptyMap(), Collections.emptyMap() ); - aggregator.aggregate(configuration, Collections.singletonList(launchResults), new InMemoryReportStorage()); + aggregateMarkdownDescriptions(aggregator, configuration, Collections.singletonList(launchResults)); assertThat(result) .extracting(TestResult::getDescription, TestResult::getDescriptionHtml) .containsExactly("desc", "

desc

\n"); } + + private void aggregateMarkdownDescriptions( + final MarkdownDescriptionsPlugin aggregator, + final Configuration configuration, + final List launchResults + ) { + Allure.step( + "Render markdown descriptions for " + launchResults.size() + " launch(es)", + () -> aggregator.aggregate(configuration, launchResults, new InMemoryReportStorage()) + ); + } } diff --git a/allure-generator/src/test/java/io/qameta/allure/prometheus/PrometheusMetricLineTest.java b/allure-generator/src/test/java/io/qameta/allure/prometheus/PrometheusMetricLineTest.java index 84599ac80..023f50788 100644 --- a/allure-generator/src/test/java/io/qameta/allure/prometheus/PrometheusMetricLineTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/prometheus/PrometheusMetricLineTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.prometheus; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -39,10 +41,17 @@ static Stream data() { ); } + /** + * Verifies returning metric for Prometheus metric formatting. + */ + @Description @ParameterizedTest @MethodSource(value = "data") void shouldReturnMetric(final String labels, final String expectedMetric) { + Allure.parameter("labels", labels); + Allure.parameter("expectedMetric", expectedMetric); PrometheusMetricLine prometheusMetric = new PrometheusMetricLine(METRIC_NAME, METRIC_KEY, METRIC_VALUE, labels); - assertThat(prometheusMetric.asString()).isEqualTo(expectedMetric); + final String metric = Allure.step("Render Prometheus metric line", prometheusMetric::asString); + assertThat(metric).isEqualTo(expectedMetric); } } diff --git a/allure-generator/src/test/java/io/qameta/allure/retry/RetryPluginTest.java b/allure-generator/src/test/java/io/qameta/allure/retry/RetryPluginTest.java index 45abe7c61..4732e35c6 100644 --- a/allure-generator/src/test/java/io/qameta/allure/retry/RetryPluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/retry/RetryPluginTest.java @@ -15,6 +15,7 @@ */ package io.qameta.allure.retry; +import io.qameta.allure.Description; import io.qameta.allure.core.LaunchResults; import io.qameta.allure.entity.Status; import io.qameta.allure.entity.TestResult; @@ -43,6 +44,10 @@ class RetryPluginTest { private RetryPlugin retryPlugin = new RetryPlugin(); + /** + * Verifies merging retries test results for retry aggregation. + */ + @Description @Test void shouldMergeRetriesTestResults() { String historyId = UUID.randomUUID().toString(); @@ -84,6 +89,10 @@ void shouldMergeRetriesTestResults() { .hasSize(2); } + /** + * Verifies retry aggregation keeps unrelated history ids separate. + */ + @Description @Test void shouldNotMergeOtherTestResults() { String firstHistoryId = UUID.randomUUID().toString(); @@ -107,6 +116,10 @@ void shouldNotMergeOtherTestResults() { .hasSize(0); } + /** + * Verifies retry aggregation keeps hidden results out of latest-result selection. + */ + @Description @Test void shouldSkipHiddenResults() { String historyId = UUID.randomUUID().toString(); @@ -131,6 +144,10 @@ void shouldSkipHiddenResults() { ); } + /** + * Verifies passed retries do not mark the latest retry result as flaky. + */ + @Description @Test void shouldNotMarkLatestAsFlakyIfRetriesArePassed() { String historyId = UUID.randomUUID().toString(); @@ -152,6 +169,10 @@ void shouldNotMarkLatestAsFlakyIfRetriesArePassed() { .containsExactlyInAnyOrder(tuple(SECOND_RESULT, false)); } + /** + * Verifies skipped retries do not mark the latest retry result as flaky. + */ + @Description @Test void shouldNotMarkLatestAsFlakyIfRetriesSkipped() { String historyId = UUID.randomUUID().toString(); diff --git a/allure-generator/src/test/java/io/qameta/allure/suites/SuitesPluginTest.java b/allure-generator/src/test/java/io/qameta/allure/suites/SuitesPluginTest.java index cbc19c03a..92d138900 100644 --- a/allure-generator/src/test/java/io/qameta/allure/suites/SuitesPluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/suites/SuitesPluginTest.java @@ -15,7 +15,9 @@ */ package io.qameta.allure.suites; +import io.qameta.allure.Allure; import io.qameta.allure.ConfigurationBuilder; +import io.qameta.allure.Description; import io.qameta.allure.Issue; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.InMemoryReportStorage; @@ -27,7 +29,10 @@ import io.qameta.allure.tree.TreeNode; import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.List; +import java.util.Map; import static io.qameta.allure.testdata.TestData.createSingleLaunchResults; import static java.util.Collections.singletonList; @@ -38,10 +43,17 @@ */ class SuitesPluginTest { + /** + * Verifies creating tree for suites aggregation. + */ + @Description @Test void shouldCreateTree() { - final Tree tree = SuitesPlugin.getData(getSimpleLaunchResults()); + final Tree tree = Allure.step( + "Build suites tree from simple launch results", + () -> SuitesPlugin.getData(getSimpleLaunchResults()) + ); assertThat(tree.getChildren()) .hasSize(2) @@ -49,6 +61,10 @@ void shouldCreateTree() { .containsExactlyInAnyOrder("s1", "s2"); } + /** + * Verifies sorting suite results by ascending start time. + */ + @Description @Issue("587") @Issue("572") @Test @@ -62,8 +78,9 @@ void shouldSortByStartTimeAsc() { final TestResult timeless = new TestResult() .setName("timeless"); - final Tree tree = SuitesPlugin.getData( - createSingleLaunchResults(second, first, timeless) + final Tree tree = Allure.step( + "Build suites tree and sort by start time", + () -> SuitesPlugin.getData(createSingleLaunchResults(second, first, timeless)) ); assertThat(tree.getChildren()) @@ -71,6 +88,10 @@ void shouldSortByStartTimeAsc() { .containsExactly("timeless", "first", "second"); } + /** + * Verifies creating CSV file for suites aggregation. + */ + @Description @Test void shouldCreateCsvFile() { final Configuration configuration = ConfigurationBuilder.bundled().build(); @@ -78,7 +99,10 @@ void shouldCreateCsvFile() { final SuitesPlugin plugin = new SuitesPlugin(); final InMemoryReportStorage storage = new InMemoryReportStorage(); - plugin.aggregate(configuration, getSimpleLaunchResults(), storage); + Allure.step("Aggregate suites report files", () -> { + plugin.aggregate(configuration, getSimpleLaunchResults(), storage); + attachStorageFiles(storage); + }); assertThat(storage.getReportDataFiles()) .containsKey("data/" + SuitesPlugin.JSON_FILE_NAME); @@ -87,6 +111,19 @@ void shouldCreateCsvFile() { .containsKey("data/" + SuitesPlugin.CSV_FILE_NAME); } + private void attachStorageFiles(final InMemoryReportStorage storage) { + Allure.step("Attach in-memory storage contents", () -> storage.getReportDataFiles().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> Allure.addAttachment( + entry.getKey(), + "text/plain", + new String( + Base64.getDecoder().decode(entry.getValue()), + StandardCharsets.UTF_8 + ) + ))); + } + private List getSimpleLaunchResults() { final TestResult first = new TestResult() .setName("first") diff --git a/allure-generator/src/test/java/io/qameta/allure/summary/SummaryPluginTest.java b/allure-generator/src/test/java/io/qameta/allure/summary/SummaryPluginTest.java index 9572712ee..c1f83b426 100644 --- a/allure-generator/src/test/java/io/qameta/allure/summary/SummaryPluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/summary/SummaryPluginTest.java @@ -15,8 +15,10 @@ */ package io.qameta.allure.summary; +import io.qameta.allure.Allure; import io.qameta.allure.ConfigurationBuilder; import io.qameta.allure.DefaultLaunchResults; +import io.qameta.allure.Description; import io.qameta.allure.ReportStorage; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.LaunchResults; @@ -40,6 +42,10 @@ */ class SummaryPluginTest { + /** + * Verifies resolving report name from configuration for summary aggregation. + */ + @Description @Test void shouldGetReportNameFromConfiguration() { final List launchResults = List.of( @@ -52,7 +58,7 @@ void shouldGetReportNameFromConfiguration() { .build(); final ReportStorage storage = mock(); - new SummaryPlugin().aggregate(configuration, launchResults, storage); + aggregateSummary(configuration, launchResults, storage); final ArgumentCaptor dataCaptor = ArgumentCaptor.captor(); @@ -68,6 +74,10 @@ void shouldGetReportNameFromConfiguration() { .isEqualTo(reportName); } + /** + * Verifies resolving report name from executor JSON for summary aggregation. + */ + @Description @Test void shouldGetReportNameFromExecutorJson() { final String reportName = "other report name"; @@ -89,7 +99,7 @@ void shouldGetReportNameFromExecutorJson() { .build(); final ReportStorage storage = mock(); - new SummaryPlugin().aggregate(configuration, launchResults, storage); + aggregateSummary(configuration, launchResults, storage); final ArgumentCaptor dataCaptor = ArgumentCaptor.captor(); @@ -105,6 +115,10 @@ void shouldGetReportNameFromExecutorJson() { .isEqualTo(reportName); } + /** + * Verifies that configured report name overrides executor metadata for summary aggregation. + */ + @Description @Test void shouldReportNameFromConfigurationShouldOverride() { final String reportName = "other report name"; @@ -126,7 +140,7 @@ void shouldReportNameFromConfigurationShouldOverride() { .build(); final ReportStorage storage = mock(); - new SummaryPlugin().aggregate(configuration, launchResults, storage); + aggregateSummary(configuration, launchResults, storage); final ArgumentCaptor dataCaptor = ArgumentCaptor.captor(); @@ -142,6 +156,10 @@ void shouldReportNameFromConfigurationShouldOverride() { .isEqualTo(reportName); } + /** + * Verifies using default report name if not specified for summary aggregation. + */ + @Description @Test void shouldUseDefaultReportNameIfNotSpecified() { final List launchResults = List.of( @@ -156,7 +174,7 @@ void shouldUseDefaultReportNameIfNotSpecified() { .build(); final ReportStorage storage = mock(); - new SummaryPlugin().aggregate(configuration, launchResults, storage); + aggregateSummary(configuration, launchResults, storage); final ArgumentCaptor dataCaptor = ArgumentCaptor.captor(); @@ -171,4 +189,15 @@ void shouldUseDefaultReportNameIfNotSpecified() { assertThat(data.getReportName()) .isEqualTo("Allure Report"); } + + private void aggregateSummary( + final Configuration configuration, + final List launchResults, + final ReportStorage storage + ) { + Allure.step( + "Aggregate summary widget for " + launchResults.size() + " launch(es)", + () -> new SummaryPlugin().aggregate(configuration, launchResults, storage) + ); + } } diff --git a/allure-generator/src/test/java/io/qameta/allure/tags/TagsPluginTest.java b/allure-generator/src/test/java/io/qameta/allure/tags/TagsPluginTest.java index 2ee34aeb3..eff5154ab 100644 --- a/allure-generator/src/test/java/io/qameta/allure/tags/TagsPluginTest.java +++ b/allure-generator/src/test/java/io/qameta/allure/tags/TagsPluginTest.java @@ -15,8 +15,10 @@ */ package io.qameta.allure.tags; +import io.qameta.allure.Allure; import io.qameta.allure.ConfigurationBuilder; import io.qameta.allure.DefaultLaunchResults; +import io.qameta.allure.Description; import io.qameta.allure.ReportStorage; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.LaunchResults; @@ -38,6 +40,10 @@ */ class TagsPluginTest { + /** + * Verifies adding tags from labels for tag aggregation. + */ + @Description @Test void shouldAddTagsFromLabels() { final TestResult testResult = new TestResult() @@ -67,11 +73,7 @@ void shouldAddTagsFromLabels() { final ReportStorage storage = mock(); - new TagsPlugin().aggregate( - configuration, - launchResults, - storage - ); + aggregateTags(configuration, launchResults, storage); assertThat(testResult.>getExtraBlock(TagsPlugin.TAGS_BLOCK_NAME)) .containsExactlyInAnyOrder( @@ -81,6 +83,10 @@ void shouldAddTagsFromLabels() { ); } + /** + * Verifies removing duplicate tags for tag aggregation. + */ + @Description @Test void shouldRemoveDuplicateTags() { final TestResult testResult = new TestResult() @@ -116,11 +122,7 @@ void shouldRemoveDuplicateTags() { final ReportStorage storage = mock(); - new TagsPlugin().aggregate( - configuration, - launchResults, - storage - ); + aggregateTags(configuration, launchResults, storage); assertThat(testResult.>getExtraBlock(TagsPlugin.TAGS_BLOCK_NAME)) .containsExactlyInAnyOrder( @@ -130,6 +132,10 @@ void shouldRemoveDuplicateTags() { ); } + /** + * Verifies trimming tag names for tag aggregation. + */ + @Description @Test void shouldTrimTagNames() { final TestResult testResult = new TestResult() @@ -159,11 +165,7 @@ void shouldTrimTagNames() { final ReportStorage storage = mock(); - new TagsPlugin().aggregate( - configuration, - launchResults, - storage - ); + aggregateTags(configuration, launchResults, storage); assertThat(testResult.>getExtraBlock(TagsPlugin.TAGS_BLOCK_NAME)) .containsExactlyInAnyOrder( @@ -173,6 +175,10 @@ void shouldTrimTagNames() { ); } + /** + * Verifies parsing labels without name for tag aggregation. + */ + @Description @Test void shouldParseLabelsWithoutName() { final TestResult testResult = new TestResult() @@ -201,11 +207,7 @@ void shouldParseLabelsWithoutName() { final ReportStorage storage = mock(); - new TagsPlugin().aggregate( - configuration, - launchResults, - storage - ); + aggregateTags(configuration, launchResults, storage); assertThat(testResult.>getExtraBlock(TagsPlugin.TAGS_BLOCK_NAME)) .containsExactlyInAnyOrder( @@ -215,6 +217,10 @@ void shouldParseLabelsWithoutName() { ); } + /** + * Verifies parsing labels without value for tag aggregation. + */ + @Description @Test void shouldParseLabelsWithoutValue() { final TestResult testResult = new TestResult() @@ -239,11 +245,7 @@ void shouldParseLabelsWithoutValue() { final ReportStorage storage = mock(); - new TagsPlugin().aggregate( - configuration, - launchResults, - storage - ); + aggregateTags(configuration, launchResults, storage); assertThat(testResult.>getExtraBlock(TagsPlugin.TAGS_BLOCK_NAME)) .containsExactlyInAnyOrder( @@ -252,6 +254,10 @@ void shouldParseLabelsWithoutValue() { ); } + /** + * Verifies adding meta tags for tag aggregation. + */ + @Description @Test void shouldAddMetaTags() { final TestResult testResult = new TestResult() @@ -290,11 +296,7 @@ void shouldAddMetaTags() { final ReportStorage storage = mock(); - new TagsPlugin().aggregate( - configuration, - launchResults, - storage - ); + aggregateTags(configuration, launchResults, storage); assertThat(testResult.getLabels()) .extracting(Label::getName, Label::getValue) @@ -313,4 +315,15 @@ void shouldAddMetaTags() { tuple("subSuite", "Mobile") ); } + + private void aggregateTags( + final Configuration configuration, + final List launchResults, + final ReportStorage storage + ) { + Allure.step( + "Aggregate tags for " + launchResults.get(0).getAllResults().size() + " test result(s)", + () -> new TagsPlugin().aggregate(configuration, launchResults, storage) + ); + } } diff --git a/allure-generator/src/test/java/io/qameta/allure/testdata/TestData.java b/allure-generator/src/test/java/io/qameta/allure/testdata/TestData.java index 3b4319a68..4476eb3ba 100644 --- a/allure-generator/src/test/java/io/qameta/allure/testdata/TestData.java +++ b/allure-generator/src/test/java/io/qameta/allure/testdata/TestData.java @@ -15,8 +15,14 @@ */ package io.qameta.allure.testdata; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.json.JsonMapper; +import io.qameta.allure.Allure; import io.qameta.allure.DefaultLaunchResults; import io.qameta.allure.core.LaunchResults; +import io.qameta.allure.entity.Attachment; import io.qameta.allure.entity.Statistic; import io.qameta.allure.entity.TestResult; import io.qameta.allure.history.HistoryTrendItem; @@ -31,9 +37,11 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; import static java.util.concurrent.ThreadLocalRandom.current; @@ -43,6 +51,12 @@ */ public final class TestData { + private static final String APPLICATION_JSON = "application/json"; + private static final String TEXT_PLAIN = "text/plain"; + private static final ObjectMapper JSON_MAPPER = JsonMapper.builder() + .enable(SerializationFeature.INDENT_OUTPUT) + .build(); + private TestData() { throw new IllegalStateException("Do not instance"); } @@ -58,15 +72,31 @@ public static List createSingleLaunchResults(Map } public static DefaultLaunchResults createLaunchResults(final Map extra, final TestResult... input) { - return new DefaultLaunchResults(Arrays.stream(input).collect(Collectors.toSet()), null, extra); + return Allure.step( + String.format( + "Create launch results with %d test result(s) and %d extra block(s)", + input.length, + extra.size() + ), + () -> { + attachJson("launch-results.json", Arrays.asList(input)); + attachJson("launch-extra.json", extra); + return new DefaultLaunchResults(Arrays.stream(input).collect(Collectors.toSet()), null, extra); + } + ); } public static void unpackFile(final String name, final Path output) { - try (InputStream is = TestData.class.getClassLoader().getResourceAsStream(name)) { - Files.copy(Objects.requireNonNull(is), output); - } catch (IOException e) { - throw new RuntimeException("Could not read resource " + name, e); - } + Allure.step("Copy fixture resource " + name + " as " + output.getFileName(), () -> { + final byte[] content; + try (InputStream is = TestData.class.getClassLoader().getResourceAsStream(name)) { + content = Objects.requireNonNull(is).readAllBytes(); + Files.write(output, content); + } catch (IOException e) { + throw new RuntimeException("Could not read resource " + name, e); + } + attachFileContent(output.getFileName().toString(), content); + }); } public static List allure1data() { @@ -109,4 +139,125 @@ public static TestResult randomTestResult() { public static String randomString() { return RandomStringUtils.randomAlphabetic(10); } + + public static String toHex(final byte[] bytes) { + return toHex(bytes, bytes.length); + } + + public static String toHex(final byte[] bytes, final int limit) { + final StringBuilder builder = new StringBuilder(); + final int length = Math.min(bytes.length, limit); + for (int i = 0; i < length; i++) { + if (i > 0) { + builder.append(' '); + } + final String hex = Integer.toHexString(Byte.toUnsignedInt(bytes[i])); + if (hex.length() == 1) { + builder.append('0'); + } + builder.append(hex); + } + if (bytes.length > limit) { + builder.append(" ..."); + } + return builder.toString(); + } + + public static void attachFileContent(final String fileName, final byte[] content) { + final String body = formatFileContent(fileName, content); + Allure.addAttachment(fileName, getContentType(fileName), body, getFileExtension(fileName)); + } + + public static void attachLaunchResults(final String stepName, final LaunchResults results) { + Allure.step(stepName, () -> { + final List testResults = sortedResults(results.getAllResults()); + attachJson("launch-results.json", testResults); + attachJson("launch-attachments.json", describeAttachments(results)); + + results.getAttachments().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> attachFileContent( + entry.getKey().getFileName().toString(), + readAttachment(entry.getKey()) + )); + }); + } + + private static List sortedResults(final Set results) { + return results.stream() + .sorted((left, right) -> resultSortKey(left).compareTo(resultSortKey(right))) + .collect(Collectors.toList()); + } + + private static String resultSortKey(final TestResult result) { + return String.join( + "|", + Objects.toString(result.getFullName(), ""), + Objects.toString(result.getName(), ""), + Objects.toString(result.getUid(), "") + ); + } + + private static List> describeAttachments(final LaunchResults results) { + return results.getAttachments().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(TestData::describeAttachment) + .collect(Collectors.toList()); + } + + private static Map describeAttachment(final Map.Entry entry) { + final Attachment attachment = entry.getValue(); + final Map result = new LinkedHashMap<>(); + result.put("fileName", entry.getKey().getFileName().toString()); + result.put("name", attachment.getName()); + result.put("type", attachment.getType()); + result.put("source", attachment.getSource()); + result.put("size", attachment.getSize()); + return result; + } + + private static byte[] readAttachment(final Path attachment) { + try { + return Files.readAllBytes(attachment); + } catch (IOException e) { + throw new RuntimeException("Could not read attachment " + attachment, e); + } + } + + private static String formatFileContent(final String fileName, final byte[] content) { + final String text = new String(content, StandardCharsets.UTF_8); + if (fileName.endsWith(".json")) { + return prettifyJson(text); + } + return text; + } + + private static String getContentType(final String fileName) { + return fileName.endsWith(".json") ? APPLICATION_JSON : TEXT_PLAIN; + } + + private static String getFileExtension(final String fileName) { + final int index = fileName.lastIndexOf('.'); + return index < 0 ? ".txt" : fileName.substring(index); + } + + private static void attachJson(final String fileName, final Object value) { + Allure.addAttachment(fileName, APPLICATION_JSON, toJson(value), ".json"); + } + + private static String prettifyJson(final String value) { + try { + return JSON_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(JSON_MAPPER.readTree(value)); + } catch (JsonProcessingException ignored) { + return value; + } + } + + private static String toJson(final Object value) { + try { + return JSON_MAPPER.writeValueAsString(value); + } catch (JsonProcessingException e) { + throw new RuntimeException("Could not serialize launch evidence", e); + } + } } diff --git a/allure-plugin-api/build.gradle.kts b/allure-plugin-api/build.gradle.kts index d279e5eaa..7552b3c47 100644 --- a/allure-plugin-api/build.gradle.kts +++ b/allure-plugin-api/build.gradle.kts @@ -1,9 +1,20 @@ plugins { `java-library` + id("io.qameta.allure") } description = "Allure Plugin Api" +allure { + version.set("2.34.0") + adapter { + allureJavaVersion.set("2.34.0") + aspectjVersion.set("1.9.25.1") + autoconfigure.set(false) + aspectjWeaver.set(true) + } +} + dependencies { annotationProcessor("org.projectlombok:lombok") api("com.fasterxml.jackson.core:jackson-databind") @@ -15,6 +26,7 @@ dependencies { implementation("com.vladsch.flexmark:flexmark-ext-tables") implementation("javax.xml.bind:jaxb-api") implementation("org.freemarker:freemarker") + testImplementation("io.qameta.allure:allure-assertj") testImplementation("io.qameta.allure:allure-junit-platform") testImplementation("org.assertj:assertj-core") testImplementation("org.junit.jupiter:junit-jupiter") diff --git a/allure-plugin-api/src/test/java/io/qameta/allure/context/FreemarkerContextTest.java b/allure-plugin-api/src/test/java/io/qameta/allure/context/FreemarkerContextTest.java index f69cd3222..7d7c5f383 100644 --- a/allure-plugin-api/src/test/java/io/qameta/allure/context/FreemarkerContextTest.java +++ b/allure-plugin-api/src/test/java/io/qameta/allure/context/FreemarkerContextTest.java @@ -15,12 +15,18 @@ */ package io.qameta.allure.context; +import io.qameta.allure.Description; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class FreemarkerContextTest { + /** + * Verifies that the FreeMarker context creates a usable template configuration. + * The test checks that consumers receive a non-null context value. + */ + @Description @Test void shouldCreateFreemarkerContext() { final FreemarkerContext context = new FreemarkerContext(); diff --git a/allure-plugin-api/src/test/java/io/qameta/allure/context/JacksonContextTest.java b/allure-plugin-api/src/test/java/io/qameta/allure/context/JacksonContextTest.java index 27eec31f1..adf5bc88f 100644 --- a/allure-plugin-api/src/test/java/io/qameta/allure/context/JacksonContextTest.java +++ b/allure-plugin-api/src/test/java/io/qameta/allure/context/JacksonContextTest.java @@ -17,12 +17,18 @@ import com.fasterxml.jackson.databind.SerializationConfig; import com.fasterxml.jackson.databind.SerializationFeature; +import io.qameta.allure.Description; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class JacksonContextTest { + /** + * Verifies that the Jackson context creates an object mapper. + * The test checks that consumers receive a non-null mapper instance. + */ + @Description @Test void shouldCreateJacksonContext() { final JacksonContext context = new JacksonContext(); @@ -30,6 +36,11 @@ void shouldCreateJacksonContext() { .isNotNull(); } + /** + * Verifies that the Jackson context uses compact JSON output by default. + * The test checks that indentation is disabled in the mapper serialization config. + */ + @Description @Test void shouldUseMinified() { final JacksonContext context = new JacksonContext(); diff --git a/allure-plugin-api/src/test/java/io/qameta/allure/context/MarkdownContextTest.java b/allure-plugin-api/src/test/java/io/qameta/allure/context/MarkdownContextTest.java index 165b7e5b2..a62224f0d 100644 --- a/allure-plugin-api/src/test/java/io/qameta/allure/context/MarkdownContextTest.java +++ b/allure-plugin-api/src/test/java/io/qameta/allure/context/MarkdownContextTest.java @@ -15,12 +15,18 @@ */ package io.qameta.allure.context; +import io.qameta.allure.Description; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; class MarkdownContextTest { + /** + * Verifies that the Markdown context creates a usable renderer. + * The test checks that consumers receive a non-null context value. + */ + @Description @Test void shouldCreateMarkdownContext() { final MarkdownContext context = new MarkdownContext(); diff --git a/allure-plugin-api/src/test/java/io/qameta/allure/context/RandomUidContextTest.java b/allure-plugin-api/src/test/java/io/qameta/allure/context/RandomUidContextTest.java index ae48114f8..ee6827877 100644 --- a/allure-plugin-api/src/test/java/io/qameta/allure/context/RandomUidContextTest.java +++ b/allure-plugin-api/src/test/java/io/qameta/allure/context/RandomUidContextTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.context; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import org.junit.jupiter.api.Test; import java.util.function.Supplier; @@ -23,6 +25,11 @@ class RandomUidContextTest { + /** + * Verifies that the random UID context creates a generator. + * The test checks that consumers receive a non-null supplier. + */ + @Description @Test void shouldCreateRandomUidContext() { final RandomUidContext context = new RandomUidContext(); @@ -30,6 +37,11 @@ void shouldCreateRandomUidContext() { .isNotNull(); } + /** + * Verifies that the random UID supplier returns usable unique values. + * The test checks two generated values are non-blank and different. + */ + @Description @Test void shouldGenerateRandomValues() { final RandomUidContext context = new RandomUidContext(); @@ -37,6 +49,21 @@ void shouldGenerateRandomValues() { final String first = generator.get(); final String second = generator.get(); + + Allure.step("Record generated UID values", () -> Allure.addAttachment( + "generated-uids.txt", + "text/plain", + String.format( + "first=%s%nsecond=%s%nfirstBlank=%s%nsecondBlank=%s%nsame=%s%n", + first, + second, + first.trim().isEmpty(), + second.trim().isEmpty(), + first.equals(second) + ), + ".txt" + )); + assertThat(first) .isNotBlank() .isNotEqualTo(second); diff --git a/allure-plugin-api/src/test/java/io/qameta/allure/datetime/CompositeDateTimeParserTest.java b/allure-plugin-api/src/test/java/io/qameta/allure/datetime/CompositeDateTimeParserTest.java index af4b2614b..f12da667d 100644 --- a/allure-plugin-api/src/test/java/io/qameta/allure/datetime/CompositeDateTimeParserTest.java +++ b/allure-plugin-api/src/test/java/io/qameta/allure/datetime/CompositeDateTimeParserTest.java @@ -15,6 +15,7 @@ */ package io.qameta.allure.datetime; +import io.qameta.allure.Description; import org.junit.jupiter.api.Test; import java.time.ZoneOffset; @@ -27,6 +28,11 @@ */ class CompositeDateTimeParserTest { + /** + * Verifies that the composite parser delegates to the first parser that can parse an input. + * The test checks local and zoned ISO timestamps are converted to the expected epoch milliseconds. + */ + @Description @Test void shouldReturnFirstParsed() { final CompositeDateTimeParser parser = new CompositeDateTimeParser( @@ -45,6 +51,11 @@ void shouldReturnFirstParsed() { .hasValue(1527775525155L); } + /** + * Verifies that the composite parser reports no value when every parser rejects the input. + * The test checks an unsupported timestamp format returns an empty optional. + */ + @Description @Test void shouldReturnEmptyOptionalIfNoMatchedFormat() { final CompositeDateTimeParser parser = new CompositeDateTimeParser( diff --git a/allure-plugin-api/src/test/java/io/qameta/allure/datetime/LocalDateTimeParserTest.java b/allure-plugin-api/src/test/java/io/qameta/allure/datetime/LocalDateTimeParserTest.java index d2f320171..4448a1393 100644 --- a/allure-plugin-api/src/test/java/io/qameta/allure/datetime/LocalDateTimeParserTest.java +++ b/allure-plugin-api/src/test/java/io/qameta/allure/datetime/LocalDateTimeParserTest.java @@ -15,6 +15,7 @@ */ package io.qameta.allure.datetime; +import io.qameta.allure.Description; import org.junit.jupiter.api.Test; import java.time.ZoneId; @@ -29,6 +30,11 @@ */ class LocalDateTimeParserTest { + /** + * Verifies parsing a local ISO timestamp in the configured UTC zone. + * The test checks the parsed value equals the expected epoch milliseconds. + */ + @Description @Test void shouldParseLocalDateTime() { final Optional parsed = new LocalDateTimeParser(ZoneOffset.UTC) @@ -38,12 +44,25 @@ void shouldParseLocalDateTime() { .hasValue(1507199782000L); } - @Test - void shouldParseLocalDateTimeWithNanoseconds() { - final Optional parsed = new LocalDateTimeParser(ZoneOffset.UTC).getEpochMilli("2019-09-24T01:19:42.578340"); - assertThat(parsed).hasValue(1569287982578L); - } - + /** + * Verifies parsing a local ISO timestamp that contains fractional seconds. + * The test checks nanosecond precision is truncated to the expected epoch milliseconds. + */ + @Description + @Test + void shouldParseLocalDateTimeWithNanoseconds() { + final Optional parsed = new LocalDateTimeParser(ZoneOffset.UTC) + .getEpochMilli("2019-09-24T01:19:42.578340"); + + assertThat(parsed) + .hasValue(1569287982578L); + } + + /** + * Verifies that the local timestamp parser honors its configured zone. + * The test checks the same local time shifts by the PST offset when converted to epoch milliseconds. + */ + @Description @Test void shouldChangeZone() { final ZoneId pst = ZoneId.of(ZoneId.SHORT_IDS.get("PST")); diff --git a/allure-plugin-api/src/test/java/io/qameta/allure/datetime/ZonedDateTimeParserTest.java b/allure-plugin-api/src/test/java/io/qameta/allure/datetime/ZonedDateTimeParserTest.java index 926da231c..653a7ac27 100644 --- a/allure-plugin-api/src/test/java/io/qameta/allure/datetime/ZonedDateTimeParserTest.java +++ b/allure-plugin-api/src/test/java/io/qameta/allure/datetime/ZonedDateTimeParserTest.java @@ -15,6 +15,7 @@ */ package io.qameta.allure.datetime; +import io.qameta.allure.Description; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -40,6 +41,11 @@ static Stream data() { ); } + /** + * Verifies parsing zoned ISO timestamps with UTC and explicit numeric offsets. + * Each example checks the timestamp is converted to the expected epoch milliseconds. + */ + @Description @ParameterizedTest @MethodSource("data") void shouldParseZonedDateTime(final String time, final Long expected) { diff --git a/allure-plugin-api/src/test/java/io/qameta/allure/entity/ExtraStatisticMethodsTest.java b/allure-plugin-api/src/test/java/io/qameta/allure/entity/ExtraStatisticMethodsTest.java index f025756b7..e4ce40315 100644 --- a/allure-plugin-api/src/test/java/io/qameta/allure/entity/ExtraStatisticMethodsTest.java +++ b/allure-plugin-api/src/test/java/io/qameta/allure/entity/ExtraStatisticMethodsTest.java @@ -15,6 +15,7 @@ */ package io.qameta.allure.entity; +import io.qameta.allure.Description; import org.junit.jupiter.api.Test; import java.util.Arrays; @@ -26,6 +27,11 @@ */ class ExtraStatisticMethodsTest { + /** + * Verifies statistic status selection follows the highest-priority non-zero counter. + * The test checks failed, broken, and passed statistics resolve to the expected statuses. + */ + @Description @Test void shouldGetStatusForStatistic() { final Statistic first = new Statistic().setFailed(2L).setPassed(1L); @@ -38,6 +44,11 @@ void shouldGetStatusForStatistic() { ); } + /** + * Verifies retrieving statistic counts by status. + * The test checks configured counters are returned and missing statuses resolve to zero. + */ + @Description @Test void shouldGetByStatus() { final Statistic statistic = new Statistic().setFailed(2L).setPassed(1L).setBroken(4L); diff --git a/allure-plugin-api/src/test/java/io/qameta/allure/entity/LabelsTest.java b/allure-plugin-api/src/test/java/io/qameta/allure/entity/LabelsTest.java index f476e6699..3f7196220 100644 --- a/allure-plugin-api/src/test/java/io/qameta/allure/entity/LabelsTest.java +++ b/allure-plugin-api/src/test/java/io/qameta/allure/entity/LabelsTest.java @@ -15,6 +15,7 @@ */ package io.qameta.allure.entity; +import io.qameta.allure.Description; import org.junit.jupiter.api.Test; import java.util.List; @@ -27,6 +28,11 @@ */ class LabelsTest { + /** + * Verifies single-label lookup on a result without labels. + * The test checks that a missing label returns an empty optional. + */ + @Description @Test void shouldFindLabelsInEmptyArray() { final Optional found = new TestResult().findOneLabel("hey"); @@ -34,6 +40,11 @@ void shouldFindLabelsInEmptyArray() { .isEmpty(); } + /** + * Verifies single-label lookup ignores a matching label whose value is null. + * The test checks that the lookup returns an empty optional instead of a null value. + */ + @Description @Test void shouldFindOneWithNullValue() { final TestResult result = new TestResult(); @@ -43,6 +54,11 @@ void shouldFindOneWithNullValue() { .isEmpty(); } + /** + * Verifies multi-label lookup preserves all matching values, including null. + * The test checks that null and non-null values are all returned for the requested label name. + */ + @Description @Test void shouldFindAllWithNullValue() { final TestResult result = new TestResult(); diff --git a/allure-plugin-api/src/test/java/io/qameta/allure/entity/WithSummaryTest.java b/allure-plugin-api/src/test/java/io/qameta/allure/entity/WithSummaryTest.java index 21474de4d..d17a7aeef 100644 --- a/allure-plugin-api/src/test/java/io/qameta/allure/entity/WithSummaryTest.java +++ b/allure-plugin-api/src/test/java/io/qameta/allure/entity/WithSummaryTest.java @@ -15,6 +15,7 @@ */ package io.qameta.allure.entity; +import io.qameta.allure.Description; import org.junit.jupiter.api.Test; import static java.util.Arrays.asList; @@ -26,6 +27,11 @@ */ class WithSummaryTest { + /** + * Verifies recursive step counting for nested steps. + * The test checks both direct and nested child steps are included in the total. + */ + @Description @Test void shouldCountSteps() { final Step step = new Step().setSteps(asList( @@ -36,6 +42,11 @@ void shouldCountSteps() { .isEqualTo(3L); } + /** + * Verifies recursive attachment counting for a step tree. + * The test checks direct attachments and child-step attachments are included in the total. + */ + @Description @Test void shouldCountAttachments() { final Step step = new Step().setSteps(asList( @@ -46,6 +57,11 @@ void shouldCountAttachments() { .isEqualTo(4L); } + /** + * Verifies an empty step is treated as having no displayable content. + * The test checks a step without message, parameters, attachments, or children returns false. + */ + @Description @Test void shouldCalculateHasContent() { final Step step = new Step(); @@ -53,6 +69,11 @@ void shouldCalculateHasContent() { .isFalse(); } + /** + * Verifies attachments make a step count as having content. + * The test checks a step with one attachment returns true for content detection. + */ + @Description @Test void shouldCountAttachmentsForHasContent() { final Step step = new Step().setAttachments(singletonList(new Attachment())); @@ -60,6 +81,11 @@ void shouldCountAttachmentsForHasContent() { .isTrue(); } + /** + * Verifies child steps make a step count as having content. + * The test checks a step with one child returns true for content detection. + */ + @Description @Test void shouldCountStepsForHasContent() { final Step step = new Step().setSteps(singletonList(new Step())); @@ -67,6 +93,11 @@ void shouldCountStepsForHasContent() { .isTrue(); } + /** + * Verifies parameters make a step count as having content. + * The test checks a step with one parameter returns true for content detection. + */ + @Description @Test void shouldCountParametersForHasContent() { final Step step = new Step().setParameters(singletonList(new Parameter())); @@ -74,6 +105,11 @@ void shouldCountParametersForHasContent() { .isTrue(); } + /** + * Verifies a status message makes a step count as having content. + * The test checks a step with a message returns true for content detection. + */ + @Description @Test void shouldCountMessageForHasContent() { final Step step = createStep("hey"); @@ -81,6 +117,11 @@ void shouldCountMessageForHasContent() { .isTrue(); } + /** + * Verifies a leaf step with a message should display that message. + * The test checks message display is enabled when there are no child steps. + */ + @Description @Test void shouldCalculateDisplayMessageFlagIfNoChildren() { final Step step = createStep("hey"); @@ -89,6 +130,11 @@ void shouldCalculateDisplayMessageFlagIfNoChildren() { .isTrue(); } + /** + * Verifies a step without a message should not display message text. + * The test checks message display is disabled when the message is absent. + */ + @Description @Test void shouldCalculateDisplayMessageFlagIfNoMessage() { final Step step = new Step(); @@ -97,6 +143,11 @@ void shouldCalculateDisplayMessageFlagIfNoMessage() { .isFalse(); } + /** + * Verifies parent message display is suppressed when a child repeats the same message. + * The test checks duplicate message text is hidden at the parent level. + */ + @Description @Test void shouldCalculateShouldMessageFlagIfChildHasTheSameMessage() { final Step step = createStep("hey") @@ -110,6 +161,11 @@ void shouldCalculateShouldMessageFlagIfChildHasTheSameMessage() { .isFalse(); } + /** + * Verifies parent message display remains enabled when child messages are different. + * The test checks no child duplicates the parent message, so the parent message is shown. + */ + @Description @Test void shouldCalculateDisplayMessageFlagIfChildrenHasDifferentMessages() { final Step step = createStep("hey") @@ -123,6 +179,11 @@ void shouldCalculateDisplayMessageFlagIfChildrenHasDifferentMessages() { .isTrue(); } + /** + * Verifies duplicate message detection includes nested child steps. + * The test checks a matching message in a grandchild suppresses display for the parent. + */ + @Description @Test void shouldCalculateDisplayMessageFlagInSubChild() { final Step step = createStep("hey") diff --git a/allure-plugin-api/src/test/java/io/qameta/allure/tree/TestResultTreeTest.java b/allure-plugin-api/src/test/java/io/qameta/allure/tree/TestResultTreeTest.java index 4adbecf1f..c29724359 100644 --- a/allure-plugin-api/src/test/java/io/qameta/allure/tree/TestResultTreeTest.java +++ b/allure-plugin-api/src/test/java/io/qameta/allure/tree/TestResultTreeTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.tree; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.entity.Label; import io.qameta.allure.entity.TestResult; import org.junit.jupiter.api.Test; @@ -32,6 +34,11 @@ */ class TestResultTreeTest { + /** + * Verifies a tree with no grouping labels starts empty. + * The test checks the created tree exposes no children before results are added. + */ + @Description @Test void shouldCreateEmptyTree() { final Tree tree = new TestResultTree( @@ -39,10 +46,17 @@ void shouldCreateEmptyTree() { item -> Collections.emptyList() ); + attachTree("empty-tree.txt", tree); + assertThat(tree.getChildren()) .hasSize(0); } + /** + * Verifies repeated labels with the same value create only one grouping leaf. + * The test checks the result appears once under the deduplicated feature node. + */ + @Description @Test void shouldNotDuplicateLeavesOnSameValues() { final Tree behaviors = new TestResultTree( @@ -56,6 +70,8 @@ void shouldNotDuplicateLeavesOnSameValues() { behaviors.add(first); + attachTree("deduplicated-feature-tree.txt", behaviors); + assertThat(behaviors.getChildren()) .extracting(TreeNode::getName) .containsExactlyInAnyOrder("f1"); @@ -67,6 +83,11 @@ void shouldNotDuplicateLeavesOnSameValues() { .containsExactlyInAnyOrder("first"); } + /** + * Verifies results are cross-grouped by every feature and story label combination. + * The test checks feature nodes, story nodes, and result leaves for overlapping label values. + */ + @Description @Test void shouldCrossGroup() { final Tree behaviors = new TestResultTree( @@ -83,6 +104,8 @@ void shouldCrossGroup() { behaviors.add(first); behaviors.add(second); + attachTree("cross-group-tree.txt", behaviors); + assertThat(behaviors.getChildren()) .extracting(TreeNode::getName) .containsExactlyInAnyOrder("f1", "f2", "f3"); @@ -131,4 +154,30 @@ private Label story(final String value) { return new Label().setName("story").setValue(value); } + private void attachTree(final String fileName, final Tree tree) { + Allure.step("Attach tree structure as " + fileName, () -> Allure.addAttachment( + fileName, + "text/plain", + describeTree(tree), + ".txt" + )); + } + + private String describeTree(final Tree tree) { + final StringBuilder builder = new StringBuilder(); + appendTree(builder, tree, 0); + return builder.toString(); + } + + private void appendTree(final StringBuilder builder, final TreeNode node, final int level) { + for (int i = 0; i < level; i++) { + builder.append(" "); + } + builder.append(node.getName()).append(System.lineSeparator()); + if (node instanceof TreeGroup) { + ((TreeGroup) node).getChildren() + .forEach(child -> appendTree(builder, child, level + 1)); + } + } + } diff --git a/allurerc.mjs b/allurerc.mjs index e0ed17fb1..1cf1a2a5c 100644 --- a/allurerc.mjs +++ b/allurerc.mjs @@ -3,11 +3,12 @@ const { ALLURE_SERVICE_TOKEN } = process.env; const allureService = ALLURE_SERVICE_TOKEN ? { accessToken: ALLURE_SERVICE_TOKEN, + legacy: true, } : undefined; export default { - name: "Allure Report", + name: "Allure 2", output: "./build/allure-report", plugins: { awesome: { diff --git a/build.gradle.kts b/build.gradle.kts index 5c5adc6ad..1e6fcde14 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -116,6 +116,10 @@ subprojects { } } + dependencies { + add("testAnnotationProcessor", "io.qameta.allure:allure-descriptions-javadoc") + } + tasks.compileJava { if (JavaVersion.current().isJava8) { java.targetCompatibility = JavaVersion.VERSION_1_8 diff --git a/docs/allure-agent-mode.md b/docs/allure-agent-mode.md new file mode 100644 index 000000000..a30ee5600 --- /dev/null +++ b/docs/allure-agent-mode.md @@ -0,0 +1,205 @@ +# Allure Agent Mode + +Use Allure agent-mode to design, review, validate, debug, and enrich tests in any project that can emit Allure results. + +Agent mode is an extra review layer around the project's normal test command. It preserves the original runner behavior and console output while adding review-oriented artifacts. It should not replace the project's existing test tasks, fixtures, or reporting configuration. + +## Review Principle + +Runtime first, source second. + +- If a command executes tests and its result will be used for smoke checking, reasoning, review, coverage analysis, debugging, or any user-facing conclusion, run it through `allure agent`. It preserves the original console logs and adds agent-mode artifacts without inheriting the normal report or export plugins from the project config. +- Use `ALLURE_AGENT_*` with `allure run` only as the lower-level fallback when you need direct environment control. +- If the agent-mode output is missing or incomplete, debug that first and treat console-only conclusions as provisional. + +## Verification Standard + +- Use `allure agent` for smoke checks too, even when the change is small or mechanical. +- Only skip agent mode when it is impossible or when you are debugging agent mode itself. +- After each agent-mode test run, print the `index.md` path from that run's output directory so users can open the run overview quickly. + +## Helpful Commands + +- `allure agent latest` prints the latest agent output directory for the current project cwd. Use it when a prior run omitted `--output` and you want to reopen the most recent agent-mode artifacts. +- `allure agent state-dir` prints the state directory for the current project cwd. Use it when you need to inspect where `latest` pointers are stored or debug sandbox behavior. +- `allure agent select --latest` or `allure agent select --from ` prints the review-targeted test plan from a prior agent run. Add `--preset failed` or exact `--label name=value` / `--environment ` filters when you need a narrower rerun plan. +- `allure agent --rerun-latest -- ` or `allure agent --rerun-from -- ` reruns only the selected tests through the framework-agnostic Allure testplan flow. The default rerun preset is `review`. + +## Advanced Reruns + +- `--rerun-preset review|failed|unsuccessful|all` changes how the rerun seed set is chosen. Use `review` for the default agent-targeted loop, `failed` for classic failure reruns, `unsuccessful` for any non-passed tests, and `all` when you want the whole previously observed set. +- `--rerun-environment ` narrows the rerun selection to one or more environment ids from the previous agent output. Repeat the flag for multiple environments. +- `--rerun-label name=value` narrows the rerun selection to tests whose prior results carried exact matching labels. Repeat the flag for multiple label filters. +- `ALLURE_AGENT_STATE_DIR` overrides the default project-scoped state directory used by `allure agent latest`, `allure agent state-dir`, and `--rerun-latest`. Use it when you need a deterministic shared location in CI or a constrained sandbox. + +## Core Loops + +### Test Review Loop + +1. Identify the exact review scope. +2. Create a fresh expectations file for this run in a temp directory. +3. Run only that scope with `allure agent`. +4. Read `index.md`, `manifest/run.json`, `manifest/tests.jsonl`, and `manifest/findings.jsonl`. +5. Read per-test markdown only for tests that failed, drifted, or have findings. +6. Only after runtime review, inspect source code for root cause or coverage gaps. +7. If evidence is weak or partial, enrich the tests and rerun. +8. When iterating on the same scope, prefer `allure agent --rerun-latest -- ` or `allure agent --rerun-from -- ` so the rerun stays focused on the review-targeted tests. + +### Feature Delivery Loop + +1. Understand the feature or issue. +2. Create a fresh expectations file for this run in a temp directory. +3. Write or update the tests. +4. Run the target scope with `allure agent`. +5. Review `index.md`, manifests, and per-test markdown. +6. Enrich tests when evidence is weak. +7. Rerun until scope and evidence are acceptable. + +### Metadata Enrichment Loop + +Use this when the run is functionally correct but too weak to review: + +1. Identify missing or low-signal findings. +2. Add real steps, attachments, or minimal metadata. +3. Rerun the same intended scope. +4. Reject noop-style or placeholder evidence. + +### Small Test Change Workflow + +1. Create a fresh expectations file and temp output directory for the touched scope. +2. Run the touched scope with `allure agent`, even if the goal is only a smoke check after a mechanical change such as typing cleanup, mock refactors, or helper extraction. +3. Review `index.md`, `manifest/run.json`, `manifest/tests.jsonl`, and `manifest/findings.jsonl`. +4. Only then make a final statement about regression safety or test correctness. + +### Coverage Review Workflow + +1. Split command or package audits into scoped groups. +2. Give each group its own expectations file and temp output directory. +3. Run each group with `allure agent`. +4. Review runtime artifacts first, then inspect source code only after the run explains what actually executed. +5. Mark the review incomplete until each scoped group either matched expectations or was explicitly documented as a broad package-health audit. + +## Running Commands + +Use the project's existing test command inside agent mode. Prefer the narrowest module, package, class, file, or test-case scope that answers the question. + +```bash +TMP_DIR="$(mktemp -d)" +EXPECTATIONS="$TMP_DIR/expectations.yaml" + +cat > "$EXPECTATIONS" <<'YAML' +goal: Review focused test scope +task_id: focused-test-review +expected: + label_values: + component: example-component +notes: + - Review runtime evidence before source inspection. +YAML + +allure agent \ + --output "$TMP_DIR/agent-output" \ + --expectations "$EXPECTATIONS" \ + -- + +printf '%s\n' "$TMP_DIR/agent-output/index.md" +``` + +For a single test file, class, method, or scenario, keep the same temp output pattern and pass the runner's native filter arguments: + +```bash +allure agent \ + --output "$TMP_DIR/agent-output" \ + --expectations "$EXPECTATIONS" \ + -- +``` + +If the project builds or vendors its own `allure` executable, invoke that binary explicitly so the run uses the intended version: + +```bash + agent -- +``` + +## Per-Run Artifacts + +- `ALLURE_AGENT_OUTPUT` must use a unique temp directory per run. +- `ALLURE_AGENT_EXPECTATIONS` must use a unique temp file per run. +- Do not reuse those paths across parallel runs. + +YAML is preferred for expectations in v1. + +Review-oriented expectations example: + +```yaml +goal: Review focused tests +task_id: focused-review +expected: + label_values: + component: example-component +notes: + - Review runtime evidence before source inspection. +``` + +Broad package-health audits may omit expectations, but the resulting scope review is weaker and should be called out explicitly. + +## Evidence Rules + +- Each test should follow a clear given-when-then shape, or the more user-friendly precondition -> action -> expectation shape. Each stage should be visible in the report. +- Each test should have a clear description or summary of what it does and what it checks. +- Steps must wrap real setup, actions, state transitions, or assertions. +- Runtime evidence must be verified and aligned with the test description. +- Attachments must contain real runtime evidence from that execution. +- If a test uses important resources such as image snapshots, text files, generated files, API requests, or API responses, attach the relevant resources to the report. Redact secrets or credentials before attaching. +- Attach the content that explains the behavior under review, not just provenance such as temp paths, copied-from paths, byte counts, or "test passed" markers. +- When evidence comes from a copied or generated file, name the attachment after the stable artifact name a reviewer recognizes, such as the target file name, and attach the file content or a focused redacted excerpt. Include paths only when the path itself is the behavior under test. +- Pretty-print structured text artifacts such as JSON when possible, especially for result, container, request, response, or generated data files. +- Evidence must prove the checked behavior from the runtime report alone. A reviewer must be able to identify the relevant precondition or input, the action, and the observed output or effect that satisfied the assertion without opening the source code. +- Every nontrivial assertion must have runtime evidence for the exact value, state change, side effect, emitted artifact, error, or interaction it checks. Attach or step-log the expected and actual values unless they are already explicit in a focused artifact. +- Counts, generic summaries, copied file paths, byte sizes, "operation completed" messages, and input-only attachments are not acceptable substitutes for asserted outputs. They may be included only as supporting context after the asserted behavior is observable. +- For any test that transforms, parses, renders, stores, sends, receives, filters, sorts, aggregates, or generates data, include the meaningful input/precondition and the focused output/effect under assertion. Broad dumps are acceptable only when the asserted fields are easy to find and named clearly. +- Do not accept enriched evidence until the runtime report answers: what behavior was exercised, what exact outcome was expected, what exact outcome was observed, and why that proves the test passed. +- Metadata should stay minimal and purposeful. +- Prefer helper-boundary instrumentation over repetitive caller wrapping. + +Good example: + +- instrument a shared helper once instead of wrapping every caller + +Rejected examples: + +- empty wrapper steps +- static `test passed` attachments +- labels that no review or policy step uses + +## When Console Errors Are Not Represented As Test Results + +- Suite-load, import, or setup failures may appear only in `artifacts/global/stderr.txt` or global errors. +- If `manifest/tests.jsonl` does not account for all visible failures from the test runner, inspect global stderr before concluding the run is fully modeled. +- Treat that state as a partial runtime review, not as a clean or complete result set. +- If runner-visible failures are present outside logical test files, final conclusions must stay provisional until the missing modeling is understood. + +## Acceptance Rules + +Accept a run only when: + +- scope matches expectations +- evidence is strong enough to explain what happened +- no high-confidence noop or placeholder findings remain + +### Review Completeness + +A test review is not complete unless: + +- the relevant scope was run with agent mode, unless that is impossible +- expectations were created for the intended scope, unless this is a broad package-health audit +- agent artifacts were reviewed before final conclusions +- missing or partial runtime modeling was called out explicitly +- console-only conclusions are treated as provisional when agent output is absent or incomplete + +## Future Loops + +Planned separately: + +- flaky detection/fix +- known-issue and mute handling +- quality-gate adoption diff --git a/plugins/behaviors-plugin/src/test/java/io.qameta.allure/behaviors/BehaviorsPluginTest.java b/plugins/behaviors-plugin/src/test/java/io.qameta.allure/behaviors/BehaviorsPluginTest.java index a65789951..79c10bd56 100644 --- a/plugins/behaviors-plugin/src/test/java/io.qameta.allure/behaviors/BehaviorsPluginTest.java +++ b/plugins/behaviors-plugin/src/test/java/io.qameta.allure/behaviors/BehaviorsPluginTest.java @@ -15,7 +15,9 @@ */ package io.qameta.allure.behaviors; +import io.qameta.allure.Allure; import io.qameta.allure.DefaultLaunchResults; +import io.qameta.allure.Description; import io.qameta.allure.Issue; import io.qameta.allure.core.LaunchResults; import io.qameta.allure.entity.Statistic; @@ -23,6 +25,7 @@ import io.qameta.allure.entity.TestResult; import io.qameta.allure.entity.Time; import io.qameta.allure.tree.Tree; +import io.qameta.allure.tree.TreeGroup; import io.qameta.allure.tree.TreeNode; import io.qameta.allure.tree.TreeWidgetData; import io.qameta.allure.tree.TreeWidgetItem; @@ -32,7 +35,9 @@ import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import static io.qameta.allure.entity.LabelName.EPIC; import static io.qameta.allure.entity.LabelName.FEATURE; @@ -45,7 +50,12 @@ */ class BehaviorsPluginTest { + /** + * Verifies behavior widget statistics are aggregated by feature and story. + * The test checks the failed and passed counts for each feature. + */ @SuppressWarnings("unchecked") + @Description @Test void storiesPerFeatureResultsAggregation() { final Set testResults = new HashSet<>(); @@ -56,10 +66,8 @@ void storiesPerFeatureResultsAggregation() { .setStatus(Status.FAILED) .setLabels(asList(FEATURE.label("feature2"), FEATURE.label("feature3"), STORY.label("story2"), STORY.label("story3")))); - LaunchResults results = new DefaultLaunchResults(testResults, Collections.emptyMap(), Collections.emptyMap()); - - TreeWidgetData behaviorsData = new BehaviorsPlugin.WidgetAggregator() - .getData(Collections.singletonList(results)); + final LaunchResults results = createLaunchResults(testResults); + final TreeWidgetData behaviorsData = aggregateBehaviorWidget(results); assertThat(behaviorsData.getItems()) .filteredOn(node2 -> node2.getName().equals("feature1")) @@ -80,6 +88,11 @@ void storiesPerFeatureResultsAggregation() { .containsExactly(Tuple.tuple(2L, 0L)); } + /** + * Verifies behavior widget aggregation groups results by epic when epics are present. + * The test checks the top-level widget item names. + */ + @Description @Test void shouldGroupByEpic() { final Set testResults = new HashSet<>(); @@ -90,17 +103,21 @@ void shouldGroupByEpic() { .setStatus(Status.FAILED) .setLabels(asList(EPIC.label("e2"), FEATURE.label("f2"), STORY.label("s2")))); - LaunchResults results = new DefaultLaunchResults(testResults, Collections.emptyMap(), Collections.emptyMap()); - TreeWidgetData behaviorsData = new BehaviorsPlugin.WidgetAggregator() - .getData(Collections.singletonList(results)); + final LaunchResults results = createLaunchResults(testResults); + final TreeWidgetData behaviorsData = aggregateBehaviorWidget(results); assertThat(behaviorsData.getItems()) .extracting("name") .containsExactlyInAnyOrder("e1", "e2"); } + /** + * Verifies behavior tree nodes are sorted by result start time. + * The test checks timeless results appear first, followed by ascending start times. + */ @Issue("587") @Issue("572") + @Description @Test void shouldSortByStartTimeAsc() { final TestResult first = new TestResult() @@ -112,16 +129,80 @@ void shouldSortByStartTimeAsc() { final TestResult timeless = new TestResult() .setName("timeless"); - final LaunchResults results = new DefaultLaunchResults( - new HashSet<>(Arrays.asList(first, second, timeless)), - Collections.emptyMap(), - Collections.emptyMap() - ); - - final Tree tree = BehaviorsPlugin.getData(Collections.singletonList(results)); + final LaunchResults results = createLaunchResults(new HashSet<>(Arrays.asList(first, second, timeless))); + final Tree tree = aggregateBehaviorTree(results); assertThat(tree.getChildren()) .extracting(TreeNode::getName) .containsExactly("timeless", "first", "second"); } + + private LaunchResults createLaunchResults(final Set testResults) { + return Allure.step("Create launch results", () -> { + Allure.addAttachment("input-test-results.txt", "text/plain", describeTestResults(testResults)); + return new DefaultLaunchResults(testResults, Collections.emptyMap(), Collections.emptyMap()); + }); + } + + private TreeWidgetData aggregateBehaviorWidget(final LaunchResults results) { + return Allure.step("Aggregate behavior widget data", () -> { + final TreeWidgetData data = new BehaviorsPlugin.WidgetAggregator().getData(Collections.singletonList(results)); + Allure.addAttachment("behavior-widget.txt", "text/plain", describeWidgetItems(data.getItems())); + return data; + }); + } + + private Tree aggregateBehaviorTree(final LaunchResults results) { + return Allure.step("Aggregate behavior tree", () -> { + final Tree tree = BehaviorsPlugin.getData(Collections.singletonList(results)); + Allure.addAttachment("behavior-tree.txt", "text/plain", describeTree(tree.getChildren(), 0)); + return tree; + }); + } + + private String describeTestResults(final Set testResults) { + return testResults.stream() + .map(result -> String.format( + "name=%s, status=%s, start=%s, labels=%s", + result.getName(), + result.getStatus(), + result.getTime() == null ? null : result.getTime().getStart(), + describeLabels(result) + )) + .sorted() + .collect(Collectors.joining(System.lineSeparator())); + } + + private String describeLabels(final TestResult result) { + return result.getLabels().stream() + .map(label -> label.getName() + "=" + label.getValue()) + .sorted() + .collect(Collectors.joining(", ")); + } + + private String describeWidgetItems(final List items) { + return items.stream() + .map(item -> String.format( + "name=%s, failed=%s, passed=%s", + item.getName(), + item.getStatistic().getFailed(), + item.getStatistic().getPassed() + )) + .sorted() + .collect(Collectors.joining(System.lineSeparator())); + } + + private String describeTree(final List nodes, final int depth) { + final StringBuilder builder = new StringBuilder(); + for (TreeNode node : nodes) { + builder + .append(" ".repeat(depth)) + .append(node.getName()) + .append(System.lineSeparator()); + if (node instanceof TreeGroup) { + builder.append(describeTree(((TreeGroup) node).getChildren(), depth + 1)); + } + } + return builder.toString(); + } } diff --git a/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraExportUtilitiesTest.java b/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraExportUtilitiesTest.java index eaacaeefa..f1a25135b 100644 --- a/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraExportUtilitiesTest.java +++ b/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraExportUtilitiesTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.jira; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.core.LaunchResults; import io.qameta.allure.entity.Statistic; import io.qameta.allure.entity.Status; @@ -25,6 +27,7 @@ import java.util.HashSet; import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import static io.qameta.allure.jira.TestData.createTestResult; import static org.assertj.core.api.Assertions.assertThat; @@ -34,6 +37,11 @@ public class JiraExportUtilitiesTest { + /** + * Verifies all non-empty status counts are converted to Jira launch statistics. + * The test checks status, color, and count values for each Allure status. + */ + @Description @Test public void testResultsFromLaunchResultsShouldConvertToLaunchStatisticExport() { final long resultCount = 1; @@ -45,10 +53,9 @@ public void testResultsFromLaunchResultsShouldConvertToLaunchStatisticExport() { final TestResult unknown = createTestResult(Status.UNKNOWN); final Set results = new HashSet<>(Arrays.asList(passed, failed, broken, skipped, unknown)); - when(launchResults.getAllResults()).thenReturn(results); + createLaunchResults(launchResults, results); - final Statistic statistic = JiraExportUtils.getStatistic(Arrays.asList(launchResults)); - List launchStatisticExports = JiraExportUtils.convertStatistics(statistic); + final List launchStatisticExports = convertStatistics(launchResults); assertThat(launchStatisticExports).isNotEmpty().hasSize(5); @@ -69,6 +76,11 @@ public void testResultsFromLaunchResultsShouldConvertToLaunchStatisticExport() { } + /** + * Verifies statuses with zero counts are omitted from Jira launch statistics. + * The test checks absent skipped and broken statuses are not exported. + */ + @Description @Test public void emptyTestResultsShouldBeIgnoredWhenConvertingToLaunchStatisticExport() { final long resultCount = 1; @@ -78,10 +90,9 @@ public void emptyTestResultsShouldBeIgnoredWhenConvertingToLaunchStatisticExport final TestResult unknown = createTestResult(Status.UNKNOWN); final Set results = new HashSet<>(Arrays.asList(passed, failed, unknown)); - when(launchResults.getAllResults()).thenReturn(results); + createLaunchResults(launchResults, results); - final Statistic statistic = JiraExportUtils.getStatistic(Arrays.asList(launchResults)); - final List launchStatisticExports = JiraExportUtils.convertStatistics(statistic); + final List launchStatisticExports = convertStatistics(launchResults); assertThat(launchStatisticExports).isNotEmpty().hasSize(3); assertThat(launchStatisticExports).extracting(LaunchStatisticExport::getStatus) @@ -99,5 +110,39 @@ public void emptyTestResultsShouldBeIgnoredWhenConvertingToLaunchStatisticExport launchStatisticExports.forEach(launchStatisticExport -> assertThat(launchStatisticExport.getCount()).isEqualTo(resultCount)); } + private void createLaunchResults(final LaunchResults launchResults, final Set results) { + Allure.step("Create launch results for Jira statistics", () -> { + when(launchResults.getAllResults()).thenReturn(results); + Allure.addAttachment("jira-statistic-input.txt", "text/plain", describeResults(results)); + }); + } + + private List convertStatistics(final LaunchResults launchResults) { + return Allure.step("Convert Jira launch statistics", () -> { + final Statistic statistic = JiraExportUtils.getStatistic(Arrays.asList(launchResults)); + final List exports = JiraExportUtils.convertStatistics(statistic); + Allure.addAttachment("jira-statistic-output.txt", "text/plain", describeStatistic(exports)); + return exports; + }); + } + + private String describeResults(final Set results) { + return results.stream() + .map(result -> String.format("uid=%s, name=%s, status=%s", result.getUid(), result.getName(), result.getStatus())) + .sorted() + .collect(Collectors.joining(System.lineSeparator())); + } + + private String describeStatistic(final List statistic) { + return statistic.stream() + .map(item -> String.format( + "status=%s, color=%s, count=%s", + item.getStatus(), + item.getColor(), + item.getCount() + )) + .sorted() + .collect(Collectors.joining(System.lineSeparator())); + } } diff --git a/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraLaunchExportPluginTest.java b/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraLaunchExportPluginTest.java index fc30b7bf2..6a258fbd7 100644 --- a/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraLaunchExportPluginTest.java +++ b/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraLaunchExportPluginTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.jira; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.InMemoryReportStorage; import io.qameta.allure.core.LaunchResults; @@ -24,6 +26,7 @@ import io.qameta.allure.entity.TestResult; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.util.Arrays; import java.util.Collections; @@ -31,11 +34,10 @@ import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; import static io.qameta.allure.jira.TestData.createTestResult; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.argThat; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -43,7 +45,11 @@ class JiraLaunchExportPluginTest { - + /** + * Verifies launch statistics are exported to Jira. + * The test checks the outgoing launch payload and issue list built from launch results and executor metadata. + */ + @Description @Test void shouldExportLaunchToJira() { final LaunchResults launchResults = mock(LaunchResults.class); @@ -63,6 +69,7 @@ void shouldExportLaunchToJira() { .setReportUrl(RandomStringUtils.random(10)); when(launchResults.getExtra("executor")).thenReturn(Optional.of(executorInfo)); final JiraService service = TestData.mockJiraService(); + attachLaunchInput(results, executorInfo, launchStatisticExports); final JiraExportPlugin jiraLaunchExportPlugin = new JiraExportPlugin( true, "ALLURE-1,ALLURE-2", @@ -75,15 +82,83 @@ void shouldExportLaunchToJira() { new InMemoryReportStorage() ); - verify(service, times(1)).createJiraLaunch(any(JiraLaunch.class), anyList()); + final CapturedLaunchExport export = captureLaunchExport(service); + + assertThat(export.launch.getExternalId()).isEqualTo(executorInfo.getBuildName()); + assertThat(export.launch.getUrl()).isEqualTo(executorInfo.getReportUrl()); + assertThat(export.launch.getStatistic()).isEqualTo(launchStatisticExports); + assertThat(export.launch.getName()).isEqualTo(executorInfo.getBuildName()); + assertThat(export.issues).isNotEmpty(); + + } + + private void attachLaunchInput(final Set results, + final ExecutorInfo executorInfo, + final List expectedStatistic) { + Allure.step("Attach Jira launch input", () -> Allure.addAttachment("jira-launch-input.txt", "text/plain", String.format( + "executorBuildName=%s%nexecutorReportUrl=%s%nresults:%n%s%nexpectedStatistic:%n%s", + executorInfo.getBuildName(), + executorInfo.getReportUrl(), + describeResults(results), + describeStatistic(expectedStatistic) + ))); + } + + private CapturedLaunchExport captureLaunchExport(final JiraService service) { + return Allure.step("Capture Jira launch export payload", () -> { + final ArgumentCaptor launchCaptor = ArgumentCaptor.forClass(JiraLaunch.class); + @SuppressWarnings("unchecked") + final ArgumentCaptor> issuesCaptor = ArgumentCaptor.forClass(List.class); + verify(service, times(1)).createJiraLaunch(launchCaptor.capture(), issuesCaptor.capture()); + final CapturedLaunchExport export = new CapturedLaunchExport(launchCaptor.getValue(), issuesCaptor.getValue()); + Allure.addAttachment("jira-launch-payload.txt", "text/plain", describeLaunchExport(export)); + return export; + }); + } - verify(service).createJiraLaunch(argThat(launch -> executorInfo.getBuildName().equals(launch.getExternalId())), anyList()); - verify(service).createJiraLaunch(argThat(launch -> executorInfo.getReportUrl().equals(launch.getUrl())), anyList()); - verify(service).createJiraLaunch(argThat(launch -> launchStatisticExports.equals(launch.getStatistic())), anyList()); - verify(service).createJiraLaunch(argThat(launch -> executorInfo.getBuildName().equals(launch.getName())), anyList()); - verify(service).createJiraLaunch(any(JiraLaunch.class), argThat(issues -> !issues.isEmpty())); + private String describeResults(final Set results) { + return results.stream() + .map(result -> String.format( + "uid=%s, name=%s, status=%s", + result.getUid(), + result.getName(), + result.getStatus() + )) + .sorted() + .collect(Collectors.joining(System.lineSeparator())); + } + private String describeLaunchExport(final CapturedLaunchExport export) { + return String.format( + "externalId=%s%nname=%s%nurl=%s%nissues=%s%nstatistic:%n%s", + export.launch.getExternalId(), + export.launch.getName(), + export.launch.getUrl(), + export.issues, + describeStatistic(export.launch.getStatistic()) + ); } + private String describeStatistic(final List statistic) { + return statistic.stream() + .map(item -> String.format( + "status=%s, color=%s, count=%s", + item.getStatus(), + item.getColor(), + item.getCount() + )) + .sorted() + .collect(Collectors.joining(System.lineSeparator())); + } + + private static final class CapturedLaunchExport { + private final JiraLaunch launch; + private final List issues; + + private CapturedLaunchExport(final JiraLaunch launch, final List issues) { + this.launch = launch; + this.issues = issues; + } + } } diff --git a/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraModeDetectorTest.java b/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraModeDetectorTest.java index 0904cbcd3..892e883ce 100644 --- a/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraModeDetectorTest.java +++ b/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraModeDetectorTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.jira; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import org.junit.jupiter.api.Test; import java.util.function.Supplier; @@ -25,6 +27,11 @@ class JiraModeDetectorTest { + /** + * Verifies Jira mode detection falls back to server when cloud credentials are absent. + * The test checks a regular server info response produces server mode. + */ + @Description @Test void shouldDetectServerWhenNoCloudCredentials() { final Supplier cloudSupplier = () -> null; @@ -35,12 +42,18 @@ void shouldDetectServerWhenNoCloudCredentials() { serverInfo.setServerTitle("Company Jira"); when(serverService.getServerInfo()).thenReturn(serverInfo); + attachServerInfo("server-info.txt", serverInfo); - final String mode = JiraModeDetector.detectMode(cloudSupplier, () -> serverService); + final String mode = detectMode(cloudSupplier, () -> serverService); assertThat(mode).isEqualTo("server"); } + /** + * Verifies Jira mode detection recognizes cloud from the server info deployment type. + * The test checks a Cloud deployment response produces cloud mode. + */ + @Description @Test void shouldDetectCloudViaServerApiWhenDeploymentTypeIsCloud() { final Supplier cloudSupplier = () -> null; @@ -51,12 +64,18 @@ void shouldDetectCloudViaServerApiWhenDeploymentTypeIsCloud() { serverInfo.setVersion("1001.0.0"); when(serverService.getServerInfo()).thenReturn(serverInfo); + attachServerInfo("server-info.txt", serverInfo); - final String mode = JiraModeDetector.detectMode(cloudSupplier, () -> serverService); + final String mode = detectMode(cloudSupplier, () -> serverService); assertThat(mode).isEqualTo("cloud"); } + /** + * Verifies Jira mode detection is conservative when service calls fail. + * The test checks exceptions from both suppliers still produce server mode. + */ + @Description @Test void shouldDefaultToServerOnException() { final Supplier cloudSupplier = () -> { @@ -66,11 +85,16 @@ void shouldDefaultToServerOnException() { final JiraService serverService = mock(JiraService.class); when(serverService.getServerInfo()).thenThrow(new RuntimeException("Server connection failed")); - final String mode = JiraModeDetector.detectMode(cloudSupplier, () -> serverService); + final String mode = detectMode(cloudSupplier, () -> serverService); assertThat(mode).isEqualTo("server"); } + /** + * Verifies Jira mode detection treats missing server info as server mode. + * The test checks a null server info response does not switch to cloud mode. + */ + @Description @Test void shouldDefaultToServerWhenServerInfoIsNull() { final Supplier cloudSupplier = () -> null; @@ -78,8 +102,26 @@ void shouldDefaultToServerWhenServerInfoIsNull() { final JiraService serverService = mock(JiraService.class); when(serverService.getServerInfo()).thenReturn(null); - final String mode = JiraModeDetector.detectMode(cloudSupplier, () -> serverService); + final String mode = detectMode(cloudSupplier, () -> serverService); assertThat(mode).isEqualTo("server"); } + + private String detectMode(final Supplier cloudSupplier, + final Supplier serverSupplier) { + return Allure.step("Detect Jira mode", () -> { + final String mode = JiraModeDetector.detectMode(cloudSupplier, serverSupplier); + Allure.addAttachment("jira-mode-output.txt", "text/plain", "mode=" + mode); + return mode; + }); + } + + private void attachServerInfo(final String fileName, final JiraServerInfo serverInfo) { + Allure.step("Attach Jira server info", () -> Allure.addAttachment(fileName, "text/plain", String.format( + "deploymentType=%s%nversion=%s%nserverTitle=%s", + serverInfo.getDeploymentType(), + serverInfo.getVersion(), + serverInfo.getServerTitle() + ))); + } } diff --git a/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraTestResultExportPluginTest.java b/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraTestResultExportPluginTest.java index 67dcb92d0..b9c2c2f7a 100644 --- a/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraTestResultExportPluginTest.java +++ b/plugins/jira-plugin/src/test/java/io/qameta/allure/jira/JiraTestResultExportPluginTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.jira; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.InMemoryReportStorage; import io.qameta.allure.core.LaunchResults; @@ -25,6 +27,7 @@ import io.qameta.allure.entity.Time; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.util.Collections; import java.util.HashSet; @@ -36,8 +39,7 @@ import static io.qameta.allure.jira.TestData.ISSUES; import static io.qameta.allure.jira.TestData.createTestResult; import static io.qameta.allure.jira.TestData.mockJiraService; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.argThat; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @@ -46,7 +48,11 @@ class JiraTestResultExportPluginTest { - + /** + * Verifies individual Allure test results are exported to Jira. + * The test checks the outgoing Jira test-result payload and linked issue list. + */ + @Description @Test void shouldExportTestResultToJira() { final LaunchResults launchResults = mock(LaunchResults.class); @@ -72,6 +78,7 @@ void shouldExportTestResultToJira() { "ALLURE-1,ALLURE-2", () -> service ); + attachTestResultInput(testResult, executorInfo); jiraExportPlugin.aggregate( mock(Configuration.class), @@ -80,19 +87,73 @@ void shouldExportTestResultToJira() { ); - verify(service, times(1)).createTestResult(any(JiraTestResult.class), eq(ISSUES)); - verify(service).createTestResult(argThat(result -> result.getExternalId().equals(testResult.getUid())), eq(ISSUES)); - verify(service).createTestResult(argThat(result -> result.getTestCaseId().equals(testResult.getUid())), eq(ISSUES)); - verify(service).createTestResult(argThat(result -> result.getHistoryKey().equals(testResult.getHistoryId())), eq(ISSUES)); - verify(service).createTestResult(argThat(result -> result.getUrl().contains(testResult.getUid())), eq(ISSUES)); - verify(service).createTestResult(argThat(result -> result.getName().equals(testResult.getName())), eq(ISSUES)); - verify(service).createTestResult(argThat(result -> result.getStatus().equals(testResult.getStatus().toString())), eq(ISSUES)); - verify(service).createTestResult(argThat(result -> result.getColor().equals(ResultStatus.PASSED.color())), eq(ISSUES)); - verify(service).createTestResult(argThat(result -> result.getDate() == testResult.getTime().getStop().longValue()), eq(ISSUES)); - verify(service).createTestResult(argThat(result -> result.getLaunchUrl().equals(executorInfo.getReportUrl())), eq(ISSUES)); - verify(service).createTestResult(argThat(result -> result.getLaunchName().equals(executorInfo.getBuildName())), eq(ISSUES)); - verify(service).createTestResult(argThat(result -> result.getLaunchExternalId().equals(executorInfo.getBuildName())), eq(ISSUES)); + final JiraTestResult exported = captureTestResultExport(service); + + assertThat(exported.getExternalId()).isEqualTo(testResult.getUid()); + assertThat(exported.getTestCaseId()).isEqualTo(testResult.getUid()); + assertThat(exported.getHistoryKey()).isEqualTo(testResult.getHistoryId()); + assertThat(exported.getUrl()).contains(testResult.getUid()); + assertThat(exported.getName()).isEqualTo(testResult.getName()); + assertThat(exported.getStatus()).isEqualTo(testResult.getStatus().toString()); + assertThat(exported.getColor()).isEqualTo(ResultStatus.PASSED.color()); + assertThat(exported.getDate()).isEqualTo(testResult.getTime().getStop()); + assertThat(exported.getLaunchUrl()).isEqualTo(executorInfo.getReportUrl()); + assertThat(exported.getLaunchName()).isEqualTo(executorInfo.getBuildName()); + assertThat(exported.getLaunchExternalId()).isEqualTo(executorInfo.getBuildName()); + + } + + private void attachTestResultInput(final TestResult testResult, final ExecutorInfo executorInfo) { + Allure.step("Attach Jira test-result input", () -> Allure.addAttachment( + "jira-test-result-input.txt", + "text/plain", + String.format( + "uid=%s%nname=%s%nhistoryId=%s%nstatus=%s%nstop=%s%nlinks=%s%n" + + "executorBuildName=%s%nexecutorReportUrl=%s", + testResult.getUid(), + testResult.getName(), + testResult.getHistoryId(), + testResult.getStatus(), + testResult.getTime().getStop(), + describeLinks(testResult.getLinks()), + executorInfo.getBuildName(), + executorInfo.getReportUrl() + ) + )); + } + + private JiraTestResult captureTestResultExport(final JiraService service) { + return Allure.step("Capture Jira test-result export payload", () -> { + final ArgumentCaptor resultCaptor = ArgumentCaptor.forClass(JiraTestResult.class); + verify(service, times(1)).createTestResult(resultCaptor.capture(), eq(ISSUES)); + final JiraTestResult result = resultCaptor.getValue(); + Allure.addAttachment("jira-test-result-payload.txt", "text/plain", describeTestResultExport(result)); + return result; + }); + } + private String describeLinks(final List links) { + return links.stream() + .map(link -> link.getType() + ":" + link.getName()) + .sorted() + .collect(Collectors.joining(", ")); } + private String describeTestResultExport(final JiraTestResult result) { + return String.format( + "externalId=%s%ntestCaseId=%s%nhistoryKey=%s%nname=%s%nurl=%s%nstatus=%s%ncolor=%s%n" + + "date=%s%nlaunchExternalId=%s%nlaunchName=%s%nlaunchUrl=%s", + result.getExternalId(), + result.getTestCaseId(), + result.getHistoryKey(), + result.getName(), + result.getUrl(), + result.getStatus(), + result.getColor(), + result.getDate(), + result.getLaunchExternalId(), + result.getLaunchName(), + result.getLaunchUrl() + ); + } } diff --git a/plugins/junit-xml-plugin/src/test/java/io/qameta/allure/junitxml/JunitXmlPluginTest.java b/plugins/junit-xml-plugin/src/test/java/io/qameta/allure/junitxml/JunitXmlPluginTest.java index 5484449e1..3837d2763 100644 --- a/plugins/junit-xml-plugin/src/test/java/io/qameta/allure/junitxml/JunitXmlPluginTest.java +++ b/plugins/junit-xml-plugin/src/test/java/io/qameta/allure/junitxml/JunitXmlPluginTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.junitxml; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.Issue; import io.qameta.allure.context.RandomUidContext; import io.qameta.allure.core.Configuration; @@ -70,6 +72,11 @@ void setUp(@TempDir final Path resultsDirectory) { this.resultsDirectory = resultsDirectory; } + /** + * Verifies a JUnit XML report is converted into Allure test results. + * The test checks the parsed result count and status distribution. + */ + @Description @Test void shouldReadJunitResults() throws Exception { process( @@ -77,16 +84,14 @@ void shouldReadJunitResults() throws Exception { "TEST-org.allurefw.report.junit.JunitTestResultsTest.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(5)).visitTestResult(captor.capture()); + final List results = captureTestResults(5); - - assertThat(captor.getAllValues()) + assertThat(results) .hasSize(5); - List failed = filterByStatus(captor.getAllValues(), Status.FAILED); - List skipped = filterByStatus(captor.getAllValues(), Status.SKIPPED); - List passed = filterByStatus(captor.getAllValues(), Status.PASSED); + final List failed = filterByStatus(results, Status.FAILED); + final List skipped = filterByStatus(results, Status.SKIPPED); + final List passed = filterByStatus(results, Status.PASSED); assertThat(failed) .describedAs("Should parse failed status") @@ -101,6 +106,11 @@ void shouldReadJunitResults() throws Exception { .hasSize(3); } + /** + * Verifies a JUnit text log is linked as an Allure stage attachment. + * The test checks the visited attachment content and the attachment name and UID in the parsed result. + */ + @Description @Test void shouldAddLogAsAttachment() throws Exception { final Attachment hey = new Attachment().setUid("some-uid"); @@ -110,17 +120,15 @@ void shouldAddLogAsAttachment() throws Exception { "junitdata/test.SampleTest.txt", "test.SampleTest.txt" ); - final ArgumentCaptor attachmentCaptor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitAttachmentFile(attachmentCaptor.capture()); + final List attachments = captureAttachmentFiles(1); - assertThat(attachmentCaptor.getValue()) + assertThat(attachments.get(0)) .isRegularFile() .hasContent("some-test-log"); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - final StageResult testStage = captor.getValue().getTestStage(); + final StageResult testStage = results.get(0).getTestStage(); assertThat(testStage) .describedAs("Should create a test stage") .isNotNull(); @@ -133,6 +141,11 @@ void shouldAddLogAsAttachment() throws Exception { .containsExactly(Tuple.tuple("System out", "some-uid")); } + /** + * Verifies JUnit log attachments resolve when the results path is relative. + * The test checks the attachment visitor receives the normalized log file content. + */ + @Description @Test void shouldResolveAttachmentsWithRelativeResultsPath() throws Exception { final Attachment hey = new Attachment().setUid("some-uid"); @@ -142,19 +155,22 @@ void shouldResolveAttachmentsWithRelativeResultsPath() throws Exception { copyFile(junitResults, "junitdata/TEST-test.SampleTest.xml", "TEST-test.SampleTest.xml"); copyFile(junitResults, "junitdata/test.SampleTest.txt", "test.SampleTest.txt"); - JunitXmlPlugin reader = new JunitXmlPlugin(ZoneOffset.UTC); final Path relative = junitResults.resolve("..").resolve("junit-results"); - reader.readResults(configuration, visitor, relative); + readResults(relative); - final ArgumentCaptor attachmentCaptor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitAttachmentFile(attachmentCaptor.capture()); + final List attachments = captureAttachmentFiles(1); - assertThat(attachmentCaptor.getValue()) + assertThat(attachments.get(0)) .isRegularFile() .hasContent("some-test-log"); } + /** + * Verifies JUnit attachment paths cannot escape the results directory. + * The test checks a traversal fixture does not send the secret file to the visitor. + */ + @Description @Test void shouldNotAllowPathTraversal() throws Exception { final Attachment hey = new Attachment().setUid("some-uid"); @@ -165,24 +181,26 @@ void shouldNotAllowPathTraversal() throws Exception { copyFile(junitResults, "junitdata/path-traversal.xml", "TEST-test.SampleTest.xml"); copyFile(resultsDirectory, "junitdata/secret-file.txt", "secret-file.txt"); - JunitXmlPlugin reader = new JunitXmlPlugin(ZoneOffset.UTC); - reader.readResults(configuration, visitor, junitResults); + readResults(junitResults); - final ArgumentCaptor attachmentCaptor = ArgumentCaptor.captor(); - verify(visitor, times(0)).visitAttachmentFile(attachmentCaptor.capture()); + verifyNoAttachmentFiles(); } + /** + * Verifies JUnit suite, package, class, and format labels are emitted. + * The test checks the exact label set parsed from a simple fixture. + */ + @Description @Test void shouldAddLabels() throws Exception { process( "junitdata/TEST-test.SampleTest.xml", "TEST-test.SampleTest.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .hasSize(1) .flatExtracting(TestResult::getLabels) .extracting(Label::getName, Label::getValue) @@ -194,25 +212,32 @@ void shouldAddLabels() throws Exception { ); } + /** + * Verifies invalid JUnit XML files are skipped safely. + * The test checks no test result is emitted for an invalid XML fixture. + */ + @Description @Test void shouldSkipInvalidXml() throws Exception { process( "junitdata/invalid.xml", "sample-testsuite.xml" ); - verify(visitor, times(0)).visitTestResult(any()); + verifyNoTestResults(); } + /** + * Verifies repeated JUnit results are modeled as retries. + * The test checks visible and hidden retry flags, history IDs, and status details. + */ + @Description @Test void shouldProcessTestsWithRetry() throws Exception { process( "junitdata/TEST-test.RetryTest.xml", "TEST-test.SampleTest.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(4)).visitTestResult(captor.capture()); - - final List results = captor.getAllValues(); + final List results = captureTestResults(4); assertThat(results) .extracting(TestResult::getName, TestResult::getStatus, TestResult::isHidden, TestResult::getHistoryId) .containsExactlyInAnyOrder( @@ -232,17 +257,20 @@ void shouldProcessTestsWithRetry() throws Exception { ); } + /** + * Verifies JUnit failure CDATA is parsed into status details. + * The test checks the exact message and trace values for failed and passed cases. + */ + @Description @Test void shouldReadStatusMessage() throws Exception { process( "junitdata/TEST-test.CdataMessage.xml", "TEST-test.SampleTest.xml" ); + final List results = captureTestResults(2); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(2)).visitTestResult(captor.capture()); - - assertThat(captor.getAllValues()) + assertThat(results) .extracting(TestResult::getStatusMessage, TestResult::getStatusTrace) .containsExactlyInAnyOrder( tuple("some-message", "some-trace"), @@ -250,32 +278,41 @@ void shouldReadStatusMessage() throws Exception { ); } + /** + * Verifies JUnit system output is converted into Allure steps. + * The test checks the parsed step names generated from stdout content. + */ + @Description @Test void shouldReadSystemOutMessage() throws Exception { process( "junitdata/TEST-test.CdataMessage.xml", "TEST-test.SampleTest.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(2)).visitTestResult(captor.capture()); + final List results = captureTestResults(2); - assertThat(captor.getAllValues()) + assertThat(results) .filteredOn(result -> result.getTestStage().getSteps().size() == 2) .filteredOn(result -> result.getTestStage().getSteps().get(0).getName().equals("output")) - .filteredOn(result -> result.getTestStage().getSteps().get(1).getName().equals("more output")); + .filteredOn(result -> result.getTestStage().getSteps().get(1).getName().equals("more output")) + .hasSize(1); } + /** + * Verifies JUnit reports wrapped in a testsuites tag are parsed. + * The test checks all child testcase names are emitted as Allure results. + */ @Issue("532") + @Description @Test void shouldParseSuitesTag() throws Exception { process( "junitdata/testsuites.xml", "TEST-test.SampleTest.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(3)).visitTestResult(captor.capture()); + final List results = captureTestResults(3); - assertThat(captor.getAllValues()) + assertThat(results) .extracting(TestResult::getName) .containsExactlyInAnyOrder( "should default path to an empty string", @@ -284,16 +321,20 @@ void shouldParseSuitesTag() throws Exception { ); } + /** + * Verifies JUnit timestamps are converted into Allure start, stop, and duration. + * The test checks the exact time values parsed from a timestamped fixture. + */ + @Description @Test void shouldProcessTimestampIfPresent() throws Exception { process( "junitdata/with-timestamp.xml", "TEST-test.SampleTest.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .extracting(TestResult::getTime) .extracting(Time::getStart, Time::getStop, Time::getDuration) .containsExactlyInAnyOrder( @@ -301,16 +342,20 @@ void shouldProcessTimestampIfPresent() throws Exception { ); } + /** + * Verifies a JUnit suite name overrides the default suite label. + * The test checks the exact suite label parsed from the fixture. + */ + @Description @Test void shouldUseSuiteNameIfPresent() throws Exception { process( "junitdata/with-timestamp.xml", "TEST-test.SampleTest.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .flatExtracting(TestResult::getLabels) .filteredOn("name", "suite") .extracting(Label::getValue) @@ -318,16 +363,20 @@ void shouldUseSuiteNameIfPresent() throws Exception { } + /** + * Verifies a JUnit hostname attribute becomes an Allure host label. + * The test checks the exact host label parsed from the fixture. + */ + @Description @Test void shouldUseHostnameIfPresent() throws Exception { process( "junitdata/with-timestamp.xml", "TEST-test.SampleTest.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .flatExtracting(TestResult::getLabels) .filteredOn("name", "host") .extracting(Label::getValue) @@ -335,16 +384,20 @@ void shouldUseHostnameIfPresent() throws Exception { } + /** + * Verifies JUnit skipped status is parsed from elements and attributes. + * The test checks the resulting skipped result count for the status fixture. + */ + @Description @Test void shouldReadSkippedStatus() throws Exception { process( "junitdata/TEST-status-attribute.xml", "TEST-test.SampleTest.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(3)).visitTestResult(captor.capture()); + final List results = captureTestResults(3); - List skipped = filterByStatus(captor.getAllValues(), Status.SKIPPED); + final List skipped = filterByStatus(results, Status.SKIPPED); assertThat(skipped) .describedAs("Should parse skipped elements and status attribute") @@ -352,17 +405,20 @@ void shouldReadSkippedStatus() throws Exception { } + /** + * Verifies JUnit testcase properties are mapped into Allure parameters. + * The test checks the exact parsed parameter names and values. + */ + @Description @Test void shouldUsePropertiesIfPresent() throws Exception { process( "junitdata/TEST-test.PropertiesTest.xml", "TEST-test.SampleTest.xml" ); + final List results = captureTestResults(1); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); - - assertThat(captor.getAllValues()) + assertThat(results) .flatExtracting(TestResult::getParameters) .extracting(Parameter::getName, Parameter::getValue) .containsExactlyInAnyOrder( @@ -371,6 +427,11 @@ void shouldUsePropertiesIfPresent() throws Exception { ); } + /** + * Verifies JUnit files with Zulu timestamps preserve timing fields. + * The test checks parsed start, stop, and duration for both test cases. + */ + @Description @Test void shouldProcessFilesWithZuluTimestamp() throws Exception { process( @@ -378,10 +439,9 @@ void shouldProcessFilesWithZuluTimestamp() throws Exception { "TEST-test.SampleTest.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(2)).visitTestResult(captor.capture()); + final List results = captureTestResults(2); - assertThat(captor.getAllValues()) + assertThat(results) .extracting(TestResult::getTime) .extracting(Time::getStart, Time::getStop, Time::getDuration) .containsExactlyInAnyOrder( @@ -390,6 +450,11 @@ void shouldProcessFilesWithZuluTimestamp() throws Exception { ); } + /** + * Verifies external XML entities are not resolved while parsing JUnit XML. + * The test checks parsed status traces do not expose content from a local secret file. + */ + @Description @Test void cveEntityReadTest(@TempDir final Path tmp) throws IOException { final Path secretFile = tmp.resolve("secretfile.ini"); @@ -400,9 +465,7 @@ void cveEntityReadTest(@TempDir final Path tmp) throws IOException { StandardCharsets.UTF_8 ); - Files.writeString( - resultsDirectory.resolve("bad-test.xml"), - "\n" + final String maliciousXml = "\n" + "\n" + "]>\n" @@ -411,42 +474,177 @@ void cveEntityReadTest(@TempDir final Path tmp) throws IOException { + " &xxe;\n" + " \n" + " \n" - + "", - StandardCharsets.UTF_8 - ); + + ""; + writeTextFile(resultsDirectory.resolve("bad-test.xml"), "bad-test.xml", maliciousXml); - JunitXmlPlugin reader = new JunitXmlPlugin(); + readResults(resultsDirectory); - reader.readResults(configuration, visitor, resultsDirectory); + final List results = captureTestResults(1); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); - - final TestResult testResult = captor.getValue(); + final TestResult testResult = results.get(0); assertThat(testResult.getStatusTrace()).doesNotContain("John Doe").doesNotContain("Example Org"); } - private void process(String... strings) throws IOException { - Iterator iterator = Arrays.asList(strings).iterator(); - while (iterator.hasNext()) { - String first = iterator.next(); - String second = iterator.next(); - copyFile(resultsDirectory, first, second); + private void process(final String... strings) throws IOException { + Allure.step("Parse JUnit XML results", () -> { + final Iterator iterator = Arrays.asList(strings).iterator(); + while (iterator.hasNext()) { + final String first = iterator.next(); + final String second = iterator.next(); + copyFile(resultsDirectory, first, second); + } + readResults(resultsDirectory); + }); + } + + private void readResults(final Path directory) { + Allure.step("Read JUnit XML results", () -> { + final JunitXmlPlugin reader = new JunitXmlPlugin(ZoneOffset.UTC); + reader.readResults(configuration, visitor, directory); + }); + } + + private void copyFile(final Path dir, final String resourceName, final String fileName) throws IOException { + Allure.step("Copy fixture resource " + resourceName + " as " + fileName, () -> { + final byte[] content; + try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { + content = Objects.requireNonNull(is).readAllBytes(); + } + final Path destination = dir.resolve(fileName); + Files.createDirectories(destination.getParent()); + Files.write(destination, content); + Allure.addAttachment( + fileName, + contentType(fileName), + new String(content, StandardCharsets.UTF_8), + extension(fileName) + ); + }); + } + + private void writeTextFile(final Path path, final String fileName, final String content) throws IOException { + Allure.step("Write text fixture " + fileName, () -> { + Files.writeString(path, content, StandardCharsets.UTF_8); + Allure.addAttachment(fileName, contentType(fileName), content, extension(fileName)); + }); + } + + private List captureTestResults(final int expectedCount) { + return Allure.step("Capture parsed JUnit test results", () -> { + final ArgumentCaptor captor = ArgumentCaptor.captor(); + verify(visitor, times(expectedCount)).visitTestResult(captor.capture()); + final List results = captor.getAllValues(); + Allure.addAttachment("parsed-test-results.txt", "text/plain", describeTestResults(results)); + return results; + }); + } + + private List captureAttachmentFiles(final int expectedCount) throws IOException { + return Allure.step("Capture visited attachment files", () -> { + final ArgumentCaptor captor = ArgumentCaptor.captor(); + verify(visitor, times(expectedCount)).visitAttachmentFile(captor.capture()); + final List attachments = captor.getAllValues(); + Allure.addAttachment("visited-attachments.txt", "text/plain", describeAttachments(attachments)); + return attachments; + }); + } + + private void verifyNoAttachmentFiles() { + Allure.step("Verify no attachment files were visited", () -> { + verify(visitor, times(0)).visitAttachmentFile(any()); + Allure.addAttachment("visited-attachments.txt", "text/plain", "attachments=0"); + }); + } + + private void verifyNoTestResults() { + Allure.step("Verify no JUnit test results were emitted", () -> { + verify(visitor, times(0)).visitTestResult(any()); + Allure.addAttachment("parsed-test-results.txt", "text/plain", "results=0"); + }); + } + + private List filterByStatus(final List testCases, final Status status) { + return Allure.step("Filter parsed results by " + status, () -> testCases.stream() + .filter(item -> status.equals(item.getStatus())) + .collect(Collectors.toList())); + } + + private String describeTestResults(final List results) { + final StringBuilder builder = new StringBuilder(); + builder.append("results=").append(results.size()).append(System.lineSeparator()); + results.forEach(result -> builder + .append(System.lineSeparator()) + .append("name=").append(result.getName()).append(System.lineSeparator()) + .append("status=").append(result.getStatus()).append(System.lineSeparator()) + .append("hidden=").append(result.isHidden()).append(System.lineSeparator()) + .append("historyId=").append(result.getHistoryId()).append(System.lineSeparator()) + .append("statusMessage=").append(result.getStatusMessage()).append(System.lineSeparator()) + .append("statusTrace=").append(result.getStatusTrace()).append(System.lineSeparator()) + .append("time=").append(describeTime(result.getTime())).append(System.lineSeparator()) + .append("labels=").append(describeLabels(result)).append(System.lineSeparator()) + .append("parameters=").append(describeParameters(result)).append(System.lineSeparator()) + .append("steps=").append(describeSteps(result)).append(System.lineSeparator()) + .append("attachments=").append(describeStageAttachments(result)).append(System.lineSeparator()) + ); + return builder.toString(); + } + + private String describeAttachments(final List attachments) throws IOException { + final StringBuilder builder = new StringBuilder(); + builder.append("attachments=").append(attachments.size()).append(System.lineSeparator()); + for (Path attachment : attachments) { + builder + .append(System.lineSeparator()) + .append("file=").append(attachment.getFileName()).append(System.lineSeparator()) + .append(Files.readString(attachment)); + } + return builder.toString(); + } + + private String describeTime(final Time time) { + if (time == null) { + return null; } - JunitXmlPlugin reader = new JunitXmlPlugin(ZoneOffset.UTC); + return String.format("start=%s, stop=%s, duration=%s", time.getStart(), time.getStop(), time.getDuration()); + } + + private String describeLabels(final TestResult result) { + return result.getLabels().stream() + .map(label -> label.getName() + "=" + label.getValue()) + .sorted() + .collect(Collectors.joining(", ")); + } - reader.readResults(configuration, visitor, resultsDirectory); + private String describeParameters(final TestResult result) { + return result.getParameters().stream() + .map(parameter -> parameter.getName() + "=" + parameter.getValue()) + .sorted() + .collect(Collectors.joining(", ")); } - private void copyFile(Path dir, String resourceName, String fileName) throws IOException { - try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { - Files.copy(Objects.requireNonNull(is), dir.resolve(fileName)); + private String describeSteps(final TestResult result) { + if (result.getTestStage() == null || result.getTestStage().getSteps() == null) { + return ""; } + return result.getTestStage().getSteps().stream() + .map(step -> step.getName()) + .collect(Collectors.joining(" | ")); } - private List filterByStatus(List testCases, Status status) { - return testCases.stream() - .filter(item -> status.equals(item.getStatus())) - .collect(Collectors.toList()); + private String describeStageAttachments(final TestResult result) { + if (result.getTestStage() == null || result.getTestStage().getAttachments() == null) { + return ""; + } + return result.getTestStage().getAttachments().stream() + .map(attachment -> attachment.getName() + "=" + attachment.getUid()) + .collect(Collectors.joining(", ")); + } + + private String contentType(final String fileName) { + return fileName.endsWith(".txt") ? "text/plain" : "application/xml"; + } + + private String extension(final String fileName) { + return fileName.endsWith(".txt") ? ".txt" : ".xml"; } } diff --git a/plugins/packages-plugin/src/test/java/io/qameta/allure/packages/PackagesPluginTest.java b/plugins/packages-plugin/src/test/java/io/qameta/allure/packages/PackagesPluginTest.java index a078cf52f..98a0fdae3 100644 --- a/plugins/packages-plugin/src/test/java/io/qameta/allure/packages/PackagesPluginTest.java +++ b/plugins/packages-plugin/src/test/java/io/qameta/allure/packages/PackagesPluginTest.java @@ -15,18 +15,24 @@ */ package io.qameta.allure.packages; +import io.qameta.allure.Allure; import io.qameta.allure.DefaultLaunchResults; +import io.qameta.allure.Description; import io.qameta.allure.Issue; import io.qameta.allure.core.LaunchResults; import io.qameta.allure.entity.TestResult; import io.qameta.allure.entity.Time; import io.qameta.allure.tree.Tree; +import io.qameta.allure.tree.TreeGroup; +import io.qameta.allure.tree.TreeNode; import org.junit.jupiter.api.Test; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; +import java.util.List; import java.util.Set; +import java.util.stream.Collectors; import static io.qameta.allure.entity.LabelName.PACKAGE; import static io.qameta.allure.entity.LabelName.TEST_METHOD; @@ -39,6 +45,11 @@ */ class PackagesPluginTest { + /** + * Verifies package labels are converted into a package tree. + * The test checks root, package, and leaf node names for labeled results. + */ + @Description @Test void shouldCreateTree() { final Set testResults = new HashSet<>(); @@ -52,14 +63,8 @@ void shouldCreateTree() { testResults.add(first); testResults.add(second); - final LaunchResults results = new DefaultLaunchResults( - testResults, - Collections.emptyMap(), - Collections.emptyMap() - ); - - final PackagesPlugin packagesPlugin = new PackagesPlugin(); - final Tree tree = packagesPlugin.getData(singletonList(results)); + final LaunchResults results = createLaunchResults(testResults); + final Tree tree = aggregatePackages(results); assertThat(tree.getChildren()) .hasSize(1) @@ -78,6 +83,11 @@ void shouldCreateTree() { .containsExactlyInAnyOrder("firstMethod", "second"); } + /** + * Verifies package tree nodes with a single child are collapsed. + * The test checks collapsed package names and leaf result names. + */ + @Description @Test void shouldCollapseNodesWithOneChild() { final Set testResults = new HashSet<>(); @@ -91,14 +101,8 @@ void shouldCollapseNodesWithOneChild() { testResults.add(first); testResults.add(second); - final LaunchResults results = new DefaultLaunchResults( - testResults, - Collections.emptyMap(), - Collections.emptyMap() - ); - - final PackagesPlugin packagesPlugin = new PackagesPlugin(); - final Tree tree = packagesPlugin.getData(singletonList(results)); + final LaunchResults results = createLaunchResults(testResults); + final Tree tree = aggregatePackages(results); assertThat(tree.getChildren()) .hasSize(1) @@ -117,7 +121,12 @@ void shouldCollapseNodesWithOneChild() { .containsExactlyInAnyOrder("first", "second"); } + /** + * Verifies tests can appear directly under a package that also has nested packages. + * The test checks the nested package shape for a mixed package fixture. + */ @Issue("531") + @Description @Test void shouldProcessTestsInNestedPackages() { final Set testResults = new HashSet<>(); @@ -131,14 +140,8 @@ void shouldProcessTestsInNestedPackages() { testResults.add(first); testResults.add(second); - final LaunchResults results = new DefaultLaunchResults( - testResults, - Collections.emptyMap(), - Collections.emptyMap() - ); - - final PackagesPlugin packagesPlugin = new PackagesPlugin(); - final Tree tree = packagesPlugin.getData(singletonList(results)); + final LaunchResults results = createLaunchResults(testResults); + final Tree tree = aggregatePackages(results); assertThat(tree.getChildren()) .hasSize(1) @@ -158,8 +161,13 @@ void shouldProcessTestsInNestedPackages() { .containsExactlyInAnyOrder("second"); } + /** + * Verifies package tree nodes are sorted by result start time. + * The test checks timeless results appear first, followed by ascending start times. + */ @Issue("587") @Issue("572") + @Description @Test void shouldSortByStartTimeAsc() { final TestResult first = new TestResult() @@ -174,17 +182,60 @@ void shouldSortByStartTimeAsc() { final TestResult timeless = new TestResult() .setName("timeless"); - final LaunchResults results = new DefaultLaunchResults( - new HashSet<>(Arrays.asList(first, second, third, timeless)), - Collections.emptyMap(), - Collections.emptyMap() - ); - - final PackagesPlugin packagesPlugin = new PackagesPlugin(); - final Tree tree = packagesPlugin.getData(singletonList(results)); + final LaunchResults results = createLaunchResults(new HashSet<>(Arrays.asList(first, second, third, timeless))); + final Tree tree = aggregatePackages(results); assertThat(tree.getChildren()) .extracting("name") .containsExactly("timeless", "first", "third", "second"); } + + private LaunchResults createLaunchResults(final Set testResults) { + return Allure.step("Create launch results", () -> { + Allure.addAttachment("input-test-results.txt", "text/plain", describeTestResults(testResults)); + return new DefaultLaunchResults(testResults, Collections.emptyMap(), Collections.emptyMap()); + }); + } + + private Tree aggregatePackages(final LaunchResults results) { + return Allure.step("Aggregate packages tree", () -> { + final PackagesPlugin packagesPlugin = new PackagesPlugin(); + final Tree tree = packagesPlugin.getData(singletonList(results)); + Allure.addAttachment("packages-tree.txt", "text/plain", describeTree(tree.getChildren(), 0)); + return tree; + }); + } + + private String describeTestResults(final Set testResults) { + return testResults.stream() + .map(result -> String.format( + "name=%s, start=%s, labels=%s", + result.getName(), + result.getTime() == null ? null : result.getTime().getStart(), + describeLabels(result) + )) + .sorted() + .collect(Collectors.joining(System.lineSeparator())); + } + + private String describeLabels(final TestResult result) { + return result.getLabels().stream() + .map(label -> label.getName() + "=" + label.getValue()) + .sorted() + .collect(Collectors.joining(", ")); + } + + private String describeTree(final List nodes, final int depth) { + final StringBuilder builder = new StringBuilder(); + for (TreeNode node : nodes) { + builder + .append(" ".repeat(depth)) + .append(node.getName()) + .append(System.lineSeparator()); + if (node instanceof TreeGroup) { + builder.append(describeTree(((TreeGroup) node).getChildren(), depth + 1)); + } + } + return builder.toString(); + } } diff --git a/plugins/trx-plugin/src/test/java/io/qameta/allure/trx/TrxPluginTest.java b/plugins/trx-plugin/src/test/java/io/qameta/allure/trx/TrxPluginTest.java index 1317c0c4b..629f2463b 100644 --- a/plugins/trx-plugin/src/test/java/io/qameta/allure/trx/TrxPluginTest.java +++ b/plugins/trx-plugin/src/test/java/io/qameta/allure/trx/TrxPluginTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.trx; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.Issue; import io.qameta.allure.context.RandomUidContext; import io.qameta.allure.core.Configuration; @@ -39,6 +41,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.stream.Collectors; import static io.qameta.allure.entity.LabelName.PACKAGE; import static io.qameta.allure.entity.LabelName.RESULT_FORMAT; @@ -68,6 +71,11 @@ void setUp(@TempDir final Path resultsDirectory) { this.resultsDirectory = resultsDirectory; } + /** + * Verifies a TRX file is converted into Allure test results. + * The test checks parsed names, statuses, descriptions, and result format labels. + */ + @Description @Test void shouldParseResults() throws Exception { process( @@ -75,10 +83,9 @@ void shouldParseResults() throws Exception { "sample.trx" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(5)).visitTestResult(captor.capture()); + final List results = captureTestResults(5); - assertThat(captor.getAllValues()) + assertThat(results) .extracting(TestResult::getName, TestResult::getStatus, TestResult::getDescription) .containsExactlyInAnyOrder( tuple("AddingSeveralNumbers_40", Status.PASSED, "Adding several numbers"), @@ -88,14 +95,19 @@ void shouldParseResults() throws Exception { tuple("SkippedTest", Status.SKIPPED, "Should Skip this test") ); - assertThat(captor.getAllValues()) + assertThat(results) .extracting(result -> result.findOneLabel(LabelName.RESULT_FORMAT)) .extracting(Optional::get) .containsOnly(TrxPlugin.TRX_RESULTS_FORMAT); } + /** + * Verifies TRX error information is mapped into Allure status details. + * The test checks the exact parsed message and stack trace values. + */ @Issue("596") + @Description @Test void shouldParseErrorInfo() throws Exception { process( @@ -103,15 +115,19 @@ void shouldParseErrorInfo() throws Exception { "sample.trx" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .extracting(TestResult::getStatusMessage, TestResult::getStatusTrace) .containsExactly(tuple("Some message", "Some trace")); } + /** + * Verifies a TRX class name is used as the Allure suite label when expected. + * The test checks the focused suite label parsed from the regression fixture. + */ @Issue("749") + @Description @Test void shouldParseClassNameAsSuite() throws Exception { process( @@ -119,15 +135,19 @@ void shouldParseClassNameAsSuite() throws Exception { "sample.trx" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .extracting(result -> result.findOneLabel(LabelName.SUITE)) .extracting(Optional::get) .containsOnly("TestClass"); } + /** + * Verifies failed TRX stdout is converted into Allure steps. + * The test checks the failed result contains the expected BDD step text from stdout. + */ + @Description @Test void shouldParseStdOutOnFail() throws Exception { process( @@ -135,10 +155,9 @@ void shouldParseStdOutOnFail() throws Exception { "sample.trx" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(5)).visitTestResult(captor.capture()); + final List results = captureTestResults(5); - assertThat(captor.getAllValues()) + assertThat(results) .filteredOn(result -> result.getStatus() == Status.FAILED) .filteredOn(result -> result.getTestStage().getSteps().size() == 10) .filteredOn(result -> result.getTestStage().getSteps().get(1).getName().contains("Given I have entered 50 into the calculator")) @@ -146,6 +165,11 @@ void shouldParseStdOutOnFail() throws Exception { .hasSize(1); } + /** + * Verifies nested TRX result children are flattened into Allure results. + * The test checks child names, statuses, and inherited labels for nested failures. + */ + @Description @Test void shouldParseTestResultChildren() throws Exception { process( @@ -153,10 +177,9 @@ void shouldParseTestResultChildren() throws Exception { "sample.trx" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(7)).visitTestResult(captor.capture()); + final List results = captureTestResults(7); - assertThat(captor.getAllValues()) + assertThat(results) .hasSize(7) .extracting(TestResult::getName, TestResult::getStatus) .containsExactlyInAnyOrder( @@ -175,7 +198,7 @@ void shouldParseTestResultChildren() throws Exception { labels.add(new Label().setName(PACKAGE.value()).setValue("Test")); labels.add(new Label().setName(RESULT_FORMAT.value()).setValue("trx")); - assertThat(captor.getAllValues()) + assertThat(results) .filteredOn(result -> result.getName().contains("UnitTest_Three")) .extracting(TestResult::getLabels) .containsOnly( @@ -184,6 +207,11 @@ void shouldParseTestResultChildren() throws Exception { } + /** + * Verifies external XML entities are not resolved while parsing TRX files. + * The test checks parsed status details do not expose content from a local secret file. + */ + @Description @Test void cveEntityReadTest(@TempDir final Path tmp) throws IOException { final Path secretFile = tmp.resolve("secretfile.ini"); @@ -194,9 +222,7 @@ void cveEntityReadTest(@TempDir final Path tmp) throws IOException { StandardCharsets.UTF_8 ); - Files.writeString( - resultsDirectory.resolve("bad-test.trx"), - "\n" + final String maliciousTrx = "\n" + "\n" + "]>\n" @@ -228,36 +254,96 @@ void cveEntityReadTest(@TempDir final Path tmp) throws IOException { + " \n" + " \n" + " \n" - + "", - StandardCharsets.UTF_8 - ); - - TrxPlugin reader = new TrxPlugin(); + + ""; + writeTextFile(resultsDirectory.resolve("bad-test.trx"), "bad-test.trx", maliciousTrx); - reader.readResults(configuration, visitor, resultsDirectory); + readResults(resultsDirectory); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - final TestResult testResult = captor.getValue(); + final TestResult testResult = results.get(0); assertThat(testResult.getStatusMessage()).doesNotContain("John Doe"); assertThat(testResult.getStatusTrace()).doesNotContain("Example Org"); } - private void process(String... strings) throws IOException { - Iterator iterator = Arrays.asList(strings).iterator(); - while (iterator.hasNext()) { - String first = iterator.next(); - String second = iterator.next(); - copyFile(resultsDirectory, first, second); - } - TrxPlugin reader = new TrxPlugin(); - reader.readResults(configuration, visitor, resultsDirectory); + private void process(final String... strings) throws IOException { + Allure.step("Parse TRX results", () -> { + final Iterator iterator = Arrays.asList(strings).iterator(); + while (iterator.hasNext()) { + final String first = iterator.next(); + final String second = iterator.next(); + copyFile(resultsDirectory, first, second); + } + readResults(resultsDirectory); + }); + } + + private void readResults(final Path directory) { + Allure.step("Read TRX results", () -> { + final TrxPlugin reader = new TrxPlugin(); + reader.readResults(configuration, visitor, directory); + }); + } + + private void copyFile(final Path dir, final String resourceName, final String fileName) throws IOException { + Allure.step("Copy fixture resource " + resourceName + " as " + fileName, () -> { + final byte[] content; + try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { + content = Objects.requireNonNull(is).readAllBytes(); + } + final Path destination = dir.resolve(fileName); + Files.createDirectories(destination.getParent()); + Files.write(destination, content); + Allure.addAttachment(fileName, "application/xml", new String(content, StandardCharsets.UTF_8), ".trx"); + }); + } + + private void writeTextFile(final Path path, final String fileName, final String content) throws IOException { + Allure.step("Write text fixture " + fileName, () -> { + Files.writeString(path, content, StandardCharsets.UTF_8); + Allure.addAttachment(fileName, "application/xml", content, ".trx"); + }); + } + + private List captureTestResults(final int expectedCount) { + return Allure.step("Capture parsed TRX test results", () -> { + final ArgumentCaptor captor = ArgumentCaptor.captor(); + verify(visitor, times(expectedCount)).visitTestResult(captor.capture()); + final List results = captor.getAllValues(); + Allure.addAttachment("parsed-test-results.txt", "text/plain", describeTestResults(results)); + return results; + }); + } + + private String describeTestResults(final List results) { + final StringBuilder builder = new StringBuilder(); + builder.append("results=").append(results.size()).append(System.lineSeparator()); + results.forEach(result -> builder + .append(System.lineSeparator()) + .append("name=").append(result.getName()).append(System.lineSeparator()) + .append("status=").append(result.getStatus()).append(System.lineSeparator()) + .append("description=").append(result.getDescription()).append(System.lineSeparator()) + .append("statusMessage=").append(result.getStatusMessage()).append(System.lineSeparator()) + .append("statusTrace=").append(result.getStatusTrace()).append(System.lineSeparator()) + .append("labels=").append(describeLabels(result)).append(System.lineSeparator()) + .append("steps=").append(describeSteps(result)).append(System.lineSeparator()) + ); + return builder.toString(); + } + + private String describeLabels(final TestResult result) { + return result.getLabels().stream() + .map(label -> label.getName() + "=" + label.getValue()) + .sorted() + .collect(Collectors.joining(", ")); } - private void copyFile(Path dir, String resourceName, String fileName) throws IOException { - try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { - Files.copy(Objects.requireNonNull(is), dir.resolve(fileName)); + private String describeSteps(final TestResult result) { + if (result.getTestStage() == null || result.getTestStage().getSteps() == null) { + return ""; } + return result.getTestStage().getSteps().stream() + .map(step -> step.getName()) + .collect(Collectors.joining(" | ")); } } diff --git a/plugins/xctest-plugin/src/test/java/io/qameta/allure/xctest/XcTestPluginTest.java b/plugins/xctest-plugin/src/test/java/io/qameta/allure/xctest/XcTestPluginTest.java index 6502bf9b8..ac81510e2 100644 --- a/plugins/xctest-plugin/src/test/java/io/qameta/allure/xctest/XcTestPluginTest.java +++ b/plugins/xctest-plugin/src/test/java/io/qameta/allure/xctest/XcTestPluginTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.xctest; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.context.JacksonContext; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.ResultsVisitor; @@ -25,9 +27,13 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.io.InputStream; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Objects; import static org.assertj.core.api.Assertions.assertThat; @@ -55,31 +61,37 @@ void setUp(@TempDir final Path resultsDirectory) { this.resultsDirectory = resultsDirectory; } + /** + * Verifies an XCTest plist is parsed into Allure test results. + * The test checks the number of emitted results from the sample report. + */ + @Description @Test void shouldParseResults() throws Exception { - try (InputStream is = getClass().getClassLoader().getResourceAsStream("sample.plist")) { - Files.copy(Objects.requireNonNull(is), resultsDirectory.resolve("sample.plist")); - } + copyResource(resultsDirectory, "sample.plist", "sample.plist"); + + readResults(resultsDirectory); - new XcTestPlugin().readResults(configuration, visitor, resultsDirectory); + final List results = captureTestResults(14); - verify(visitor, times(14)) - .visitTestResult(any(TestResult.class)); + assertThat(results) + .hasSize(14); } + /** + * Verifies XCTest start and stop times are converted into Allure time fields. + * The test checks the exact parsed time window for each sample test. + */ + @Description @Test void shouldSetTestStartAndStop() throws Exception { - try (InputStream is = getClass().getClassLoader().getResourceAsStream("sample.plist")) { - Files.copy(Objects.requireNonNull(is), resultsDirectory.resolve("sample.plist")); - } + copyResource(resultsDirectory, "sample.plist", "sample.plist"); - new XcTestPlugin().readResults(configuration, visitor, resultsDirectory); + readResults(resultsDirectory); - final ArgumentCaptor captor = ArgumentCaptor.forClass(TestResult.class); - verify(visitor, times(14)) - .visitTestResult(captor.capture()); + final List results = captureTestResults(14); - assertThat(captor.getAllValues()) + assertThat(results) .extracting(TestResult::getName, TestResult::getTime) .contains( tuple("test_C1433()", Time.create(1494595000L, 1494626548L)), @@ -99,124 +111,226 @@ void shouldSetTestStartAndStop() throws Exception { ); } + /** + * Verifies XCTest screenshot references are sent to the attachment visitor. + * The test checks the screenshot file from the plist is visited once. + */ + @Description @Test public void shouldParseHasScreenShotData() throws Exception { - try (InputStream is = getClass().getClassLoader().getResourceAsStream("has-screenshot-data.plist")) { - Files.copy(Objects.requireNonNull(is), resultsDirectory.resolve("sample.plist")); - } + copyResource(resultsDirectory, "has-screenshot-data.plist", "sample.plist"); final Path attachments = resultsDirectory.resolve("Attachments"); Files.createDirectories(attachments); - final Path screenshot = attachments.resolve("Screenshot_92D015E5-965D-4171-849C-35CC0945FEA2.png"); - try (InputStream is = getClass().getClassLoader().getResourceAsStream("screenshot.png")) { - Files.copy(Objects.requireNonNull(is), screenshot); - } + final Path screenshot = copyResource(attachments, "screenshot.png", + "Screenshot_92D015E5-965D-4171-849C-35CC0945FEA2.png"); - new XcTestPlugin().readResults(configuration, visitor, resultsDirectory); + readResults(resultsDirectory); - verify(visitor, times(1)) - .visitAttachmentFile(screenshot); + assertThat(captureAttachmentFiles(1)) + .containsExactly(screenshot); } + /** + * Verifies XCTest screenshots resolve when the results path is relative. + * The test checks the normalized screenshot file is visited once. + */ + @Description @Test public void shouldAddScreenshotsWhenRelativeResultsDir() throws Exception { final Path xctestResults = resultsDirectory.resolve("xctest-results"); Files.createDirectories(xctestResults); - try (InputStream is = getClass().getClassLoader().getResourceAsStream("has-screenshot-data.plist")) { - Files.copy(Objects.requireNonNull(is), xctestResults.resolve("sample.plist")); - } + copyResource(xctestResults, "has-screenshot-data.plist", "sample.plist"); final Path attachments = xctestResults.resolve("Attachments"); Files.createDirectories(attachments); - final Path screenshot = attachments.resolve("Screenshot_92D015E5-965D-4171-849C-35CC0945FEA2.png"); - try (InputStream is = getClass().getClassLoader().getResourceAsStream("screenshot.png")) { - Files.copy(Objects.requireNonNull(is), screenshot); - } + final Path screenshot = copyResource(attachments, "screenshot.png", + "Screenshot_92D015E5-965D-4171-849C-35CC0945FEA2.png"); final Path relative = xctestResults.resolve("..").resolve("xctest-results"); - new XcTestPlugin().readResults(configuration, visitor, relative); + readResults(relative); - verify(visitor, times(1)) - .visitAttachmentFile(screenshot); + assertThat(captureAttachmentFiles(1)) + .containsExactly(screenshot); } + /** + * Verifies screenshot paths cannot escape the XCTest results directory. + * The test checks a traversal plist does not send any attachment file to the visitor. + */ + @Description @Test public void shouldNotAllowPathTraversalForScreenshots() throws Exception { - try (InputStream is = getClass().getClassLoader().getResourceAsStream("has-screenshot-data-path-traversal.plist")) { - Files.copy(Objects.requireNonNull(is), resultsDirectory.resolve("sample.plist")); - } + copyResource(resultsDirectory, "has-screenshot-data-path-traversal.plist", "sample.plist"); final Path attachments = resultsDirectory.resolve("Attachments"); Files.createDirectories(attachments); Files.createDirectories(attachments.resolve("Screenshot_")); - final Path secretFile = resultsDirectory.resolve("secret-file.png"); - try (InputStream is = getClass().getClassLoader().getResourceAsStream("screenshot.png")) { - Files.copy(Objects.requireNonNull(is), secretFile); - } + copyResource(resultsDirectory, "screenshot.png", "secret-file.png"); - new XcTestPlugin().readResults(configuration, visitor, resultsDirectory); + readResults(resultsDirectory); - verify(visitor, times(0)) - .visitAttachmentFile(any()); + verifyNoAttachmentFiles(); } + /** + * Verifies generic attachment paths cannot escape the XCTest results directory. + * The test checks a traversal plist does not visit the secret attachment file. + */ + @Description @Test public void shouldNotAllowPathTraversalForAttachments() throws Exception { - try (InputStream is = getClass().getClassLoader().getResourceAsStream("attachments-data-path-traversal.plist")) { - Files.copy(Objects.requireNonNull(is), resultsDirectory.resolve("sample.plist")); - } + copyResource(resultsDirectory, "attachments-data-path-traversal.plist", "sample.plist"); final Path attachments = resultsDirectory.resolve("Attachments"); Files.createDirectories(attachments); - final Path secretFile = resultsDirectory.resolve("secret-file.png"); - try (InputStream is = getClass().getClassLoader().getResourceAsStream("screenshot.png")) { - Files.copy(Objects.requireNonNull(is), secretFile); - } + final Path secretFile = copyResource(resultsDirectory, "screenshot.png", "secret-file.png"); - new XcTestPlugin().readResults(configuration, visitor, resultsDirectory); + readResults(resultsDirectory); - verify(visitor, times(0)) - .visitAttachmentFile(secretFile); + verifyAttachmentFileWasNotVisited(secretFile); } + /** + * Verifies XCTest attachment records are sent to the attachment visitor. + * The test checks the referenced JPEG file is visited once. + */ + @Description @Test public void shouldParseAttachmentsData() throws Exception { - try (InputStream is = getClass().getClassLoader().getResourceAsStream("attachments-data.plist")) { - Files.copy(Objects.requireNonNull(is), resultsDirectory.resolve("sample.plist")); - } + copyResource(resultsDirectory, "attachments-data.plist", "sample.plist"); final Path attachments = resultsDirectory.resolve("Attachments"); Files.createDirectories(attachments); - final Path screenshot = attachments.resolve("Screenshot_1_1FBB627A-3D11-41E3-B4E6-5C717C75F175.jpeg"); - try (InputStream is = getClass().getClassLoader().getResourceAsStream("screenshot.png")) { - Files.copy(Objects.requireNonNull(is), screenshot); - } + final Path screenshot = copyResource(attachments, "screenshot.png", + "Screenshot_1_1FBB627A-3D11-41E3-B4E6-5C717C75F175.jpeg"); - new XcTestPlugin().readResults(configuration, visitor, resultsDirectory); + readResults(resultsDirectory); - verify(visitor, times(1)) - .visitAttachmentFile(screenshot); + assertThat(captureAttachmentFiles(1)) + .containsExactly(screenshot); } + /** + * Verifies XCTest attachment records resolve when the results path is relative. + * The test checks the normalized JPEG file is visited once. + */ + @Description @Test public void shouldAllureAttachmentsDataWithRelativeResultsDir() throws Exception { final Path xctestResults = resultsDirectory.resolve("xctest-results"); Files.createDirectories(xctestResults); - try (InputStream is = getClass().getClassLoader().getResourceAsStream("attachments-data.plist")) { - Files.copy(Objects.requireNonNull(is), xctestResults.resolve("sample.plist")); - } + copyResource(xctestResults, "attachments-data.plist", "sample.plist"); final Path attachments = xctestResults.resolve("Attachments"); Files.createDirectories(attachments); - final Path screenshot = attachments.resolve("Screenshot_1_1FBB627A-3D11-41E3-B4E6-5C717C75F175.jpeg"); - try (InputStream is = getClass().getClassLoader().getResourceAsStream("screenshot.png")) { - Files.copy(Objects.requireNonNull(is), screenshot); - } + final Path screenshot = copyResource(attachments, "screenshot.png", + "Screenshot_1_1FBB627A-3D11-41E3-B4E6-5C717C75F175.jpeg"); final Path relative = xctestResults.resolve("..").resolve("xctest-results"); - new XcTestPlugin().readResults(configuration, visitor, relative); + readResults(relative); + + assertThat(captureAttachmentFiles(1)) + .containsExactly(screenshot); + } + + private void readResults(final Path directory) { + Allure.step("Read XCTest results", () -> new XcTestPlugin().readResults(configuration, visitor, directory)); + } + + private Path copyResource(final Path directory, final String resourceName, final String fileName) throws IOException { + return Allure.step("Copy fixture resource " + resourceName + " as " + fileName, () -> { + final byte[] content; + try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { + content = Objects.requireNonNull(is).readAllBytes(); + } + final Path destination = directory.resolve(fileName); + Files.createDirectories(destination.getParent()); + Files.write(destination, content); + attachResourceContent(fileName, content); + return destination; + }); + } + + private List captureTestResults(final int expectedCount) { + return Allure.step("Capture parsed XCTest test results", () -> { + final ArgumentCaptor captor = ArgumentCaptor.forClass(TestResult.class); + verify(visitor, times(expectedCount)).visitTestResult(captor.capture()); + final List results = captor.getAllValues(); + Allure.addAttachment("parsed-test-results.txt", "text/plain", describeTestResults(results)); + return results; + }); + } + + private List captureAttachmentFiles(final int expectedCount) { + return Allure.step("Capture visited XCTest attachment files", () -> { + final ArgumentCaptor captor = ArgumentCaptor.forClass(Path.class); + verify(visitor, times(expectedCount)).visitAttachmentFile(captor.capture()); + final List attachments = captor.getAllValues(); + Allure.addAttachment("visited-attachments.txt", "text/plain", describeAttachments(attachments)); + return attachments; + }); + } + + private void verifyNoAttachmentFiles() { + Allure.step("Verify no XCTest attachment files were visited", () -> { + verify(visitor, times(0)).visitAttachmentFile(any()); + Allure.addAttachment("visited-attachments.txt", "text/plain", "attachments=0"); + }); + } + + private void verifyAttachmentFileWasNotVisited(final Path attachment) { + Allure.step("Verify XCTest attachment file was not visited", () -> { + verify(visitor, times(0)).visitAttachmentFile(attachment); + Allure.addAttachment("visited-attachments.txt", "text/plain", + "attachments=0" + System.lineSeparator() + "blockedFile=" + attachment.getFileName()); + }); + } + + private void attachResourceContent(final String fileName, final byte[] content) { + if (fileName.endsWith(".plist")) { + Allure.addAttachment(fileName, "application/xml", new String(content, StandardCharsets.UTF_8), ".plist"); + return; + } + final String type = fileName.endsWith(".jpeg") ? "image/jpeg" : "image/png"; + final String extension = fileName.endsWith(".jpeg") ? ".jpeg" : ".png"; + Allure.addAttachment(fileName, type, new ByteArrayInputStream(content), extension); + } + + private String describeTestResults(final List results) { + final StringBuilder builder = new StringBuilder(); + builder.append("results=").append(results.size()).append(System.lineSeparator()); + results.forEach(result -> builder + .append(System.lineSeparator()) + .append("name=").append(result.getName()).append(System.lineSeparator()) + .append("time=").append(describeTime(result.getTime())).append(System.lineSeparator()) + ); + return builder.toString(); + } + + private String describeAttachments(final List attachments) { + final StringBuilder builder = new StringBuilder(); + builder.append("attachments=").append(attachments.size()).append(System.lineSeparator()); + attachments.forEach(attachment -> builder + .append(System.lineSeparator()) + .append("file=").append(attachment.getFileName()).append(System.lineSeparator()) + .append("size=").append(sizeOf(attachment)).append(System.lineSeparator()) + ); + return builder.toString(); + } - verify(visitor, times(1)) - .visitAttachmentFile(screenshot); + private long sizeOf(final Path attachment) { + try { + return Files.size(attachment); + } catch (IOException e) { + return -1L; + } + } + + private String describeTime(final Time time) { + if (time == null) { + return null; + } + return String.format("start=%s, stop=%s, duration=%s", time.getStart(), time.getStop(), time.getDuration()); } } diff --git a/plugins/xray-plugin/src/test/java/io/qameta/allure/xray/XrayTestRunExportPluginTest.java b/plugins/xray-plugin/src/test/java/io/qameta/allure/xray/XrayTestRunExportPluginTest.java index 0ac654705..1f6516498 100644 --- a/plugins/xray-plugin/src/test/java/io/qameta/allure/xray/XrayTestRunExportPluginTest.java +++ b/plugins/xray-plugin/src/test/java/io/qameta/allure/xray/XrayTestRunExportPluginTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.xray; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.InMemoryReportStorage; import io.qameta.allure.core.LaunchResults; @@ -22,19 +24,24 @@ import io.qameta.allure.entity.Link; import io.qameta.allure.entity.Status; import io.qameta.allure.entity.TestResult; +import io.qameta.allure.jira.JiraIssueComment; import io.qameta.allure.jira.JiraService; import io.qameta.allure.jira.XrayTestRun; import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; +import java.util.stream.Collectors; -import static org.mockito.ArgumentMatchers.argThat; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.tuple; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -47,6 +54,11 @@ class XrayTestRunExportPluginTest { private static final Integer TESTRUN_ID = 1; private static final int DEFAULT_PAGE = 1; + /** + * Verifies a failed Allure result updates the matching Xray test run to FAIL. + * The test checks the outgoing execution comment and status update payloads. + */ + @Description @Test void shouldExportTestRunToXray() { final LaunchResults launchResults = mock(LaunchResults.class); @@ -65,6 +77,8 @@ void shouldExportTestRunToXray() { when(service.getTestRunsForTestExecution(EXECUTION_ISSUES, DEFAULT_PAGE)).thenReturn( Collections.singletonList(new XrayTestRun().setId(TESTRUN_ID).setKey(TESTRUN_KEY).setStatus("TODO")) ); + attachXrayInput(EXECUTION_ISSUES, results, executorInfo, + Collections.singletonList(new XrayTestRun().setId(TESTRUN_ID).setKey(TESTRUN_KEY).setStatus("TODO"))); final XrayTestRunExportPlugin xrayTestRunExportPlugin = new XrayTestRunExportPlugin( true, @@ -80,13 +94,19 @@ void shouldExportTestRunToXray() { ); final String reportLink = String.format("[%s|%s]", executorInfo.getBuildName(), executorInfo.getReportUrl()); - verify(service, times(1)).createIssueComment( - argThat(issue -> issue.equals(EXECUTION_ISSUES)), - argThat(comment -> comment.getBody().contains(reportLink) - )); - verify(service, times(1)).updateTestRunStatus(TESTRUN_ID, "FAIL"); + assertThat(captureComments(service, 1)) + .extracting(CapturedComment::issue, CapturedComment::body) + .containsExactly(tuple(EXECUTION_ISSUES, "Execution updated from launch " + reportLink)); + assertThat(captureStatusUpdates(service, 1)) + .extracting(StatusUpdate::id, StatusUpdate::status) + .containsExactly(tuple(TESTRUN_ID, "FAIL")); } + /** + * Verifies Xray status precedence chooses FAIL when mixed statuses share a test-run key. + * The test checks only a FAIL status update is sent for the mixed launch. + */ + @Description @Test void shouldExportTestRunToXrayWithAllTypesOfStatues() { final LaunchResults launchResults = mock(LaunchResults.class); @@ -132,6 +152,8 @@ void shouldExportTestRunToXrayWithAllTypesOfStatues() { when(service.getTestRunsForTestExecution(EXECUTION_ISSUES, DEFAULT_PAGE)).thenReturn( Collections.singletonList(new XrayTestRun().setId(TESTRUN_ID).setKey(TESTRUN_KEY).setStatus("TODO")) ); + attachXrayInput(EXECUTION_ISSUES, results, executorInfo, + Collections.singletonList(new XrayTestRun().setId(TESTRUN_ID).setKey(TESTRUN_KEY).setStatus("TODO"))); final XrayTestRunExportPlugin xrayTestRunExportPlugin = new XrayTestRunExportPlugin( true, @@ -147,15 +169,19 @@ void shouldExportTestRunToXrayWithAllTypesOfStatues() { ); final String reportLink = String.format("[%s|%s]", executorInfo.getBuildName(), executorInfo.getReportUrl()); - verify(service, times(1)).createIssueComment( - argThat(issue -> issue.equals(EXECUTION_ISSUES)), - argThat(comment -> comment.getBody().contains(reportLink) - )); - verify(service, times(1)).updateTestRunStatus(TESTRUN_ID, "FAIL"); - verify(service, times(0)).updateTestRunStatus(TESTRUN_ID, "PASS"); - verify(service, times(0)).updateTestRunStatus(TESTRUN_ID, "TODO"); + assertThat(captureComments(service, 1)) + .extracting(CapturedComment::issue, CapturedComment::body) + .containsExactly(tuple(EXECUTION_ISSUES, "Execution updated from launch " + reportLink)); + assertThat(captureStatusUpdates(service, 1)) + .extracting(StatusUpdate::id, StatusUpdate::status) + .containsExactly(tuple(TESTRUN_ID, "FAIL")); } + /** + * Verifies matching Xray test runs are updated across multiple executions. + * The test checks all resolved test-run IDs receive PASS and each execution is commented. + */ + @Description @Test void shouldUpdateSimilarTestRunsInDifferentExecutions() { final String xrayExecutions = " ALLURE-2, ALLURE-4,ALLURE-6 "; @@ -187,6 +213,7 @@ void shouldUpdateSimilarTestRunsInDifferentExecutions() { when(service.getTestRunsForTestExecution("ALLURE-6", DEFAULT_PAGE)).thenReturn( Collections.singletonList(testRuns.get(2)) ); + attachXrayInput(xrayExecutions, results, executorInfo, testRuns); final XrayTestRunExportPlugin xrayTestRunExportPlugin = new XrayTestRunExportPlugin( true, @@ -202,13 +229,109 @@ void shouldUpdateSimilarTestRunsInDifferentExecutions() { ); final String reportLink = String.format("[%s|%s]", executorInfo.getBuildName(), executorInfo.getReportUrl()); - testRuns.forEach(testRun -> verify(service, times(1)).createIssueComment( - argThat(issue -> issue.equals(EXECUTION_ISSUES)), - argThat(comment -> comment.getBody().contains(reportLink) - ))); - testRuns.forEach(r -> verify(service, times(1)).updateTestRunStatus(r.getId(), "PASS")); + assertThat(captureComments(service, 3)) + .extracting(CapturedComment::issue, CapturedComment::body) + .containsExactlyInAnyOrder( + tuple("ALLURE-2", "Execution updated from launch " + reportLink), + tuple("ALLURE-4", "Execution updated from launch " + reportLink), + tuple("ALLURE-6", "Execution updated from launch " + reportLink) + ); + assertThat(captureStatusUpdates(service, 3)) + .extracting(StatusUpdate::id, StatusUpdate::status) + .containsExactlyInAnyOrder( + tuple(0, "PASS"), + tuple(1, "PASS"), + tuple(2, "PASS") + ); + } + + private void attachXrayInput(final String executionIssues, + final Set results, + final ExecutorInfo executorInfo, + final List testRuns) { + Allure.step("Attach Xray export input", () -> Allure.addAttachment("xray-export-input.txt", "text/plain", String.format( + "executionIssues=%s%nexecutorBuildName=%s%nexecutorReportUrl=%s%nresults:%n%s%ntestRuns:%n%s", + executionIssues, + executorInfo.getBuildName(), + executorInfo.getReportUrl(), + describeResults(results), + describeTestRuns(testRuns) + ))); + } + + private List captureComments(final JiraService service, final int expectedCount) { + return Allure.step("Capture Xray execution comments", () -> { + final ArgumentCaptor issueCaptor = ArgumentCaptor.forClass(String.class); + final ArgumentCaptor commentCaptor = ArgumentCaptor.forClass(JiraIssueComment.class); + verify(service, times(expectedCount)).createIssueComment(issueCaptor.capture(), commentCaptor.capture()); + final List comments = new ArrayList<>(); + for (int i = 0; i < issueCaptor.getAllValues().size(); i++) { + comments.add(new CapturedComment( + issueCaptor.getAllValues().get(i), + commentCaptor.getAllValues().get(i).getBody() + )); + } + Allure.addAttachment("xray-comments.txt", "text/plain", describeComments(comments)); + return comments; + }); } + private List captureStatusUpdates(final JiraService service, final int expectedCount) { + return Allure.step("Capture Xray status updates", () -> { + final ArgumentCaptor idCaptor = ArgumentCaptor.forClass(Integer.class); + final ArgumentCaptor statusCaptor = ArgumentCaptor.forClass(String.class); + verify(service, times(expectedCount)).updateTestRunStatus(idCaptor.capture(), statusCaptor.capture()); + final List updates = new ArrayList<>(); + for (int i = 0; i < idCaptor.getAllValues().size(); i++) { + updates.add(new StatusUpdate(idCaptor.getAllValues().get(i), statusCaptor.getAllValues().get(i))); + } + Allure.addAttachment("xray-status-updates.txt", "text/plain", describeStatusUpdates(updates)); + return updates; + }); + } + + private String describeResults(final Set results) { + return results.stream() + .map(result -> String.format( + "uid=%s, name=%s, status=%s, links=%s", + result.getUid(), + result.getName(), + result.getStatus(), + describeLinks(result.getLinks()) + )) + .sorted() + .collect(Collectors.joining(System.lineSeparator())); + } + + private String describeLinks(final List links) { + return links.stream() + .map(link -> link.getType() + ":" + link.getName()) + .sorted() + .collect(Collectors.joining(", ")); + } + + private String describeTestRuns(final List testRuns) { + return testRuns.stream() + .map(testRun -> String.format( + "id=%s, key=%s, status=%s", + testRun.getId(), + testRun.getKey(), + testRun.getStatus() + )) + .collect(Collectors.joining(System.lineSeparator())); + } + + private String describeComments(final List comments) { + return comments.stream() + .map(comment -> String.format("issue=%s, body=%s", comment.issue, comment.body)) + .collect(Collectors.joining(System.lineSeparator())); + } + + private String describeStatusUpdates(final List updates) { + return updates.stream() + .map(update -> String.format("id=%s, status=%s", update.id, update.status)) + .collect(Collectors.joining(System.lineSeparator())); + } static TestResult createTestResult(final Status status) { return new TestResult() @@ -217,4 +340,39 @@ static TestResult createTestResult(final Status status) { .setStatus(status); } + private static final class CapturedComment { + private final String issue; + private final String body; + + private CapturedComment(final String issue, final String body) { + this.issue = issue; + this.body = body; + } + + private String issue() { + return issue; + } + + private String body() { + return body; + } + } + + private static final class StatusUpdate { + private final Integer id; + private final String status; + + private StatusUpdate(final Integer id, final String status) { + this.id = id; + this.status = status; + } + + private Integer id() { + return id; + } + + private String status() { + return status; + } + } } diff --git a/plugins/xunit-xml-plugin/src/test/java/io/qameta/allure/xunitxml/XunitXmlPluginTest.java b/plugins/xunit-xml-plugin/src/test/java/io/qameta/allure/xunitxml/XunitXmlPluginTest.java index 228d2b1be..fcbf97a16 100644 --- a/plugins/xunit-xml-plugin/src/test/java/io/qameta/allure/xunitxml/XunitXmlPluginTest.java +++ b/plugins/xunit-xml-plugin/src/test/java/io/qameta/allure/xunitxml/XunitXmlPluginTest.java @@ -15,6 +15,8 @@ */ package io.qameta.allure.xunitxml; +import io.qameta.allure.Allure; +import io.qameta.allure.Description; import io.qameta.allure.context.RandomUidContext; import io.qameta.allure.core.Configuration; import io.qameta.allure.core.ResultsVisitor; @@ -39,7 +41,9 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.Iterator; +import java.util.List; import java.util.Objects; +import java.util.stream.Collectors; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -65,6 +69,11 @@ void setUp(@TempDir final Path resultsDirectory) { this.resultsDirectory = resultsDirectory; } + /** + * Verifies an xUnit XML test case is converted into an Allure result. + * The test checks parsed name, history identifier, and passed status. + */ + @Description @Test void shouldCreateTest() throws Exception { process( @@ -72,10 +81,9 @@ void shouldCreateTest() throws Exception { "passed-test.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .hasSize(1) .extracting(TestResult::getName, TestResult::getHistoryId, TestResult::getStatus) .containsExactlyInAnyOrder( @@ -83,6 +91,11 @@ void shouldCreateTest() throws Exception { ); } + /** + * Verifies xUnit execution duration is preserved in the Allure result. + * The test checks the parsed duration field for a fixture with a known runtime. + */ + @Description @Test void shouldSetTime() throws Exception { process( @@ -90,16 +103,20 @@ void shouldSetTime() throws Exception { "passed-test.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .hasSize(1) .extracting(TestResult::getTime) .extracting(Time::getDuration) .containsExactlyInAnyOrder(44L); } + /** + * Verifies suite, package, class, and format labels are derived from xUnit data. + * The test checks the exact labels emitted for a simple passed fixture. + */ + @Description @Test void shouldSetLabels() throws Exception { process( @@ -107,10 +124,9 @@ void shouldSetLabels() throws Exception { "passed-test.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .hasSize(1) .flatExtracting(TestResult::getLabels) .extracting(Label::getName, Label::getValue) @@ -122,6 +138,11 @@ void shouldSetLabels() throws Exception { ); } + /** + * Verifies the xUnit full name is mapped into the Allure result. + * The test checks the parsed full name for a representative fixture. + */ + @Description @Test void shouldSetFullName() throws Exception { process( @@ -129,10 +150,9 @@ void shouldSetFullName() throws Exception { "passed-test.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .hasSize(1) .extracting(TestResult::getFullName) .containsExactlyInAnyOrder( @@ -140,6 +160,11 @@ void shouldSetFullName() throws Exception { ); } + /** + * Verifies an xUnit framework attribute is emitted as an Allure framework label. + * The test checks the focused framework label value parsed from the fixture. + */ + @Description @Test void shouldSetFramework() throws Exception { process( @@ -147,10 +172,9 @@ void shouldSetFramework() throws Exception { "passed-test.xml" ); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .hasSize(1) .flatExtracting(TestResult::getLabels) .filteredOn(label -> label.getName().equals(LabelName.FRAMEWORK.value())) @@ -167,6 +191,11 @@ static Stream data() { ); } + /** + * Verifies xUnit status details are parsed for failed and passed cases. + * The test checks message and trace fields for each fixture variant. + */ + @Description @ParameterizedTest @MethodSource("data") void shouldSetStatusDetails(final String resource, @@ -175,10 +204,9 @@ void shouldSetStatusDetails(final String resource, final String trace) throws Exception { process(resource, fileName); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - assertThat(captor.getAllValues()) + assertThat(results) .hasSize(1) .extracting(TestResult::getStatusMessage, TestResult::getStatusTrace) .containsExactlyInAnyOrder( @@ -186,6 +214,11 @@ void shouldSetStatusDetails(final String resource, ); } + /** + * Verifies external XML entities are not resolved while parsing xUnit results. + * The test checks parsed status details do not leak content from a local secret file. + */ + @Description @Test void cveEntityReadTest(@TempDir final Path tmp) throws IOException { final Path secretFile = tmp.resolve("secretfile.ini"); @@ -196,9 +229,7 @@ void cveEntityReadTest(@TempDir final Path tmp) throws IOException { StandardCharsets.UTF_8 ); - Files.writeString( - resultsDirectory.resolve("bad-test.xml"), - "\n" + final String maliciousXml = "\n" + "\n" + "]>\n" @@ -213,38 +244,91 @@ void cveEntityReadTest(@TempDir final Path tmp) throws IOException { + " \n" + " \n" + " \n" - + "", - StandardCharsets.UTF_8 - ); - - XunitXmlPlugin reader = new XunitXmlPlugin(); + + ""; + writeTextFile(resultsDirectory.resolve("bad-test.xml"), "bad-test.xml", maliciousXml); - reader.readResults(configuration, visitor, resultsDirectory); + readResults(resultsDirectory); - final ArgumentCaptor captor = ArgumentCaptor.captor(); - verify(visitor, times(1)).visitTestResult(captor.capture()); + final List results = captureTestResults(1); - final TestResult testResult = captor.getValue(); + final TestResult testResult = results.get(0); assertThat(testResult.getStatusMessage()).doesNotContain("John Doe"); assertThat(testResult.getStatusTrace()).doesNotContain("Example Org"); } - private void process(String... strings) throws IOException { - Iterator iterator = Arrays.asList(strings).iterator(); - while (iterator.hasNext()) { - String first = iterator.next(); - String second = iterator.next(); - copyFile(resultsDirectory, first, second); - } - XunitXmlPlugin reader = new XunitXmlPlugin(); + private void process(final String... strings) throws IOException { + Allure.step("Parse xUnit XML results", () -> { + final Iterator iterator = Arrays.asList(strings).iterator(); + while (iterator.hasNext()) { + final String first = iterator.next(); + final String second = iterator.next(); + copyFile(resultsDirectory, first, second); + } + readResults(resultsDirectory); + }); + } + + private void readResults(final Path directory) { + Allure.step("Read xUnit XML results", () -> { + final XunitXmlPlugin reader = new XunitXmlPlugin(); + reader.readResults(configuration, visitor, directory); + }); + } + + private void copyFile(final Path dir, final String resourceName, final String fileName) throws IOException { + Allure.step("Copy fixture resource " + resourceName + " as " + fileName, () -> { + final byte[] content; + try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { + content = Objects.requireNonNull(is).readAllBytes(); + } + final Path destination = dir.resolve(fileName); + Files.createDirectories(destination.getParent()); + Files.write(destination, content); + Allure.addAttachment(fileName, "application/xml", new String(content, StandardCharsets.UTF_8), ".xml"); + }); + } + + private void writeTextFile(final Path path, final String fileName, final String content) throws IOException { + Allure.step("Write text fixture " + fileName, () -> { + Files.writeString(path, content, StandardCharsets.UTF_8); + Allure.addAttachment(fileName, "application/xml", content, ".xml"); + }); + } - reader.readResults(configuration, visitor, resultsDirectory); + private List captureTestResults(final int expectedCount) { + return Allure.step("Capture parsed xUnit test results", () -> { + final ArgumentCaptor captor = ArgumentCaptor.captor(); + verify(visitor, times(expectedCount)).visitTestResult(captor.capture()); + final List results = captor.getAllValues(); + Allure.addAttachment("parsed-test-results.txt", "text/plain", describeTestResults(results)); + return results; + }); } - private void copyFile(Path dir, String resourceName, String fileName) throws IOException { - try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourceName)) { - Files.copy(Objects.requireNonNull(is), dir.resolve(fileName)); - } + private String describeTestResults(final List results) { + final StringBuilder builder = new StringBuilder(); + builder.append("results=").append(results.size()).append(System.lineSeparator()); + results.forEach(result -> builder + .append(System.lineSeparator()) + .append("name=").append(result.getName()).append(System.lineSeparator()) + .append("fullName=").append(result.getFullName()).append(System.lineSeparator()) + .append("historyId=").append(result.getHistoryId()).append(System.lineSeparator()) + .append("status=").append(result.getStatus()).append(System.lineSeparator()) + .append("duration=") + .append(result.getTime() == null ? null : result.getTime().getDuration()) + .append(System.lineSeparator()) + .append("statusMessage=").append(result.getStatusMessage()).append(System.lineSeparator()) + .append("statusTrace=").append(result.getStatusTrace()).append(System.lineSeparator()) + .append("labels=").append(describeLabels(result)) + .append(System.lineSeparator()) + ); + return builder.toString(); } + private String describeLabels(final TestResult result) { + return result.getLabels().stream() + .map(label -> label.getName() + "=" + label.getValue()) + .sorted() + .collect(Collectors.joining(", ")); + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index ee75b7a42..e2dd19893 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -57,6 +57,7 @@ pluginManagement { id("com.gorylenko.gradle-git-properties") version "2.5.7" id("com.netflix.nebula.ospackage") version "12.3.0" id("io.github.gradle-nexus.publish-plugin") version "2.0.0" + id("io.qameta.allure") version "4.0.0" id("io.spring.dependency-management") version "1.1.7" id("org.owasp.dependencycheck") version "12.2.0" id("com.github.spotbugs") version "6.5.0"