From 53acff5c17ba7e1b43be39d0097c774fc1e844fa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 04:19:20 +0000 Subject: [PATCH 1/3] [grid] Forward file upload/download for Kubernetes Dynamic Grid sessions Remote file upload (LocalFileDetector) failed on the Kubernetes Dynamic Grid: LocalNode.uploadFile()/downloadFile() only forwarded the command to the downstream session for DockerSession, so on node-kubernetes the file was written to the Node Pod while sendKeys ran in a separate browser Job Pod that could not see it. Rather than adding an `instanceof KubernetesSession` check in LocalNode (which would drag the optional fabric8 kubernetes-client dependency into the core `local` package, breaking the --ext extension model), introduce a capability method on ActiveSession: default boolean isRemoteFileSystem() { return false; } DockerSession and KubernetesSession both override it to return true, and LocalNode forwards upload/download whenever the session reports a remote filesystem. Plain (static Grid) sessions keep the default and continue to be handled on the Node's local filesystem. This also fixes downloadFile(), which had the same Docker-only asymmetry (managed downloads / se/files would likewise fail on Kubernetes). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018RszyJNdSaBmaSRM5i1c3A --- .../selenium/grid/node/ActiveSession.java | 13 ++ .../grid/node/docker/DockerSession.java | 7 + .../node/kubernetes/KubernetesSession.java | 7 + .../selenium/grid/node/local/LocalNode.java | 16 +- .../grid/node/local/LocalNodeTest.java | 163 ++++++++++++++++++ 5 files changed, 199 insertions(+), 7 deletions(-) diff --git a/java/src/org/openqa/selenium/grid/node/ActiveSession.java b/java/src/org/openqa/selenium/grid/node/ActiveSession.java index f6476a6d36fbf..c8ceff8eae86b 100644 --- a/java/src/org/openqa/selenium/grid/node/ActiveSession.java +++ b/java/src/org/openqa/selenium/grid/node/ActiveSession.java @@ -40,5 +40,18 @@ public interface ActiveSession extends HttpHandler { Dialect getDownstreamDialect(); + /** + * Indicates whether the browser backing this session runs in a separate environment from the + * Node process (for example, a Docker container or a Kubernetes Pod) and therefore does not share + * the Node's local filesystem. When {@code true}, file upload and download commands must be + * forwarded to the session so files are written to (or read from) the environment where the + * browser actually runs, instead of being handled on the Node's own filesystem. + * + * @return {@code true} if file transfer commands must be forwarded to the browser environment + */ + default boolean isRemoteFileSystem() { + return false; + } + void stop(); } diff --git a/java/src/org/openqa/selenium/grid/node/docker/DockerSession.java b/java/src/org/openqa/selenium/grid/node/docker/DockerSession.java index 986ca70cb71ba..70699642ac789 100644 --- a/java/src/org/openqa/selenium/grid/node/docker/DockerSession.java +++ b/java/src/org/openqa/selenium/grid/node/docker/DockerSession.java @@ -77,6 +77,13 @@ public class DockerSession extends DefaultActiveSession { Require.nonNull("Video container stop timeout", videoContainerStopTimeout); } + @Override + public boolean isRemoteFileSystem() { + // The browser runs inside a Docker container that does not share the Node's filesystem, so + // file upload and download commands must be forwarded to the container. + return true; + } + @Override public void stop() { try { diff --git a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java index cfddd94a916e8..59aeaea43ad02 100644 --- a/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java +++ b/java/src/org/openqa/selenium/grid/node/kubernetes/KubernetesSession.java @@ -79,6 +79,13 @@ public class KubernetesSession extends DefaultActiveSession { this.portForward = portForward; } + @Override + public boolean isRemoteFileSystem() { + // The browser runs in a separate Kubernetes Job Pod that does not share the Node Pod's + // filesystem, so file upload and download commands must be forwarded to the browser Pod. + return true; + } + @Override public void stop() { LOG.info(String.format("Stopping session, deleting K8s Job: %s/%s", namespace, jobName)); diff --git a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java index 7c15b4b718b9a..d06126cd0299a 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java @@ -112,7 +112,6 @@ import org.openqa.selenium.grid.node.NodeCommandInterceptor; import org.openqa.selenium.grid.node.SessionFactory; import org.openqa.selenium.grid.node.config.NodeOptions; -import org.openqa.selenium.grid.node.docker.DockerSession; import org.openqa.selenium.grid.security.Secret; import org.openqa.selenium.internal.Debug; import org.openqa.selenium.internal.Either; @@ -901,10 +900,11 @@ private static HttpResponse callUnchecked(Callable callable) { @Override public HttpResponse downloadFile(HttpRequest req, SessionId id) { - // When the session is running in a Docker container, the download file command - // needs to be forwarded to the container as well. + // When the browser runs in a separate environment from the Node (e.g. a Docker container or a + // Kubernetes Pod), the download file command needs to be forwarded to that environment as well, + // since the Node does not share a filesystem with the browser. SessionSlot slot = currentSessions.getIfPresent(id); - if (slot != null && slot.getSession() instanceof DockerSession) { + if (slot != null && slot.getSession() != null && slot.getSession().isRemoteFileSystem()) { return executeWebDriverCommand(req); } if (!this.managedDownloadsEnabled) { @@ -1109,10 +1109,12 @@ private HttpResponse deleteDownloadedFile(File downloadsDirectory) { @Override public HttpResponse uploadFile(HttpRequest req, SessionId id) { - // When the session is running in a Docker container, the upload file command - // needs to be forwarded to the container as well. + // When the browser runs in a separate environment from the Node (e.g. a Docker container or a + // Kubernetes Pod), the upload file command needs to be forwarded to that environment as well, + // since the Node does not share a filesystem with the browser. Otherwise the file would be + // written to the Node's filesystem and be unreachable from the browser when sendKeys runs. SessionSlot slot = currentSessions.getIfPresent(id); - if (slot != null && slot.getSession() instanceof DockerSession) { + if (slot != null && slot.getSession() != null && slot.getSession().isRemoteFileSystem()) { return executeWebDriverCommand(req); } diff --git a/java/test/org/openqa/selenium/grid/node/local/LocalNodeTest.java b/java/test/org/openqa/selenium/grid/node/local/LocalNodeTest.java index 0b8741ff1e7a4..859ecfdb1bb0f 100644 --- a/java/test/org/openqa/selenium/grid/node/local/LocalNodeTest.java +++ b/java/test/org/openqa/selenium/grid/node/local/LocalNodeTest.java @@ -24,9 +24,14 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.openqa.selenium.remote.Dialect.W3C; import static org.openqa.selenium.remote.http.HttpMethod.GET; +import static org.openqa.selenium.remote.http.HttpMethod.POST; +import java.io.UncheckedIOException; +import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; +import java.net.URL; +import java.util.UUID; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; @@ -51,7 +56,10 @@ import org.openqa.selenium.grid.data.NodeStatus; import org.openqa.selenium.grid.data.Session; import org.openqa.selenium.grid.data.Slot; +import org.openqa.selenium.grid.node.ActiveSession; +import org.openqa.selenium.grid.node.BaseActiveSession; import org.openqa.selenium.grid.node.Node; +import org.openqa.selenium.grid.node.SessionFactory; import org.openqa.selenium.grid.security.Secret; import org.openqa.selenium.grid.testing.EitherAssert; import org.openqa.selenium.grid.testing.TestSessionFactory; @@ -545,6 +553,161 @@ public HttpResponse intercept(SessionId id, HttpRequest req, Callable + localFsNode.downloadFile( + new HttpRequest(GET, "/session/" + id + "/se/files"), id)) + .withMessageContaining("enable-managed-downloads"); + assertThat(factory.session.lastRequest).isNull(); + } + + private static SessionId createSession(LocalNode node, Capabilities caps) { + Either response = + node.newSession(new CreateSessionRequest(Set.of(W3C), caps, emptyMap())); + if (response.isLeft()) { + throw new AssertionError("Unable to create session: " + response.left().getMessage()); + } + return response.right().getSession().getId(); + } + + /** + * An {@link ActiveSession} that records the last request it was asked to execute, so tests can + * assert whether a file command was forwarded to the session. Its {@link #isRemoteFileSystem()} + * value is configurable to emulate both remote (Docker/Kubernetes) and local (static Grid Node) + * sessions. + */ + private static class RecordingActiveSession extends BaseActiveSession { + private final boolean remoteFileSystem; + private volatile HttpRequest lastRequest; + + RecordingActiveSession( + SessionId id, + URL url, + Capabilities stereotype, + Capabilities capabilities, + boolean remoteFileSystem) { + super(id, url, W3C, W3C, stereotype, capabilities, Instant.now()); + this.remoteFileSystem = remoteFileSystem; + } + + @Override + public boolean isRemoteFileSystem() { + return remoteFileSystem; + } + + @Override + public void stop() { + // Do nothing. + } + + @Override + public HttpResponse execute(HttpRequest req) throws UncheckedIOException { + this.lastRequest = req; + return new HttpResponse().setHeader("X-Forwarded", "true"); + } + } + + private static class RecordingSessionFactory implements SessionFactory { + private final URI uri; + private final Capabilities stereotype; + private final boolean remoteFileSystem; + private volatile RecordingActiveSession session; + + RecordingSessionFactory(URI uri, Capabilities stereotype, boolean remoteFileSystem) { + this.uri = uri; + this.stereotype = ImmutableCapabilities.copyOf(stereotype); + this.remoteFileSystem = remoteFileSystem; + } + + @Override + public Capabilities getStereotype() { + return stereotype; + } + + @Override + public boolean test(Capabilities capabilities) { + return true; + } + + @Override + public Either apply(CreateSessionRequest sessionRequest) { + SessionId id = new SessionId(UUID.randomUUID()); + URL url; + try { + url = uri.toURL(); + } catch (MalformedURLException e) { + throw new UncheckedIOException(e); + } + RecordingActiveSession created = + new RecordingActiveSession( + id, url, stereotype, sessionRequest.getDesiredCapabilities(), remoteFileSystem); + this.session = created; + return Either.right(created); + } + } + private void waitUntilNodeStopped(SessionId sessionId) { long timeout = Duration.ofSeconds(5).toMillis(); From 0c281036782cb0c977b0baae1e95ea23ca43240d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 04:28:46 +0000 Subject: [PATCH 2/3] [grid] Forward file upload/download for relay sessions too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relay session forwards to an external endpoint (a cloud provider, an Appium device farm, another Selenium) whose browser does not share the relay Node's filesystem — the same situation as Docker/Kubernetes. Have the relay-produced session report isRemoteFileSystem() = true so LocalNode forwards file upload/download commands to the relay target instead of writing them to the relay Node's own filesystem. Also refreshes the surrounding comments/javadoc to mention relay. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018RszyJNdSaBmaSRM5i1c3A --- .../selenium/grid/node/ActiveSession.java | 9 ++-- .../selenium/grid/node/local/LocalNode.java | 15 +++--- .../grid/node/relay/RelaySessionFactory.java | 11 +++- .../node/relay/RelaySessionFactoryTest.java | 54 +++++++++++++++++++ 4 files changed, 77 insertions(+), 12 deletions(-) diff --git a/java/src/org/openqa/selenium/grid/node/ActiveSession.java b/java/src/org/openqa/selenium/grid/node/ActiveSession.java index c8ceff8eae86b..1e365221d18a4 100644 --- a/java/src/org/openqa/selenium/grid/node/ActiveSession.java +++ b/java/src/org/openqa/selenium/grid/node/ActiveSession.java @@ -42,10 +42,11 @@ public interface ActiveSession extends HttpHandler { /** * Indicates whether the browser backing this session runs in a separate environment from the - * Node process (for example, a Docker container or a Kubernetes Pod) and therefore does not share - * the Node's local filesystem. When {@code true}, file upload and download commands must be - * forwarded to the session so files are written to (or read from) the environment where the - * browser actually runs, instead of being handled on the Node's own filesystem. + * Node process (for example, a Docker container, a Kubernetes Pod, or a relayed remote endpoint) + * and therefore does not share the Node's local filesystem. When {@code true}, file upload and + * download commands must be forwarded to the session so files are written to (or read from) the + * environment where the browser actually runs, instead of being handled on the Node's own + * filesystem. * * @return {@code true} if file transfer commands must be forwarded to the browser environment */ diff --git a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java index d06126cd0299a..5447a4dfca64d 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java @@ -900,9 +900,9 @@ private static HttpResponse callUnchecked(Callable callable) { @Override public HttpResponse downloadFile(HttpRequest req, SessionId id) { - // When the browser runs in a separate environment from the Node (e.g. a Docker container or a - // Kubernetes Pod), the download file command needs to be forwarded to that environment as well, - // since the Node does not share a filesystem with the browser. + // When the browser runs in a separate environment from the Node (e.g. a Docker container, a + // Kubernetes Pod, or a relayed remote endpoint), the download file command needs to be + // forwarded to that environment, since the Node does not share a filesystem with the browser. SessionSlot slot = currentSessions.getIfPresent(id); if (slot != null && slot.getSession() != null && slot.getSession().isRemoteFileSystem()) { return executeWebDriverCommand(req); @@ -1109,10 +1109,11 @@ private HttpResponse deleteDownloadedFile(File downloadsDirectory) { @Override public HttpResponse uploadFile(HttpRequest req, SessionId id) { - // When the browser runs in a separate environment from the Node (e.g. a Docker container or a - // Kubernetes Pod), the upload file command needs to be forwarded to that environment as well, - // since the Node does not share a filesystem with the browser. Otherwise the file would be - // written to the Node's filesystem and be unreachable from the browser when sendKeys runs. + // When the browser runs in a separate environment from the Node (e.g. a Docker container, a + // Kubernetes Pod, or a relayed remote endpoint), the upload file command needs to be forwarded + // to that environment, since the Node does not share a filesystem with the browser. Otherwise + // the file would be written to the Node's filesystem and be unreachable from the browser when + // sendKeys runs. SessionSlot slot = currentSessions.getIfPresent(id); if (slot != null && slot.getSession() != null && slot.getSession().isRemoteFileSystem()) { return executeWebDriverCommand(req); diff --git a/java/src/org/openqa/selenium/grid/node/relay/RelaySessionFactory.java b/java/src/org/openqa/selenium/grid/node/relay/RelaySessionFactory.java index 4689c0616a9da..2e939d67ec166 100644 --- a/java/src/org/openqa/selenium/grid/node/relay/RelaySessionFactory.java +++ b/java/src/org/openqa/selenium/grid/node/relay/RelaySessionFactory.java @@ -216,7 +216,16 @@ public Either apply(CreateSessionRequest sess upstream, stereotype, mergedCapabilities, - Instant.now()) {}); + Instant.now()) { + @Override + public boolean isRemoteFileSystem() { + // The relay forwards the session to an external endpoint (for example a cloud + // provider or an Appium device farm) that does not share the Node's filesystem, so + // file upload and download commands must be forwarded to that endpoint rather than + // handled on the Node's own filesystem. + return true; + } + }); } catch (Exception e) { span.setAttribute(AttributeKey.ERROR.getKey(), true); span.setStatus(Status.CANCELLED); diff --git a/java/test/org/openqa/selenium/grid/node/relay/RelaySessionFactoryTest.java b/java/test/org/openqa/selenium/grid/node/relay/RelaySessionFactoryTest.java index 952fcf7998014..a03491ff7edf0 100644 --- a/java/test/org/openqa/selenium/grid/node/relay/RelaySessionFactoryTest.java +++ b/java/test/org/openqa/selenium/grid/node/relay/RelaySessionFactoryTest.java @@ -351,4 +351,58 @@ void doesNotForwardClientRemoteUrlToTheRemoteEnd() { assertThat(result.right().getCapabilities().getCapability("se:remoteUrl")) .isEqualTo("http://localhost:4444"); } + + @Test + void relaySessionReportsRemoteFileSystemSoFileCommandsAreForwarded() { + String fakeSessionId = UUID.randomUUID().toString(); + + Map responsePayload = + Map.of( + "value", + Map.of( + "sessionId", + fakeSessionId, + "capabilities", + Map.of("browserName", "chrome", "platformName", "android"))); + + Route route = + Route.post("/session") + .to( + () -> + req -> { + HttpResponse response = new HttpResponse(); + response.setStatus(200); + response.setContent(Contents.asJson(responsePayload)); + return response; + }); + + PassthroughHttpClient.Factory clientFactory = new PassthroughHttpClient.Factory(route); + Tracer tracer = DefaultTestTracer.createTracer(); + + Capabilities stereotype = + new ImmutableCapabilities("browserName", "chrome", "platformName", "android"); + + RelaySessionFactory factory = + new RelaySessionFactory( + tracer, + clientFactory, + Duration.ofSeconds(300), + URI.create("http://localhost:4723"), + null, + "", + stereotype); + + Capabilities requestCaps = + new ImmutableCapabilities("browserName", "chrome", "platformName", "android"); + + CreateSessionRequest sessionRequest = + new CreateSessionRequest(Set.of(Dialect.W3C), requestCaps, Map.of()); + + Either result = factory.apply(sessionRequest); + + assertThat(result.isRight()).isTrue(); + // The browser runs on an external endpoint that does not share the Node's filesystem, so + // LocalNode must forward file upload/download commands to it instead of handling them locally. + assertThat(result.right().isRemoteFileSystem()).isTrue(); + } } From a996c3a2a420bce085521c3be6d7f724f8d62f35 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 04:42:59 +0000 Subject: [PATCH 3/3] [grid] Apply google-java-format Formatting-only: ran google-java-format 1.36.1 (the repo-pinned version) on the changed files. Fixes javadoc line-fill in ActiveSession and import ordering / a call-site line-join in LocalNodeTest. No behavior change. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018RszyJNdSaBmaSRM5i1c3A --- java/src/org/openqa/selenium/grid/node/ActiveSession.java | 6 +++--- .../org/openqa/selenium/grid/node/local/LocalNodeTest.java | 5 ++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/java/src/org/openqa/selenium/grid/node/ActiveSession.java b/java/src/org/openqa/selenium/grid/node/ActiveSession.java index 1e365221d18a4..323d50d699de9 100644 --- a/java/src/org/openqa/selenium/grid/node/ActiveSession.java +++ b/java/src/org/openqa/selenium/grid/node/ActiveSession.java @@ -41,9 +41,9 @@ public interface ActiveSession extends HttpHandler { Dialect getDownstreamDialect(); /** - * Indicates whether the browser backing this session runs in a separate environment from the - * Node process (for example, a Docker container, a Kubernetes Pod, or a relayed remote endpoint) - * and therefore does not share the Node's local filesystem. When {@code true}, file upload and + * Indicates whether the browser backing this session runs in a separate environment from the Node + * process (for example, a Docker container, a Kubernetes Pod, or a relayed remote endpoint) and + * therefore does not share the Node's local filesystem. When {@code true}, file upload and * download commands must be forwarded to the session so files are written to (or read from) the * environment where the browser actually runs, instead of being handled on the Node's own * filesystem. diff --git a/java/test/org/openqa/selenium/grid/node/local/LocalNodeTest.java b/java/test/org/openqa/selenium/grid/node/local/LocalNodeTest.java index 859ecfdb1bb0f..bf249b35ecb21 100644 --- a/java/test/org/openqa/selenium/grid/node/local/LocalNodeTest.java +++ b/java/test/org/openqa/selenium/grid/node/local/LocalNodeTest.java @@ -31,7 +31,6 @@ import java.net.URI; import java.net.URISyntaxException; import java.net.URL; -import java.util.UUID; import java.time.Duration; import java.time.Instant; import java.util.ArrayList; @@ -39,6 +38,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.UUID; import java.util.concurrent.Callable; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -617,8 +617,7 @@ void fileCommandsAreHandledLocallyForSessionsSharingTheNodeFilesystem() assertThatExceptionOfType(WebDriverException.class) .isThrownBy( () -> - localFsNode.downloadFile( - new HttpRequest(GET, "/session/" + id + "/se/files"), id)) + localFsNode.downloadFile(new HttpRequest(GET, "/session/" + id + "/se/files"), id)) .withMessageContaining("enable-managed-downloads"); assertThat(factory.session.lastRequest).isNull(); }