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..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
@@ -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;
@@ -22,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);
@@ -185,6 +189,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 +216,7 @@ public SuiteResult endSuite(String fqn) {
if (startedSuite == null || !startedSuite.getFqn().equals(fqn))
return null;
startedSuite.end();
+ reportRuntimeSuiteStatus(startedSuite, RunStatusEnum.FINISHED);
return startedSuite;
}
@@ -219,10 +225,15 @@ public SuiteResult endStartedSuite() {
return null;
SuiteResult startedSuite = lastSuiteResult.get();
startedSuite.end();
+ reportRuntimeSuiteStatus(startedSuite, RunStatusEnum.FINISHED);
return startedSuite;
}
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)
@@ -232,9 +243,16 @@ 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);
+ reportRuntimeCaseStatus(newCase, startedSuite, RunStatusEnum.RUNNING);
return newCase;
}
@@ -269,8 +287,24 @@ 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);
}
return startedCase;
@@ -303,6 +337,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;
@@ -579,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-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..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;
@@ -21,7 +22,6 @@
import static org.junit.jupiter.api.extension.ExtensionContext.Namespace.GLOBAL;
-//@AutoService(TestExecutionListener.class)
public class CbJunitExtension implements
BeforeAllCallback,
BeforeEachCallback,
@@ -485,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);
}
@@ -518,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);
@@ -527,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( );
}
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