diff --git a/java/src/org/openqa/selenium/grid/node/DownloadFile.java b/java/src/org/openqa/selenium/grid/node/DownloadFile.java new file mode 100644 index 0000000000000..eee0a6ffe93ce --- /dev/null +++ b/java/src/org/openqa/selenium/grid/node/DownloadFile.java @@ -0,0 +1,41 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package org.openqa.selenium.grid.node; + +import java.io.UncheckedIOException; +import org.openqa.selenium.internal.Require; +import org.openqa.selenium.remote.SessionId; +import org.openqa.selenium.remote.http.HttpHandler; +import org.openqa.selenium.remote.http.HttpRequest; +import org.openqa.selenium.remote.http.HttpResponse; + +class DownloadFile implements HttpHandler { + + private final Node node; + private final SessionId id; + + DownloadFile(Node node, SessionId id) { + this.node = Require.nonNull("Node", node); + this.id = Require.nonNull("Session id", id); + } + + @Override + public HttpResponse execute(HttpRequest req) throws UncheckedIOException { + return node.downloadFile(req, id); + } +} diff --git a/java/src/org/openqa/selenium/grid/node/Node.java b/java/src/org/openqa/selenium/grid/node/Node.java index afc5e7471a73b..75cf2113a49bc 100644 --- a/java/src/org/openqa/selenium/grid/node/Node.java +++ b/java/src/org/openqa/selenium/grid/node/Node.java @@ -150,6 +150,9 @@ protected Node(Tracer tracer, NodeId id, URI uri, Secret registrationSecret) { post("/session/{sessionId}/se/file") .to(params -> new UploadFile(this, sessionIdFrom(params))) .with(spanDecorator("node.upload_file")), + get("/session/{sessionId}/se/file") + .to(params -> new DownloadFile(this, sessionIdFrom(params))) + .with(spanDecorator("node.download_file")), get("/se/grid/node/owner/{sessionId}") .to(params -> new IsSessionOwner(this, sessionIdFrom(params))) .with(spanDecorator("node.is_session_owner").andThen(requiresSecret)), @@ -216,6 +219,8 @@ public TemporaryFilesystem getTemporaryFilesystem(SessionId id) throws IOExcepti public abstract HttpResponse uploadFile(HttpRequest req, SessionId id); + public abstract HttpResponse downloadFile(HttpRequest req, SessionId id); + public abstract void stop(SessionId id) throws NoSuchSessionException; public abstract boolean isSessionOwner(SessionId id); diff --git a/java/src/org/openqa/selenium/grid/node/config/NodeFlags.java b/java/src/org/openqa/selenium/grid/node/config/NodeFlags.java index f4589cf1609ec..0103183fc749d 100644 --- a/java/src/org/openqa/selenium/grid/node/config/NodeFlags.java +++ b/java/src/org/openqa/selenium/grid/node/config/NodeFlags.java @@ -221,6 +221,14 @@ public class NodeFlags implements HasRoles { example = DEFAULT_NODE_IMPLEMENTATION) private String nodeImplementation = DEFAULT_NODE_IMPLEMENTATION; + @Parameter( + names = {"--downloads-path"}, + description = "The default location wherein all browser triggered file downloads would be " + + "available to be retrieved from. This is usually the directory that you configure in " + + "your browser as the default location for storing downloaded files.") + @ConfigValue(section = NODE_SECTION, name = "downloads-path", example = "") + private String downloadsPath = ""; + @Override public Set getRoles() { return Collections.singleton(NODE_ROLE); diff --git a/java/src/org/openqa/selenium/grid/node/config/NodeOptions.java b/java/src/org/openqa/selenium/grid/node/config/NodeOptions.java index 31ccdc9527ee5..316e016ab40da 100644 --- a/java/src/org/openqa/selenium/grid/node/config/NodeOptions.java +++ b/java/src/org/openqa/selenium/grid/node/config/NodeOptions.java @@ -148,6 +148,10 @@ public Optional getPublicGridUri() { } } + public Optional getDownloadsPath() { + return config.get(NODE_SECTION, "downloads-path"); + } + public Node getNode() { return config.getClass(NODE_SECTION, "implementation", Node.class, DEFAULT_NODE_IMPLEMENTATION); } diff --git a/java/src/org/openqa/selenium/grid/node/k8s/OneShotNode.java b/java/src/org/openqa/selenium/grid/node/k8s/OneShotNode.java index 88ea6755faeef..e4dbfcd1d17bd 100644 --- a/java/src/org/openqa/selenium/grid/node/k8s/OneShotNode.java +++ b/java/src/org/openqa/selenium/grid/node/k8s/OneShotNode.java @@ -307,6 +307,11 @@ public HttpResponse uploadFile(HttpRequest req, SessionId id) { return null; } + @Override + public HttpResponse downloadFile(HttpRequest req, SessionId id) { + return null; + } + @Override public void stop(SessionId id) throws NoSuchSessionException { LOG.info("Stop has been called: " + id); 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 d3a6dad2735a7..100ebcd8b3cb1 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java @@ -122,6 +122,7 @@ public class LocalNode extends Node { private final int maxSessionCount; private final int configuredSessionCount; private final boolean cdpEnabled; + private final String downloadsPath; private final boolean bidiEnabled; private final AtomicBoolean drainAfterSessions = new AtomicBoolean(); @@ -145,7 +146,8 @@ private LocalNode( Duration sessionTimeout, Duration heartbeatPeriod, List factories, - Secret registrationSecret) { + Secret registrationSecret, + String downloadsPath) { super(tracer, new NodeId(UUID.randomUUID()), uri, registrationSecret); this.bus = Require.nonNull("Event bus", bus); @@ -162,6 +164,7 @@ private LocalNode( this.sessionCount.set(drainAfterSessionCount); this.cdpEnabled = cdpEnabled; this.bidiEnabled = bidiEnabled; + this.downloadsPath = Optional.ofNullable(downloadsPath).orElse(""); this.healthCheck = healthCheck == null ? () -> { @@ -471,6 +474,50 @@ public HttpResponse executeWebDriverCommand(HttpRequest req) { return toReturn; } + @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. + SessionSlot slot = currentSessions.getIfPresent(id); + if (slot != null && slot.getSession() instanceof DockerSession) { + return executeWebDriverCommand(req); + } + if (this.downloadsPath.isEmpty()) { + String msg = "Please specify the path wherein the files downloaded using the browser " + + "would be available via the command line arg [--downloads-path] and restart the node"; + throw new WebDriverException(msg); + } + File dir = new File(this.downloadsPath); + if (!dir.exists()) { + throw new WebDriverException( + String.format("Cannot locate downloads directory %s.", downloadsPath)); + } + if (!dir.isDirectory()) { + throw new WebDriverException(String.format("Invalid directory: %s.", downloadsPath)); + } + String filename = req.getQueryParameter("filename"); + try { + File[] allFiles = Optional.ofNullable( + dir.listFiles((dir1, name) -> name.equals(filename)) + ).orElse(new File[]{}); + if (allFiles.length == 0) { + throw new WebDriverException( + String.format("Cannot find file [%s] in directory %s.", filename, downloadsPath)); + } + if (allFiles.length != 1) { + throw new WebDriverException( + String.format("Expected there to be only 1 file. There were: %s.", allFiles.length)); + } + String content = Zip.zip(allFiles[0]); + ImmutableMap result = ImmutableMap.of( + "filename", filename, + "contents", content); + return new HttpResponse().setContent(asJson(result)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + @Override public HttpResponse uploadFile(HttpRequest req, SessionId id) { @@ -701,6 +748,7 @@ public static class Builder { private Duration sessionTimeout = Duration.ofSeconds(NodeOptions.DEFAULT_SESSION_TIMEOUT); private HealthCheck healthCheck; private Duration heartbeatPeriod = Duration.ofSeconds(NodeOptions.DEFAULT_HEARTBEAT_PERIOD); + private String downloadsPath = ""; private Builder( Tracer tracer, @@ -755,6 +803,11 @@ public Builder heartbeatPeriod(Duration heartbeatPeriod) { return this; } + public Builder downloadsPath(String path) { + this.downloadsPath = path; + return this; + } + public LocalNode build() { return new LocalNode( tracer, @@ -770,7 +823,8 @@ public LocalNode build() { sessionTimeout, heartbeatPeriod, factories.build(), - registrationSecret); + registrationSecret, + downloadsPath); } public Advanced advanced() { diff --git a/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java b/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java index 4c26e5833dc81..6475845c593e7 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java @@ -76,6 +76,8 @@ public static Node create(Config config) { List> builders = new ArrayList<>(); ServiceLoader.load(DriverService.Builder.class).forEach(builders::add); + nodeOptions.getDownloadsPath().ifPresent(builder::downloadsPath); + nodeOptions .getSessionFactories( caps -> createSessionFactory(tracer, clientFactory, sessionTimeout, builders, caps)) diff --git a/java/src/org/openqa/selenium/grid/node/remote/RemoteNode.java b/java/src/org/openqa/selenium/grid/node/remote/RemoteNode.java index 34988c81f12d3..76e59b7f0c866 100644 --- a/java/src/org/openqa/selenium/grid/node/remote/RemoteNode.java +++ b/java/src/org/openqa/selenium/grid/node/remote/RemoteNode.java @@ -189,6 +189,11 @@ public HttpResponse uploadFile(HttpRequest req, SessionId id) { return client.execute(req); } + @Override + public HttpResponse downloadFile(HttpRequest req, SessionId id) { + return client.execute(req); + } + @Override public void stop(SessionId id) throws NoSuchSessionException { Require.nonNull("Session ID", id); diff --git a/java/src/org/openqa/selenium/remote/http/Contents.java b/java/src/org/openqa/selenium/remote/http/Contents.java index a3dffd9290fc1..061fd24aed3e4 100644 --- a/java/src/org/openqa/selenium/remote/http/Contents.java +++ b/java/src/org/openqa/selenium/remote/http/Contents.java @@ -22,6 +22,11 @@ import com.google.common.io.ByteStreams; import com.google.common.io.FileBackedOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.nio.file.Files; +import java.util.Base64; +import java.util.zip.ZipOutputStream; import org.openqa.selenium.internal.Require; import org.openqa.selenium.json.Json; import org.openqa.selenium.json.JsonInput; @@ -138,6 +143,18 @@ public static Supplier memoize(Supplier delegate) { return new MemoizedSupplier(delegate); } + public static String string(File input) throws IOException { + try (ByteArrayOutputStream bos = new ByteArrayOutputStream(); + InputStream isr = Files.newInputStream(input.toPath())) { + int len; + byte[] buffer = new byte[4096]; + while ((len = isr.read(buffer)) != -1) { + bos.write(buffer, 0, len); + } + return Base64.getEncoder().encodeToString(bos.toByteArray()); + } + } + private static final class MemoizedSupplier implements Supplier { private volatile boolean initialized; diff --git a/java/test/org/openqa/selenium/grid/distributor/AddingNodesTest.java b/java/test/org/openqa/selenium/grid/distributor/AddingNodesTest.java index 2f1d980e08ffd..66b8674da3dfc 100644 --- a/java/test/org/openqa/selenium/grid/distributor/AddingNodesTest.java +++ b/java/test/org/openqa/selenium/grid/distributor/AddingNodesTest.java @@ -384,6 +384,11 @@ public HttpResponse uploadFile(HttpRequest req, SessionId id) { throw new UnsupportedOperationException("uploadFile"); } + @Override + public HttpResponse downloadFile(HttpRequest req, SessionId id) { + throw new UnsupportedOperationException("downloadFile"); + } + @Override public Session getSession(SessionId id) throws NoSuchSessionException { if (running == null || !running.getId().equals(id)) { diff --git a/java/test/org/openqa/selenium/grid/node/NodeTest.java b/java/test/org/openqa/selenium/grid/node/NodeTest.java index de14a0e88cd23..11a183a9e000c 100644 --- a/java/test/org/openqa/selenium/grid/node/NodeTest.java +++ b/java/test/org/openqa/selenium/grid/node/NodeTest.java @@ -30,6 +30,7 @@ import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.openqa.selenium.Capabilities; @@ -115,6 +116,7 @@ public void setUp() throws URISyntaxException { caps = new ImmutableCapabilities("browserName", "cheese"); uri = new URI("http://localhost:1234"); + File downloadsPath = new File(System.getProperty("java.io.tmpdir")); class Handler extends Session implements HttpHandler { private Handler(Capabilities capabilities) { @@ -131,6 +133,7 @@ public HttpResponse execute(HttpRequest req) throws UncheckedIOException { .add(caps, new TestSessionFactory((id, c) -> new Handler(c))) .add(caps, new TestSessionFactory((id, c) -> new Handler(c))) .add(caps, new TestSessionFactory((id, c) -> new Handler(c))) + .downloadsPath(downloadsPath.getAbsolutePath()) .maximumConcurrentSessions(2) .build(); @@ -484,6 +487,28 @@ void canUploadAFile() throws IOException { assertThat(baseDir).doesNotExist(); } + @Test + void canDownloadAFile() throws IOException { + Either response = + node.newSession(createSessionRequest(caps)); + assertThatEither(response).isRight(); + Session session = response.right().getSession(); + String hello = "Hello, world!"; + + HttpRequest req = new HttpRequest(GET, String.format("/session/%s/se/file", session.getId())); + File zip = createTmpFile(hello); + req.addQueryParameter("filename", zip.getName()); + HttpResponse rsp = node.execute(req); + node.stop(session.getId()); + Map map = new Json().toType(string(rsp), Json.MAP_TYPE); + File baseDir = getTemporaryFilesystemBaseDir(TemporaryFilesystem.getDefaultTmpFS()); + String encodedContents = map.get("contents").toString(); + Zip.unzip(encodedContents, baseDir); + Path path = new File(baseDir.getAbsolutePath() + "/" + map.get("filename")).toPath(); + String decodedContents = String.join("", Files.readAllLines(path)); + assertThat(decodedContents).isEqualTo(hello); + } + @Test void shouldNotCreateSessionIfDraining() { node.drain(); @@ -573,6 +598,17 @@ void shouldAllowsWebDriverCommandsForOngoingSessionIfDraining() throws Interrupt assertThat(latch.getCount()).isEqualTo(1); } + private File createFile(String content, File directory) { + try { + File f = new File(directory.getAbsolutePath(), UUID.randomUUID().toString()); + f.deleteOnExit(); + Files.write(directory.toPath(), content.getBytes(StandardCharsets.UTF_8)); + return f; + } catch (IOException e) { + throw new RuntimeException(e); + } + + } private File createTmpFile(String content) { try { File f = File.createTempFile("webdriver", "tmp");