From a6c243630e783c9661b1bc7acd660c847ff08f55 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 18 Aug 2025 19:18:18 +0200 Subject: [PATCH 01/21] Use a message based TeamCityPlugin --- .../cucumber/core/plugin/TeamCityPlugin.java | 726 +++++++++++------- .../core/plugin/TeamCityPluginTest.java | 34 +- .../TeamCityPluginTestStepDefinition.java | 30 + 3 files changed, 496 insertions(+), 294 deletions(-) create mode 100644 cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTestStepDefinition.java diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index cc2f997a39..24d32eb422 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -1,29 +1,39 @@ package io.cucumber.core.plugin; +import io.cucumber.messages.Convertor; +import io.cucumber.messages.types.Attachment; +import io.cucumber.messages.types.Envelope; +import io.cucumber.messages.types.Examples; +import io.cucumber.messages.types.Exception; +import io.cucumber.messages.types.Feature; +import io.cucumber.messages.types.GherkinDocument; +import io.cucumber.messages.types.Hook; +import io.cucumber.messages.types.JavaMethod; +import io.cucumber.messages.types.JavaStackTraceElement; +import io.cucumber.messages.types.Location; +import io.cucumber.messages.types.Pickle; +import io.cucumber.messages.types.PickleStep; +import io.cucumber.messages.types.Rule; +import io.cucumber.messages.types.Scenario; +import io.cucumber.messages.types.SourceReference; +import io.cucumber.messages.types.TableRow; +import io.cucumber.messages.types.TestCaseFinished; +import io.cucumber.messages.types.TestCaseStarted; +import io.cucumber.messages.types.TestRunStarted; +import io.cucumber.messages.types.TestStep; +import io.cucumber.messages.types.TestStepFinished; +import io.cucumber.messages.types.TestStepResult; +import io.cucumber.messages.types.TestStepResultStatus; +import io.cucumber.messages.types.TestStepStarted; +import io.cucumber.messages.types.Timestamp; import io.cucumber.plugin.EventListener; -import io.cucumber.plugin.event.EmbedEvent; -import io.cucumber.plugin.event.Event; import io.cucumber.plugin.event.EventPublisher; -import io.cucumber.plugin.event.HookTestStep; -import io.cucumber.plugin.event.HookType; -import io.cucumber.plugin.event.Location; -import io.cucumber.plugin.event.Node; -import io.cucumber.plugin.event.PickleStepTestStep; -import io.cucumber.plugin.event.Result; import io.cucumber.plugin.event.SnippetsSuggestedEvent; import io.cucumber.plugin.event.SnippetsSuggestedEvent.Suggestion; -import io.cucumber.plugin.event.Status; -import io.cucumber.plugin.event.TestCase; -import io.cucumber.plugin.event.TestCaseFinished; -import io.cucumber.plugin.event.TestCaseStarted; -import io.cucumber.plugin.event.TestRunFinished; -import io.cucumber.plugin.event.TestRunStarted; -import io.cucumber.plugin.event.TestSourceParsed; -import io.cucumber.plugin.event.TestStep; -import io.cucumber.plugin.event.TestStepFinished; -import io.cucumber.plugin.event.TestStepStarted; -import io.cucumber.plugin.event.WriteEvent; +import io.cucumber.query.LineageReducer; +import io.cucumber.query.Query; +import java.io.Closeable; import java.io.PrintStream; import java.net.URI; import java.time.ZoneOffset; @@ -32,23 +42,18 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Locale; -import java.util.Map; +import java.util.Objects; import java.util.Optional; -import java.util.function.BiFunction; -import java.util.function.Function; -import java.util.function.Predicate; -import java.util.function.Supplier; import java.util.regex.Matcher; import java.util.regex.Pattern; -import java.util.stream.Collectors; -import static io.cucumber.core.exception.ExceptionUtils.printStackTrace; +import static io.cucumber.messages.Convertor.toDuration; +import static io.cucumber.query.LineageReducer.descending; import static java.util.Collections.emptyList; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.joining; +import static java.util.stream.Collectors.toList; /** * Outputs Teamcity services messages to std out. @@ -104,32 +109,13 @@ public class TeamCityPlugin implements EventListener { private static final String TEMPLATE_ATTACH_WRITE_EVENT = TEAMCITY_PREFIX + "[message text='%s' status='NORMAL']"; - private static final Pattern ANNOTATION_GLUE_CODE_LOCATION_PATTERN = Pattern.compile("^(.*)\\.(.*)\\([^:]*\\)"); - private static final Pattern LAMBDA_GLUE_CODE_LOCATION_PATTERN = Pattern.compile("^(.*)\\.(.*)\\(.*:.*\\)"); - - private static final Pattern[] COMPARE_PATTERNS = new Pattern[] { - // Hamcrest 2 MatcherAssert.assertThat - Pattern.compile("expected: (.*)(?:\r\n|\r|\n) {5}but: was (.*)$", - Pattern.DOTALL | Pattern.CASE_INSENSITIVE), - // AssertJ 3 ShouldBeEqual.smartErrorMessage - Pattern.compile("expected: (.*)(?:\r\n|\r|\n) but was: (.*)$", - Pattern.DOTALL | Pattern.CASE_INSENSITIVE), - // JUnit 5 AssertionFailureBuilder - Pattern.compile("expected: <(.*)> but was: <(.*)>$", - Pattern.DOTALL | Pattern.CASE_INSENSITIVE), - // JUnit 4 Assert.assertEquals - Pattern.compile("expected:\\s?<(.*)> but was:\\s?<(.*)>$", - Pattern.DOTALL | Pattern.CASE_INSENSITIVE), - // TestNG 7 Assert.assertEquals - Pattern.compile("expected \\[(.*)] but found \\[(.*)]\n$", - Pattern.DOTALL | Pattern.CASE_INSENSITIVE), - }; - - private final PrintStream out; + private final LineageReducer> pathCollector = descending(PathCollector::new); + private final Query query = new Query(); private final List suggestions = new ArrayList<>(); - private final Map> parsedTestSources = new HashMap<>(); - private List currentStack = new ArrayList<>(); - private TestCase currentTestCase; + private final TeamCityCommandWriter out; + + private List currentPath = new ArrayList<>(); + private Pickle currentPickle; @SuppressWarnings("unused") // Used by PluginFactory public TeamCityPlugin() { @@ -141,82 +127,68 @@ public TeamCityPlugin() { } TeamCityPlugin(PrintStream out) { - this.out = out; + this.out = new TeamCityCommandWriter(out); } @Override public void setEventPublisher(EventPublisher publisher) { - publisher.registerHandlerFor(TestRunStarted.class, this::printTestRunStarted); - publisher.registerHandlerFor(TestCaseStarted.class, this::printTestCaseStarted); - publisher.registerHandlerFor(TestStepStarted.class, this::printTestStepStarted); - publisher.registerHandlerFor(TestStepFinished.class, this::printTestStepFinished); - publisher.registerHandlerFor(TestCaseFinished.class, this::printTestCaseFinished); - publisher.registerHandlerFor(TestRunFinished.class, this::printTestRunFinished); + publisher.registerHandlerFor(Envelope.class, event -> { + query.update(event); + event.getTestRunStarted().ifPresent(this::printTestRunStarted); + event.getTestCaseStarted().ifPresent(this::printTestCaseStarted); + event.getTestStepStarted().ifPresent(this::printTestStepStarted); + event.getTestStepFinished().ifPresent(this::printTestStepFinished); + event.getTestCaseFinished().ifPresent(this::printTestCaseFinished); + event.getTestRunFinished().ifPresent(this::printTestRunFinished); + event.getAttachment().ifPresent(this::handleEmbedEvent); + }); + // TODO: Replace with messages publisher.registerHandlerFor(SnippetsSuggestedEvent.class, this::handleSnippetSuggested); - publisher.registerHandlerFor(EmbedEvent.class, this::handleEmbedEvent); - publisher.registerHandlerFor(WriteEvent.class, this::handleWriteEvent); - publisher.registerHandlerFor(TestSourceParsed.class, this::handleTestSourceParsed); - } - - private void handleTestSourceParsed(TestSourceParsed event) { - parsedTestSources.put(event.getUri(), event.getNodes()); } private void printTestRunStarted(TestRunStarted event) { - String timestamp = extractTimeStamp(event); - print(TEMPLATE_ENTER_THE_MATRIX, timestamp); - print(TEMPLATE_TEST_RUN_STARTED, timestamp); - print(TEMPLATE_PROGRESS_COUNTING_STARTED, timestamp); - } - - private String extractTimeStamp(Event event) { - ZonedDateTime date = event.getInstant().atZone(ZoneOffset.UTC); - return DATE_FORMAT.format(date); + String timestamp = formatTimeStamp(event.getTimestamp()); + out.print(TEMPLATE_ENTER_THE_MATRIX, timestamp); + out.print(TEMPLATE_TEST_RUN_STARTED, timestamp); + out.print(TEMPLATE_PROGRESS_COUNTING_STARTED, timestamp); } private void printTestCaseStarted(TestCaseStarted event) { - TestCase testCase = event.getTestCase(); - URI uri = testCase.getUri(); - String timestamp = extractTimeStamp(event); - - Location location = testCase.getLocation(); - Predicate withLocation = candidate -> location.equals(candidate.getLocation()); - List path = parsedTestSources.get(uri) - .stream() - .map(node -> node.findPathTo(withLocation)) - .filter(Optional::isPresent) - .map(Optional::get) - .findFirst() - .orElse(emptyList()); - - poppedNodes(path).forEach(node -> finishNode(timestamp, node)); - pushedNodes(path).forEach(node -> startNode(uri, timestamp, node)); - this.currentStack = path; - this.currentTestCase = testCase; + query.findPickleBy(event) + .ifPresent(pickle -> findPathTo(pickle) + .ifPresent(path -> { + String timestamp = formatTimeStamp(event.getTimestamp()); + poppedNodes(path).forEach(node -> finishNode(timestamp, node)); + pushedNodes(path).forEach(node -> startNode(timestamp, node)); + this.currentPath = path; + this.currentPickle = pickle; + out.print(TEMPLATE_PROGRESS_TEST_STARTED, timestamp); + })); + } - print(TEMPLATE_PROGRESS_TEST_STARTED, timestamp); + private Optional> findPathTo(Pickle pickle) { + return query.findLineageBy(pickle) + .map(lineage -> pathCollector.reduce(lineage, pickle)); } - private void startNode(URI uri, String timestamp, Node node) { - Supplier keyword = () -> node.getKeyword().orElse("Unknown"); - String name = node.getName().orElseGet(keyword); - String location = uri + ":" + node.getLocation().getLine(); - print(TEMPLATE_TEST_SUITE_STARTED, timestamp, location, name); + private void startNode(String timestamp, TreeNode node) { + String name = node.getName(); + String location = node.getUri() + ":" + node.getLocation().getLine(); + out.print(TEMPLATE_TEST_SUITE_STARTED, timestamp, location, name); } - private void finishNode(String timestamp, Node node) { - Supplier keyword = () -> node.getKeyword().orElse("Unknown"); - String name = node.getName().orElseGet(keyword); - print(TEMPLATE_TEST_SUITE_FINISHED, timestamp, name); + private void finishNode(String timestamp, TreeNode node) { + String name = node.getName(); + out.print(TEMPLATE_TEST_SUITE_FINISHED, timestamp, name); } - private List poppedNodes(List newStack) { - List nodes = new ArrayList<>(reversedPoppedNodes(currentStack, newStack)); + private List poppedNodes(List newStack) { + List nodes = new ArrayList<>(reversedPoppedNodes(currentPath, newStack)); Collections.reverse(nodes); return nodes; } - private List reversedPoppedNodes(List currentStack, List newStack) { + private List reversedPoppedNodes(List currentStack, List newStack) { for (int i = 0; i < currentStack.size() && i < newStack.size(); i++) { if (!currentStack.get(i).equals(newStack.get(i))) { return currentStack.subList(i, currentStack.size()); @@ -228,161 +200,198 @@ private List reversedPoppedNodes(List currentStack, List newSt return emptyList(); } - private List pushedNodes(List newStack) { - for (int i = 0; i < currentStack.size() && i < newStack.size(); i++) { - if (!currentStack.get(i).equals(newStack.get(i))) { + private List pushedNodes(List newStack) { + for (int i = 0; i < currentPath.size() && i < newStack.size(); i++) { + if (!currentPath.get(i).equals(newStack.get(i))) { return newStack.subList(i, newStack.size()); } } - if (newStack.size() < currentStack.size()) { + if (newStack.size() < currentPath.size()) { return emptyList(); } - return newStack.subList(currentStack.size(), newStack.size()); + return newStack.subList(currentPath.size(), newStack.size()); } - private void printTestStepStarted(TestStepStarted event) { - String timestamp = extractTimeStamp(event); - String name = extractName(event.getTestStep()); - String location = extractLocation(event); - print(TEMPLATE_TEST_STARTED, timestamp, location, name); + private void printTestStepStarted(io.cucumber.messages.types.TestStepStarted event) { + String timestamp = formatTimeStamp(event.getTimestamp()); + query.findTestStepBy(event).ifPresent(testStep -> { + String name = formatTestStepName(testStep); + String location = findPickleTestStepLocation(event, testStep) + .orElseGet(() -> findHookStepLocation(testStep) + .orElse("")); + out.print(TEMPLATE_TEST_STARTED, timestamp, location, name); + }); } - private String extractLocation(TestStepStarted event) { - TestStep testStep = event.getTestStep(); - if (testStep instanceof PickleStepTestStep) { - PickleStepTestStep pickleStepTestStep = (PickleStepTestStep) testStep; - return pickleStepTestStep.getUri() + ":" + pickleStepTestStep.getStep().getLine(); - } - if (testStep instanceof HookTestStep) { - return formatHookStepLocation( - (HookTestStep) testStep, - javaTestLocationUri(), - TestStep::getCodeLocation); - } - return testStep.getCodeLocation(); + private Optional findPickleTestStepLocation(TestStepStarted testStepStarted, TestStep testStep) { + return query.findPickleStepBy(testStep) + .flatMap(query::findStepBy) + .flatMap(step -> query.findPickleBy(testStepStarted) + .map(pickle -> pickle.getUri() + ":" + step.getLocation().getLine())); } - private static BiFunction javaTestLocationUri() { - return (fqDeclaringClassName, classOrMethodName) -> String.format("java:test://%s/%s", fqDeclaringClassName, - classOrMethodName); + private Optional findHookStepLocation(TestStep testStep) { + return query.findHookBy(testStep) + .map(Hook::getSourceReference) + .map(TeamCityPlugin::formatSourceLocation); } - private String formatHookStepLocation( - HookTestStep hookTestStep, BiFunction hookStepCase, - Function defaultHookName - ) { - Matcher javaMatcher = ANNOTATION_GLUE_CODE_LOCATION_PATTERN.matcher(hookTestStep.getCodeLocation()); - if (javaMatcher.matches()) { - String fqDeclaringClassName = javaMatcher.group(1); - String methodName = javaMatcher.group(2); - return hookStepCase.apply(fqDeclaringClassName, methodName); - } - Matcher java8Matcher = LAMBDA_GLUE_CODE_LOCATION_PATTERN.matcher(hookTestStep.getCodeLocation()); - if (java8Matcher.matches()) { - String fqDeclaringClassName = java8Matcher.group(1); - String declaringClassName; - int indexOfPackageSeparator = fqDeclaringClassName.lastIndexOf("."); - if (indexOfPackageSeparator != -1) { - declaringClassName = fqDeclaringClassName.substring(indexOfPackageSeparator + 1); - } else { - declaringClassName = fqDeclaringClassName; - } - return hookStepCase.apply(fqDeclaringClassName, declaringClassName); - } - return defaultHookName.apply(hookTestStep); + private static String formatSourceLocation(SourceReference sourceReference) { + return sourceReference.getJavaMethod() + .map(TeamCityPlugin::formatJavaMethodLocation) + .orElseGet(() -> sourceReference.getJavaStackTraceElement() + .map(TeamCityPlugin::formatJavaStackTraceLocation) + .orElse("")); + } + + private static String formatJavaStackTraceLocation(JavaStackTraceElement javaStackTraceElement) { + String fqClassName = javaStackTraceElement.getClassName(); + String methodName = javaStackTraceElement.getMethodName(); + return createJavaTestUri(fqClassName, sanitizeMethodName(fqClassName, methodName)); + } + + private static String formatJavaMethodLocation(JavaMethod javaMethod) { + String fqClassName = javaMethod.getClassName(); + String methodName = javaMethod.getMethodName(); + return createJavaTestUri(fqClassName, methodName); + } + + private static String createJavaTestUri(String fqClassName, String methodName) { + // See: + // https://github.com/JetBrains/intellij-community/blob/master/java/execution/impl/src/com/intellij/execution/testframework/JavaTestLocator.java + return String.format("java:test://%s/%s", fqClassName, methodName); } private void printTestStepFinished(TestStepFinished event) { - String timeStamp = extractTimeStamp(event); - long duration = extractDuration(event.getResult()); - String name = extractName(event.getTestStep()); - - Throwable error = event.getResult().getError(); - Status status = event.getResult().getStatus(); - switch (status) { - case SKIPPED: { - String message = error == null ? "Step skipped" : error.getMessage(); - print(TEMPLATE_TEST_IGNORED, timeStamp, duration, message, name); - break; - } - case PENDING: { - String details = error == null ? "" : error.getMessage(); - print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step pending", details, name); - break; - } - case UNDEFINED: { - String snippets = getSnippets(currentTestCase); - print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step undefined", snippets, name); - break; - } - case AMBIGUOUS: - case FAILED: { - String details = printStackTrace(error); - String message = error.getMessage(); - if (message == null) { - print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step failed", details, name); + String timeStamp = formatTimeStamp(event.getTimestamp()); + TestStepResult testStepResult = event.getTestStepResult(); + long duration = toDuration(testStepResult.getDuration()).toMillis(); + + query.findTestStepBy(event).ifPresent(testStep -> { + String name = formatTestStepName(testStep); + + Optional error = testStepResult.getException(); + TestStepResultStatus status = testStepResult.getStatus(); + switch (status) { + case SKIPPED: { + String message = error.flatMap(Exception::getMessage).orElse("Step skipped"); + out.print(TEMPLATE_TEST_IGNORED, timeStamp, duration, message, name); + break; + } + case PENDING: { + String details = error.flatMap(Exception::getMessage).orElse(""); + out.print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step pending", details, name); + break; + } + case UNDEFINED: { + String snippets = findSnippets(currentPickle).orElse(""); + out.print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step undefined", snippets, name); break; } - ComparisonFailure comparisonFailure = ComparisonFailure.parse(message.trim()); - if (comparisonFailure == null) { - print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step failed", details, name); + case AMBIGUOUS: + case FAILED: { + String details = error.flatMap(Exception::getStackTrace).orElse(""); + String message = error.flatMap(Exception::getMessage).orElse(null); + if (message == null) { + out.print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step failed", details, name); + break; + } + ComparisonFailure comparisonFailure = ComparisonFailure.parse(message.trim()); + if (comparisonFailure == null) { + out.print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step failed", details, name); + break; + } + out.print(TEMPLATE_TEST_COMPARISON_FAILED, timeStamp, duration, "Step failed", details, + comparisonFailure.getExpected(), comparisonFailure.getActual(), name); break; } - print(TEMPLATE_TEST_COMPARISON_FAILED, timeStamp, duration, "Step failed", details, - comparisonFailure.getExpected(), comparisonFailure.getActual(), name); - break; + default: + break; } - default: - break; - } - print(TEMPLATE_TEST_FINISHED, timeStamp, duration, name); - } - - private String getHookName(HookTestStep hook) { - HookType hookType = hook.getHookType(); - switch (hookType) { - case BEFORE: - return "Before"; - case AFTER: - return "After"; - case BEFORE_STEP: - return "BeforeStep"; - case AFTER_STEP: - return "AfterStep"; - default: - return hookType.name().toLowerCase(Locale.US); - } + out.print(TEMPLATE_TEST_FINISHED, timeStamp, duration, name); + }); + } + + private String formatTestStepName(TestStep testStep) { + return query.findPickleStepBy(testStep) + .map(PickleStep::getText) + .orElseGet(() -> query.findHookBy(testStep) + .map(TeamCityPlugin::formatHookStepName) + .orElse("Unknown step")); } - private String extractName(TestStep testStep) { - if (testStep instanceof PickleStepTestStep) { - PickleStepTestStep pickleStepTestStep = (PickleStepTestStep) testStep; - return pickleStepTestStep.getStep().getText(); + private static String formatHookStepName(Hook hook) { + // TODO: Use hook name. + SourceReference sourceReference = hook.getSourceReference(); + return sourceReference.getJavaMethod() + .map(javaMethod -> formatJavaMethodName(hook, javaMethod)) + .orElseGet(() -> sourceReference.getJavaStackTraceElement() + .map(javaStackTraceElement -> formatJavaStackTraceName(hook, javaStackTraceElement)) + .orElse("Unknown")); + } + + private static String formatJavaStackTraceName(Hook hook, JavaStackTraceElement javaStackTraceElement) { + String methodName = javaStackTraceElement.getMethodName(); + String fqClassName = javaStackTraceElement.getClassName(); + String hookName = getHookName(hook); + String sanitizeMethodName = sanitizeMethodName(fqClassName, methodName); + return String.format("%s(%s)", hookName, sanitizeMethodName); + } + + private static String sanitizeMethodName(String fqClassName, String methodName) { + if (!methodName.equals("")) { + return methodName; } - if (testStep instanceof HookTestStep) { - HookTestStep hookTestStep = (HookTestStep) testStep; - return formatHookStepLocation( - hookTestStep, - hookNameFormat(hookTestStep), - this::getHookName); + // Replace constructor name, not recognized by IDEA. + int classNameIndex = fqClassName.lastIndexOf('.'); + if (classNameIndex > 0) { + return fqClassName.substring(classNameIndex + 1); } - return "Unknown step"; + return methodName; + } + + private static String formatJavaMethodName(Hook hook, JavaMethod javaMethod) { + String methodName = javaMethod.getMethodName(); + String hookName = getHookName(hook); + return String.format("%s(%s)", hookName, methodName); + } + + private static String getHookName(Hook hook) { + return hook.getType().map( + hookType -> { + switch (hookType) { + case BEFORE_TEST_RUN: + return "BeforeAll"; + case AFTER_TEST_RUN: + return "AfterAll"; + case BEFORE_TEST_CASE: + return "Before"; + case AFTER_TEST_CASE: + return "After"; + case BEFORE_TEST_STEP: + return "BeforeStep"; + case AFTER_TEST_STEP: + return "AfterStep"; + default: + return "Unknown"; + } + }).orElse("Unknown"); } - private BiFunction hookNameFormat(HookTestStep hookTestStep) { - return (fqDeclaringClassName, classOrMethodName) -> String.format("%s(%s)", getHookName(hookTestStep), - classOrMethodName); + private Optional findSnippets(Pickle pickle) { + return query.findLocationOf(pickle) + .map(location -> { + URI uri = URI.create(pickle.getUri()); + List suggestionForTestCase = suggestions.stream() + .filter(suggestion -> isSuggestionForPickleAt(suggestion, uri, location)) + .map(SnippetsSuggestedEvent::getSuggestion) + .collect(toList()); + return createMessage(suggestionForTestCase); + }); } - private String getSnippets(TestCase testCase) { - URI uri = testCase.getUri(); - Location location = testCase.getLocation(); - List suggestionForTestCase = suggestions.stream() - .filter(suggestion -> suggestion.getUri().equals(uri) && - suggestion.getTestCaseLocation().equals(location)) - .map(SnippetsSuggestedEvent::getSuggestion) - .collect(Collectors.toList()); - return createMessage(suggestionForTestCase); + private static boolean isSuggestionForPickleAt(SnippetsSuggestedEvent suggestion, URI uri, Location location) { + return suggestion.getUri().equals(uri) && suggestion.getTestCaseLocation().getLine() == location.getLine(); } private static String createMessage(Collection suggestions) { @@ -405,84 +414,121 @@ private static String createMessage(Collection suggestions) { } private void printTestCaseFinished(TestCaseFinished event) { - String timestamp = extractTimeStamp(event); - print(TEMPLATE_PROGRESS_TEST_FINISHED, timestamp); - finishNode(timestamp, currentStack.remove(currentStack.size() - 1)); - this.currentTestCase = null; - } - - private long extractDuration(Result result) { - return result.getDuration().toMillis(); + String timestamp = formatTimeStamp(event.getTimestamp()); + out.print(TEMPLATE_PROGRESS_TEST_FINISHED, timestamp); + finishNode(timestamp, currentPath.remove(currentPath.size() - 1)); + this.currentPickle = null; } - private void printTestRunFinished(TestRunFinished event) { - String timestamp = extractTimeStamp(event); - print(TEMPLATE_PROGRESS_COUNTING_FINISHED, timestamp); + private void printTestRunFinished(io.cucumber.messages.types.TestRunFinished event) { + String timestamp = formatTimeStamp(event.getTimestamp()); + out.print(TEMPLATE_PROGRESS_COUNTING_FINISHED, timestamp); - List emptyStack = new ArrayList<>(); - poppedNodes(emptyStack).forEach(node -> finishNode(timestamp, node)); - currentStack = emptyStack; + List emptyPath = new ArrayList<>(); + poppedNodes(emptyPath).forEach(node -> finishNode(timestamp, node)); + currentPath = emptyPath; printBeforeAfterAllResult(event, timestamp); - print(TEMPLATE_TEST_RUN_FINISHED, timestamp); + out.print(TEMPLATE_TEST_RUN_FINISHED, timestamp); } - private void printBeforeAfterAllResult(TestRunFinished event, String timestamp) { - Throwable error = event.getResult().getError(); - if (error == null) { + private void printBeforeAfterAllResult(io.cucumber.messages.types.TestRunFinished event, String timestamp) { + Optional error = event.getException(); + if (!error.isPresent()) { return; } // Use dummy test to display before all after all failures String name = "Before All/After All"; - print(TEMPLATE_BEFORE_ALL_AFTER_ALL_STARTED, timestamp, name); - String details = printStackTrace(error); - print(TEMPLATE_BEFORE_ALL_AFTER_ALL_FAILED, timestamp, "Before All/After All failed", details, name); - print(TEMPLATE_BEFORE_ALL_AFTER_ALL_FINISHED, timestamp, name); + out.print(TEMPLATE_BEFORE_ALL_AFTER_ALL_STARTED, timestamp, name); + String details = error.flatMap(Exception::getStackTrace).orElse(""); + out.print(TEMPLATE_BEFORE_ALL_AFTER_ALL_FAILED, timestamp, "Before All/After All failed", details, name); + out.print(TEMPLATE_BEFORE_ALL_AFTER_ALL_FINISHED, timestamp, name); } private void handleSnippetSuggested(SnippetsSuggestedEvent event) { suggestions.add(event); } - private void handleEmbedEvent(EmbedEvent event) { - String name = event.getName() == null ? "" : event.getName() + " "; - print(TEMPLATE_ATTACH_WRITE_EVENT, - "Embed event: " + name + "[" + event.getMediaType() + " " + event.getData().length + " bytes]\n"); + private void handleEmbedEvent(Attachment event) { + switch (event.getContentEncoding()) { + case IDENTITY: + out.print(TEMPLATE_ATTACH_WRITE_EVENT, "Write event:\n" + event.getBody() + "\n"); + return; + case BASE64: + String name = event.getFileName().map(s -> s + " ").orElse(""); + out.print(TEMPLATE_ATTACH_WRITE_EVENT, + "Embed event: " + name + "[" + event.getMediaType() + " " + (event.getBody().length() / 4) * 3 + + " bytes]\n"); + return; + default: + // Ignore. + } } - private void handleWriteEvent(WriteEvent event) { - print(TEMPLATE_ATTACH_WRITE_EVENT, "Write event:\n" + event.getText() + "\n"); + private static String formatTimeStamp(Timestamp timestamp) { + ZonedDateTime date = Convertor.toInstant(timestamp).atZone(ZoneOffset.UTC); + return DATE_FORMAT.format(date); } - private void print(String command, Object... args) { - out.println(formatCommand(command, args)); - } + private static class TeamCityCommandWriter implements Closeable { + private final PrintStream out; - private String formatCommand(String command, Object... parameters) { - String[] escapedParameters = new String[parameters.length]; - for (int i = 0; i < escapedParameters.length; i++) { - escapedParameters[i] = escape(parameters[i].toString()); + public TeamCityCommandWriter(PrintStream out) { + this.out = out; } - return String.format(command, (Object[]) escapedParameters); - } + private void print(String command, Object... args) { + out.println(formatCommand(command, args)); + } - private String escape(String source) { - if (source == null) { - return ""; + private String formatCommand(String command, Object... parameters) { + String[] escapedParameters = new String[parameters.length]; + for (int i = 0; i < escapedParameters.length; i++) { + escapedParameters[i] = escape(parameters[i].toString()); + } + + return String.format(command, (Object[]) escapedParameters); + } + + private String escape(String source) { + if (source == null) { + return ""; + } + return source + .replace("|", "||") + .replace("'", "|'") + .replace("\n", "|n") + .replace("\r", "|r") + .replace("[", "|[") + .replace("]", "|]"); + } + + @Override + public void close() { + out.close(); } - // https://www.jetbrains.com/help/teamcity/service-messages.html#Escaped+Values - return source - .replace("|", "||") - .replace("'", "|'") - .replace("\n", "|n") - .replace("\r", "|r") - .replace("[", "|[") - .replace("]", "|]"); } private static class ComparisonFailure { + private static final Pattern[] COMPARE_PATTERNS = new Pattern[] { + // Hamcrest 2 MatcherAssert.assertThat + Pattern.compile("expected: (.*)(?:\r\n|\r|\n) {5}but: was (.*)$", + Pattern.DOTALL | Pattern.CASE_INSENSITIVE), + // AssertJ 3 ShouldBeEqual.smartErrorMessage + Pattern.compile("expected: (.*)(?:\r\n|\r|\n) but was: (.*)$", + Pattern.DOTALL | Pattern.CASE_INSENSITIVE), + // JUnit 5 AssertionFailureBuilder + Pattern.compile("expected: <(.*)> but was: <(.*)>$", + Pattern.DOTALL | Pattern.CASE_INSENSITIVE), + // JUnit 4 Assert.assertEquals + Pattern.compile("expected:\\s?<(.*)> but was:\\s?<(.*)>$", + Pattern.DOTALL | Pattern.CASE_INSENSITIVE), + // TestNG 7 Assert.assertEquals + Pattern.compile("expected \\[(.*)] but found \\[(.*)]\n$", + Pattern.DOTALL | Pattern.CASE_INSENSITIVE), + }; + static ComparisonFailure parse(String message) { for (Pattern pattern : COMPARE_PATTERNS) { ComparisonFailure result = parse(message, pattern); @@ -520,4 +566,122 @@ public String getActual() { return actual; } } + + private static final class TreeNode { + private final String name; + private final String uri; + private final io.cucumber.messages.types.Location location; + + private TreeNode(String name, String uri, io.cucumber.messages.types.Location location) { + this.name = name; + this.uri = uri; + this.location = location; + } + + public String getName() { + return name; + } + + public String getUri() { + return uri; + } + + public io.cucumber.messages.types.Location getLocation() { + return location; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) + return false; + TreeNode that = (TreeNode) o; + return Objects.equals(name, that.name) && Objects.equals(uri, that.uri) + && Objects.equals(location, that.location); + } + + @Override + public int hashCode() { + return Objects.hash(name, uri, location); + } + } + + private static class PathCollector implements LineageReducer.Collector> { + // There are at most 5 levels to a feature file. + private final List path = new ArrayList<>(5); + private String uri; + private String scenarioName; + private int examplesIndex; + private boolean isExample; + + @Override + public void add(GherkinDocument document) { + uri = document.getUri().orElse(""); + } + + @Override + public void add(Feature feature) { + String name = getNameOrKeyword(feature.getName(), feature.getKeyword()); + path.add(new TreeNode(name, uri, feature.getLocation())); + } + + @Override + public void add(Rule rule) { + String name = getNameOrKeyword(rule.getName(), rule.getKeyword()); + path.add(new TreeNode(name, uri, rule.getLocation())); + } + + @Override + public void add(Scenario scenario) { + String name = getNameOrKeyword(scenario.getName(), scenario.getKeyword()); + path.add(new TreeNode(name, uri, scenario.getLocation())); + scenarioName = name; + } + + @Override + public void add(Examples examples, int index) { + String name = getNameOrKeyword(examples.getName(), examples.getKeyword()); + path.add(new TreeNode(name, uri, examples.getLocation())); + examplesIndex = index; + } + + @Override + public void add(TableRow example, int index) { + isExample = true; + String name = "#" + (examplesIndex + 1) + "." + (index + 1); + path.add(new TreeNode(name, uri, example.getLocation())); + } + + @Override + public void add(Pickle pickle) { + // Case 1: Pickles from a scenario outline + if (isExample) { + String pickleName = pickle.getName(); + boolean parameterized = !scenarioName.equals(pickleName); + if (parameterized) { + TreeNode example = path.remove(path.size() - 1); + String parameterizedExampleName = example.getName() + ": " + pickleName; + path.add(new TreeNode(parameterizedExampleName, example.getUri(), example.getLocation())); + } + } + // Case 2: Pickles from a scenario + // Nothing to do, scenario name and pickle name are the same. + } + + @Override + public List finish() { + return path; + } + + private static String getNameOrKeyword(String name, String keyword) { + if (!name.isEmpty()) { + return name; + } + if (!keyword.isEmpty()) { + return keyword; + } + // Always return a non-empty string otherwise the tree diagram is + // hard to click. + return "Unknown"; + } + } } diff --git a/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java b/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java index 4a433a0b4e..77af5f1ae9 100755 --- a/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java +++ b/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java @@ -20,7 +20,11 @@ import java.io.PrintStream; import java.util.UUID; +import static io.cucumber.core.backend.HookDefinition.HookType.BEFORE; import static io.cucumber.core.plugin.Bytes.bytes; +import static io.cucumber.core.plugin.TeamCityPluginTestStepDefinition.getAnnotationSourceReference; +import static io.cucumber.core.plugin.TeamCityPluginTestStepDefinition.getStackSourceReference; +import static java.nio.charset.StandardCharsets.UTF_8; import static java.time.Clock.fixed; import static java.time.Instant.EPOCH; import static java.time.ZoneId.of; @@ -72,7 +76,7 @@ void should_handle_scenario_outline() { "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location + "path/test.feature:5' name = 'examples name']\n" + "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location - + "path/test.feature:7' name = 'Example #1.1']\n" + + + "path/test.feature:7' name = '#1.1: name 1']\n" + "##teamcity[customProgressStatus type = 'testStarted' timestamp = '1970-01-01T12:00:00.000+0000']\n" + "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location + "path/test.feature:3' captureStandardOutput = 'true' name = 'first step']\n" + @@ -83,9 +87,10 @@ void should_handle_scenario_outline() { "##teamcity[testFinished timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' name = 'second step']\n" + "##teamcity[customProgressStatus type = 'testFinished' timestamp = '1970-01-01T12:00:00.000+0000']\n" + - "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:00.000+0000' name = 'Example #1.1']\n" + + "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:00.000+0000' name = '#1.1: name 1']\n" + + "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location - + "path/test.feature:8' name = 'Example #1.2']\n" + + + "path/test.feature:8' name = '#1.2: name 2']\n" + "##teamcity[customProgressStatus type = 'testStarted' timestamp = '1970-01-01T12:00:00.000+0000']\n" + "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location + "path/test.feature:3' captureStandardOutput = 'true' name = 'first step']\n" + @@ -96,7 +101,8 @@ void should_handle_scenario_outline() { "##teamcity[testFinished timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' name = 'third step']\n" + "##teamcity[customProgressStatus type = 'testFinished' timestamp = '1970-01-01T12:00:00.000+0000']\n" + - "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:00.000+0000' name = 'Example #1.2']\n" + + "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:00.000+0000' name = '#1.2: name 2']\n" + + "##teamcity[customProgressStatus testsCategory = '' count = '0' timestamp = '1970-01-01T12:00:00.000+0000']\n" + "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:00.000+0000' name = 'examples name']\n" + @@ -121,7 +127,7 @@ void should_handle_nameless_attach_events() { .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) .withBackendSupplier(new StubBackendSupplier( singletonList(new StubHookDefinition( - (TestCaseState state) -> state.attach("A message", "text/plain", null))), + (TestCaseState state) -> state.attach("A message".getBytes(UTF_8), "text/plain", null))), singletonList(new StubStepDefinition("first step")), emptyList())) .build() @@ -168,7 +174,8 @@ void should_handle_attach_events() { .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) .withBackendSupplier(new StubBackendSupplier( singletonList(new StubHookDefinition( - (TestCaseState state) -> state.attach("A message", "text/plain", "message.txt"))), + (TestCaseState state) -> state.attach("A message".getBytes(UTF_8), "text/plain", + "message.txt"))), singletonList(new StubStepDefinition("first step")), emptyList())) .build() @@ -258,7 +265,7 @@ void should_print_error_message_for_before_hooks() { .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) .withBackendSupplier(new StubBackendSupplier( singletonList( - new StubHookDefinition(new StubException("Step failed") + new StubHookDefinition(getAnnotationSourceReference(), BEFORE, new StubException() .withStacktrace("the stack trace"))), singletonList(new StubStepDefinition("first step")), emptyList())) @@ -266,9 +273,9 @@ void should_print_error_message_for_before_hooks() { .run(); assertThat(out, bytes(containsString("" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '{stubbed location with details}' captureStandardOutput = 'true' name = 'Before']\n" + "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'java:test://io.cucumber.core.plugin.TeamCityPluginTestStepDefinition/beforeHook' captureStandardOutput = 'true' name = 'Before(beforeHook)']\n" + - "##teamcity[testFailed timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' message = 'Step failed' details = 'Step failed|n\tthe stack trace|n' name = 'Before']"))); + "##teamcity[testFailed timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' message = 'Step failed' details = 'stub exception|n\tthe stack trace|n' name = 'Before(beforeHook)']"))); } @Test @@ -284,14 +291,14 @@ void should_print_location_hint_for_java_hooks() { .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) .withBackendSupplier(new StubBackendSupplier( - singletonList(new StubHookDefinition("com.example.HookDefinition.beforeHook()")), + singletonList(new StubHookDefinition(getAnnotationSourceReference(), BEFORE)), singletonList(new StubStepDefinition("first step")), emptyList())) .build() .run(); assertThat(out, bytes(containsString("" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'java:test://com.example.HookDefinition/beforeHook' captureStandardOutput = 'true' name = 'Before(beforeHook)']\n"))); + "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'java:test://io.cucumber.core.plugin.TeamCityPluginTestStepDefinition/beforeHook' captureStandardOutput = 'true' name = 'Before(beforeHook)']\n"))); } @Test @@ -307,14 +314,14 @@ void should_print_location_hint_for_lambda_hooks() { .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) .withBackendSupplier(new StubBackendSupplier( - singletonList(new StubHookDefinition("com.example.HookDefinition.(HookDefinition.java:12)")), + singletonList(new StubHookDefinition(getStackSourceReference(), BEFORE)), singletonList(new StubStepDefinition("first step")), emptyList())) .build() .run(); assertThat(out, bytes(containsString("" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'java:test://com.example.HookDefinition/HookDefinition' captureStandardOutput = 'true' name = 'Before(HookDefinition)']\n"))); + "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'java:test://io.cucumber.core.plugin.TeamCityPluginTestStepDefinition/TeamCityPluginTestStepDefinition' captureStandardOutput = 'true' name = 'Before(TeamCityPluginTestStepDefinition)']\n"))); } @Test @@ -397,4 +404,5 @@ void should_print_comparison_failure_for_failed_assert_equal_with_prefix() { assertThat(out, bytes(containsString("expected = 'one value' actual = 'another value' name = 'first step']"))); } + } diff --git a/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTestStepDefinition.java b/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTestStepDefinition.java new file mode 100644 index 0000000000..3e86861875 --- /dev/null +++ b/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTestStepDefinition.java @@ -0,0 +1,30 @@ +package io.cucumber.core.plugin; + +import io.cucumber.core.backend.SourceReference; + +import java.lang.reflect.Method; + +class TeamCityPluginTestStepDefinition { + SourceReference source; + + TeamCityPluginTestStepDefinition() { + source = SourceReference.fromStackTraceElement(new Exception().getStackTrace()[0]); + } + + public void beforeHook() { + + } + + static SourceReference getAnnotationSourceReference() { + try { + Method method = TeamCityPluginTestStepDefinition.class.getMethod("beforeHook"); + return SourceReference.fromMethod(method); + } catch (NoSuchMethodException e) { + throw new IllegalStateException(e); + } + } + + static SourceReference getStackSourceReference() { + return new TeamCityPluginTestStepDefinition().source; + } +} From a5f74cdad981315f5b5d34fefbedd520b48f450d Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 18 Aug 2025 19:20:23 +0200 Subject: [PATCH 02/21] Add notes --- .../src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index 24d32eb422..6628667a17 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -114,6 +114,8 @@ public class TeamCityPlugin implements EventListener { private final List suggestions = new ArrayList<>(); private final TeamCityCommandWriter out; + // TODO: Does not work with concurrency + // https://github.com/cucumber/cucumber-jvm/issues/3042 private List currentPath = new ArrayList<>(); private Pickle currentPickle; @@ -494,6 +496,8 @@ private String escape(String source) { if (source == null) { return ""; } + // https://www.jetbrains.com/help/teamcity/service-messages.html#Escaped+Values + // TODO: Missing \\uXXXX return source .replace("|", "||") .replace("'", "|'") From 18f80e06681d1ff7fe5370640d7047b0f108dc86 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Thu, 28 Aug 2025 15:22:09 +0200 Subject: [PATCH 03/21] Spotless --- .../src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index 6628667a17..5b34f4ea65 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -497,7 +497,7 @@ private String escape(String source) { return ""; } // https://www.jetbrains.com/help/teamcity/service-messages.html#Escaped+Values - // TODO: Missing \\uXXXX + // TODO: Missing \\uXXXX return source .replace("|", "||") .replace("'", "|'") From 2b4b2aac7f25d468132504b6edd9f9873854483c Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Thu, 11 Sep 2025 01:06:28 +0200 Subject: [PATCH 04/21] Make it work with latest query --- cucumber-bom/pom.xml | 2 +- .../io/cucumber/core/backend/Snippet.java | 5 ++ .../cucumber/core/plugin/TeamCityPlugin.java | 55 +++++----------- .../java/io/cucumber/core/runner/Runner.java | 66 ++++++++++++------- .../core/snippets/SnippetGenerator.java | 6 ++ .../cucumber/core/snippets/TestSnippet.java | 6 ++ .../io/cucumber/java/AbstractJavaSnippet.java | 6 ++ 7 files changed, 84 insertions(+), 62 deletions(-) diff --git a/cucumber-bom/pom.xml b/cucumber-bom/pom.xml index df5838d732..f4797763b0 100644 --- a/cucumber-bom/pom.xml +++ b/cucumber-bom/pom.xml @@ -21,7 +21,7 @@ 0.8.1 29.0.1 2.1.0 - 13.6.0 + 13.6.1-SNAPSHOT 6.1.2 0.5.0 diff --git a/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java b/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java index ae0c00ab4e..43cc4860e2 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java +++ b/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java @@ -5,10 +5,15 @@ import java.lang.reflect.Type; import java.text.MessageFormat; import java.util.Map; +import java.util.Optional; @API(status = API.Status.STABLE) public interface Snippet { + default Optional language(){ + return Optional.empty(); + } + /** * @return a {@link java.text.MessageFormat} template used to generate a * snippet. The template can access the following variables: diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index 5b34f4ea65..c61686cb4b 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -10,12 +10,13 @@ import io.cucumber.messages.types.Hook; import io.cucumber.messages.types.JavaMethod; import io.cucumber.messages.types.JavaStackTraceElement; -import io.cucumber.messages.types.Location; import io.cucumber.messages.types.Pickle; import io.cucumber.messages.types.PickleStep; import io.cucumber.messages.types.Rule; import io.cucumber.messages.types.Scenario; +import io.cucumber.messages.types.Snippet; import io.cucumber.messages.types.SourceReference; +import io.cucumber.messages.types.Suggestion; import io.cucumber.messages.types.TableRow; import io.cucumber.messages.types.TestCaseFinished; import io.cucumber.messages.types.TestCaseStarted; @@ -28,14 +29,11 @@ import io.cucumber.messages.types.Timestamp; import io.cucumber.plugin.EventListener; import io.cucumber.plugin.event.EventPublisher; -import io.cucumber.plugin.event.SnippetsSuggestedEvent; -import io.cucumber.plugin.event.SnippetsSuggestedEvent.Suggestion; import io.cucumber.query.LineageReducer; import io.cucumber.query.Query; import java.io.Closeable; import java.io.PrintStream; -import java.net.URI; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; @@ -53,7 +51,6 @@ import static java.util.Collections.emptyList; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.joining; -import static java.util.stream.Collectors.toList; /** * Outputs Teamcity services messages to std out. @@ -111,13 +108,11 @@ public class TeamCityPlugin implements EventListener { private final LineageReducer> pathCollector = descending(PathCollector::new); private final Query query = new Query(); - private final List suggestions = new ArrayList<>(); private final TeamCityCommandWriter out; // TODO: Does not work with concurrency // https://github.com/cucumber/cucumber-jvm/issues/3042 private List currentPath = new ArrayList<>(); - private Pickle currentPickle; @SuppressWarnings("unused") // Used by PluginFactory public TeamCityPlugin() { @@ -144,8 +139,6 @@ public void setEventPublisher(EventPublisher publisher) { event.getTestRunFinished().ifPresent(this::printTestRunFinished); event.getAttachment().ifPresent(this::handleEmbedEvent); }); - // TODO: Replace with messages - publisher.registerHandlerFor(SnippetsSuggestedEvent.class, this::handleSnippetSuggested); } private void printTestRunStarted(TestRunStarted event) { @@ -157,15 +150,14 @@ private void printTestRunStarted(TestRunStarted event) { private void printTestCaseStarted(TestCaseStarted event) { query.findPickleBy(event) - .ifPresent(pickle -> findPathTo(pickle) - .ifPresent(path -> { - String timestamp = formatTimeStamp(event.getTimestamp()); - poppedNodes(path).forEach(node -> finishNode(timestamp, node)); - pushedNodes(path).forEach(node -> startNode(timestamp, node)); - this.currentPath = path; - this.currentPickle = pickle; - out.print(TEMPLATE_PROGRESS_TEST_STARTED, timestamp); - })); + .flatMap(this::findPathTo) + .ifPresent(path -> { + String timestamp = formatTimeStamp(event.getTimestamp()); + poppedNodes(path).forEach(node -> finishNode(timestamp, node)); + pushedNodes(path).forEach(node -> startNode(timestamp, node)); + this.currentPath = path; + out.print(TEMPLATE_PROGRESS_TEST_STARTED, timestamp); + }); } private Optional> findPathTo(Pickle pickle) { @@ -286,7 +278,7 @@ private void printTestStepFinished(TestStepFinished event) { break; } case UNDEFINED: { - String snippets = findSnippets(currentPickle).orElse(""); + String snippets = findSnippets(event).orElse(""); out.print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step undefined", snippets, name); break; } @@ -380,20 +372,11 @@ private static String getHookName(Hook hook) { }).orElse("Unknown"); } - private Optional findSnippets(Pickle pickle) { - return query.findLocationOf(pickle) - .map(location -> { - URI uri = URI.create(pickle.getUri()); - List suggestionForTestCase = suggestions.stream() - .filter(suggestion -> isSuggestionForPickleAt(suggestion, uri, location)) - .map(SnippetsSuggestedEvent::getSuggestion) - .collect(toList()); - return createMessage(suggestionForTestCase); - }); - } - - private static boolean isSuggestionForPickleAt(SnippetsSuggestedEvent suggestion, URI uri, Location location) { - return suggestion.getUri().equals(uri) && suggestion.getTestCaseLocation().getLine() == location.getLine(); + private Optional findSnippets(TestStepFinished event) { + return query.findPickleBy(event) + .map(query::findSuggestionsBy) + .map(TeamCityPlugin::createMessage); + } private static String createMessage(Collection suggestions) { @@ -409,6 +392,7 @@ private static String createMessage(Collection suggestions) { .stream() .map(Suggestion::getSnippets) .flatMap(Collection::stream) + .map(Snippet::getCode) .distinct() .collect(joining("\n", "", "\n")); sb.append(snippets); @@ -419,7 +403,6 @@ private void printTestCaseFinished(TestCaseFinished event) { String timestamp = formatTimeStamp(event.getTimestamp()); out.print(TEMPLATE_PROGRESS_TEST_FINISHED, timestamp); finishNode(timestamp, currentPath.remove(currentPath.size() - 1)); - this.currentPickle = null; } private void printTestRunFinished(io.cucumber.messages.types.TestRunFinished event) { @@ -447,10 +430,6 @@ private void printBeforeAfterAllResult(io.cucumber.messages.types.TestRunFinishe out.print(TEMPLATE_BEFORE_ALL_AFTER_ALL_FINISHED, timestamp, name); } - private void handleSnippetSuggested(SnippetsSuggestedEvent event) { - suggestions.add(event); - } - private void handleEmbedEvent(Attachment event) { switch (event.getContentEncoding()) { case IDENTITY: diff --git a/cucumber-core/src/main/java/io/cucumber/core/runner/Runner.java b/cucumber-core/src/main/java/io/cucumber/core/runner/Runner.java index 80468d2703..7a145f5b34 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/runner/Runner.java +++ b/cucumber-core/src/main/java/io/cucumber/core/runner/Runner.java @@ -13,6 +13,8 @@ import io.cucumber.core.logging.LoggerFactory; import io.cucumber.core.snippets.SnippetGenerator; import io.cucumber.core.stepexpression.StepTypeRegistry; +import io.cucumber.messages.types.Envelope; +import io.cucumber.messages.types.Snippet; import io.cucumber.plugin.event.HookType; import io.cucumber.plugin.event.Location; import io.cucumber.plugin.event.SnippetsSuggestedEvent; @@ -31,6 +33,7 @@ import static io.cucumber.core.exception.ExceptionUtils.throwAsUncheckedException; import static io.cucumber.core.runner.StackManipulation.removeFrameworkFrames; import static java.util.Collections.emptyList; +import static java.util.stream.Collectors.toList; public final class Runner { @@ -113,9 +116,9 @@ private void executeHook(StaticHookDefinition hookDefinition) { hookDefinition.execute(); } catch (CucumberBackendException e) { CucumberException exception = new CucumberException(String.format("" + - "Could not invoke hook defined at '%s'.\n" + - "It appears there was a problem with the hook definition.", - hookDefinition.getLocation()), e); + "Could not invoke hook defined at '%s'.\n" + + "It appears there was a problem with the hook definition.", + hookDefinition.getLocation()), e); throwAsUncheckedException(exception); } catch (CucumberInvocationTargetException e) { Throwable throwable = removeFrameworkFrames(e); @@ -128,7 +131,7 @@ private List createSnippetGeneratorsForPickle(StepTypeRegistry .map(Backend::getSnippet) .filter(Objects::nonNull) .map(s -> new SnippetGenerator(s, stepTypeRegistry.parameterTypeRegistry())) - .collect(Collectors.toList()); + .collect(toList()); } private void buildBackendWorlds() { @@ -141,7 +144,7 @@ private void buildBackendWorlds() { private TestCase createTestCaseForPickle(Pickle pickle) { if (pickle.getSteps().isEmpty()) { return new TestCase(bus.generateId(), emptyList(), emptyList(), emptyList(), pickle, - runnerOptions.isDryRun()); + runnerOptions.isDryRun()); } List testSteps = createTestStepsForPickleSteps(pickle); @@ -165,7 +168,7 @@ private List createTestStepsForPickleSteps(Pickle pickle) { List afterStepHookSteps = createAfterStepHooks(pickle.getTags()); List beforeStepHookSteps = createBeforeStepHooks(pickle.getTags()); testSteps.add(new PickleStepTestStep(bus.generateId(), pickle.getUri(), step, beforeStepHookSteps, - afterStepHookSteps, match)); + afterStepHookSteps, match)); } return testSteps; @@ -193,16 +196,31 @@ private PickleStepDefinitionMatch matchStepToStepDefinition(Pickle pickle, Step } private void emitSnippetSuggestedEvent(Pickle pickle, Step step) { - List snippets = generateSnippetsForStep(step); + List snippets = generateSnippetsForStep(step); if (snippets.isEmpty()) { return; } - Suggestion suggestion = new Suggestion(step.getText(), snippets); - Location scenarioLocation = pickle.getLocation(); - Location stepLocation = step.getLocation(); - SnippetsSuggestedEvent event = new SnippetsSuggestedEvent(bus.getInstant(), pickle.getUri(), scenarioLocation, - stepLocation, suggestion); - bus.send(event); + + bus.send(new SnippetsSuggestedEvent( + bus.getInstant(), + pickle.getUri(), + pickle.getLocation(), + step.getLocation(), + new Suggestion( + step.getText(), + snippets.stream() + .map(Snippet::getCode) + .collect(toList())) + )); + + bus.send( + Envelope.of( + new io.cucumber.messages.types.Suggestion( + bus.generateId().toString(), + step.getId(), + snippets + ) + )); } private List createAfterStepHooks(List tags) { @@ -219,16 +237,18 @@ private List createTestStepsForHooks( return hooks.stream() .filter(hook -> hook.matches(tags)) .map(hook -> new HookTestStep(bus.generateId(), hookType, new HookDefinitionMatch(hook))) - .collect(Collectors.toList()); - } - - private List generateSnippetsForStep(Step step) { - List snippets = new ArrayList<>(); - for (SnippetGenerator snippetGenerator : snippetGenerators) { - List snippet = snippetGenerator.getSnippet(step, runnerOptions.getSnippetType()); - snippets.addAll(snippet); - } - return snippets; + .collect(toList()); + } + + private List generateSnippetsForStep(Step step) { + return snippetGenerators.stream() + .flatMap(generator -> { + String language = generator.getLanguage().orElse("unknown"); + return generator.getSnippet(step, runnerOptions.getSnippetType()) + .stream() + .map(code -> new Snippet(language, code)); + }) + .collect(toList()); } } diff --git a/cucumber-core/src/main/java/io/cucumber/core/snippets/SnippetGenerator.java b/cucumber-core/src/main/java/io/cucumber/core/snippets/SnippetGenerator.java index 7e49f6373c..5893336945 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/snippets/SnippetGenerator.java +++ b/cucumber-core/src/main/java/io/cucumber/core/snippets/SnippetGenerator.java @@ -11,10 +11,12 @@ import io.cucumber.plugin.event.DocStringArgument; import io.cucumber.plugin.event.StepArgument; +import javax.swing.text.html.Option; import java.lang.reflect.Type; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.regex.Pattern; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -36,6 +38,10 @@ public SnippetGenerator(Snippet snippet, ParameterTypeRegistry parameterTypeRegi this.snippet = snippet; this.generator = new CucumberExpressionGenerator(parameterTypeRegistry); } + + public Optional getLanguage(){ + return snippet.language(); + } public List getSnippet(Step step, SnippetType snippetType) { List generatedExpressions = generator.generateExpressions(step.getText()); diff --git a/cucumber-core/src/test/java/io/cucumber/core/snippets/TestSnippet.java b/cucumber-core/src/test/java/io/cucumber/core/snippets/TestSnippet.java index e224a735ce..f24c123db4 100644 --- a/cucumber-core/src/test/java/io/cucumber/core/snippets/TestSnippet.java +++ b/cucumber-core/src/test/java/io/cucumber/core/snippets/TestSnippet.java @@ -5,9 +5,15 @@ import java.lang.reflect.Type; import java.text.MessageFormat; import java.util.Map; +import java.util.Optional; public class TestSnippet implements Snippet { + @Override + public Optional language() { + return Optional.of("test"); + } + private int i; @Override diff --git a/cucumber-java/src/main/java/io/cucumber/java/AbstractJavaSnippet.java b/cucumber-java/src/main/java/io/cucumber/java/AbstractJavaSnippet.java index e330f501cf..0fbc2b1bea 100644 --- a/cucumber-java/src/main/java/io/cucumber/java/AbstractJavaSnippet.java +++ b/cucumber-java/src/main/java/io/cucumber/java/AbstractJavaSnippet.java @@ -5,11 +5,17 @@ import java.lang.reflect.Type; import java.util.Map; +import java.util.Optional; import static java.util.stream.Collectors.joining; abstract class AbstractJavaSnippet implements Snippet { + @Override + public Optional language() { + return Optional.of("java"); + } + @Override public final String tableHint() { return "" + From 958edd4e027801016a376616cdfd8f59da4cfa89 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Thu, 11 Sep 2025 01:24:07 +0200 Subject: [PATCH 05/21] Make it work with latest query --- .../io/cucumber/core/backend/Snippet.java | 4 +- .../cucumber/core/plugin/TeamCityPlugin.java | 6 +-- .../java/io/cucumber/core/runner/Runner.java | 43 ++++++++----------- .../core/snippets/SnippetGenerator.java | 5 +-- .../cucumber/core/snippets/TestSnippet.java | 2 +- 5 files changed, 27 insertions(+), 33 deletions(-) diff --git a/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java b/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java index 43cc4860e2..ea0871d6e6 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java +++ b/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java @@ -10,10 +10,10 @@ @API(status = API.Status.STABLE) public interface Snippet { - default Optional language(){ + default Optional language() { return Optional.empty(); } - + /** * @return a {@link java.text.MessageFormat} template used to generate a * snippet. The template can access the following variables: diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index c61686cb4b..aa3b13de73 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -27,7 +27,7 @@ import io.cucumber.messages.types.TestStepResultStatus; import io.cucumber.messages.types.TestStepStarted; import io.cucumber.messages.types.Timestamp; -import io.cucumber.plugin.EventListener; +import io.cucumber.plugin.ConcurrentEventListener; import io.cucumber.plugin.event.EventPublisher; import io.cucumber.query.LineageReducer; import io.cucumber.query.Query; @@ -59,7 +59,7 @@ * href=https://www.jetbrains.com/help/teamcity/service-messages.html>TeamCity * - Service Messages */ -public class TeamCityPlugin implements EventListener { +public class TeamCityPlugin implements ConcurrentEventListener { private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'hh:mm:ss.SSSZ"); @@ -376,7 +376,7 @@ private Optional findSnippets(TestStepFinished event) { return query.findPickleBy(event) .map(query::findSuggestionsBy) .map(TeamCityPlugin::createMessage); - + } private static String createMessage(Collection suggestions) { diff --git a/cucumber-core/src/main/java/io/cucumber/core/runner/Runner.java b/cucumber-core/src/main/java/io/cucumber/core/runner/Runner.java index 7a145f5b34..30ffe98c36 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/runner/Runner.java +++ b/cucumber-core/src/main/java/io/cucumber/core/runner/Runner.java @@ -16,7 +16,6 @@ import io.cucumber.messages.types.Envelope; import io.cucumber.messages.types.Snippet; import io.cucumber.plugin.event.HookType; -import io.cucumber.plugin.event.Location; import io.cucumber.plugin.event.SnippetsSuggestedEvent; import io.cucumber.plugin.event.SnippetsSuggestedEvent.Suggestion; @@ -28,7 +27,6 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.stream.Collectors; import static io.cucumber.core.exception.ExceptionUtils.throwAsUncheckedException; import static io.cucumber.core.runner.StackManipulation.removeFrameworkFrames; @@ -116,9 +114,9 @@ private void executeHook(StaticHookDefinition hookDefinition) { hookDefinition.execute(); } catch (CucumberBackendException e) { CucumberException exception = new CucumberException(String.format("" + - "Could not invoke hook defined at '%s'.\n" + - "It appears there was a problem with the hook definition.", - hookDefinition.getLocation()), e); + "Could not invoke hook defined at '%s'.\n" + + "It appears there was a problem with the hook definition.", + hookDefinition.getLocation()), e); throwAsUncheckedException(exception); } catch (CucumberInvocationTargetException e) { Throwable throwable = removeFrameworkFrames(e); @@ -144,7 +142,7 @@ private void buildBackendWorlds() { private TestCase createTestCaseForPickle(Pickle pickle) { if (pickle.getSteps().isEmpty()) { return new TestCase(bus.generateId(), emptyList(), emptyList(), emptyList(), pickle, - runnerOptions.isDryRun()); + runnerOptions.isDryRun()); } List testSteps = createTestStepsForPickleSteps(pickle); @@ -168,7 +166,7 @@ private List createTestStepsForPickleSteps(Pickle pickle) { List afterStepHookSteps = createAfterStepHooks(pickle.getTags()); List beforeStepHookSteps = createBeforeStepHooks(pickle.getTags()); testSteps.add(new PickleStepTestStep(bus.generateId(), pickle.getUri(), step, beforeStepHookSteps, - afterStepHookSteps, match)); + afterStepHookSteps, match)); } return testSteps; @@ -202,25 +200,22 @@ private void emitSnippetSuggestedEvent(Pickle pickle, Step step) { } bus.send(new SnippetsSuggestedEvent( - bus.getInstant(), - pickle.getUri(), - pickle.getLocation(), - step.getLocation(), - new Suggestion( - step.getText(), - snippets.stream() - .map(Snippet::getCode) - .collect(toList())) - )); + bus.getInstant(), + pickle.getUri(), + pickle.getLocation(), + step.getLocation(), + new Suggestion( + step.getText(), + snippets.stream() + .map(Snippet::getCode) + .collect(toList())))); bus.send( - Envelope.of( - new io.cucumber.messages.types.Suggestion( - bus.generateId().toString(), - step.getId(), - snippets - ) - )); + Envelope.of( + new io.cucumber.messages.types.Suggestion( + bus.generateId().toString(), + step.getId(), + snippets))); } private List createAfterStepHooks(List tags) { diff --git a/cucumber-core/src/main/java/io/cucumber/core/snippets/SnippetGenerator.java b/cucumber-core/src/main/java/io/cucumber/core/snippets/SnippetGenerator.java index 5893336945..ec2a1fc51f 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/snippets/SnippetGenerator.java +++ b/cucumber-core/src/main/java/io/cucumber/core/snippets/SnippetGenerator.java @@ -11,7 +11,6 @@ import io.cucumber.plugin.event.DocStringArgument; import io.cucumber.plugin.event.StepArgument; -import javax.swing.text.html.Option; import java.lang.reflect.Type; import java.util.LinkedHashMap; import java.util.List; @@ -38,8 +37,8 @@ public SnippetGenerator(Snippet snippet, ParameterTypeRegistry parameterTypeRegi this.snippet = snippet; this.generator = new CucumberExpressionGenerator(parameterTypeRegistry); } - - public Optional getLanguage(){ + + public Optional getLanguage() { return snippet.language(); } diff --git a/cucumber-core/src/test/java/io/cucumber/core/snippets/TestSnippet.java b/cucumber-core/src/test/java/io/cucumber/core/snippets/TestSnippet.java index f24c123db4..b1e34193a2 100644 --- a/cucumber-core/src/test/java/io/cucumber/core/snippets/TestSnippet.java +++ b/cucumber-core/src/test/java/io/cucumber/core/snippets/TestSnippet.java @@ -13,7 +13,7 @@ public class TestSnippet implements Snippet { public Optional language() { return Optional.of("test"); } - + private int i; @Override From ab95be549bcdb54c4c4675f28d0981b6b477bec5 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Thu, 11 Sep 2025 16:41:19 +0200 Subject: [PATCH 06/21] Make it work with latest query --- cucumber-bom/pom.xml | 10 +++++----- .../io/cucumber/core/plugin/TeamCityPlugin.java | 15 +++++++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/cucumber-bom/pom.xml b/cucumber-bom/pom.xml index f4797763b0..7dda11103e 100644 --- a/cucumber-bom/pom.xml +++ b/cucumber-bom/pom.xml @@ -15,15 +15,15 @@ 10.0.1 18.0.1 - 0.1.3 + 0.1.4-SNAPSHOT 34.0.0 21.13.0 - 0.8.1 + 0.8.2-SNAPSHOT 29.0.1 - 2.1.0 - 13.6.1-SNAPSHOT + 2.1.1-SNAPSHOT + 14.0.0 6.1.2 - 0.5.0 + 0.5.1-SNAPSHOT diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index aa3b13de73..dfb145570c 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -31,6 +31,7 @@ import io.cucumber.plugin.event.EventPublisher; import io.cucumber.query.LineageReducer; import io.cucumber.query.Query; +import io.cucumber.query.Repository; import java.io.Closeable; import java.io.PrintStream; @@ -48,6 +49,10 @@ import static io.cucumber.messages.Convertor.toDuration; import static io.cucumber.query.LineageReducer.descending; +import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_GHERKIN_DOCUMENT; +import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_HOOKS; +import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_STEP_DEFINITIONS; +import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_SUGGESTIONS; import static java.util.Collections.emptyList; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.joining; @@ -107,7 +112,13 @@ public class TeamCityPlugin implements ConcurrentEventListener { private static final String TEMPLATE_ATTACH_WRITE_EVENT = TEAMCITY_PREFIX + "[message text='%s' status='NORMAL']"; private final LineageReducer> pathCollector = descending(PathCollector::new); - private final Query query = new Query(); + private final Repository repository = Repository.builder() + .feature(INCLUDE_GHERKIN_DOCUMENT, true) + .feature(INCLUDE_STEP_DEFINITIONS, true) + .feature(INCLUDE_HOOKS, true) + .feature(INCLUDE_SUGGESTIONS, true) + .build(); + private final Query query = new Query(repository); private final TeamCityCommandWriter out; // TODO: Does not work with concurrency @@ -130,7 +141,7 @@ public TeamCityPlugin() { @Override public void setEventPublisher(EventPublisher publisher) { publisher.registerHandlerFor(Envelope.class, event -> { - query.update(event); + repository.update(event); event.getTestRunStarted().ifPresent(this::printTestRunStarted); event.getTestCaseStarted().ifPresent(this::printTestCaseStarted); event.getTestStepStarted().ifPresent(this::printTestStepStarted); From b93ff99bcd9ff9075ef8ed3cac91f880941ab2e8 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Thu, 11 Sep 2025 22:45:12 +0200 Subject: [PATCH 07/21] Fix imports --- .../io/cucumber/core/plugin/TeamCityPlugin.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index a969ccdf33..0826b876f2 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -10,6 +10,7 @@ import io.cucumber.messages.types.Hook; import io.cucumber.messages.types.JavaMethod; import io.cucumber.messages.types.JavaStackTraceElement; +import io.cucumber.messages.types.Location; import io.cucumber.messages.types.Pickle; import io.cucumber.messages.types.PickleStep; import io.cucumber.messages.types.Rule; @@ -20,6 +21,7 @@ import io.cucumber.messages.types.TableRow; import io.cucumber.messages.types.TestCaseFinished; import io.cucumber.messages.types.TestCaseStarted; +import io.cucumber.messages.types.TestRunFinished; import io.cucumber.messages.types.TestRunStarted; import io.cucumber.messages.types.TestStep; import io.cucumber.messages.types.TestStepFinished; @@ -217,7 +219,7 @@ private List pushedNodes(List newStack) { return newStack.subList(currentPath.size(), newStack.size()); } - private void printTestStepStarted(io.cucumber.messages.types.TestStepStarted event) { + private void printTestStepStarted(TestStepStarted event) { String timestamp = formatTimeStamp(event.getTimestamp()); query.findTestStepBy(event).ifPresent(testStep -> { String name = formatTestStepName(testStep); @@ -416,7 +418,7 @@ private void printTestCaseFinished(TestCaseFinished event) { finishNode(timestamp, currentPath.remove(currentPath.size() - 1)); } - private void printTestRunFinished(io.cucumber.messages.types.TestRunFinished event) { + private void printTestRunFinished(TestRunFinished event) { String timestamp = formatTimeStamp(event.getTimestamp()); out.print(TEMPLATE_PROGRESS_COUNTING_FINISHED, timestamp); @@ -428,7 +430,7 @@ private void printTestRunFinished(io.cucumber.messages.types.TestRunFinished eve out.print(TEMPLATE_TEST_RUN_FINISHED, timestamp); } - private void printBeforeAfterAllResult(io.cucumber.messages.types.TestRunFinished event, String timestamp) { + private void printBeforeAfterAllResult(TestRunFinished event, String timestamp) { Optional error = event.getException(); if (!error.isPresent()) { return; @@ -564,9 +566,9 @@ public String getActual() { private static final class TreeNode { private final String name; private final String uri; - private final io.cucumber.messages.types.Location location; + private final Location location; - private TreeNode(String name, String uri, io.cucumber.messages.types.Location location) { + private TreeNode(String name, String uri, Location location) { this.name = name; this.uri = uri; this.location = location; @@ -580,7 +582,7 @@ public String getUri() { return uri; } - public io.cucumber.messages.types.Location getLocation() { + public Location getLocation() { return location; } From b5b26dbdd1340d103b702e1e5df5403490aa232e Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Fri, 12 Sep 2025 00:34:13 +0200 Subject: [PATCH 08/21] Support concurrency --- .../plugin/CanonicalOrderEventPublisher.java | 4 + .../java/io/cucumber/core/plugin/Plugins.java | 7 +- .../cucumber/core/plugin/TeamCityPlugin.java | 151 +++++++++++++++--- .../plugin/ConcurrentEventListener.java | 5 + 4 files changed, 144 insertions(+), 23 deletions(-) diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java index 5e687f9604..5367f75274 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java @@ -1,6 +1,7 @@ package io.cucumber.core.plugin; import io.cucumber.core.eventbus.AbstractEventPublisher; +import io.cucumber.messages.types.Envelope; import io.cucumber.plugin.event.Event; import io.cucumber.plugin.event.TestRunFinished; @@ -20,4 +21,7 @@ public void handle(final Event event) { } } + public void handle(final Envelope event) { + send(event); + } } diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java index 9dcb5c1960..db6059d6c8 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java @@ -1,5 +1,6 @@ package io.cucumber.core.plugin; +import io.cucumber.messages.types.Envelope; import io.cucumber.plugin.ColorAware; import io.cucumber.plugin.ConcurrentEventListener; import io.cucumber.plugin.EventListener; @@ -68,7 +69,7 @@ public void addPlugin(Plugin plugin) { public void setEventBusOnEventListenerPlugins(EventPublisher eventPublisher) { for (Plugin plugin : plugins) { if (plugin instanceof ConcurrentEventListener) { - ((ConcurrentEventListener) plugin).setEventPublisher(eventPublisher); + ((ConcurrentEventListener) plugin).setEventPublisher(eventPublisher, false); } else if (plugin instanceof EventListener) { ((EventListener) plugin).setEventPublisher(eventPublisher); } @@ -78,7 +79,7 @@ public void setEventBusOnEventListenerPlugins(EventPublisher eventPublisher) { public void setSerialEventBusOnEventListenerPlugins(EventPublisher eventPublisher) { for (Plugin plugin : plugins) { if (plugin instanceof ConcurrentEventListener) { - ((ConcurrentEventListener) plugin).setEventPublisher(eventPublisher); + ((ConcurrentEventListener) plugin).setEventPublisher(eventPublisher, true); } else if (plugin instanceof EventListener) { EventPublisher orderedEventPublisher = getOrderedEventPublisher(eventPublisher); ((EventListener) plugin).setEventPublisher(orderedEventPublisher); @@ -98,6 +99,8 @@ private EventPublisher getOrderedEventPublisher(EventPublisher eventPublisher) { private static EventPublisher createCanonicalOrderEventPublisher(EventPublisher eventPublisher) { final CanonicalOrderEventPublisher canonicalOrderEventPublisher = new CanonicalOrderEventPublisher(); eventPublisher.registerHandlerFor(Event.class, canonicalOrderEventPublisher::handle); + // Pass through for messages + eventPublisher.registerHandlerFor(Envelope.class, canonicalOrderEventPublisher::handle); return canonicalOrderEventPublisher; } diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index 0826b876f2..dc849195cd 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -43,11 +43,16 @@ import java.util.ArrayList; import java.util.Collection; import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.BiFunction; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Stream; import static io.cucumber.messages.Convertor.toDuration; import static io.cucumber.query.LineageReducer.descending; @@ -56,6 +61,8 @@ import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_STEP_DEFINITIONS; import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_SUGGESTIONS; import static java.util.Collections.emptyList; +import static java.util.Comparator.naturalOrder; +import static java.util.Comparator.nullsFirst; import static java.util.Objects.requireNonNull; import static java.util.stream.Collectors.joining; @@ -122,10 +129,9 @@ public class TeamCityPlugin implements ConcurrentEventListener { .build(); private final Query query = new Query(repository); private final TeamCityCommandWriter out; - - // TODO: Does not work with concurrency - // https://github.com/cucumber/cucumber-jvm/issues/3042 private List currentPath = new ArrayList<>(); + // Only used when executing concurrently. + private final Map> attachmentMessagesByStepId = new HashMap<>(); @SuppressWarnings("unused") // Used by PluginFactory public TeamCityPlugin() { @@ -142,16 +148,111 @@ public TeamCityPlugin() { @Override public void setEventPublisher(EventPublisher publisher) { - publisher.registerHandlerFor(Envelope.class, event -> { - repository.update(event); - event.getTestRunStarted().ifPresent(this::printTestRunStarted); - event.getTestCaseStarted().ifPresent(this::printTestCaseStarted); - event.getTestStepStarted().ifPresent(this::printTestStepStarted); - event.getTestStepFinished().ifPresent(this::printTestStepFinished); - event.getTestCaseFinished().ifPresent(this::printTestCaseFinished); - event.getTestRunFinished().ifPresent(this::printTestRunFinished); - event.getAttachment().ifPresent(this::handleEmbedEvent); - }); + setEventPublisher(publisher, true); + } + + @Override + public void setEventPublisher(EventPublisher publisher, boolean isMultiThreaded) { + publisher.registerHandlerFor(Envelope.class, isMultiThreaded ? this::printTestCasesAfterTestRun : this::printTestCasesRealTime); + } + + private void printTestCasesRealTime(Envelope event) { + repository.update(event); + event.getTestRunStarted().ifPresent(this::printTestRunStarted); + event.getTestCaseStarted().ifPresent(this::printTestCaseStarted); + event.getTestStepStarted().ifPresent(this::printTestStepStarted); + event.getTestStepFinished().ifPresent(this::printTestStepFinished); + event.getTestCaseFinished().ifPresent(this::printTestCaseFinished); + event.getTestRunFinished().ifPresent(this::printTestRunFinished); + event.getAttachment().ifPresent(this::handleAttachment); + } + + private void printTestCasesAfterTestRun(Envelope event) { + repository.update(event); + event.getTestRunStarted().ifPresent(this::printTestRunStarted); + event.getTestRunFinished().ifPresent(this::printCompleteTestRun); + event.getAttachment().ifPresent(this::storeStepAttachments); + } + + private void printCompleteTestRun(TestRunFinished event) { + findTestCasesInCanonicalOrder() + .forEach(this::printCompleteTestCase); + printTestRunFinished(event); + } + + private Stream findTestCasesInCanonicalOrder() { + Comparator> comparing = Comparator + .comparing((Orderable ord) -> ord.uri, nullsFirst(naturalOrder())) + .thenComparing(ord -> ord.line, nullsFirst(naturalOrder())); + return query.findAllTestCaseStarted().stream() + .map(testCaseStarted -> { + Optional pickle = query.findPickleBy(testCaseStarted); + String uri = pickle.map(Pickle::getUri).orElse(null); + Long line = pickle.flatMap(query::findLocationOf).map(Location::getLine).orElse(null); + return new Orderable<>(testCaseStarted, uri, line); + }) + .sorted(comparing) + .map(ord -> ord.event); + } + + private static final class Orderable { + private final T event; + private final String uri; + private final Long line; + + private Orderable(T event, String uri, Long line) { + this.event = event; + this.uri = uri; + this.line = line; + } + } + + private void printCompleteTestCase(TestCaseStarted testCaseStarted) { + printTestCaseStarted(testCaseStarted); + + query.findTestStepsStartedBy(testCaseStarted) + .forEach(testStepStarted -> { + printTestStepStarted(testStepStarted); + findAttachmentBy(testStepStarted).forEach(this::handleAttachment); + findTestStepFinishedBy(testCaseStarted, testStepStarted).ifPresent(this::printTestStepFinished); + }); + + query.findTestCaseFinishedBy(testCaseStarted) + .ifPresent(this::printTestCaseFinished); + } + + private List findAttachmentBy(TestStepStarted testStepStarted) { + return attachmentMessagesByStepId.getOrDefault(testStepStarted.getTestStepId(), emptyList()); + } + + private Optional findTestStepFinishedBy( + TestCaseStarted testCaseStarted, TestStepStarted testStepStarted + ) { + return query.findTestStepsFinishedBy(testCaseStarted).stream() + .filter(testStepFinished -> testStepFinished.getTestStepId().equals(testStepStarted.getTestStepId())) + .findFirst(); + } + + + private void storeStepAttachments(Attachment event) { + Optional testStepId = event.getTestStepId(); + if (testStepId.isPresent()) { + attachmentMessagesByStepId.compute(testStepId.get(), updateList(extractAttachmentMessage(event))); + } else { + handleAttachment(event); + } + } + + private BiFunction, List> updateList(E element) { + return (key, existing) -> { + if (existing != null) { + existing.add(element); + return existing; + } + List list = new ArrayList<>(); + list.add(element); + return list; + }; } private void printTestRunStarted(TestRunStarted event) { @@ -443,19 +544,27 @@ private void printBeforeAfterAllResult(TestRunFinished event, String timestamp) out.print(TEMPLATE_BEFORE_ALL_AFTER_ALL_FINISHED, timestamp, name); } - private void handleEmbedEvent(Attachment event) { + private void handleAttachment(Attachment event) { + String message = extractAttachmentMessage(event); + if (message != null) { + handleAttachment(message); + } + } + + private void handleAttachment(String message) { + out.print(TEMPLATE_ATTACH_WRITE_EVENT, message); + } + + private static String extractAttachmentMessage(Attachment event) { switch (event.getContentEncoding()) { case IDENTITY: - out.print(TEMPLATE_ATTACH_WRITE_EVENT, "Write event:\n" + event.getBody() + "\n"); - return; + return "Write event:\n" + event.getBody() + "\n"; case BASE64: String name = event.getFileName().map(s -> s + " ").orElse(""); - out.print(TEMPLATE_ATTACH_WRITE_EVENT, - "Embed event: " + name + "[" + event.getMediaType() + " " + (event.getBody().length() / 4) * 3 - + " bytes]\n"); - return; + return "Embed event: " + name + "[" + event.getMediaType() + " " + (event.getBody().length() / 4) * 3 + + " bytes]\n"; default: - // Ignore. + return null; } } diff --git a/cucumber-plugin/src/main/java/io/cucumber/plugin/ConcurrentEventListener.java b/cucumber-plugin/src/main/java/io/cucumber/plugin/ConcurrentEventListener.java index ead3bf3441..514972a11e 100644 --- a/cucumber-plugin/src/main/java/io/cucumber/plugin/ConcurrentEventListener.java +++ b/cucumber-plugin/src/main/java/io/cucumber/plugin/ConcurrentEventListener.java @@ -37,5 +37,10 @@ public interface ConcurrentEventListener extends Plugin { * @param publisher the event publisher */ void setEventPublisher(EventPublisher publisher); + + default void setEventPublisher(EventPublisher publisher, boolean isMultiThreaded) { + setEventPublisher(publisher); + } + } From 787f52453ff93e2e3f5108c8da8f19ace2ef9a59 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Fri, 12 Sep 2025 00:35:40 +0200 Subject: [PATCH 09/21] Spotless --- .../src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java | 4 ++-- .../main/java/io/cucumber/plugin/ConcurrentEventListener.java | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index dc849195cd..519227c126 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -153,7 +153,8 @@ public void setEventPublisher(EventPublisher publisher) { @Override public void setEventPublisher(EventPublisher publisher, boolean isMultiThreaded) { - publisher.registerHandlerFor(Envelope.class, isMultiThreaded ? this::printTestCasesAfterTestRun : this::printTestCasesRealTime); + publisher.registerHandlerFor(Envelope.class, + isMultiThreaded ? this::printTestCasesAfterTestRun : this::printTestCasesRealTime); } private void printTestCasesRealTime(Envelope event) { @@ -232,7 +233,6 @@ private Optional findTestStepFinishedBy( .filter(testStepFinished -> testStepFinished.getTestStepId().equals(testStepStarted.getTestStepId())) .findFirst(); } - private void storeStepAttachments(Attachment event) { Optional testStepId = event.getTestStepId(); diff --git a/cucumber-plugin/src/main/java/io/cucumber/plugin/ConcurrentEventListener.java b/cucumber-plugin/src/main/java/io/cucumber/plugin/ConcurrentEventListener.java index 514972a11e..77dda69864 100644 --- a/cucumber-plugin/src/main/java/io/cucumber/plugin/ConcurrentEventListener.java +++ b/cucumber-plugin/src/main/java/io/cucumber/plugin/ConcurrentEventListener.java @@ -37,10 +37,9 @@ public interface ConcurrentEventListener extends Plugin { * @param publisher the event publisher */ void setEventPublisher(EventPublisher publisher); - + default void setEventPublisher(EventPublisher publisher, boolean isMultiThreaded) { setEventPublisher(publisher); } - } From 06f72417396a3f6fc34d6f25cf769aa45ee0ecbe Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Fri, 12 Sep 2025 00:43:40 +0200 Subject: [PATCH 10/21] Fix PluginsTest --- .../java/io/cucumber/core/plugin/PluginsTest.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cucumber-core/src/test/java/io/cucumber/core/plugin/PluginsTest.java b/cucumber-core/src/test/java/io/cucumber/core/plugin/PluginsTest.java index 307057c5c9..e4233eb0ae 100644 --- a/cucumber-core/src/test/java/io/cucumber/core/plugin/PluginsTest.java +++ b/cucumber-core/src/test/java/io/cucumber/core/plugin/PluginsTest.java @@ -57,7 +57,17 @@ void shouldSetConcurrentEventListener() { ConcurrentEventListener plugin = mock(ConcurrentEventListener.class); plugins.addPlugin(plugin); plugins.setEventBusOnEventListenerPlugins(rootEventPublisher); - verify(plugin, times(1)).setEventPublisher(rootEventPublisher); + verify(plugin, times(1)).setEventPublisher(rootEventPublisher, false); + } + + @Test + void shouldSetSerialEventBusOnConcurrentEventListener() { + RuntimeOptions runtimeOptions = RuntimeOptions.defaultOptions(); + Plugins plugins = new Plugins(pluginFactory, runtimeOptions); + ConcurrentEventListener plugin = mock(ConcurrentEventListener.class); + plugins.addPlugin(plugin); + plugins.setSerialEventBusOnEventListenerPlugins(rootEventPublisher); + verify(plugin, times(1)).setEventPublisher(rootEventPublisher, true); } @Test From faaaa281fd649d628476a3c5caf820ed1c038145 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Fri, 12 Sep 2025 13:19:00 +0200 Subject: [PATCH 11/21] Update CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87d215f332..da8aba78dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [Core] Emit StepMatchArgumentsList for ambiguous steps ([#3066](https://github.com/cucumber/cucumber-jvm/pull/3066) M.P. Korstanje) ### Changed +- [Core] Use a message based TeamCity plugin ([#3050](https://github.com/cucumber/cucumber-jvm/pull/3050) M.P. Korstanje) - [Core] Update dependency io.cucumber:cucumber-json-formatter to v0.2.0 - [Core] Update dependency io.cucumber:gherkin to v35.0.0 - [Core] Update dependency io.cucumber:html-formatter to v21.15.0 From 53c9f1af2ddad851c81cae502ab951dc64865cd2 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Fri, 12 Sep 2025 14:08:55 +0200 Subject: [PATCH 12/21] Extract to repo --- cucumber-bom/pom.xml | 6 + cucumber-core/pom.xml | 4 + .../io/cucumber/core/backend/Snippet.java | 4 - .../cucumber/core/plugin/TeamCityPlugin.java | 763 +----------------- 4 files changed, 25 insertions(+), 752 deletions(-) diff --git a/cucumber-bom/pom.xml b/cucumber-bom/pom.xml index 3449a8aac2..395c436c90 100644 --- a/cucumber-bom/pom.xml +++ b/cucumber-bom/pom.xml @@ -23,6 +23,7 @@ 2.2.0 14.0.1 6.1.2 + 0.0.1-SNAPSHOT 0.6.0 @@ -79,6 +80,11 @@ tag-expressions ${tag-expressions.version} + + io.cucumber + teamcity-formatter + ${teamcity-formatter.version} + io.cucumber testng-xml-formatter diff --git a/cucumber-core/pom.xml b/cucumber-core/pom.xml index 5548e6799b..51549fc20f 100644 --- a/cucumber-core/pom.xml +++ b/cucumber-core/pom.xml @@ -68,6 +68,10 @@ io.cucumber pretty-formatter + + io.cucumber + teamcity-formatter + io.cucumber testng-xml-formatter diff --git a/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java b/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java index e40d2dfd5f..88392e4245 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java +++ b/cucumber-core/src/main/java/io/cucumber/core/backend/Snippet.java @@ -10,10 +10,6 @@ @API(status = API.Status.STABLE) public interface Snippet { - default Optional language() { - return Optional.empty(); - } - /** * The language of the generated snippet. * diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index 519227c126..c8ea0e18a8 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -1,70 +1,14 @@ package io.cucumber.core.plugin; -import io.cucumber.messages.Convertor; -import io.cucumber.messages.types.Attachment; import io.cucumber.messages.types.Envelope; -import io.cucumber.messages.types.Examples; -import io.cucumber.messages.types.Exception; -import io.cucumber.messages.types.Feature; -import io.cucumber.messages.types.GherkinDocument; -import io.cucumber.messages.types.Hook; -import io.cucumber.messages.types.JavaMethod; -import io.cucumber.messages.types.JavaStackTraceElement; -import io.cucumber.messages.types.Location; -import io.cucumber.messages.types.Pickle; -import io.cucumber.messages.types.PickleStep; -import io.cucumber.messages.types.Rule; -import io.cucumber.messages.types.Scenario; -import io.cucumber.messages.types.Snippet; -import io.cucumber.messages.types.SourceReference; -import io.cucumber.messages.types.Suggestion; -import io.cucumber.messages.types.TableRow; -import io.cucumber.messages.types.TestCaseFinished; -import io.cucumber.messages.types.TestCaseStarted; -import io.cucumber.messages.types.TestRunFinished; -import io.cucumber.messages.types.TestRunStarted; -import io.cucumber.messages.types.TestStep; -import io.cucumber.messages.types.TestStepFinished; -import io.cucumber.messages.types.TestStepResult; -import io.cucumber.messages.types.TestStepResultStatus; -import io.cucumber.messages.types.TestStepStarted; -import io.cucumber.messages.types.Timestamp; import io.cucumber.plugin.ConcurrentEventListener; import io.cucumber.plugin.event.EventPublisher; -import io.cucumber.query.LineageReducer; -import io.cucumber.query.Query; -import io.cucumber.query.Repository; +import io.cucumber.teamcityformatter.MessagesToTeamCityWriter; -import java.io.Closeable; +import java.io.IOException; import java.io.PrintStream; -import java.time.ZoneOffset; -import java.time.ZonedDateTime; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.function.BiFunction; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Stream; -import static io.cucumber.messages.Convertor.toDuration; -import static io.cucumber.query.LineageReducer.descending; -import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_GHERKIN_DOCUMENTS; -import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_HOOKS; -import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_STEP_DEFINITIONS; -import static io.cucumber.query.Repository.RepositoryFeature.INCLUDE_SUGGESTIONS; -import static java.util.Collections.emptyList; -import static java.util.Comparator.naturalOrder; -import static java.util.Comparator.nullsFirst; -import static java.util.Objects.requireNonNull; -import static java.util.stream.Collectors.joining; +import static io.cucumber.teamcityformatter.MessagesToTeamCityWriter.TeamCityFeature.PRINT_TEST_CASES_AFTER_TEST_RUN; /** * Outputs Teamcity services messages to std out. @@ -75,63 +19,8 @@ */ public class TeamCityPlugin implements ConcurrentEventListener { - private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'hh:mm:ss.SSSZ"); - - private static final String TEAMCITY_PREFIX = "##teamcity"; - - private static final String TEMPLATE_ENTER_THE_MATRIX = TEAMCITY_PREFIX + "[enteredTheMatrix timestamp = '%s']"; - private static final String TEMPLATE_TEST_RUN_STARTED = TEAMCITY_PREFIX - + "[testSuiteStarted timestamp = '%s' name = 'Cucumber']"; - private static final String TEMPLATE_TEST_RUN_FINISHED = TEAMCITY_PREFIX - + "[testSuiteFinished timestamp = '%s' name = 'Cucumber']"; - - private static final String TEMPLATE_TEST_SUITE_STARTED = TEAMCITY_PREFIX - + "[testSuiteStarted timestamp = '%s' locationHint = '%s' name = '%s']"; - private static final String TEMPLATE_TEST_SUITE_FINISHED = TEAMCITY_PREFIX - + "[testSuiteFinished timestamp = '%s' name = '%s']"; - - private static final String TEMPLATE_TEST_STARTED = TEAMCITY_PREFIX - + "[testStarted timestamp = '%s' locationHint = '%s' captureStandardOutput = 'true' name = '%s']"; - private static final String TEMPLATE_TEST_FINISHED = TEAMCITY_PREFIX - + "[testFinished timestamp = '%s' duration = '%s' name = '%s']"; - private static final String TEMPLATE_TEST_FAILED = TEAMCITY_PREFIX - + "[testFailed timestamp = '%s' duration = '%s' message = '%s' details = '%s' name = '%s']"; - - private static final String TEMPLATE_TEST_COMPARISON_FAILED = TEAMCITY_PREFIX - + "[testFailed timestamp = '%s' duration = '%s' message = '%s' details = '%s' expected = '%s' actual = '%s' name = '%s']"; - private static final String TEMPLATE_TEST_IGNORED = TEAMCITY_PREFIX - + "[testIgnored timestamp = '%s' duration = '%s' message = '%s' name = '%s']"; - - private static final String TEMPLATE_BEFORE_ALL_AFTER_ALL_STARTED = TEAMCITY_PREFIX - + "[testStarted timestamp = '%s' name = '%s']"; - private static final String TEMPLATE_BEFORE_ALL_AFTER_ALL_FAILED = TEAMCITY_PREFIX - + "[testFailed timestamp = '%s' message = '%s' details = '%s' name = '%s']"; - private static final String TEMPLATE_BEFORE_ALL_AFTER_ALL_FINISHED = TEAMCITY_PREFIX - + "[testFinished timestamp = '%s' name = '%s']"; - - private static final String TEMPLATE_PROGRESS_COUNTING_STARTED = TEAMCITY_PREFIX - + "[customProgressStatus testsCategory = 'Scenarios' count = '0' timestamp = '%s']"; - private static final String TEMPLATE_PROGRESS_COUNTING_FINISHED = TEAMCITY_PREFIX - + "[customProgressStatus testsCategory = '' count = '0' timestamp = '%s']"; - private static final String TEMPLATE_PROGRESS_TEST_STARTED = TEAMCITY_PREFIX - + "[customProgressStatus type = 'testStarted' timestamp = '%s']"; - private static final String TEMPLATE_PROGRESS_TEST_FINISHED = TEAMCITY_PREFIX - + "[customProgressStatus type = 'testFinished' timestamp = '%s']"; - - private static final String TEMPLATE_ATTACH_WRITE_EVENT = TEAMCITY_PREFIX + "[message text='%s' status='NORMAL']"; - - private final LineageReducer> pathCollector = descending(PathCollector::new); - private final Repository repository = Repository.builder() - .feature(INCLUDE_GHERKIN_DOCUMENTS, true) - .feature(INCLUDE_STEP_DEFINITIONS, true) - .feature(INCLUDE_HOOKS, true) - .feature(INCLUDE_SUGGESTIONS, true) - .build(); - private final Query query = new Query(repository); - private final TeamCityCommandWriter out; - private List currentPath = new ArrayList<>(); - // Only used when executing concurrently. - private final Map> attachmentMessagesByStepId = new HashMap<>(); + private final PrintStream out; + private MessagesToTeamCityWriter writer; @SuppressWarnings("unused") // Used by PluginFactory public TeamCityPlugin() { @@ -143,7 +32,7 @@ public TeamCityPlugin() { } TeamCityPlugin(PrintStream out) { - this.out = new TeamCityCommandWriter(out); + this.out = out; } @Override @@ -153,640 +42,18 @@ public void setEventPublisher(EventPublisher publisher) { @Override public void setEventPublisher(EventPublisher publisher, boolean isMultiThreaded) { - publisher.registerHandlerFor(Envelope.class, - isMultiThreaded ? this::printTestCasesAfterTestRun : this::printTestCasesRealTime); - } - - private void printTestCasesRealTime(Envelope event) { - repository.update(event); - event.getTestRunStarted().ifPresent(this::printTestRunStarted); - event.getTestCaseStarted().ifPresent(this::printTestCaseStarted); - event.getTestStepStarted().ifPresent(this::printTestStepStarted); - event.getTestStepFinished().ifPresent(this::printTestStepFinished); - event.getTestCaseFinished().ifPresent(this::printTestCaseFinished); - event.getTestRunFinished().ifPresent(this::printTestRunFinished); - event.getAttachment().ifPresent(this::handleAttachment); - } - - private void printTestCasesAfterTestRun(Envelope event) { - repository.update(event); - event.getTestRunStarted().ifPresent(this::printTestRunStarted); - event.getTestRunFinished().ifPresent(this::printCompleteTestRun); - event.getAttachment().ifPresent(this::storeStepAttachments); - } - - private void printCompleteTestRun(TestRunFinished event) { - findTestCasesInCanonicalOrder() - .forEach(this::printCompleteTestCase); - printTestRunFinished(event); - } - - private Stream findTestCasesInCanonicalOrder() { - Comparator> comparing = Comparator - .comparing((Orderable ord) -> ord.uri, nullsFirst(naturalOrder())) - .thenComparing(ord -> ord.line, nullsFirst(naturalOrder())); - return query.findAllTestCaseStarted().stream() - .map(testCaseStarted -> { - Optional pickle = query.findPickleBy(testCaseStarted); - String uri = pickle.map(Pickle::getUri).orElse(null); - Long line = pickle.flatMap(query::findLocationOf).map(Location::getLine).orElse(null); - return new Orderable<>(testCaseStarted, uri, line); - }) - .sorted(comparing) - .map(ord -> ord.event); - } - - private static final class Orderable { - private final T event; - private final String uri; - private final Long line; - - private Orderable(T event, String uri, Long line) { - this.event = event; - this.uri = uri; - this.line = line; - } - } - - private void printCompleteTestCase(TestCaseStarted testCaseStarted) { - printTestCaseStarted(testCaseStarted); - - query.findTestStepsStartedBy(testCaseStarted) - .forEach(testStepStarted -> { - printTestStepStarted(testStepStarted); - findAttachmentBy(testStepStarted).forEach(this::handleAttachment); - findTestStepFinishedBy(testCaseStarted, testStepStarted).ifPresent(this::printTestStepFinished); - }); - - query.findTestCaseFinishedBy(testCaseStarted) - .ifPresent(this::printTestCaseFinished); - } - - private List findAttachmentBy(TestStepStarted testStepStarted) { - return attachmentMessagesByStepId.getOrDefault(testStepStarted.getTestStepId(), emptyList()); - } - - private Optional findTestStepFinishedBy( - TestCaseStarted testCaseStarted, TestStepStarted testStepStarted - ) { - return query.findTestStepsFinishedBy(testCaseStarted).stream() - .filter(testStepFinished -> testStepFinished.getTestStepId().equals(testStepStarted.getTestStepId())) - .findFirst(); - } - - private void storeStepAttachments(Attachment event) { - Optional testStepId = event.getTestStepId(); - if (testStepId.isPresent()) { - attachmentMessagesByStepId.compute(testStepId.get(), updateList(extractAttachmentMessage(event))); - } else { - handleAttachment(event); - } - } - - private BiFunction, List> updateList(E element) { - return (key, existing) -> { - if (existing != null) { - existing.add(element); - return existing; - } - List list = new ArrayList<>(); - list.add(element); - return list; - }; - } - - private void printTestRunStarted(TestRunStarted event) { - String timestamp = formatTimeStamp(event.getTimestamp()); - out.print(TEMPLATE_ENTER_THE_MATRIX, timestamp); - out.print(TEMPLATE_TEST_RUN_STARTED, timestamp); - out.print(TEMPLATE_PROGRESS_COUNTING_STARTED, timestamp); - } - - private void printTestCaseStarted(TestCaseStarted event) { - query.findPickleBy(event) - .flatMap(this::findPathTo) - .ifPresent(path -> { - String timestamp = formatTimeStamp(event.getTimestamp()); - poppedNodes(path).forEach(node -> finishNode(timestamp, node)); - pushedNodes(path).forEach(node -> startNode(timestamp, node)); - this.currentPath = path; - out.print(TEMPLATE_PROGRESS_TEST_STARTED, timestamp); - }); - } - - private Optional> findPathTo(Pickle pickle) { - return query.findLineageBy(pickle) - .map(lineage -> pathCollector.reduce(lineage, pickle)); - } - - private void startNode(String timestamp, TreeNode node) { - String name = node.getName(); - String location = node.getUri() + ":" + node.getLocation().getLine(); - out.print(TEMPLATE_TEST_SUITE_STARTED, timestamp, location, name); - } - - private void finishNode(String timestamp, TreeNode node) { - String name = node.getName(); - out.print(TEMPLATE_TEST_SUITE_FINISHED, timestamp, name); - } - - private List poppedNodes(List newStack) { - List nodes = new ArrayList<>(reversedPoppedNodes(currentPath, newStack)); - Collections.reverse(nodes); - return nodes; - } - - private List reversedPoppedNodes(List currentStack, List newStack) { - for (int i = 0; i < currentStack.size() && i < newStack.size(); i++) { - if (!currentStack.get(i).equals(newStack.get(i))) { - return currentStack.subList(i, currentStack.size()); - } - } - if (newStack.size() < currentStack.size()) { - return currentStack.subList(newStack.size(), currentStack.size()); - } - return emptyList(); - } - - private List pushedNodes(List newStack) { - for (int i = 0; i < currentPath.size() && i < newStack.size(); i++) { - if (!currentPath.get(i).equals(newStack.get(i))) { - return newStack.subList(i, newStack.size()); - } - } - if (newStack.size() < currentPath.size()) { - return emptyList(); - } - return newStack.subList(currentPath.size(), newStack.size()); - } - - private void printTestStepStarted(TestStepStarted event) { - String timestamp = formatTimeStamp(event.getTimestamp()); - query.findTestStepBy(event).ifPresent(testStep -> { - String name = formatTestStepName(testStep); - String location = findPickleTestStepLocation(event, testStep) - .orElseGet(() -> findHookStepLocation(testStep) - .orElse("")); - out.print(TEMPLATE_TEST_STARTED, timestamp, location, name); - }); - } - - private Optional findPickleTestStepLocation(TestStepStarted testStepStarted, TestStep testStep) { - return query.findPickleStepBy(testStep) - .flatMap(query::findStepBy) - .flatMap(step -> query.findPickleBy(testStepStarted) - .map(pickle -> pickle.getUri() + ":" + step.getLocation().getLine())); - } - - private Optional findHookStepLocation(TestStep testStep) { - return query.findHookBy(testStep) - .map(Hook::getSourceReference) - .map(TeamCityPlugin::formatSourceLocation); - } - - private static String formatSourceLocation(SourceReference sourceReference) { - return sourceReference.getJavaMethod() - .map(TeamCityPlugin::formatJavaMethodLocation) - .orElseGet(() -> sourceReference.getJavaStackTraceElement() - .map(TeamCityPlugin::formatJavaStackTraceLocation) - .orElse("")); - } - - private static String formatJavaStackTraceLocation(JavaStackTraceElement javaStackTraceElement) { - String fqClassName = javaStackTraceElement.getClassName(); - String methodName = javaStackTraceElement.getMethodName(); - return createJavaTestUri(fqClassName, sanitizeMethodName(fqClassName, methodName)); - } - - private static String formatJavaMethodLocation(JavaMethod javaMethod) { - String fqClassName = javaMethod.getClassName(); - String methodName = javaMethod.getMethodName(); - return createJavaTestUri(fqClassName, methodName); - } - - private static String createJavaTestUri(String fqClassName, String methodName) { - // See: - // https://github.com/JetBrains/intellij-community/blob/master/java/execution/impl/src/com/intellij/execution/testframework/JavaTestLocator.java - return String.format("java:test://%s/%s", fqClassName, methodName); - } - - private void printTestStepFinished(TestStepFinished event) { - String timeStamp = formatTimeStamp(event.getTimestamp()); - TestStepResult testStepResult = event.getTestStepResult(); - long duration = toDuration(testStepResult.getDuration()).toMillis(); - - query.findTestStepBy(event).ifPresent(testStep -> { - String name = formatTestStepName(testStep); - - Optional error = testStepResult.getException(); - TestStepResultStatus status = testStepResult.getStatus(); - switch (status) { - case SKIPPED: { - String message = error.flatMap(Exception::getMessage).orElse("Step skipped"); - out.print(TEMPLATE_TEST_IGNORED, timeStamp, duration, message, name); - break; - } - case PENDING: { - String details = error.flatMap(Exception::getMessage).orElse(""); - out.print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step pending", details, name); - break; - } - case UNDEFINED: { - String snippets = findSnippets(event).orElse(""); - out.print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step undefined", snippets, name); - break; - } - case AMBIGUOUS: - case FAILED: { - String details = error.flatMap(Exception::getStackTrace).orElse(""); - String message = error.flatMap(Exception::getMessage).orElse(null); - if (message == null) { - out.print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step failed", details, name); - break; - } - ComparisonFailure comparisonFailure = ComparisonFailure.parse(message.trim()); - if (comparisonFailure == null) { - out.print(TEMPLATE_TEST_FAILED, timeStamp, duration, "Step failed", details, name); - break; - } - out.print(TEMPLATE_TEST_COMPARISON_FAILED, timeStamp, duration, "Step failed", details, - comparisonFailure.getExpected(), comparisonFailure.getActual(), name); - break; - } - default: - break; - } - out.print(TEMPLATE_TEST_FINISHED, timeStamp, duration, name); - }); - } - - private String formatTestStepName(TestStep testStep) { - return query.findPickleStepBy(testStep) - .map(PickleStep::getText) - .orElseGet(() -> query.findHookBy(testStep) - .map(TeamCityPlugin::formatHookStepName) - .orElse("Unknown step")); - } - - private static String formatHookStepName(Hook hook) { - // TODO: Use hook name. - SourceReference sourceReference = hook.getSourceReference(); - return sourceReference.getJavaMethod() - .map(javaMethod -> formatJavaMethodName(hook, javaMethod)) - .orElseGet(() -> sourceReference.getJavaStackTraceElement() - .map(javaStackTraceElement -> formatJavaStackTraceName(hook, javaStackTraceElement)) - .orElse("Unknown")); - } - - private static String formatJavaStackTraceName(Hook hook, JavaStackTraceElement javaStackTraceElement) { - String methodName = javaStackTraceElement.getMethodName(); - String fqClassName = javaStackTraceElement.getClassName(); - String hookName = getHookName(hook); - String sanitizeMethodName = sanitizeMethodName(fqClassName, methodName); - return String.format("%s(%s)", hookName, sanitizeMethodName); - } - - private static String sanitizeMethodName(String fqClassName, String methodName) { - if (!methodName.equals("")) { - return methodName; - } - // Replace constructor name, not recognized by IDEA. - int classNameIndex = fqClassName.lastIndexOf('.'); - if (classNameIndex > 0) { - return fqClassName.substring(classNameIndex + 1); - } - return methodName; - } - - private static String formatJavaMethodName(Hook hook, JavaMethod javaMethod) { - String methodName = javaMethod.getMethodName(); - String hookName = getHookName(hook); - return String.format("%s(%s)", hookName, methodName); - } - - private static String getHookName(Hook hook) { - return hook.getType().map( - hookType -> { - switch (hookType) { - case BEFORE_TEST_RUN: - return "BeforeAll"; - case AFTER_TEST_RUN: - return "AfterAll"; - case BEFORE_TEST_CASE: - return "Before"; - case AFTER_TEST_CASE: - return "After"; - case BEFORE_TEST_STEP: - return "BeforeStep"; - case AFTER_TEST_STEP: - return "AfterStep"; - default: - return "Unknown"; - } - }).orElse("Unknown"); - } - - private Optional findSnippets(TestStepFinished event) { - return query.findPickleBy(event) - .map(query::findSuggestionsBy) - .map(TeamCityPlugin::createMessage); - - } - - private static String createMessage(Collection suggestions) { - if (suggestions.isEmpty()) { - return ""; - } - StringBuilder sb = new StringBuilder("You can implement this step"); - if (suggestions.size() > 1) { - sb.append(" and ").append(suggestions.size() - 1).append(" other step(s)"); - } - sb.append(" using the snippet(s) below:\n\n"); - String snippets = suggestions - .stream() - .map(Suggestion::getSnippets) - .flatMap(Collection::stream) - .map(Snippet::getCode) - .distinct() - .collect(joining("\n", "", "\n")); - sb.append(snippets); - return sb.toString(); - } - - private void printTestCaseFinished(TestCaseFinished event) { - String timestamp = formatTimeStamp(event.getTimestamp()); - out.print(TEMPLATE_PROGRESS_TEST_FINISHED, timestamp); - finishNode(timestamp, currentPath.remove(currentPath.size() - 1)); - } - - private void printTestRunFinished(TestRunFinished event) { - String timestamp = formatTimeStamp(event.getTimestamp()); - out.print(TEMPLATE_PROGRESS_COUNTING_FINISHED, timestamp); - - List emptyPath = new ArrayList<>(); - poppedNodes(emptyPath).forEach(node -> finishNode(timestamp, node)); - currentPath = emptyPath; - - printBeforeAfterAllResult(event, timestamp); - out.print(TEMPLATE_TEST_RUN_FINISHED, timestamp); - } - - private void printBeforeAfterAllResult(TestRunFinished event, String timestamp) { - Optional error = event.getException(); - if (!error.isPresent()) { - return; - } - // Use dummy test to display before all after all failures - String name = "Before All/After All"; - out.print(TEMPLATE_BEFORE_ALL_AFTER_ALL_STARTED, timestamp, name); - String details = error.flatMap(Exception::getStackTrace).orElse(""); - out.print(TEMPLATE_BEFORE_ALL_AFTER_ALL_FAILED, timestamp, "Before All/After All failed", details, name); - out.print(TEMPLATE_BEFORE_ALL_AFTER_ALL_FINISHED, timestamp, name); - } - - private void handleAttachment(Attachment event) { - String message = extractAttachmentMessage(event); - if (message != null) { - handleAttachment(message); - } - } - - private void handleAttachment(String message) { - out.print(TEMPLATE_ATTACH_WRITE_EVENT, message); + this.writer = MessagesToTeamCityWriter.builder() + .feature(PRINT_TEST_CASES_AFTER_TEST_RUN, isMultiThreaded) + .build(out); + publisher.registerHandlerFor(Envelope.class, this::write); } - private static String extractAttachmentMessage(Attachment event) { - switch (event.getContentEncoding()) { - case IDENTITY: - return "Write event:\n" + event.getBody() + "\n"; - case BASE64: - String name = event.getFileName().map(s -> s + " ").orElse(""); - return "Embed event: " + name + "[" + event.getMediaType() + " " + (event.getBody().length() / 4) * 3 - + " bytes]\n"; - default: - return null; + private void write(Envelope event) { + try { + writer.write(event); + } catch (IOException e) { + throw new IllegalStateException(e); } } - private static String formatTimeStamp(Timestamp timestamp) { - ZonedDateTime date = Convertor.toInstant(timestamp).atZone(ZoneOffset.UTC); - return DATE_FORMAT.format(date); - } - - private static class TeamCityCommandWriter implements Closeable { - private final PrintStream out; - - public TeamCityCommandWriter(PrintStream out) { - this.out = out; - } - - private void print(String command, Object... args) { - out.println(formatCommand(command, args)); - } - - private String formatCommand(String command, Object... parameters) { - String[] escapedParameters = new String[parameters.length]; - for (int i = 0; i < escapedParameters.length; i++) { - escapedParameters[i] = escape(parameters[i].toString()); - } - - return String.format(command, (Object[]) escapedParameters); - } - - private String escape(String source) { - if (source == null) { - return ""; - } - // https://www.jetbrains.com/help/teamcity/service-messages.html#Escaped+Values - // TODO: Missing \\uXXXX - return source - .replace("|", "||") - .replace("'", "|'") - .replace("\n", "|n") - .replace("\r", "|r") - .replace("[", "|[") - .replace("]", "|]"); - } - - @Override - public void close() { - out.close(); - } - } - - private static class ComparisonFailure { - - private static final Pattern[] COMPARE_PATTERNS = new Pattern[] { - // Hamcrest 2 MatcherAssert.assertThat - Pattern.compile("expected: (.*)(?:\r\n|\r|\n) {5}but: was (.*)$", - Pattern.DOTALL | Pattern.CASE_INSENSITIVE), - // AssertJ 3 ShouldBeEqual.smartErrorMessage - Pattern.compile("expected: (.*)(?:\r\n|\r|\n) but was: (.*)$", - Pattern.DOTALL | Pattern.CASE_INSENSITIVE), - // JUnit 5 AssertionFailureBuilder - Pattern.compile("expected: <(.*)> but was: <(.*)>$", - Pattern.DOTALL | Pattern.CASE_INSENSITIVE), - // JUnit 4 Assert.assertEquals - Pattern.compile("expected:\\s?<(.*)> but was:\\s?<(.*)>$", - Pattern.DOTALL | Pattern.CASE_INSENSITIVE), - // TestNG 7 Assert.assertEquals - Pattern.compile("expected \\[(.*)] but found \\[(.*)]\n$", - Pattern.DOTALL | Pattern.CASE_INSENSITIVE), - }; - - static ComparisonFailure parse(String message) { - for (Pattern pattern : COMPARE_PATTERNS) { - ComparisonFailure result = parse(message, pattern); - if (result != null) { - return result; - } - } - return null; - } - - static ComparisonFailure parse(String message, Pattern pattern) { - final Matcher matcher = pattern.matcher(message); - if (!matcher.find()) { - return null; - } - String expected = matcher.group(1); - String actual = matcher.group(2); - return new ComparisonFailure(expected, actual); - } - - private final String expected; - - private final String actual; - - ComparisonFailure(String expected, String actual) { - this.expected = requireNonNull(expected); - this.actual = requireNonNull(actual); - } - - public String getExpected() { - return expected; - } - - public String getActual() { - return actual; - } - } - - private static final class TreeNode { - private final String name; - private final String uri; - private final Location location; - - private TreeNode(String name, String uri, Location location) { - this.name = name; - this.uri = uri; - this.location = location; - } - - public String getName() { - return name; - } - - public String getUri() { - return uri; - } - - public Location getLocation() { - return location; - } - - @Override - public boolean equals(Object o) { - if (o == null || getClass() != o.getClass()) - return false; - TreeNode that = (TreeNode) o; - return Objects.equals(name, that.name) && Objects.equals(uri, that.uri) - && Objects.equals(location, that.location); - } - - @Override - public int hashCode() { - return Objects.hash(name, uri, location); - } - } - - private static class PathCollector implements LineageReducer.Collector> { - // There are at most 5 levels to a feature file. - private final List path = new ArrayList<>(5); - private String uri; - private String scenarioName; - private int examplesIndex; - private boolean isExample; - - @Override - public void add(GherkinDocument document) { - uri = document.getUri().orElse(""); - } - - @Override - public void add(Feature feature) { - String name = getNameOrKeyword(feature.getName(), feature.getKeyword()); - path.add(new TreeNode(name, uri, feature.getLocation())); - } - - @Override - public void add(Rule rule) { - String name = getNameOrKeyword(rule.getName(), rule.getKeyword()); - path.add(new TreeNode(name, uri, rule.getLocation())); - } - - @Override - public void add(Scenario scenario) { - String name = getNameOrKeyword(scenario.getName(), scenario.getKeyword()); - path.add(new TreeNode(name, uri, scenario.getLocation())); - scenarioName = name; - } - - @Override - public void add(Examples examples, int index) { - String name = getNameOrKeyword(examples.getName(), examples.getKeyword()); - path.add(new TreeNode(name, uri, examples.getLocation())); - examplesIndex = index; - } - - @Override - public void add(TableRow example, int index) { - isExample = true; - String name = "#" + (examplesIndex + 1) + "." + (index + 1); - path.add(new TreeNode(name, uri, example.getLocation())); - } - - @Override - public void add(Pickle pickle) { - // Case 1: Pickles from a scenario outline - if (isExample) { - String pickleName = pickle.getName(); - boolean parameterized = !scenarioName.equals(pickleName); - if (parameterized) { - TreeNode example = path.remove(path.size() - 1); - String parameterizedExampleName = example.getName() + ": " + pickleName; - path.add(new TreeNode(parameterizedExampleName, example.getUri(), example.getLocation())); - } - } - // Case 2: Pickles from a scenario - // Nothing to do, scenario name and pickle name are the same. - } - - @Override - public List finish() { - return path; - } - - private static String getNameOrKeyword(String name, String keyword) { - if (!name.isEmpty()) { - return name; - } - if (!keyword.isEmpty()) { - return keyword; - } - // Always return a non-empty string otherwise the tree diagram is - // hard to click. - return "Unknown"; - } - } } From fe10f7c94b04a36f240a9ede56318611755576bd Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Fri, 12 Sep 2025 14:31:13 +0200 Subject: [PATCH 13/21] Extract to repo --- .../cucumber/core/plugin/TeamCityPlugin.java | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index c8ea0e18a8..a25b10691c 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -6,6 +6,7 @@ import io.cucumber.teamcityformatter.MessagesToTeamCityWriter; import java.io.IOException; +import java.io.OutputStream; import java.io.PrintStream; import static io.cucumber.teamcityformatter.MessagesToTeamCityWriter.TeamCityFeature.PRINT_TEST_CASES_AFTER_TEST_RUN; @@ -19,7 +20,7 @@ */ public class TeamCityPlugin implements ConcurrentEventListener { - private final PrintStream out; + private final OutputStream out; private MessagesToTeamCityWriter writer; @SuppressWarnings("unused") // Used by PluginFactory @@ -28,10 +29,15 @@ public TeamCityPlugin() { // allows them to associate the output to specific test cases. Printing // to system out - and potentially mixing with other formatters - is // intentional. - this(System.out); + this(new PrintStream(System.out) { + @Override + public void close() { + // Don't close System.out + } + }); } - TeamCityPlugin(PrintStream out) { + TeamCityPlugin(OutputStream out) { this.out = out; } @@ -54,6 +60,17 @@ private void write(Envelope event) { } catch (IOException e) { throw new IllegalStateException(e); } + + // TODO: Plugins should implement the closable interface + // and be closed by Cucumber + if (event.getTestRunFinished().isPresent()) { + try { + // Does not close System.out but will flush the intermediate writers + writer.close(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } } } From 7a34d40cf3e36aaedb1663517de6e0defbe39687 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 14 Sep 2025 15:04:13 +0200 Subject: [PATCH 14/21] Revert unneeded changes --- .../cucumber/core/plugin/CanonicalOrderEventPublisher.java | 5 ----- .../src/main/java/io/cucumber/core/plugin/Plugins.java | 2 -- 2 files changed, 7 deletions(-) diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java index 5367f75274..4aa665c826 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java @@ -1,7 +1,6 @@ package io.cucumber.core.plugin; import io.cucumber.core.eventbus.AbstractEventPublisher; -import io.cucumber.messages.types.Envelope; import io.cucumber.plugin.event.Event; import io.cucumber.plugin.event.TestRunFinished; @@ -20,8 +19,4 @@ public void handle(final Event event) { queue.clear(); } } - - public void handle(final Envelope event) { - send(event); - } } diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java index db6059d6c8..09f628eb91 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java @@ -99,8 +99,6 @@ private EventPublisher getOrderedEventPublisher(EventPublisher eventPublisher) { private static EventPublisher createCanonicalOrderEventPublisher(EventPublisher eventPublisher) { final CanonicalOrderEventPublisher canonicalOrderEventPublisher = new CanonicalOrderEventPublisher(); eventPublisher.registerHandlerFor(Event.class, canonicalOrderEventPublisher::handle); - // Pass through for messages - eventPublisher.registerHandlerFor(Envelope.class, canonicalOrderEventPublisher::handle); return canonicalOrderEventPublisher; } From 1877e48a16fca70cbac7210dbb8059100eeabbab Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 14 Sep 2025 15:05:56 +0200 Subject: [PATCH 15/21] Revert unneeded changes --- .../io/cucumber/core/plugin/CanonicalOrderEventPublisher.java | 1 + 1 file changed, 1 insertion(+) diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java index 4aa665c826..5e687f9604 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/CanonicalOrderEventPublisher.java @@ -19,4 +19,5 @@ public void handle(final Event event) { queue.clear(); } } + } From 3c5852debb77aa9839998d6ee5d5b620cbc1a0fb Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 14 Sep 2025 16:54:04 +0200 Subject: [PATCH 16/21] Bump version --- cucumber-bom/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cucumber-bom/pom.xml b/cucumber-bom/pom.xml index 395c436c90..aa915480fc 100644 --- a/cucumber-bom/pom.xml +++ b/cucumber-bom/pom.xml @@ -23,7 +23,7 @@ 2.2.0 14.0.1 6.1.2 - 0.0.1-SNAPSHOT + 0.1.0 0.6.0 From 875d0a3c6945230f982a75e553f2dbcaa8da1d8d Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 14 Sep 2025 17:15:03 +0200 Subject: [PATCH 17/21] Bump version --- cucumber-bom/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cucumber-bom/pom.xml b/cucumber-bom/pom.xml index aa915480fc..5700ca5069 100644 --- a/cucumber-bom/pom.xml +++ b/cucumber-bom/pom.xml @@ -23,7 +23,7 @@ 2.2.0 14.0.1 6.1.2 - 0.1.0 + 0.1.1 0.6.0 From 9851011455fe76141c1fcb4b2ee4534269f312d2 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 14 Sep 2025 17:15:08 +0200 Subject: [PATCH 18/21] Simplify tests --- .../core/plugin/TeamCityPluginTest.java | 403 ++---------------- 1 file changed, 33 insertions(+), 370 deletions(-) diff --git a/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java b/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java index 77af5f1ae9..83153a7a5a 100755 --- a/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java +++ b/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java @@ -1,408 +1,71 @@ package io.cucumber.core.plugin; -import io.cucumber.core.backend.StubHookDefinition; -import io.cucumber.core.backend.StubPendingException; -import io.cucumber.core.backend.StubStaticHookDefinition; import io.cucumber.core.backend.StubStepDefinition; -import io.cucumber.core.backend.TestCaseState; import io.cucumber.core.feature.TestFeatureParser; import io.cucumber.core.gherkin.Feature; +import io.cucumber.core.options.RuntimeOptionsBuilder; +import io.cucumber.core.runner.StepDurationTimeService; import io.cucumber.core.runtime.Runtime; import io.cucumber.core.runtime.StubBackendSupplier; import io.cucumber.core.runtime.StubFeatureSupplier; import io.cucumber.core.runtime.TimeServiceEventBus; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.condition.DisabledOnOs; -import org.junit.jupiter.api.condition.OS; import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.PrintStream; +import java.time.Duration; import java.util.UUID; -import static io.cucumber.core.backend.HookDefinition.HookType.BEFORE; import static io.cucumber.core.plugin.Bytes.bytes; -import static io.cucumber.core.plugin.TeamCityPluginTestStepDefinition.getAnnotationSourceReference; -import static io.cucumber.core.plugin.TeamCityPluginTestStepDefinition.getStackSourceReference; -import static java.nio.charset.StandardCharsets.UTF_8; -import static java.time.Clock.fixed; -import static java.time.Instant.EPOCH; -import static java.time.ZoneId.of; -import static java.util.Collections.emptyList; -import static java.util.Collections.singletonList; -import static org.hamcrest.CoreMatchers.containsString; +import static io.cucumber.core.plugin.IsEqualCompressingLineSeparators.equalCompressingLineSeparators; +import static io.cucumber.core.plugin.PrettyFormatterStepDefinition.oneReference; +import static io.cucumber.core.plugin.PrettyFormatterStepDefinition.threeReference; +import static io.cucumber.core.plugin.PrettyFormatterStepDefinition.twoReference; import static org.hamcrest.MatcherAssert.assertThat; -import static org.junit.jupiter.api.AssertionFailureBuilder.assertionFailure; -import static org.junit.jupiter.api.Assertions.assertThrows; -@DisabledOnOs(OS.WINDOWS) class TeamCityPluginTest { @Test - void should_handle_scenario_outline() { + void writes_teamcity_report() { Feature feature = TestFeatureParser.parse("path/test.feature", "" + "Feature: feature name\n" + - " Scenario Outline: \n" + + " Scenario: scenario name\n" + " Given first step\n" + - " Then step\n" + - " Examples: examples name\n" + - " | name | arg |\n" + - " | name 1 | second |\n" + - " | name 2 | third |\n"); + " When second step\n" + + " Then third step\n"); + StepDurationTimeService timeService = new StepDurationTimeService(Duration.ofMillis(1000)); ByteArrayOutputStream out = new ByteArrayOutputStream(); Runtime.builder() + .withEventBus(new TimeServiceEventBus(timeService, UUID::randomUUID)) .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) + .withAdditionalPlugins(timeService, new TeamCityPlugin(out)) + .withRuntimeOptions(new RuntimeOptionsBuilder().setMonochrome().build()) .withBackendSupplier(new StubBackendSupplier( - new StubStepDefinition("first step"), - new StubStepDefinition("second step"), - new StubStepDefinition("third step"))) + new StubStepDefinition("first step", oneReference()), + new StubStepDefinition("second step", twoReference()), + new StubStepDefinition("third step", threeReference()))) .build() .run(); - String location = new File("").toURI().toString(); - - String expected = "" + + assertThat(out, bytes(equalCompressingLineSeparators("" + "##teamcity[enteredTheMatrix timestamp = '1970-01-01T12:00:00.000+0000']\n" + "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' name = 'Cucumber']\n" + - "##teamcity[customProgressStatus testsCategory = 'Scenarios' count = '0' timestamp = '1970-01-01T12:00:00.000+0000']\n" - + - "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location - + "path/test.feature:1' name = 'feature name']\n" + - "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location - + "path/test.feature:2' name = '']\n" + - "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location - + "path/test.feature:5' name = 'examples name']\n" + - "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location - + "path/test.feature:7' name = '#1.1: name 1']\n" + - "##teamcity[customProgressStatus type = 'testStarted' timestamp = '1970-01-01T12:00:00.000+0000']\n" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location - + "path/test.feature:3' captureStandardOutput = 'true' name = 'first step']\n" + - "##teamcity[testFinished timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' name = 'first step']\n" - + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location - + "path/test.feature:4' captureStandardOutput = 'true' name = 'second step']\n" + - "##teamcity[testFinished timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' name = 'second step']\n" - + - "##teamcity[customProgressStatus type = 'testFinished' timestamp = '1970-01-01T12:00:00.000+0000']\n" + - "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:00.000+0000' name = '#1.1: name 1']\n" - + - "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location - + "path/test.feature:8' name = '#1.2: name 2']\n" + + "##teamcity[customProgressStatus testsCategory = 'Scenarios' count = '0' timestamp = '1970-01-01T12:00:00.000+0000']\n" + + "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:1' name = 'feature name']\n" + + "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:2' name = 'scenario name']\n" + "##teamcity[customProgressStatus type = 'testStarted' timestamp = '1970-01-01T12:00:00.000+0000']\n" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location - + "path/test.feature:3' captureStandardOutput = 'true' name = 'first step']\n" + - "##teamcity[testFinished timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' name = 'first step']\n" - + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = '" + location - + "path/test.feature:4' captureStandardOutput = 'true' name = 'third step']\n" + - "##teamcity[testFinished timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' name = 'third step']\n" - + - "##teamcity[customProgressStatus type = 'testFinished' timestamp = '1970-01-01T12:00:00.000+0000']\n" + - "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:00.000+0000' name = '#1.2: name 2']\n" - + - "##teamcity[customProgressStatus testsCategory = '' count = '0' timestamp = '1970-01-01T12:00:00.000+0000']\n" - + - "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:00.000+0000' name = 'examples name']\n" + - "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:00.000+0000' name = '']\n" + - "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:00.000+0000' name = 'feature name']\n" + - "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:00.000+0000' name = 'Cucumber']\n"; - - assertThat(out, bytes(containsString(expected))); - } - - @Test - void should_handle_nameless_attach_events() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier(new StubBackendSupplier( - singletonList(new StubHookDefinition( - (TestCaseState state) -> state.attach("A message".getBytes(UTF_8), "text/plain", null))), - singletonList(new StubStepDefinition("first step")), - emptyList())) - .build() - .run(); - - assertThat(out, bytes(containsString("" + - "##teamcity[message text='Embed event: |[text/plain 9 bytes|]|n' status='NORMAL']\n"))); - } - - @Test - void should_handle_write_events() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier(new StubBackendSupplier( - singletonList(new StubHookDefinition((TestCaseState state) -> state.log("A message"))), - singletonList(new StubStepDefinition("first step")), - emptyList())) - .build() - .run(); - - assertThat(out, bytes(containsString("" + - "##teamcity[message text='Write event:|nA message|n' status='NORMAL']\n"))); - } - - @Test - void should_handle_attach_events() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier(new StubBackendSupplier( - singletonList(new StubHookDefinition( - (TestCaseState state) -> state.attach("A message".getBytes(UTF_8), "text/plain", - "message.txt"))), - singletonList(new StubStepDefinition("first step")), - emptyList())) - .build() - .run(); - - assertThat(out, bytes(containsString("" + - "##teamcity[message text='Embed event: message.txt |[text/plain 9 bytes|]|n' status='NORMAL']\n"))); - } - - @Test - void should_print_error_message_for_failed_steps() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier(new StubBackendSupplier( - new StubStepDefinition("first step", - new StubException("Step failed") - .withStacktrace("the stack trace")))) - .build() - .run(); - - assertThat(out, bytes(containsString("" + - "##teamcity[testFailed timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' message = 'Step failed' details = 'Step failed|n\tthe stack trace|n' name = 'first step']\n"))); - } - - @Test - void should_print_error_message_for_undefined_steps() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n" + - " Given second step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier(new StubBackendSupplier()) - .build() - .run(); - - assertThat(out, bytes(containsString("" + - "##teamcity[testFailed timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' message = 'Step undefined' details = 'You can implement this step and 1 other step(s) using the snippet(s) below:|n|ntest snippet 0|ntest snippet 1|n' name = 'first step']"))); - } - - @Test - void should_print_error_message_for_pending_steps() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n" + - " Given second step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier( - new StubBackendSupplier(new StubStepDefinition("first step", new StubPendingException()))) - .build() - .run(); - - assertThat(out, bytes(containsString("" + - "##teamcity[testFailed timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' message = 'Step pending' details = 'TODO: implement me' name = 'first step']"))); - } - - @Test - void should_print_error_message_for_before_hooks() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier(new StubBackendSupplier( - singletonList( - new StubHookDefinition(getAnnotationSourceReference(), BEFORE, new StubException() - .withStacktrace("the stack trace"))), - singletonList(new StubStepDefinition("first step")), - emptyList())) - .build() - .run(); - - assertThat(out, bytes(containsString("" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'java:test://io.cucumber.core.plugin.TeamCityPluginTestStepDefinition/beforeHook' captureStandardOutput = 'true' name = 'Before(beforeHook)']\n" - + - "##teamcity[testFailed timestamp = '1970-01-01T12:00:00.000+0000' duration = '0' message = 'Step failed' details = 'stub exception|n\tthe stack trace|n' name = 'Before(beforeHook)']"))); + "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:3' captureStandardOutput = 'true' name = 'first step']\n" + + "##teamcity[testFinished timestamp = '1970-01-01T12:00:01.000+0000' duration = '1000' name = 'first step']\n" + + "##teamcity[testStarted timestamp = '1970-01-01T12:00:01.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:4' captureStandardOutput = 'true' name = 'second step']\n" + + "##teamcity[testFinished timestamp = '1970-01-01T12:00:02.000+0000' duration = '1000' name = 'second step']\n" + + "##teamcity[testStarted timestamp = '1970-01-01T12:00:02.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:5' captureStandardOutput = 'true' name = 'third step']\n" + + "##teamcity[testFinished timestamp = '1970-01-01T12:00:03.000+0000' duration = '1000' name = 'third step']\n" + + "##teamcity[customProgressStatus type = 'testFinished' timestamp = '1970-01-01T12:00:03.000+0000']\n" + + "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:03.000+0000' name = 'scenario name']\n" + + "##teamcity[customProgressStatus testsCategory = '' count = '0' timestamp = '1970-01-01T12:00:03.000+0000']\n" + + "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:03.000+0000' name = 'feature name']\n" + + "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:03.000+0000' name = 'Cucumber']"))); } - @Test - void should_print_location_hint_for_java_hooks() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier(new StubBackendSupplier( - singletonList(new StubHookDefinition(getAnnotationSourceReference(), BEFORE)), - singletonList(new StubStepDefinition("first step")), - emptyList())) - .build() - .run(); - - assertThat(out, bytes(containsString("" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'java:test://io.cucumber.core.plugin.TeamCityPluginTestStepDefinition/beforeHook' captureStandardOutput = 'true' name = 'Before(beforeHook)']\n"))); - } - - @Test - void should_print_location_hint_for_lambda_hooks() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier(new StubBackendSupplier( - singletonList(new StubHookDefinition(getStackSourceReference(), BEFORE)), - singletonList(new StubStepDefinition("first step")), - emptyList())) - .build() - .run(); - - assertThat(out, bytes(containsString("" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'java:test://io.cucumber.core.plugin.TeamCityPluginTestStepDefinition/TeamCityPluginTestStepDefinition' captureStandardOutput = 'true' name = 'Before(TeamCityPluginTestStepDefinition)']\n"))); - } - - @Test - void should_print_system_failure_for_failed_hooks() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - - assertThrows(StubException.class, () -> Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier(new StubBackendSupplier( - emptyList(), - emptyList(), - emptyList(), - emptyList(), - emptyList(), - emptyList(), - singletonList(new StubStaticHookDefinition( - new StubException("Hook failed") - .withStacktrace("the stack trace"))))) - .build() - .run()); - - assertThat(out, bytes(containsString("" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' name = 'Before All/After All']\n" + - "##teamcity[testFailed timestamp = '1970-01-01T12:00:00.000+0000' message = 'Before All/After All failed' details = 'Hook failed|n\tthe stack trace|n' name = 'Before All/After All']\n" - + - "##teamcity[testFinished timestamp = '1970-01-01T12:00:00.000+0000' name = 'Before All/After All']"))); - } - - @Test - void should_print_comparison_failure_for_failed_assert_equal() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier(new StubBackendSupplier( - emptyList(), - singletonList( - new StubStepDefinition("first step", assertionFailure().expected(1).actual(2).build())), - emptyList())) - .build() - .run(); - - assertThat(out, bytes(containsString("expected = '1' actual = '2' name = 'first step']"))); - } - - @Test - void should_print_comparison_failure_for_failed_assert_equal_with_prefix() { - Feature feature = TestFeatureParser.parse("path/test.feature", "" + - "Feature: feature name\n" + - " Scenario: scenario name\n" + - " Given first step\n"); - - ByteArrayOutputStream out = new ByteArrayOutputStream(); - Runtime.builder() - .withFeatureSupplier(new StubFeatureSupplier(feature)) - .withAdditionalPlugins(new TeamCityPlugin(new PrintStream(out))) - .withEventBus(new TimeServiceEventBus(fixed(EPOCH, of("UTC")), UUID::randomUUID)) - .withBackendSupplier(new StubBackendSupplier( - emptyList(), - singletonList( - new StubStepDefinition("first step", - assertionFailure().message("oops").expected("one value").actual("another value").build())), - emptyList())) - .build() - .run(); - - assertThat(out, - bytes(containsString("expected = 'one value' actual = 'another value' name = 'first step']"))); - } } From d149af55d0aa47d8e8dba370085a0b328dfd587c Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 14 Sep 2025 17:16:37 +0200 Subject: [PATCH 19/21] Spotless --- .../cucumber/core/plugin/TeamCityPlugin.java | 3 +- .../core/plugin/TeamCityPluginTest.java | 37 ++++++++++++------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java index a25b10691c..9a4102a8cb 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/TeamCityPlugin.java @@ -65,7 +65,8 @@ private void write(Envelope event) { // and be closed by Cucumber if (event.getTestRunFinished().isPresent()) { try { - // Does not close System.out but will flush the intermediate writers + // Does not close System.out but will flush the intermediate + // writers writer.close(); } catch (IOException e) { throw new IllegalStateException(e); diff --git a/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java b/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java index 83153a7a5a..aa3df1ab31 100755 --- a/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java +++ b/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java @@ -41,31 +41,40 @@ void writes_teamcity_report() { .withAdditionalPlugins(timeService, new TeamCityPlugin(out)) .withRuntimeOptions(new RuntimeOptionsBuilder().setMonochrome().build()) .withBackendSupplier(new StubBackendSupplier( - new StubStepDefinition("first step", oneReference()), - new StubStepDefinition("second step", twoReference()), - new StubStepDefinition("third step", threeReference()))) + new StubStepDefinition("first step", oneReference()), + new StubStepDefinition("second step", twoReference()), + new StubStepDefinition("third step", threeReference()))) .build() .run(); assertThat(out, bytes(equalCompressingLineSeparators("" + "##teamcity[enteredTheMatrix timestamp = '1970-01-01T12:00:00.000+0000']\n" + "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' name = 'Cucumber']\n" + - "##teamcity[customProgressStatus testsCategory = 'Scenarios' count = '0' timestamp = '1970-01-01T12:00:00.000+0000']\n" + - "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:1' name = 'feature name']\n" + - "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:2' name = 'scenario name']\n" + + "##teamcity[customProgressStatus testsCategory = 'Scenarios' count = '0' timestamp = '1970-01-01T12:00:00.000+0000']\n" + + + "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:1' name = 'feature name']\n" + + + "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:2' name = 'scenario name']\n" + + "##teamcity[customProgressStatus type = 'testStarted' timestamp = '1970-01-01T12:00:00.000+0000']\n" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:3' captureStandardOutput = 'true' name = 'first step']\n" + - "##teamcity[testFinished timestamp = '1970-01-01T12:00:01.000+0000' duration = '1000' name = 'first step']\n" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:01.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:4' captureStandardOutput = 'true' name = 'second step']\n" + - "##teamcity[testFinished timestamp = '1970-01-01T12:00:02.000+0000' duration = '1000' name = 'second step']\n" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:02.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:5' captureStandardOutput = 'true' name = 'third step']\n" + - "##teamcity[testFinished timestamp = '1970-01-01T12:00:03.000+0000' duration = '1000' name = 'third step']\n" + + "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:3' captureStandardOutput = 'true' name = 'first step']\n" + + + "##teamcity[testFinished timestamp = '1970-01-01T12:00:01.000+0000' duration = '1000' name = 'first step']\n" + + + "##teamcity[testStarted timestamp = '1970-01-01T12:00:01.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:4' captureStandardOutput = 'true' name = 'second step']\n" + + + "##teamcity[testFinished timestamp = '1970-01-01T12:00:02.000+0000' duration = '1000' name = 'second step']\n" + + + "##teamcity[testStarted timestamp = '1970-01-01T12:00:02.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:5' captureStandardOutput = 'true' name = 'third step']\n" + + + "##teamcity[testFinished timestamp = '1970-01-01T12:00:03.000+0000' duration = '1000' name = 'third step']\n" + + "##teamcity[customProgressStatus type = 'testFinished' timestamp = '1970-01-01T12:00:03.000+0000']\n" + "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:03.000+0000' name = 'scenario name']\n" + - "##teamcity[customProgressStatus testsCategory = '' count = '0' timestamp = '1970-01-01T12:00:03.000+0000']\n" + + "##teamcity[customProgressStatus testsCategory = '' count = '0' timestamp = '1970-01-01T12:00:03.000+0000']\n" + + "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:03.000+0000' name = 'feature name']\n" + "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:03.000+0000' name = 'Cucumber']"))); } - } From 531c35eb3c0531b8c5e1cecbf93497353c9e51a0 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 14 Sep 2025 17:23:23 +0200 Subject: [PATCH 20/21] Touch ups --- CHANGELOG.md | 16 ++++++++-------- cucumber-bom/pom.xml | 2 +- .../java/io/cucumber/core/plugin/Plugins.java | 1 - 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bcd1c7c70..caa8cfc3a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - [Core] Use a message based TeamCity plugin ([#3050](https://github.com/cucumber/cucumber-jvm/pull/3050) M.P. Korstanje) -- [Core] Update dependency io.cucumber:cucumber-json-formatter to v0.2.0 -- [Core] Update dependency io.cucumber:gherkin to v35.0.0 -- [Core] Update dependency io.cucumber:html-formatter to v21.15.0 -- [Core] Update dependency io.cucumber:junit-xml-formatter to v0.9.0 -- [Core] Update dependency io.cucumber:messages to v29.0.1 -- [Core] Update dependency io.cucumber:pretty-formatter to v2.2.0 -- [Core] Update dependency io.cucumber:query to v14.0.1 -- [Core] Update dependency io.cucumber:testng-xml-formatter to v0.6.0 +- [Core] Update dependency `io.cucumber:cucumber-json-formatter` to v0.2.1 +- [Core] Update dependency `io.cucumber:gherkin` to v35.0.0 +- [Core] Update dependency `io.cucumber:html-formatter` to v21.15.0 +- [Core] Update dependency `io.cucumber:junit-xml-formatter` to v0.9.0 +- [Core] Update dependency `io.cucumber:messages` to v29.0.1 +- [Core] Update dependency `io.cucumber:pretty-formatter` to v2.2.0 +- [Core] Update dependency `io.cucumber:query` to v14.0.1 +- [Core] Update dependency `io.cucumber:testng-xml-formatter` to v0.6.0 ## [7.28.2] - 2025-09-09 ### Fixed diff --git a/cucumber-bom/pom.xml b/cucumber-bom/pom.xml index 5700ca5069..efbabd8733 100644 --- a/cucumber-bom/pom.xml +++ b/cucumber-bom/pom.xml @@ -15,7 +15,7 @@ 10.0.1 18.0.1 - 0.2.0 + 0.2.1 35.0.0 21.15.1 0.9.0 diff --git a/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java b/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java index 09f628eb91..7c13621240 100644 --- a/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java +++ b/cucumber-core/src/main/java/io/cucumber/core/plugin/Plugins.java @@ -1,6 +1,5 @@ package io.cucumber.core.plugin; -import io.cucumber.messages.types.Envelope; import io.cucumber.plugin.ColorAware; import io.cucumber.plugin.ConcurrentEventListener; import io.cucumber.plugin.EventListener; From bcf4a7cd5b2ce949d0e2a204833c0805daad66e8 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sun, 14 Sep 2025 17:45:25 +0200 Subject: [PATCH 21/21] Fixup --- .../core/plugin/TeamCityPluginTest.java | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java b/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java index aa3df1ab31..a0a85af12d 100755 --- a/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java +++ b/cucumber-core/src/test/java/io/cucumber/core/plugin/TeamCityPluginTest.java @@ -10,8 +10,11 @@ import io.cucumber.core.runtime.StubFeatureSupplier; import io.cucumber.core.runtime.TimeServiceEventBus; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.DisabledOnOs; +import org.junit.jupiter.api.condition.OS; import java.io.ByteArrayOutputStream; +import java.io.File; import java.time.Duration; import java.util.UUID; @@ -22,6 +25,7 @@ import static io.cucumber.core.plugin.PrettyFormatterStepDefinition.twoReference; import static org.hamcrest.MatcherAssert.assertThat; +@DisabledOnOs(OS.WINDOWS) class TeamCityPluginTest { @Test @@ -47,25 +51,26 @@ void writes_teamcity_report() { .build() .run(); - assertThat(out, bytes(equalCompressingLineSeparators("" + + String featureFile = new File("").toURI() + "path/test.feature"; + assertThat(out, bytes(equalCompressingLineSeparators(("" + "##teamcity[enteredTheMatrix timestamp = '1970-01-01T12:00:00.000+0000']\n" + "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' name = 'Cucumber']\n" + "##teamcity[customProgressStatus testsCategory = 'Scenarios' count = '0' timestamp = '1970-01-01T12:00:00.000+0000']\n" + - "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:1' name = 'feature name']\n" + "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'path/test.feature:1' name = 'feature name']\n" + - "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:2' name = 'scenario name']\n" + "##teamcity[testSuiteStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'path/test.feature:2' name = 'scenario name']\n" + "##teamcity[customProgressStatus type = 'testStarted' timestamp = '1970-01-01T12:00:00.000+0000']\n" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:3' captureStandardOutput = 'true' name = 'first step']\n" + "##teamcity[testStarted timestamp = '1970-01-01T12:00:00.000+0000' locationHint = 'path/test.feature:3' captureStandardOutput = 'true' name = 'first step']\n" + "##teamcity[testFinished timestamp = '1970-01-01T12:00:01.000+0000' duration = '1000' name = 'first step']\n" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:01.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:4' captureStandardOutput = 'true' name = 'second step']\n" + "##teamcity[testStarted timestamp = '1970-01-01T12:00:01.000+0000' locationHint = 'path/test.feature:4' captureStandardOutput = 'true' name = 'second step']\n" + "##teamcity[testFinished timestamp = '1970-01-01T12:00:02.000+0000' duration = '1000' name = 'second step']\n" + - "##teamcity[testStarted timestamp = '1970-01-01T12:00:02.000+0000' locationHint = 'file:/home/mpkorstanje/Projects/cucumber/cucumber-jvm/cucumber-core/path/test.feature:5' captureStandardOutput = 'true' name = 'third step']\n" + "##teamcity[testStarted timestamp = '1970-01-01T12:00:02.000+0000' locationHint = 'path/test.feature:5' captureStandardOutput = 'true' name = 'third step']\n" + "##teamcity[testFinished timestamp = '1970-01-01T12:00:03.000+0000' duration = '1000' name = 'third step']\n" + @@ -74,7 +79,8 @@ void writes_teamcity_report() { "##teamcity[customProgressStatus testsCategory = '' count = '0' timestamp = '1970-01-01T12:00:03.000+0000']\n" + "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:03.000+0000' name = 'feature name']\n" + - "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:03.000+0000' name = 'Cucumber']"))); + "##teamcity[testSuiteFinished timestamp = '1970-01-01T12:00:03.000+0000' name = 'Cucumber']") + .replaceAll("path/test.feature", featureFile)))); } }