From 5a0e9e583599d9ea85f45eb67efb14e11bfd429e Mon Sep 17 00:00:00 2001 From: romovs Date: Wed, 9 Sep 2026 17:38:55 +0300 Subject: [PATCH 1/2] Support live status --- cb-kit-common/pom.xml | 2 +- .../common/client/api/GatewayApi.java | 10 ++ .../common/client/api/GatewayApiRetro.java | 6 + .../client/dto/CaseStatusUpdateRequest.java | 158 ++++++++++++++++++ .../common/client/dto/RunStatusEnum.java | 27 +++ .../client/dto/SuiteStatusUpdateRequest.java | 149 +++++++++++++++++ .../common/reporter/CbTestReporter.java | 122 ++++++++++++++ .../junit4/CloudBeatJUnit4Listener.java | 78 ++++++++- cb-kit-junit5/pom.xml | 14 ++ .../junit/CbJunitExecutionListener.java | 56 +++++++ .../io/cloudbeat/junit/CbJunitExtension.java | 1 - ...it.platform.launcher.TestExecutionListener | 1 + .../io/cloudbeat/testng/CbTestNGListener.java | 6 +- .../io/cloudbeat/testng/CbTestNGReporter.java | 18 ++ pom.xml | 7 +- 15 files changed, 641 insertions(+), 14 deletions(-) create mode 100644 cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/CaseStatusUpdateRequest.java create mode 100644 cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/RunStatusEnum.java create mode 100644 cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/SuiteStatusUpdateRequest.java create mode 100644 cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExecutionListener.java create mode 100644 cb-kit-junit5/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener diff --git a/cb-kit-common/pom.xml b/cb-kit-common/pom.xml index 8ee4eee..b26bbcd 100755 --- a/cb-kit-common/pom.xml +++ b/cb-kit-common/pom.xml @@ -25,7 +25,7 @@ - 2.16.1 + 2.18.9 diff --git a/cb-kit-common/src/main/java/io/cloudbeat/common/client/api/GatewayApi.java b/cb-kit-common/src/main/java/io/cloudbeat/common/client/api/GatewayApi.java index 7499a76..c15088d 100644 --- a/cb-kit-common/src/main/java/io/cloudbeat/common/client/api/GatewayApi.java +++ b/cb-kit-common/src/main/java/io/cloudbeat/common/client/api/GatewayApi.java @@ -3,7 +3,9 @@ import io.cloudbeat.common.client.CbClientException; import io.cloudbeat.common.client.RetrofitApiBase; import io.cloudbeat.common.client.dto.CaseStatusInfoDto; +import io.cloudbeat.common.client.dto.CaseStatusUpdateRequest; import io.cloudbeat.common.client.dto.LoadTestMetricsUpdateRequest; +import io.cloudbeat.common.client.dto.SuiteStatusUpdateRequest; import io.cloudbeat.common.client.dto.TestStatusRequest; import io.cloudbeat.common.model.runtime.NewInstanceOptions; import io.cloudbeat.common.model.runtime.NewRunOptions; @@ -71,4 +73,12 @@ else if (status.get() == TestStatus.SKIPPED) } executeAsync(retroApi.updateTestCaseStatus(req)); } + + public void updateRuntimeCaseStatus(CaseStatusUpdateRequest request) throws CbClientException { + executeAsync(retroApi.updateRuntimeCaseStatus(request.getRunId(), request.getInstanceId(), request)); + } + + public void updateRuntimeSuiteStatus(SuiteStatusUpdateRequest request) throws CbClientException { + executeAsync(retroApi.updateRuntimeSuiteStatus(request.getRunId(), request.getInstanceId(), request)); + } } diff --git a/cb-kit-common/src/main/java/io/cloudbeat/common/client/api/GatewayApiRetro.java b/cb-kit-common/src/main/java/io/cloudbeat/common/client/api/GatewayApiRetro.java index 7fe6f34..4549443 100644 --- a/cb-kit-common/src/main/java/io/cloudbeat/common/client/api/GatewayApiRetro.java +++ b/cb-kit-common/src/main/java/io/cloudbeat/common/client/api/GatewayApiRetro.java @@ -1,6 +1,8 @@ package io.cloudbeat.common.client.api; +import io.cloudbeat.common.client.dto.CaseStatusUpdateRequest; import io.cloudbeat.common.client.dto.LoadTestMetricsUpdateRequest; +import io.cloudbeat.common.client.dto.SuiteStatusUpdateRequest; import io.cloudbeat.common.client.dto.TestStatusRequest; import retrofit2.Call; import retrofit2.http.Body; @@ -12,4 +14,8 @@ public interface GatewayApiRetro { Call updateTestCaseStatus(@Body TestStatusRequest statusRequest); @POST("testresult/load/run/{runId}/instance/{instanceId}/metrics") Call updateLoadTestMetrics(@Path("runId") String runId, @Path("instanceId") String instanceId, @Body LoadTestMetricsUpdateRequest request); + @POST("testresult/runtime/run/{runId}/instance/{instanceId}/case/status") + Call updateRuntimeCaseStatus(@Path("runId") String runId, @Path("instanceId") String instanceId, @Body CaseStatusUpdateRequest request); + @POST("testresult/runtime/run/{runId}/instance/{instanceId}/suite/status") + Call updateRuntimeSuiteStatus(@Path("runId") String runId, @Path("instanceId") String instanceId, @Body SuiteStatusUpdateRequest request); } diff --git a/cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/CaseStatusUpdateRequest.java b/cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/CaseStatusUpdateRequest.java new file mode 100644 index 0000000..81ec0e5 --- /dev/null +++ b/cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/CaseStatusUpdateRequest.java @@ -0,0 +1,158 @@ +package io.cloudbeat.common.client.dto; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.cloudbeat.common.reporter.model.TestStatus; +import io.cloudbeat.common.reporter.serializer.TestStatusSerializer; + +/** + * Request body for the new Redis-backed runtime status API: + * POST runtime/run/{runId}/instance/{instanceId}/case/status + * Field shape mirrors CaseStatusUpdateRequest in cb-new-arch (and its Node/.NET equivalents). + */ +public class CaseStatusUpdateRequest { + private Long timestamp; + private String runId; + private String instanceId; + private String id; + private String fqn; + private String parentFqn; + private String parentId; + private String parentName; + private String name; + private String displayName; + private Long startTime; + private Long endTime; + private RunStatusEnum runStatus; + @JsonSerialize(using = TestStatusSerializer.class) + private TestStatus testStatus; + private String framework; + private String language; + + public Long getTimestamp() { + return timestamp; + } + + public void setTimestamp(Long timestamp) { + this.timestamp = timestamp; + } + + public String getRunId() { + return runId; + } + + public void setRunId(String runId) { + this.runId = runId; + } + + public String getInstanceId() { + return instanceId; + } + + public void setInstanceId(String instanceId) { + this.instanceId = instanceId; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFqn() { + return fqn; + } + + public void setFqn(String fqn) { + this.fqn = fqn; + } + + public String getParentFqn() { + return parentFqn; + } + + public void setParentFqn(String parentFqn) { + this.parentFqn = parentFqn; + } + + public String getParentId() { + return parentId; + } + + public void setParentId(String parentId) { + this.parentId = parentId; + } + + public String getParentName() { + return parentName; + } + + public void setParentName(String parentName) { + this.parentName = parentName; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(String displayName) { + this.displayName = displayName; + } + + public Long getStartTime() { + return startTime; + } + + public void setStartTime(Long startTime) { + this.startTime = startTime; + } + + public Long getEndTime() { + return endTime; + } + + public void setEndTime(Long endTime) { + this.endTime = endTime; + } + + public RunStatusEnum getRunStatus() { + return runStatus; + } + + public void setRunStatus(RunStatusEnum runStatus) { + this.runStatus = runStatus; + } + + public TestStatus getTestStatus() { + return testStatus; + } + + public void setTestStatus(TestStatus testStatus) { + this.testStatus = testStatus; + } + + public String getFramework() { + return framework; + } + + public void setFramework(String framework) { + this.framework = framework; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } +} diff --git a/cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/RunStatusEnum.java b/cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/RunStatusEnum.java new file mode 100644 index 0000000..2faf37a --- /dev/null +++ b/cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/RunStatusEnum.java @@ -0,0 +1,27 @@ +package io.cloudbeat.common.client.dto; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Lifecycle status for the new runtime/case/suite status API, numerically aligned with the + * platform-wide contract (matches CloudBeat.Infrastructure.Models RunStatus in cb-new-arch and + * the RunStatusEnum used by the Node/.NET reporters). + */ +public enum RunStatusEnum { + PENDING(0), + INITIALIZING(1), + RUNNING(2), + FINISHED(3), + CANCELED(5); + + private final int value; + + RunStatusEnum(final int value) { + this.value = value; + } + + @JsonValue + public int getValue() { + return value; + } +} diff --git a/cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/SuiteStatusUpdateRequest.java b/cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/SuiteStatusUpdateRequest.java new file mode 100644 index 0000000..f1ef396 --- /dev/null +++ b/cb-kit-common/src/main/java/io/cloudbeat/common/client/dto/SuiteStatusUpdateRequest.java @@ -0,0 +1,149 @@ +package io.cloudbeat.common.client.dto; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.cloudbeat.common.reporter.model.TestStatus; +import io.cloudbeat.common.reporter.serializer.TestStatusSerializer; + +/** + * Request body for the new Redis-backed runtime status API: + * POST runtime/run/{runId}/instance/{instanceId}/suite/status + * Field shape mirrors SuiteStatusUpdateRequest in cb-new-arch (and its Node/.NET equivalents). + */ +public class SuiteStatusUpdateRequest { + private Long timestamp; + private String runId; + private String instanceId; + private String id; + private String fqn; + private String parentFqn; + private String parentId; + private String parentName; + private String name; + private Long startTime; + private Long endTime; + private RunStatusEnum runStatus; + @JsonSerialize(using = TestStatusSerializer.class) + private TestStatus testStatus; + private String framework; + private String language; + + public Long getTimestamp() { + return timestamp; + } + + public void setTimestamp(Long timestamp) { + this.timestamp = timestamp; + } + + public String getRunId() { + return runId; + } + + public void setRunId(String runId) { + this.runId = runId; + } + + public String getInstanceId() { + return instanceId; + } + + public void setInstanceId(String instanceId) { + this.instanceId = instanceId; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getFqn() { + return fqn; + } + + public void setFqn(String fqn) { + this.fqn = fqn; + } + + public String getParentFqn() { + return parentFqn; + } + + public void setParentFqn(String parentFqn) { + this.parentFqn = parentFqn; + } + + public String getParentId() { + return parentId; + } + + public void setParentId(String parentId) { + this.parentId = parentId; + } + + public String getParentName() { + return parentName; + } + + public void setParentName(String parentName) { + this.parentName = parentName; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getStartTime() { + return startTime; + } + + public void setStartTime(Long startTime) { + this.startTime = startTime; + } + + public Long getEndTime() { + return endTime; + } + + public void setEndTime(Long endTime) { + this.endTime = endTime; + } + + public RunStatusEnum getRunStatus() { + return runStatus; + } + + public void setRunStatus(RunStatusEnum runStatus) { + this.runStatus = runStatus; + } + + public TestStatus getTestStatus() { + return testStatus; + } + + public void setTestStatus(TestStatus testStatus) { + this.testStatus = testStatus; + } + + public String getFramework() { + return framework; + } + + public void setFramework(String framework) { + this.framework = framework; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } +} diff --git a/cb-kit-common/src/main/java/io/cloudbeat/common/reporter/CbTestReporter.java b/cb-kit-common/src/main/java/io/cloudbeat/common/reporter/CbTestReporter.java index 5bd0340..ec7c746 100755 --- a/cb-kit-common/src/main/java/io/cloudbeat/common/reporter/CbTestReporter.java +++ b/cb-kit-common/src/main/java/io/cloudbeat/common/reporter/CbTestReporter.java @@ -5,6 +5,9 @@ import io.cloudbeat.common.client.CbGatewayHttpClient; import io.cloudbeat.common.client.api.GatewayApi; import io.cloudbeat.common.client.api.RuntimeApi; +import io.cloudbeat.common.client.dto.CaseStatusUpdateRequest; +import io.cloudbeat.common.client.dto.RunStatusEnum; +import io.cloudbeat.common.client.dto.SuiteStatusUpdateRequest; import io.cloudbeat.common.config.CbConfig; import io.cloudbeat.common.helper.AttachmentHelper; import io.cloudbeat.common.model.runtime.NewInstanceOptions; @@ -185,6 +188,7 @@ public SuiteResult startSuite(final String name, final String fqn) { startedStepsQueue.remove(); lastScreenshotOnException.remove(); lastSuiteResult.set(newSuite); + reportRuntimeSuiteStatus(newSuite, RunStatusEnum.RUNNING); return newSuite; } @@ -211,6 +215,7 @@ public SuiteResult endSuite(String fqn) { if (startedSuite == null || !startedSuite.getFqn().equals(fqn)) return null; startedSuite.end(); + reportRuntimeSuiteStatus(startedSuite, RunStatusEnum.FINISHED); return startedSuite; } @@ -219,6 +224,7 @@ public SuiteResult endStartedSuite() { return null; SuiteResult startedSuite = lastSuiteResult.get(); startedSuite.end(); + reportRuntimeSuiteStatus(startedSuite, RunStatusEnum.FINISHED); return startedSuite; } @@ -235,6 +241,7 @@ public CaseResult startCase(final String name, final String fqn) { startedStepsQueue.remove(); lastCaseResult.set(newCase); reportCaseStatus(newCase, Optional.empty(), null); + reportRuntimeCaseStatus(newCase, startedSuite, RunStatusEnum.RUNNING); return newCase; } @@ -271,6 +278,7 @@ public CaseResult endCase(final CaseResult startedCase, final TestStatus status, endStartedSteps(status, throwable); startedCase.end(status, throwable); reportCaseStatus(startedCase, Optional.of(startedCase.getStatus()), throwable); + reportRuntimeCaseStatus(startedCase, lastSuiteResult.get(), RunStatusEnum.FINISHED); } return startedCase; @@ -303,6 +311,120 @@ else if (this.runtimeApi.isPresent()) } } + /** + * Reports a case's status to the new Redis-backed runtime status API that feeds the live + * progress screen. Gateway-only (this API has no public-API-key equivalent), best-effort: + * failures are swallowed so live-status reporting can never break the actual test run. + */ + private void reportRuntimeCaseStatus(final CaseResult caseResult, final SuiteResult parentSuite, final RunStatusEnum runStatus) { + if (!config.isRunningInCb() || !this.gatewayApi.isPresent()) + return; + try { + CaseStatusUpdateRequest req = new CaseStatusUpdateRequest(); + req.setTimestamp(System.currentTimeMillis()); + req.setRunId(result.getRunId()); + req.setInstanceId(result.getInstanceId()); + req.setId(caseResult.getId()); + req.setFqn(caseResult.getFqn()); + req.setName(caseResult.getName()); + req.setDisplayName(caseResult.getDisplayName()); + if (parentSuite != null) { + req.setParentFqn(parentSuite.getFqn()); + req.setParentId(parentSuite.getId()); + req.setParentName(parentSuite.getName()); + } + req.setStartTime(caseResult.getStartTime()); + req.setEndTime(caseResult.getEndTime()); + req.setRunStatus(runStatus); + req.setTestStatus(caseResult.getStatus()); + req.setFramework(frameworkName); + req.setLanguage(language); + this.gatewayApi.get().updateRuntimeCaseStatus(req); + } catch (CbClientException e) { + // best-effort - never fail the test run because live-status reporting failed + } + } + + /** + * Announces a suite that will run, before it actually starts - lets the live progress screen + * show the full picture upfront instead of only revealing suites one at a time as each one + * happens to begin. Unlike {@link #reportRuntimeSuiteStatus}, there's no real SuiteResult yet + * (it hasn't started), so this builds the update request directly and never touches `result`. + */ + public void reportPendingSuite(final String name, final String fqn) { + if (!config.isRunningInCb() || !this.gatewayApi.isPresent() || result == null) + return; + try { + SuiteStatusUpdateRequest req = new SuiteStatusUpdateRequest(); + req.setTimestamp(System.currentTimeMillis()); + req.setRunId(result.getRunId()); + req.setInstanceId(result.getInstanceId()); + req.setId(UUID.randomUUID().toString()); + req.setFqn(fqn); + req.setName(name); + req.setRunStatus(RunStatusEnum.PENDING); + req.setFramework(frameworkName); + req.setLanguage(language); + this.gatewayApi.get().updateRuntimeSuiteStatus(req); + } catch (CbClientException e) { + // best-effort - never fail the test run because live-status reporting failed + } + } + + /** + * Announces a case that will run, before it actually starts. See {@link #reportPendingSuite}. + */ + public void reportPendingCase(final String name, final String fqn, final String parentFqn, final String parentName) { + if (!config.isRunningInCb() || !this.gatewayApi.isPresent() || result == null) + return; + try { + CaseStatusUpdateRequest req = new CaseStatusUpdateRequest(); + req.setTimestamp(System.currentTimeMillis()); + req.setRunId(result.getRunId()); + req.setInstanceId(result.getInstanceId()); + req.setId(UUID.randomUUID().toString()); + req.setFqn(fqn); + req.setName(name); + if (parentFqn != null) { + req.setParentFqn(parentFqn); + req.setParentName(parentName); + } + req.setRunStatus(RunStatusEnum.PENDING); + req.setFramework(frameworkName); + req.setLanguage(language); + this.gatewayApi.get().updateRuntimeCaseStatus(req); + } catch (CbClientException e) { + // best-effort - never fail the test run because live-status reporting failed + } + } + + /** + * Reports a suite's status to the new Redis-backed runtime status API. See + * {@link #reportRuntimeCaseStatus} for the gating/best-effort rationale. + */ + private void reportRuntimeSuiteStatus(final SuiteResult suiteResult, final RunStatusEnum runStatus) { + if (!config.isRunningInCb() || !this.gatewayApi.isPresent()) + return; + try { + SuiteStatusUpdateRequest req = new SuiteStatusUpdateRequest(); + req.setTimestamp(System.currentTimeMillis()); + req.setRunId(result.getRunId()); + req.setInstanceId(result.getInstanceId()); + req.setId(suiteResult.getId()); + req.setFqn(suiteResult.getFqn()); + req.setName(suiteResult.getName()); + req.setStartTime(suiteResult.getStartTime()); + req.setEndTime(suiteResult.getEndTime()); + req.setRunStatus(runStatus); + req.setTestStatus(suiteResult.getStatus()); + req.setFramework(frameworkName); + req.setLanguage(language); + this.gatewayApi.get().updateRuntimeSuiteStatus(req); + } catch (CbClientException e) { + // best-effort - never fail the test run because live-status reporting failed + } + } + private void endStartedSteps(TestStatus status, Throwable throwable) { if (startedStepsQueue.get() == null || startedStepsQueue.get().isEmpty()) return; diff --git a/cb-kit-junit4/src/main/java/io/cloudbeat/junit4/CloudBeatJUnit4Listener.java b/cb-kit-junit4/src/main/java/io/cloudbeat/junit4/CloudBeatJUnit4Listener.java index 178f648..efafc34 100755 --- a/cb-kit-junit4/src/main/java/io/cloudbeat/junit4/CloudBeatJUnit4Listener.java +++ b/cb-kit-junit4/src/main/java/io/cloudbeat/junit4/CloudBeatJUnit4Listener.java @@ -15,14 +15,20 @@ */ package io.cloudbeat.junit4; +import io.cloudbeat.common.CbTestContext; +import io.cloudbeat.common.reporter.CbTestReporter; +import io.cloudbeat.common.reporter.model.TestStatus; import org.junit.runner.Description; import org.junit.runner.Result; import org.junit.runner.notification.Failure; import org.junit.runner.notification.RunListener; - /** * CloudBeat reporter plugin for JUnit 4. + * + * JUnit 4.12's RunListener has no explicit "class/suite started" event (that was only added in + * 4.13's testSuiteStarted/testSuiteFinished), so a suite (= test class) boundary is inferred by + * tracking the class name of consecutive testStarted/testIgnored calls on this thread. */ @RunListener.ThreadSafe @SuppressWarnings({ @@ -32,41 +38,95 @@ "unused" }) public class CloudBeatJUnit4Listener extends RunListener { + private final CbTestContext ctx = CbTestContext.getInstance(); + private final ThreadLocal currentSuiteFqn = new ThreadLocal<>(); + public CloudBeatJUnit4Listener() { - System.out.println("CloudBeatJUnit4Listener"); } + @Override public void testRunStarted(final Description description) { - //do nothing + if (!ctx.isActive()) + return; + final CbTestReporter reporter = ctx.getReporter(); + reporter.setFramework("JUnit", "4"); + if (!reporter.getInstance().isPresent()) + reporter.startInstance(); } @Override public void testRunFinished(final Result result) { - //do nothing + if (!ctx.isActive()) + return; + final CbTestReporter reporter = ctx.getReporter(); + endCurrentSuite(reporter); + if (reporter.getInstance().isPresent()) + reporter.endInstance(); } @Override public void testStarted(final Description description) { - + if (!ctx.isActive()) + return; + final CbTestReporter reporter = ctx.getReporter(); + ensureSuiteStarted(reporter, description); + reporter.startCase(description.getMethodName(), getMethodFqn(description)); } @Override public void testFinished(final Description description) { - + if (!ctx.isActive()) + return; + // guarded by CbTestReporter.endCase (no-op if already ended by testFailure/testAssumptionFailure) + ctx.getReporter().endCase(getMethodFqn(description), null, null); } @Override public void testFailure(final Failure failure) { - + if (!ctx.isActive()) + return; + ctx.getReporter().endCase(getMethodFqn(failure.getDescription()), TestStatus.FAILED, failure.getException()); } @Override public void testAssumptionFailure(final Failure failure) { - + if (!ctx.isActive()) + return; + // a failed assumption means the test was skipped, not failed + ctx.getReporter().endCase(getMethodFqn(failure.getDescription()), TestStatus.SKIPPED, null); } @Override public void testIgnored(final Description description) { + if (!ctx.isActive()) + return; + // @Ignore'd tests never get testStarted/testFinished, only this standalone event + final CbTestReporter reporter = ctx.getReporter(); + ensureSuiteStarted(reporter, description); + final String methodFqn = getMethodFqn(description); + reporter.startCase(description.getMethodName(), methodFqn); + reporter.endCase(methodFqn, TestStatus.SKIPPED, null); + } + + private void ensureSuiteStarted(final CbTestReporter reporter, final Description description) { + final String suiteFqn = description.getClassName(); + if (suiteFqn.equals(currentSuiteFqn.get())) + return; + endCurrentSuite(reporter); + final Class testClass = description.getTestClass(); + reporter.startSuite(testClass != null ? testClass.getSimpleName() : suiteFqn, suiteFqn); + currentSuiteFqn.set(suiteFqn); + } + + private void endCurrentSuite(final CbTestReporter reporter) { + final String suiteFqn = currentSuiteFqn.get(); + if (suiteFqn == null) + return; + reporter.endSuite(suiteFqn); + currentSuiteFqn.remove(); + } + private static String getMethodFqn(final Description description) { + return String.format("%s#%s", description.getClassName(), description.getMethodName()); } -} \ No newline at end of file +} diff --git a/cb-kit-junit5/pom.xml b/cb-kit-junit5/pom.xml index 537224d..b9d70d6 100644 --- a/cb-kit-junit5/pom.xml +++ b/cb-kit-junit5/pom.xml @@ -35,5 +35,19 @@ junit-jupiter-api ${junit.jupiter.version} + + + org.junit.platform + junit-platform-launcher + 1.9.1 + provided + diff --git a/cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExecutionListener.java b/cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExecutionListener.java new file mode 100644 index 0000000..9e2f81a --- /dev/null +++ b/cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExecutionListener.java @@ -0,0 +1,56 @@ +package io.cloudbeat.junit; + +import io.cloudbeat.common.CbTestContext; +import io.cloudbeat.common.reporter.CbTestReporter; +import org.junit.platform.engine.TestSource; +import org.junit.platform.engine.support.descriptor.ClassSource; +import org.junit.platform.engine.support.descriptor.MethodSource; +import org.junit.platform.launcher.TestExecutionListener; +import org.junit.platform.launcher.TestIdentifier; +import org.junit.platform.launcher.TestPlan; + +import java.util.Optional; + +/** + * Platform-level listener, auto-registered via META-INF/services ServiceLoader discovery. Unlike + * {@link CbJunitExtension}, which is scoped per test class and only ever sees its own class, + * this sees the full discovered TestPlan across every class before any of them run - so it can + * announce every suite/case that WILL run as "Pending" upfront, instead of the live progress + * screen only revealing tests one at a time as each class's extension happens to start them. + */ +public class CbJunitExecutionListener implements TestExecutionListener { + @Override + public void testPlanExecutionStarted(TestPlan testPlan) { + CbTestContext ctx = CbTestContext.getInstance(); + if (!ctx.isActive()) + return; + CbTestReporter reporter = ctx.getReporter(); + // testPlanExecutionStarted always fires before any class's beforeAll, so this listener + // is always the one that bootstraps the instance/reporter first - startInstance/setFramework + // are both idempotent, so CbJunitExtension.setup() calling them again later is harmless + if (!reporter.isStarted()) { + reporter.setFramework("JUnit", "5"); + JunitReporterUtils.startInstance(reporter, true); + } + for (TestIdentifier root : testPlan.getRoots()) { + reportPending(testPlan, root, reporter); + } + } + + private void reportPending(TestPlan testPlan, TestIdentifier identifier, CbTestReporter reporter) { + Optional source = identifier.getSource(); + if (source.isPresent() && source.get() instanceof ClassSource) { + final String classFqn = ((ClassSource) source.get()).getClassName(); + reporter.reportPendingSuite(identifier.getDisplayName(), classFqn); + } + else if (source.isPresent() && source.get() instanceof MethodSource) { + final MethodSource methodSource = (MethodSource) source.get(); + final String classFqn = methodSource.getClassName(); + final String methodFqn = String.format(JunitReporterUtils.JAVA_METHOD_FQN_FORMAT, classFqn, methodSource.getMethodName()); + reporter.reportPendingCase(identifier.getDisplayName(), methodFqn, classFqn, classFqn); + } + for (TestIdentifier child : testPlan.getChildren(identifier)) { + reportPending(testPlan, child, reporter); + } + } +} diff --git a/cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExtension.java b/cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExtension.java index 0202ea5..778abf7 100644 --- a/cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExtension.java +++ b/cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExtension.java @@ -21,7 +21,6 @@ import static org.junit.jupiter.api.extension.ExtensionContext.Namespace.GLOBAL; -//@AutoService(TestExecutionListener.class) public class CbJunitExtension implements BeforeAllCallback, BeforeEachCallback, diff --git a/cb-kit-junit5/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener b/cb-kit-junit5/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener new file mode 100644 index 0000000..28049db --- /dev/null +++ b/cb-kit-junit5/src/main/resources/META-INF/services/org.junit.platform.launcher.TestExecutionListener @@ -0,0 +1 @@ +io.cloudbeat.junit.CbJunitExecutionListener diff --git a/cb-kit-testng/src/main/java/io/cloudbeat/testng/CbTestNGListener.java b/cb-kit-testng/src/main/java/io/cloudbeat/testng/CbTestNGListener.java index db60fa3..6c4d417 100644 --- a/cb-kit-testng/src/main/java/io/cloudbeat/testng/CbTestNGListener.java +++ b/cb-kit-testng/src/main/java/io/cloudbeat/testng/CbTestNGListener.java @@ -220,8 +220,12 @@ public void onTestFailedButWithinSuccessPercentage(ITestResult iTestResult) { @Override public void onStart(ISuite suite) { - if (ctx.isActive()) + if (ctx.isActive()) { + // announce all methods this suite will run as Pending before marking the suite itself + // Running, mirroring the order the Cypress/JUnit5 reporters use for the same purpose + CbTestNGReporter.reportPendingMethods(ctx.getReporter(), suite); CbTestNGReporter.startSuite(ctx.getReporter(), suite); + } } @Override diff --git a/cb-kit-testng/src/main/java/io/cloudbeat/testng/CbTestNGReporter.java b/cb-kit-testng/src/main/java/io/cloudbeat/testng/CbTestNGReporter.java index e6bffa3..540d3a7 100644 --- a/cb-kit-testng/src/main/java/io/cloudbeat/testng/CbTestNGReporter.java +++ b/cb-kit-testng/src/main/java/io/cloudbeat/testng/CbTestNGReporter.java @@ -48,6 +48,24 @@ private static String generateFqnForSuite(XmlSuite suite) { return sb.toString(); } + /** + * Announces every test method the suite is about to run as Pending, before any of them + * actually start - lets the live progress screen show the full picture upfront instead of + * only revealing methods one at a time as onTestStart happens to fire for each. + * suite.getAllMethods() already gives the complete, suite-wide list TestNG resolved before + * execution began, so unlike JUnit5 (see CbJunitExecutionListener) no extra listener is needed. + */ + public static void reportPendingMethods(CbTestReporter reporter, ISuite suite) { + if (!reporter.getInstance().isPresent()) + return; + final String suiteFqn = generateFqnForSuite(suite.getXmlSuite()); + for (ITestNGMethod testMethod : suite.getAllMethods()) { + final String methodDisplayName = testMethod.getMethodName(); + final String methodFqn = fixFqnWithHash(testMethod.getQualifiedName()); + reporter.reportPendingCase(methodDisplayName, methodFqn, suiteFqn, suite.getName()); + } + } + public static void endSuite(CbTestReporter reporter, ISuite suite) { if (!reporter.getInstance().isPresent()) return; diff --git a/pom.xml b/pom.xml index baa73c2..5c32244 100644 --- a/pom.xml +++ b/pom.xml @@ -44,8 +44,11 @@ cb-kit-selenium4 cb-kit-testng cb-kit-junit4 - cb-kit-cucumber1 - cb-kit-cucumber1-runner + + + cb-kit-junit5 cb-kit-quantum From 45b8fd72e5ef81ea48549ca54a3e5d06c832e05d Mon Sep 17 00:00:00 2001 From: romovs Date: Thu, 10 Sep 2026 09:03:53 +0300 Subject: [PATCH 2/2] Fix missing screenshots and wrong test names --- .../common/reporter/CbTestReporter.java | 45 +++++++++++++++++++ .../io/cloudbeat/junit/CbJunitExtension.java | 18 ++++++++ .../cloudbeat/junit/JunitReporterUtils.java | 7 ++- 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/cb-kit-common/src/main/java/io/cloudbeat/common/reporter/CbTestReporter.java b/cb-kit-common/src/main/java/io/cloudbeat/common/reporter/CbTestReporter.java index ec7c746..60f9ad2 100755 --- a/cb-kit-common/src/main/java/io/cloudbeat/common/reporter/CbTestReporter.java +++ b/cb-kit-common/src/main/java/io/cloudbeat/common/reporter/CbTestReporter.java @@ -25,6 +25,7 @@ import java.util.*; import java.util.concurrent.ConcurrentLinkedDeque; import java.util.function.Consumer; +import java.util.function.Supplier; public class CbTestReporter { //private static final Logger LOGGER = LoggerFactory.getLogger(CbTestReporter.class); @@ -229,6 +230,10 @@ public SuiteResult endStartedSuite() { } public CaseResult startCase(final String name, final String fqn) { + return startCase(name, fqn, null); + } + + public CaseResult startCase(final String name, final String fqn, final String displayName) { SuiteResult startedSuite = lastSuiteResult.get(); if (startedSuite == null) { if (result.getSuites().size() > 0) @@ -238,6 +243,12 @@ public CaseResult startCase(final String name, final String fqn) { } CaseResult newCase = startedSuite.addNewCaseResult(name); newCase.setFqn(fqn); + // must be set before reportRuntimeCaseStatus below, which reads getDisplayName() - setting + // it after startCase returns (as callers used to do) means the first "Running" status + // ping always goes out with a null displayName, so the live status screen falls back to + // showing the raw method name instead, even though it later gets it right + if (displayName != null) + newCase.setDisplayName(displayName); startedStepsQueue.remove(); lastCaseResult.set(newCase); reportCaseStatus(newCase, Optional.empty(), null); @@ -276,6 +287,21 @@ public CaseResult endCase(final CaseResult startedCase, final TestStatus status, // so make sure we do not update the status and report it back to CB twice if (startedCase.getStatus() == null) { endStartedSteps(status, throwable); + // a WebDriver command failure right before this exception may have auto-captured a + // screenshot (see WebDriverEventHandler.onException -> setScreenshotOnException), but + // that only gets consumed by a step's own end() call - if the failure surfaced + // directly in the test method rather than inside an explicit step()/startStep() block, + // there was never an open step to claim it, and it would otherwise be silently + // dropped. Attach it directly to this case instead (not via the ThreadLocal-based + // addScreenshotAttachment helper, since lastCaseResult might not be `startedCase` + // when endCase is reached via the fqn-lookup overload). + if (throwable != null && lastScreenshotOnException.get() != null) { + Attachment screenshotAttachment = AttachmentHelper.prepareScreenshotAttachment( + Base64.getDecoder().decode(lastScreenshotOnException.get())); + if (screenshotAttachment != null) + startedCase.addAttachment(screenshotAttachment); + lastScreenshotOnException.remove(); + } startedCase.end(status, throwable); reportCaseStatus(startedCase, Optional.of(startedCase.getStatus()), throwable); reportRuntimeCaseStatus(startedCase, lastSuiteResult.get(), RunStatusEnum.FINISHED); @@ -701,6 +727,25 @@ public void setScreenshotOnException(String base64Data) { this.lastScreenshotOnException.set(base64Data); } + /** + * Like {@link #setScreenshotOnException}, but only invokes the (potentially expensive) + * supplier if nothing is already pending - used to proactively capture a screenshot on ANY + * test failure (e.g. a plain assertion that fails after WebDriver already returned data + * successfully), without overwriting a more relevant one already captured at the exact + * moment a WebDriver command itself threw (see WebDriverEventHandler.onException). + */ + public void setScreenshotOnExceptionIfMissing(final Supplier screenshotSupplier) { + if (this.lastScreenshotOnException.get() != null) + return; + try { + String screenshot = screenshotSupplier.get(); + if (screenshot != null) + this.lastScreenshotOnException.set(screenshot); + } catch (Throwable e) { + // best-effort - never fail the test run because screenshot capture failed + } + } + public void logInfo(final String message) { final LogMessage logMessage = new LogMessage(); logMessage.setSrc(LogSource.USER); diff --git a/cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExtension.java b/cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExtension.java index 778abf7..4e3305c 100644 --- a/cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExtension.java +++ b/cb-kit-junit5/src/main/java/io/cloudbeat/junit/CbJunitExtension.java @@ -10,6 +10,7 @@ import io.cloudbeat.common.reporter.model.CaseResult; import io.cloudbeat.common.reporter.model.StepResult; +import io.cloudbeat.common.wrapper.webdriver.AbstractWebDriver; import io.cloudbeat.common.wrapper.webdriver.WebDriverWrapper; import io.cloudbeat.common.wrapper.webdriver.WrapperOptions; import org.junit.jupiter.api.TestInfo; @@ -484,6 +485,8 @@ public void afterTestExecution(ExtensionContext context) { return; try { ctx.setLastTestException(context.getExecutionException().orElse(null)); + if (context.getExecutionException().isPresent()) + captureFailureScreenshotIfMissing(); JunitReporterUtils.endCase(ctx.getReporter(), context); ctx.setCurrentTestClass(null); } @@ -517,6 +520,9 @@ public void testFailed(ExtensionContext context, Throwable throwable) { if (!ctx.isActive()) return; try { + // covers e.g. a beforeEach/beforeAll hook failure, where afterTestExecution never + // fires at all since the test method itself never got a chance to run + captureFailureScreenshotIfMissing(); JunitReporterUtils.failedCase(ctx.getReporter(), context, throwable); // addPendingBeforeHooks(); ctx.setCurrentTestClass(null); @@ -526,6 +532,18 @@ public void testFailed(ExtensionContext context, Throwable throwable) { } } + /** + * Proactively captures a screenshot on ANY test failure where a WebDriver session is active - + * not just ones where the WebDriver command itself threw (see CbTestReporter's Javadoc on + * setScreenshotOnExceptionIfMissing for why a plain assertion failure needs this too). + */ + private void captureFailureScreenshotIfMissing() { + ctx.getReporter().setScreenshotOnExceptionIfMissing(() -> { + AbstractWebDriver driver = ctx.getAbstractWebDriver(); + return driver != null ? driver.getScreenshot() : null; + }); + } + private void setup(final ExtensionContext context) { if (!ctx.isActive()) return; diff --git a/cb-kit-junit5/src/main/java/io/cloudbeat/junit/JunitReporterUtils.java b/cb-kit-junit5/src/main/java/io/cloudbeat/junit/JunitReporterUtils.java index 6581366..7240fc4 100644 --- a/cb-kit-junit5/src/main/java/io/cloudbeat/junit/JunitReporterUtils.java +++ b/cb-kit-junit5/src/main/java/io/cloudbeat/junit/JunitReporterUtils.java @@ -50,8 +50,7 @@ public static CaseResult startOrGetCase(CbTestReporter reporter, ExtensionContex final String classFqn = context.getTestClass().get().getName(); final String methodName = context.getTestMethod().get().getName(); final String methodFqn = String.format(JAVA_METHOD_FQN_FORMAT, classFqn, methodName); - CaseResult caseResult = reporter.startCase(methodName, methodFqn); - caseResult.setDisplayName(context.getDisplayName()); + CaseResult caseResult = reporter.startCase(methodName, methodFqn, context.getDisplayName()); caseResultByUniqueId.get().put(uniqueId, caseResult); return caseResult; } @@ -80,7 +79,7 @@ public static void disabledCase(CbTestReporter reporter, ExtensionContext contex final String methodName = context.getTestMethod().get().getName(); final String methodFqn = String.format(JAVA_METHOD_FQN_FORMAT, classFqn, methodName); //CbTestReporter reporter = CbTestContext.getReporter(); - reporter.startCase(methodName, methodFqn); + reporter.startCase(methodName, methodFqn, context.getDisplayName()); reporter.skipCase(methodFqn); } public static void failedCase( @@ -93,7 +92,7 @@ public static void failedCase( final String methodFqn = String.format(JAVA_METHOD_FQN_FORMAT, classFqn, methodName); if (reporter.getStartedCase() == null) { - CaseResult startedCase = reporter.startCase(methodName, methodFqn); + CaseResult startedCase = reporter.startCase(methodName, methodFqn, context.getDisplayName()); // startedCase.setStartTime( ); }