Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ An example on how to do this: `((RemoteWebDriver) driver).getSessionId().toStrin

A full example that you can use is available on our GitHub [Jenkins-Demo](https://github.com/testingbot/Jenkins-Demo) page.

## Embedded TestingBot Build report

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use the configured setext heading style.

Markdownlint expects a setext heading here.

Proposed fix
-## Embedded TestingBot Build report
+Embedded TestingBot Build report
+--------------------------------
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## Embedded TestingBot Build report
Embedded TestingBot Build report
--------------------------------
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 57-57: Heading style
Expected: setext; Actual: atx

(MD003, heading-style)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` at line 57, Update the “Embedded TestingBot Build report” heading
in the README to use the configured Markdown setext style instead of the current
heading syntax, preserving the existing heading text.

Source: Linters/SAST tools


Whenever the plugin injects credentials — through the freestyle **Build Environment** option or the `testingbot { }` / `testingbotTunnel { }` pipeline steps — it exposes a `TESTINGBOT_BUILD` environment variable and adds a **TestingBot Build** page to the build.

Set your test's `build` desired capability to `$TESTINGBOT_BUILD` so all sessions from a single Jenkins build are grouped under one TestingBot build. The **TestingBot Build** link on the build page then embeds that build's TestingBot report (every session, with status and video) directly inside Jenkins, without leaving the CI UI.

## Pipeline
The plugin offers pipeline support, which can be used with a Jenkinsfile.

Expand All @@ -75,6 +81,7 @@ Inside **both** `testingbot { }` and `testingbotTunnel { }` blocks:

* `TESTINGBOT_KEY` / `TB_KEY` – your TestingBot API key
* `TESTINGBOT_SECRET` / `TB_SECRET` – your TestingBot API secret (masked in the build log)
* `TESTINGBOT_BUILD` – a per-build identifier. Pass its value as your test's `build` desired capability so all sessions from this Jenkins build are grouped together and shown on the build's embedded **TestingBot Build** page (see below).

Inside a `testingbotTunnel { }` block only (these describe the tunnel started for that block):

Expand Down
139 changes: 139 additions & 0 deletions src/main/java/testingbot/TestingBotBuildReportAction.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package testingbot;

import hudson.model.Run;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import jenkins.model.RunAction2;

/**
* Adds a "TestingBot Build" page to a build that embeds the TestingBot build report (all TestingBot
* sessions grouped under this build) in an iframe.
*
* <p>Tests associate themselves with the build by using the {@code TESTINGBOT_BUILD} environment
* variable (exposed by the {@code testingbot}/{@code testingbotTunnel} steps and the freestyle build
* wrapper) as their {@code build} desired capability. The embedded report lives at
* {@code /mini/builds/<clientKey>/<buildId>} and is authenticated with a plain
* {@code MD5(clientKey:clientSecret:buildId)} token — computed once here so the raw secret is never
* stored on the build.</p>
*/
public class TestingBotBuildReportAction implements RunAction2 {

/**
* Env var holding the build identifier. Tests should pass its value as their {@code build}
* desired capability so their sessions are grouped under this Jenkins build's report — the
* TestingBot analog of Sauce Labs' {@code SAUCE_BUILD_NAME}.
*/
public static final String TESTINGBOT_BUILD = "TESTINGBOT_BUILD";

private transient Run<?, ?> run;
private final String clientKey;
private final String buildId;
private final String authHash;

private TestingBotBuildReportAction(String clientKey, String buildId, String authHash) {
this.clientKey = clientKey;
this.buildId = buildId;
this.authHash = authHash;
}

/**
* Builds the action for a run, computing the embed auth token from the credentials.
*/
public static TestingBotBuildReportAction create(Run<?, ?> run, TestingBotCredentials credentials) {
String buildId = buildIdFor(run);
String auth = md5(credentials.getKey() + ":" + credentials.getDecryptedSecret() + ":" + buildId);
return new TestingBotBuildReportAction(credentials.getKey(), buildId, auth);
}

/**
* The value tests should use as their {@code build} capability to appear in this build's report.
* Safe both as a capability value and as a URL path segment, and injective: distinct job
* names never collapse to the same identifier.
*/
public static String buildIdFor(Run<?, ?> run) {
String raw = run.getParent().getFullName() + "_" + run.getNumber();
String sanitized = raw.replaceAll("[^A-Za-z0-9._-]", "-");
// Sanitizing can map distinct names onto the same string (e.g. "qa smoke" and "qa-smoke").
// When characters were actually replaced, append a short deterministic hash of the raw value
// so the identifier stays unique; already-safe names are returned unchanged for readability.
if (sanitized.equals(raw)) {
return sanitized;
}
return sanitized + "-" + md5(raw).substring(0, 8);
}
Comment on lines +54 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Make the build identifier collision-resistant.

Line 54 maps distinct job names such as qa smoke and qa-smoke to the same ID. With equal build numbers, their TestingBot sessions merge into one report and may cross job boundaries. Use an injective URL-safe encoding instead of replacing characters.

Proposed fix
+import java.util.Base64;
...
 public static String buildIdFor(Run<?, ?> run) {
-    return (run.getParent().getFullName() + "_" + run.getNumber()).replaceAll("[^A-Za-z0-9._-]", "-");
+    String rawId = run.getParent().getFullName() + "_" + run.getNumber();
+    return Base64.getUrlEncoder()
+            .withoutPadding()
+            .encodeToString(rawId.getBytes(StandardCharsets.UTF_8));
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/testingbot/TestingBotBuildReportAction.java` around lines 53 -
55, Update buildIdFor to use an injective, URL-safe encoding of the full job
name and build number instead of replacing unsupported characters with hyphens.
Preserve deterministic output while ensuring distinct names such as “qa smoke”
and “qa-smoke” cannot produce the same identifier.


/**
* Adds the report action to the run (once) and returns the build identifier to expose as
* {@link #TESTINGBOT_BUILD}. Callers that only need the identifier — e.g. a run without
* resolvable credentials — can use {@link #buildIdFor(Run)} directly.
*
* <p>Synchronized on the run so that concurrent steps in one build cannot attach duplicate
* actions. If an action already exists for a different TestingBot account (different client key),
* it is replaced so the embedded report and its auth token match the credentials now in effect.</p>
*/
public static String attach(Run<?, ?> run, TestingBotCredentials credentials) {
synchronized (run) {
TestingBotBuildReportAction existing = run.getAction(TestingBotBuildReportAction.class);
if (existing != null && existing.clientKey.equals(credentials.getKey())) {
return existing.getBuildId();
}
if (existing != null) {
run.removeAction(existing);
}
TestingBotBuildReportAction created = create(run, credentials);
run.addAction(created);
return created.getBuildId();
}
}

public String getReportUrl() {
return "https://testingbot.com/mini/builds/" + clientKey + "/" + buildId + "?auth=" + authHash;
}

public String getBuildId() {
return buildId;
}

public Run<?, ?> getRun() {
return run;
}

@Override
public String getIconFileName() {
return "/plugin/testingbot/images/logo.svg";
}

@Override
public String getDisplayName() {
return "TestingBot Build";
}

@Override
public String getUrlName() {
return "testingbot-build";
}

@Override
public void onAttached(Run<?, ?> run) {
this.run = run;
}

@Override
public void onLoad(Run<?, ?> run) {
this.run = run;
}

private static String md5(String value) {
try {
byte[] digest = MessageDigest.getInstance("MD5").digest(value.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("MD5 not available", e);
}
}
}
18 changes: 14 additions & 4 deletions src/main/java/testingbot/TestingBotBuildWrapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,14 @@ public Environment setUp(AbstractBuild build, Launcher launcher, BuildListener l
build.addAction(action);
}

// Expose the build id and add the embeddable build-report page so all sessions using
// build=$TESTINGBOT_BUILD are grouped and viewable inside Jenkins.
final String buildId = credentials != null ? TestingBotBuildReportAction.attach(build, credentials) : null;

if (this.useTunnel) {
if (credentials == null) {
listener.getLogger().println("No TestingBot key/secret found while trying to start a TestingBot Tunnel");
return new TestingBotBuildEnvironment(null, null);
return new TestingBotBuildEnvironment(null, null, null);
}

final App app = new App();
Expand All @@ -102,10 +106,10 @@ public Environment setUp(AbstractBuild build, Launcher launcher, BuildListener l
TunnelManager.stop(app, listener);
throw new IOException("Failed to start TestingBot tunnel", ex);
}
return new TestingBotBuildEnvironment(credentials, app);
return new TestingBotBuildEnvironment(credentials, app, buildId);
}

return new TestingBotBuildEnvironment(credentials, null);
return new TestingBotBuildEnvironment(credentials, null, buildId);
}

/**
Expand Down Expand Up @@ -149,10 +153,12 @@ private class TestingBotBuildEnvironment extends BuildWrapper.Environment {

private final App app;
private final TestingBotCredentials credentials;
private final String buildId;

public TestingBotBuildEnvironment(TestingBotCredentials credentials, @Nullable App app) {
public TestingBotBuildEnvironment(TestingBotCredentials credentials, @Nullable App app, @Nullable String buildId) {
this.credentials = credentials;
this.app = app;
this.buildId = buildId;
}

@Override
Expand All @@ -161,6 +167,10 @@ public void buildEnvVars(Map<String, String> env) {
TunnelManager.populateCredentialEnv(env, credentials);
}

if (buildId != null) {
env.put(TestingBotBuildReportAction.TESTINGBOT_BUILD, buildId);
}

if (app != null) {
env.put(TESTINGBOT_TUNNEL,"true");
}
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/testingbot/pipeline/TestingBotStep.java
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import org.kohsuke.stapler.DataBoundConstructor;
import org.kohsuke.stapler.verb.POST;
import testingbot.TestingBotBuildAction;
import testingbot.TestingBotBuildReportAction;
import testingbot.TestingBotCredentials;
import testingbot.TunnelManager;

Expand Down Expand Up @@ -87,6 +88,9 @@ public boolean start() throws Exception {
if (run.getAction(TestingBotBuildAction.class) == null) {
run.addAction(new TestingBotBuildAction(credentials));
}
// Expose the build id and add the embeddable build-report page so all sessions using
// build=$TESTINGBOT_BUILD are grouped and viewable inside Jenkins.
env.put(TestingBotBuildReportAction.TESTINGBOT_BUILD, TestingBotBuildReportAction.attach(run, credentials));

body = context.newBodyInvoker()
.withContext(credentials)
Expand Down
4 changes: 4 additions & 0 deletions src/main/java/testingbot/pipeline/TestingBotTunnelStep.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
import org.kohsuke.stapler.DataBoundSetter;
import org.kohsuke.stapler.verb.POST;
import testingbot.TestingBotBuildAction;
import testingbot.TestingBotBuildReportAction;
import testingbot.TestingBotCredentials;
import testingbot.TunnelManager;

Expand Down Expand Up @@ -235,6 +236,9 @@ public boolean start() throws Exception {

HashMap<String, String> env = new HashMap<>();
TunnelManager.populateCredentialEnv(env, tbCredentials);
// Expose the build id and add the embeddable build-report page so all sessions using
// build=$TESTINGBOT_BUILD are grouped and viewable inside Jenkins.
env.put(TestingBotBuildReportAction.TESTINGBOT_BUILD, TestingBotBuildReportAction.attach(run, tbCredentials));
Comment on lines +239 to +241

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant files first.
ast-grep outline src/main/java/testingbot/pipeline/TestingBotStep.java --view expanded || true
ast-grep outline src/main/java/testingbot/pipeline/TestingBotTunnelStep.java --view expanded || true
ast-grep outline src/main/java/testingbot/TestingBotBuildReportAction.java --view expanded || true

# Show the relevant line ranges with line numbers.
printf '\n--- TestingBotStep.java ---\n'
sed -n '1,180p' src/main/java/testingbot/pipeline/TestingBotStep.java | cat -n
printf '\n--- TestingBotTunnelStep.java ---\n'
sed -n '200,280p' src/main/java/testingbot/pipeline/TestingBotTunnelStep.java | cat -n
printf '\n--- TestingBotBuildReportAction.java ---\n'
sed -n '1,260p' src/main/java/testingbot/TestingBotBuildReportAction.java | cat -n

Repository: testingbot/TestingBot-Jenkins-Plugin

Length of output: 19661


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n--- usages of TestingBotBuildReportAction.attach ---\n'
rg -n "TestingBotBuildReportAction\.attach\(" src/main/java || true

printf '\n--- TestingBotBuildAction.java ---\n'
sed -n '1,260p' src/main/java/testingbot/TestingBotBuildAction.java | cat -n

printf '\n--- testingbot credential usages ---\n'
rg -n "getCredentialsId\\(|TestingBotCredentials\\.getCredentials|run\\.addAction\\(new TestingBotBuildAction|TestingBotBuildReportAction" src/main/java/testingbot -g '!**/target/**' || true

Repository: testingbot/TestingBot-Jenkins-Plugin

Length of output: 4928


Reject mixed TestingBot credentials on the same run. TestingBotBuildReportAction.attach keeps the first report action on the Run, so a later testingbot {} or testingbotTunnel {} block with different credentials will still export the original report URL/hash. Fail on credential mismatch or replace the attached action before setting TESTINGBOT_BUILD.

  • src/main/java/testingbot/pipeline/TestingBotStep.java#L91-L93
  • src/main/java/testingbot/pipeline/TestingBotTunnelStep.java#L239-L241
📍 Affects 2 files
  • src/main/java/testingbot/pipeline/TestingBotTunnelStep.java#L239-L241 (this comment)
  • src/main/java/testingbot/pipeline/TestingBotStep.java#L91-L93
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/testingbot/pipeline/TestingBotTunnelStep.java` around lines 239
- 241, Ensure TestingBotBuildReportAction.attach rejects credential mismatches
on the same Run or replaces the existing action before exporting
TESTINGBOT_BUILD. Apply this consistently at TestingBotTunnelStep.java lines
239-241 and TestingBotStep.java lines 91-93, preserving the existing behavior
when credentials match.

env.put(TESTINGBOT_TUNNEL_IDENTIFIER, tunnelIdentifier);
String hubHost = "localhost";
String hubPort = Integer.toString(defaultSeleniumPort());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?jelly escape-by-default='true'?>
<j:jelly xmlns:j="jelly:core" xmlns:l="/lib/layout" xmlns:st="jelly:stapler">
<l:layout title="TestingBot Build">
<l:side-panel>
<st:include page="sidepanel.jelly" it="${it.run}" optional="true" />
</l:side-panel>
<l:main-panel>
<h1>TestingBot Build</h1>
<p>
All TestingBot sessions run under this build (tests using
<code>build=${it.buildId}</code>, exposed as <code>$TESTINGBOT_BUILD</code>).
</p>
<a href="${it.reportUrl}&amp;ref=jenkins" target="_blank">View on TestingBot</a>
<l:ajax>
<div>
<iframe src="${it.reportUrl}&amp;ref=jenkins" title="Embedded TestingBot build report" style="width: 100%; min-height: 100vh; border: none"></iframe>
</div>
</l:ajax>
</l:main-panel>
</l:layout>
</j:jelly>