diff --git a/README.md b/README.md index a95f838..bf54ded 100644 --- a/README.md +++ b/README.md @@ -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 + +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. @@ -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): diff --git a/src/main/java/testingbot/TestingBotBuildReportAction.java b/src/main/java/testingbot/TestingBotBuildReportAction.java new file mode 100644 index 0000000..43ee486 --- /dev/null +++ b/src/main/java/testingbot/TestingBotBuildReportAction.java @@ -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. + * + *

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//} 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.

+ */ +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); + } + + /** + * 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. + * + *

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.

+ */ + 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); + } + } +} diff --git a/src/main/java/testingbot/TestingBotBuildWrapper.java b/src/main/java/testingbot/TestingBotBuildWrapper.java index c419c82..e23a3fb 100644 --- a/src/main/java/testingbot/TestingBotBuildWrapper.java +++ b/src/main/java/testingbot/TestingBotBuildWrapper.java @@ -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(); @@ -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); } /** @@ -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 @@ -161,6 +167,10 @@ public void buildEnvVars(Map env) { TunnelManager.populateCredentialEnv(env, credentials); } + if (buildId != null) { + env.put(TestingBotBuildReportAction.TESTINGBOT_BUILD, buildId); + } + if (app != null) { env.put(TESTINGBOT_TUNNEL,"true"); } diff --git a/src/main/java/testingbot/pipeline/TestingBotStep.java b/src/main/java/testingbot/pipeline/TestingBotStep.java index 47cd530..a2fbdb5 100644 --- a/src/main/java/testingbot/pipeline/TestingBotStep.java +++ b/src/main/java/testingbot/pipeline/TestingBotStep.java @@ -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; @@ -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) diff --git a/src/main/java/testingbot/pipeline/TestingBotTunnelStep.java b/src/main/java/testingbot/pipeline/TestingBotTunnelStep.java index 076b304..ce98eb4 100644 --- a/src/main/java/testingbot/pipeline/TestingBotTunnelStep.java +++ b/src/main/java/testingbot/pipeline/TestingBotTunnelStep.java @@ -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; @@ -235,6 +236,9 @@ public boolean start() throws Exception { HashMap 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)); env.put(TESTINGBOT_TUNNEL_IDENTIFIER, tunnelIdentifier); String hubHost = "localhost"; String hubPort = Integer.toString(defaultSeleniumPort()); diff --git a/src/main/resources/testingbot/TestingBotBuildReportAction/index.jelly b/src/main/resources/testingbot/TestingBotBuildReportAction/index.jelly new file mode 100644 index 0000000..97688a2 --- /dev/null +++ b/src/main/resources/testingbot/TestingBotBuildReportAction/index.jelly @@ -0,0 +1,21 @@ + + + + + + + +

TestingBot Build

+

+ All TestingBot sessions run under this build (tests using + build=${it.buildId}, exposed as $TESTINGBOT_BUILD). +

+ View on TestingBot + +
+ +
+
+
+
+