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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ This Jenkins plugin integrates TestingBot.com features inside Jenkins.

## Prerequisites

* Minimum supported Jenkins version is 2.338
* Minimum supported Jenkins version is 2.541.3 (the plugin builds and runs on Java 17+)
* A TestingBot account

## Setting up the plugin
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/testingbot/TestingBotBuildObject.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public TestingBotBuildObject(String sessionId, String className, String testName
this.isPassed = isPassed;
this.authHash = authHash;
this.test = test;
this.environmentName = test.getBrowser() + " | " + test.getOs();
this.environmentName = test == null ? "" : test.getBrowser() + " | " + test.getOs();
}

/**
Expand Down
16 changes: 13 additions & 3 deletions src/main/java/testingbot/TestingBotBuildSummary.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

public class TestingBotBuildSummary extends InvisibleAction implements Serializable {
private static final long serialVersionUID = 1L;
Expand Down Expand Up @@ -79,9 +81,17 @@ public void onCompleted(AbstractBuild<?, ?> r, TaskListener listener) {
continue;
}
String sessionId = sessionIds.get(0);
TestingbotTest test = apiClient.getTest(sessionId);
TestingBotBuildObject tbo = new TestingBotBuildObject(sessionId, cr.getClassName(), cr.getName(), cr.isPassed(), apiClient.getAuthenticationHash(sessionId), test);
ids.add(tbo);
try {
TestingbotTest test = apiClient.getTest(sessionId);
TestingBotBuildObject tbo = new TestingBotBuildObject(sessionId, cr.getClassName(), cr.getName(), cr.isPassed(), apiClient.getAuthenticationHash(sessionId), test);
ids.add(tbo);
} catch (RuntimeException e) {
// A single bad session id (typo, wrong account, expired/purged session) must not
// drop the report for the other valid sessions in this build. Log at WARNING so
// the failed lookup is visible (unlike the normal "no session" case).
Logger.getLogger(TestingBotBuildSummary.class.getName())
.log(Level.WARNING, "Skipping TestingBot session " + sessionId, e);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
if (ids.isEmpty()) {
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/testingbot/TestingBotBuildWrapper.java
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ public String getDisplayName() {

@POST
public ListBoxModel doFillCredentialsIdItems(@AncestorInPath final Item context) {
if (context != null && !context.hasPermission(Item.CONFIGURE)) {
if (context == null ? !Jenkins.get().hasPermission(Jenkins.ADMINISTER)
: !context.hasPermission(Item.CONFIGURE)) {
return new StandardListBoxModel();
}

Expand Down
15 changes: 6 additions & 9 deletions src/main/java/testingbot/TestingBotCredentials.java
Original file line number Diff line number Diff line change
Expand Up @@ -194,17 +194,14 @@ public static TestingBotCredentials getCredentials(final AbstractItem buildItem,
return null;
}

CredentialsMatcher matcher;
if (credentialsId != null) {
matcher = CredentialsMatchers.allOf(CredentialsMatchers.withId(credentialsId));
} else {
matcher = CredentialsMatchers.always();
// A specific id was requested: return it or null — never silently fall back to a
// different account when the id does not match (renamed/mistyped/deleted credential).
return CredentialsMatchers.firstOrNull(available,
CredentialsMatchers.withId(credentialsId));
}

return CredentialsMatchers.firstOrDefault(
available,
matcher,
available.get(0));
// No id specified: use the first available credential.
return available.get(0);
}

public static List<TestingBotCredentials> availableCredentials(final AbstractItem abstractItem) {
Expand Down
3 changes: 2 additions & 1 deletion src/main/java/testingbot/TestingBotReportFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ public List<TestingBotReport> getTestAction(TestObject to) {
return Collections.singletonList(new TestingBotReport(credentials, cr, ids));
}
}
logger.log(Level.WARNING, "Empty list for TB sessions");
// Fires for the majority of test-tree nodes (any case without a TestingBotSessionID); keep it quiet.
logger.log(Level.FINE, "No TestingBot sessions for this test object");
return Collections.emptyList();
}

Expand Down
24 changes: 23 additions & 1 deletion src/main/java/testingbot/TunnelManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ static void applyOptions(App app, String rawOptions, TaskListener listener) {
}
String[] tokens = tokenize(trimmed);
List<String> ignored = new ArrayList<>();
List<String> auth = new ArrayList<>();
for (int i = 0; i < tokens.length; i++) {
String token = tokens[i];
switch (token) {
Expand Down Expand Up @@ -120,7 +121,7 @@ static void applyOptions(App app, String rawOptions, TaskListener listener) {
case "-a":
case "--auth":
if (hasValue(tokens, i)) {
app.setBasicAuth(new String[]{tokens[++i]});
auth.add(tokens[++i]);
} else {
ignored.add(token + " (missing value)");
}
Expand All @@ -129,6 +130,10 @@ static void applyOptions(App app, String rawOptions, TaskListener listener) {
ignored.add(token);
}
}
if (!auth.isEmpty()) {
// --auth may be repeated for multiple hosts; apply them all at once.
app.setBasicAuth(auth.toArray(new String[0]));
}
if (!ignored.isEmpty()) {
listener.getLogger().println("[TestingBot] Ignoring invalid or unsupported tunnel option(s): "
+ String.join(", ", ignored));
Expand All @@ -139,6 +144,23 @@ private static boolean hasValue(String[] tokens, int index) {
return index + 1 < tokens.length && !tokens[index + 1].startsWith("-");
}

/**
* Returns the value of a user-supplied {@code --tunnel-identifier} / {@code -i} option, or
* {@code null} if the options don't specify one.
*/
public static String extractTunnelIdentifier(String rawOptions) {
if (rawOptions == null) {
return null;
}
String[] tokens = tokenize(rawOptions.trim());
for (int i = 0; i < tokens.length; i++) {
if (("-i".equals(tokens[i]) || "--tunnel-identifier".equals(tokens[i])) && hasValue(tokens, i)) {
return tokens[i + 1];
}
}
return null;
}

Comment on lines +147 to +163

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 | 🔵 Trivial | ⚡ Quick win

Duplicate -i/--tunnel-identifier scan logic.

This re-implements the same flag detection already done inside applyOptions's switch-case (Lines 85-92). Both must stay in sync if a new alias or quoting rule is ever added; consider extracting a shared token-scan helper (e.g., findOptionValue(tokens, aliases...)) used by both.

🤖 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/TunnelManager.java` around lines 147 - 163, Extract
the tunnel-identifier token matching and value lookup from
extractTunnelIdentifier and applyOptions into a shared helper such as
findOptionValue(tokens, aliases...). Update both call sites to use that helper,
preserving existing handling for -i, --tunnel-identifier, missing values,
aliases, and tokenization.

/**
* Configures and boots the given {@link App}, blocking until the tunnel
* reports READY (or a poll error occurs). Credentials are passed as already
Expand Down
45 changes: 37 additions & 8 deletions src/main/java/testingbot/pipeline/TestingBotTunnelStep.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import hudson.model.Run;
import hudson.model.TaskListener;
import hudson.model.TopLevelItem;
import hudson.remoting.VirtualChannel;
import hudson.security.ACL;
import hudson.util.ListBoxModel;
import java.util.ArrayList;
Expand Down Expand Up @@ -221,7 +222,12 @@ public boolean start() throws Exception {
run.addAction(new TestingBotBuildAction(tbCredentials));
}

tunnelIdentifier = "jenkins-" + job.getName() + "-" + run.getNumber() + "-" + TUNNEL_SEQ.incrementAndGet();
// Respect a user-supplied --tunnel-identifier (their Selenium/Appium capabilities may
// target it); otherwise generate a unique one so parallel tunnels stay isolated.
String userIdentifier = TunnelManager.extractTunnelIdentifier(options);
tunnelIdentifier = (userIdentifier != null && !userIdentifier.isEmpty())
? userIdentifier
: "jenkins-" + run.getParent().getFullName().replace('/', '-') + "-" + run.getNumber() + "-" + TUNNEL_SEQ.incrementAndGet();

// Resolve secrets on the controller; only plaintext crosses the channel (over the encrypted remoting link).
String key = tbCredentials.getKey();
Expand All @@ -239,7 +245,7 @@ public boolean start() throws Exception {
env.put("SELENIUM_HOST", hubHost);
env.put("SELENIUM_PORT", hubPort);

computer.getChannel().call(new TbStartTunnelHandler(key, secret, options, tunnelIdentifier, listener));
requireChannel(computer).call(new TbStartTunnelHandler(key, secret, options, tunnelIdentifier, listener));

try {
body = getContext().newBodyInvoker()
Expand All @@ -251,17 +257,40 @@ public boolean start() throws Exception {
} catch (Exception e) {
// The tunnel started but the body could not be invoked; stop it now, otherwise the
// Callback that would normally stop it never runs and the tunnel leaks.
try {
computer.getChannel().call(new TbStopTunnelHandler(tunnelIdentifier, listener));
} catch (Exception ignored) {
// best effort
}
stopTunnelQuietly(computer, tunnelIdentifier, listener);
throw e;
}

return false;
}

private static VirtualChannel requireChannel(Computer computer) throws Exception {
VirtualChannel channel = computer == null ? null : computer.getChannel();
if (channel == null) {
throw new Exception("The agent is offline; cannot start the TestingBot tunnel");
}
return channel;
}

/** Best-effort tunnel teardown that tolerates an offline agent (no channel). */
private static void stopTunnelQuietly(Computer computer, String tunnelIdentifier, TaskListener listener) {
VirtualChannel channel = computer == null ? null : computer.getChannel();
if (channel == null) {
RUNNING_TUNNELS.remove(tunnelIdentifier);
if (listener != null) {
listener.getLogger().println("[TestingBot] Agent offline: remote teardown of tunnel "
+ tunnelIdentifier + " could not be performed. It may still be running on the agent "
+ "and should stop when the agent process exits (best-effort cleanup).");
}
return;
}
try {
channel.call(new TbStopTunnelHandler(tunnelIdentifier, listener));
} catch (Exception ignored) {
// best effort
}
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
@Override
public void stop(@NonNull Throwable cause) throws Exception {
if (body != null) {
Expand All @@ -286,7 +315,7 @@ private static final class Callback extends BodyExecutionCallback.TailCall {
protected void finished(StepContext context) throws Exception {
TaskListener listener = context.get(TaskListener.class);
Computer computer = context.get(Computer.class);
computer.getChannel().call(new TbStopTunnelHandler(tunnelIdentifier, listener));
stopTunnelQuietly(computer, tunnelIdentifier, listener);
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions src/test/java/testingbot/TunnelManagerOptionsTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,19 @@ public void reportsInvalidMetricsPort() throws Exception {
String log = applyAndCapture(app, "--metrics-port notanumber");
assertThat(log).contains("metrics-port", "invalid value");
}

@Test
public void accumulatesRepeatedAuthOptions() {
App app = new App();
TunnelManager.applyOptions(app, "--auth host1:80:u:p --auth host2:80:u:p", TaskListener.NULL);
assertThat(app.getBasicAuth()).containsExactly("host1:80:u:p", "host2:80:u:p");
}

@Test
public void extractsUserTunnelIdentifier() {
assertThat(TunnelManager.extractTunnelIdentifier("--debug --tunnel-identifier my-suite")).isEqualTo("my-suite");
assertThat(TunnelManager.extractTunnelIdentifier("-i short-id")).isEqualTo("short-id");
assertThat(TunnelManager.extractTunnelIdentifier("--debug --proxy host:8080")).isNull();
assertThat(TunnelManager.extractTunnelIdentifier(null)).isNull();
}
}