diff --git a/common/src/web/downloads/download.html b/common/src/web/downloads/download.html new file mode 100644 index 0000000000000..8c69ffed5e9d5 --- /dev/null +++ b/common/src/web/downloads/download.html @@ -0,0 +1,40 @@ + + + + + Downloads + + + + + + + + +
+
+
+
+

Downloads

+
+
+
+ + +
+
+
+ + + diff --git a/common/src/web/downloads/file_1.txt b/common/src/web/downloads/file_1.txt new file mode 100644 index 0000000000000..8ab686eafeb1f --- /dev/null +++ b/common/src/web/downloads/file_1.txt @@ -0,0 +1 @@ +Hello, World! diff --git a/common/src/web/downloads/file_2.jpg b/common/src/web/downloads/file_2.jpg new file mode 100644 index 0000000000000..402237cbdd6cf Binary files /dev/null and b/common/src/web/downloads/file_2.jpg differ diff --git a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java index 8c55229761c2d..56b00bee09e63 100644 --- a/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java +++ b/java/src/org/openqa/selenium/grid/data/DefaultSlotMatcher.java @@ -64,6 +64,10 @@ public boolean matches(Capabilities stereotype, Capabilities capabilities) { return false; } + if (!managedDownloadsEnabled(stereotype, capabilities)) { + return false; + } + if (!platformVersionMatch(stereotype, capabilities)) { return false; } @@ -106,6 +110,19 @@ private Boolean initialMatch(Capabilities stereotype, Capabilities capabilities) .orElse(true); } + private Boolean managedDownloadsEnabled(Capabilities stereotype, Capabilities capabilities) { + // First lets check if user wanted a Node with managed downloads enabled + Object raw = capabilities.getCapability("se:downloadsEnabled"); + if (raw == null || !Boolean.parseBoolean(raw.toString())) { + // User didn't ask. So lets move on to the next matching criteria + return true; + } + // User wants managed downloads enabled to be done on this Node, let's check the stereotype + raw = stereotype.getCapability("se:downloadsEnabled"); + // Try to match what the user requested + return raw != null && Boolean.parseBoolean(raw.toString()); + } + private Boolean platformVersionMatch(Capabilities stereotype, Capabilities capabilities) { /* This platform version match is not W3C compliant but users can add Appium servers as diff --git a/java/src/org/openqa/selenium/grid/node/Node.java b/java/src/org/openqa/selenium/grid/node/Node.java index c6368f2b4fb85..bd4d498d1c461 100644 --- a/java/src/org/openqa/selenium/grid/node/Node.java +++ b/java/src/org/openqa/selenium/grid/node/Node.java @@ -18,9 +18,11 @@ package org.openqa.selenium.grid.node; import com.google.common.collect.ImmutableMap; + import org.openqa.selenium.BuildInfo; import org.openqa.selenium.Capabilities; import org.openqa.selenium.NoSuchSessionException; +import org.openqa.selenium.WebDriverException; import org.openqa.selenium.grid.data.CreateSessionRequest; import org.openqa.selenium.grid.data.CreateSessionResponse; import org.openqa.selenium.grid.data.NodeId; @@ -41,13 +43,13 @@ import org.openqa.selenium.remote.tracing.SpanDecorator; import org.openqa.selenium.remote.tracing.Tracer; import org.openqa.selenium.status.HasReadyState; -import org.openqa.selenium.WebDriverException; import java.io.IOException; import java.net.URI; import java.util.Map; import java.util.ServiceLoader; import java.util.Set; +import java.util.UUID; import java.util.logging.Logger; import java.util.stream.Collectors; import java.util.stream.StreamSupport; @@ -210,16 +212,21 @@ public ImmutableMap getOsInfo() { return OS_INFO; } - public abstract Either newSession(CreateSessionRequest sessionRequest); + public abstract Either newSession( + CreateSessionRequest sessionRequest); public abstract HttpResponse executeWebDriverCommand(HttpRequest req); public abstract Session getSession(SessionId id) throws NoSuchSessionException; - public TemporaryFilesystem getTemporaryFilesystem(SessionId id) throws IOException { + public TemporaryFilesystem getUploadsFilesystem(SessionId id) throws IOException { throw new UnsupportedOperationException(); } + public TemporaryFilesystem getDownloadsFilesystem(UUID uuid) throws IOException { + throw new UnsupportedOperationException(); + } + public abstract HttpResponse uploadFile(HttpRequest req, SessionId id); public abstract HttpResponse downloadFile(HttpRequest req, 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 c004c1c206514..d05c67ce32836 100644 --- a/java/src/org/openqa/selenium/grid/node/config/NodeFlags.java +++ b/java/src/org/openqa/selenium/grid/node/config/NodeFlags.java @@ -230,12 +230,16 @@ public class NodeFlags implements HasRoles { 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 = ""; + names = {"--enable-managed-downloads"}, + arity = 1, + description = "When enabled, the Grid node will automatically do the following: " + + "1. Creates a directory named '$HOME/.cache/selenium/downloads/' which " + + "will now represent the directory into which files downloaded by " + + "Chrome/Firefox/Edge browser will be under. " + + "2. For every new session, a sub-directory will be created/deleted so that " + + "all files that were downloaded for a given session are stored in.") + @ConfigValue(section = NODE_SECTION, name = "enable-managed-downloads", example = "false") + public Boolean managedDownloadsEnabled; @Override public Set getRoles() { 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 ef8647a28afb1..ca52c43c6c836 100644 --- a/java/src/org/openqa/selenium/grid/node/config/NodeOptions.java +++ b/java/src/org/openqa/selenium/grid/node/config/NodeOptions.java @@ -40,6 +40,7 @@ import org.openqa.selenium.json.JsonOutput; import org.openqa.selenium.net.NetworkUtils; import org.openqa.selenium.net.Urls; +import org.openqa.selenium.remote.Browser; import org.openqa.selenium.remote.service.DriverService; import java.io.File; @@ -149,8 +150,9 @@ public Optional getPublicGridUri() { } } - public Optional getDownloadsPath() { - return config.get(NODE_SECTION, "downloads-path"); + public boolean isManagedDownloadsEnabled() { + return config.getBool(NODE_SECTION, "enable-managed-downloads") + .orElse(Boolean.FALSE); } public Node getNode() { @@ -634,9 +636,17 @@ private Capabilities enhanceStereotype(Capabilities capabilities) { .setCapability("se:vncEnabled", true) .setCapability("se:noVncPort", noVncPort()); } + if (isManagedDownloadsEnabled() && canConfigureDownloadsDir(capabilities)) { + capabilities = new PersistentCapabilities(capabilities) + .setCapability("se:downloadsEnabled", true); + } return capabilities; } + private boolean canConfigureDownloadsDir(Capabilities caps) { + return Browser.FIREFOX.is(caps) || Browser.CHROME.is(caps) || Browser.EDGE.is(caps); + } + private void report(Map.Entry> entry) { StringBuilder caps = new StringBuilder(); try (JsonOutput out = JSON.newOutput(caps)) { 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 58d942de950de..e22a96a0cdbbb 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNode.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNode.java @@ -26,7 +26,6 @@ import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import java.util.Arrays; import org.openqa.selenium.Capabilities; import org.openqa.selenium.ImmutableCapabilities; import org.openqa.selenium.MutableCapabilities; @@ -63,6 +62,7 @@ import org.openqa.selenium.io.TemporaryFilesystem; import org.openqa.selenium.io.Zip; import org.openqa.selenium.json.Json; +import org.openqa.selenium.remote.Browser; import org.openqa.selenium.remote.SessionId; import org.openqa.selenium.remote.http.HttpMethod; import org.openqa.selenium.remote.http.HttpRequest; @@ -76,12 +76,14 @@ import java.io.File; import java.io.IOException; +import java.io.Serializable; import java.io.UncheckedIOException; import java.net.URI; import java.net.URISyntaxException; import java.time.Clock; import java.time.Duration; import java.time.Instant; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -116,6 +118,7 @@ public class LocalNode extends Node { private static final Json JSON = new Json(); private static final Logger LOG = Logger.getLogger(LocalNode.class.getName()); + private final EventBus bus; private final URI externalUri; private final URI gridUri; @@ -124,13 +127,15 @@ public class LocalNode extends Node { private final int maxSessionCount; private final int configuredSessionCount; private final boolean cdpEnabled; - private final String downloadsPath; + private final boolean managedDownloadsEnabled; private final boolean bidiEnabled; private final AtomicBoolean drainAfterSessions = new AtomicBoolean(); private final List factories; private final Cache currentSessions; - private final Cache tempFileSystems; + private final Cache uploadsTempFileSystem; + private final Cache downloadsTempFileSystem; + private final Cache sessionToDownloadsDir; private final AtomicInteger pendingSessions = new AtomicInteger(); private final AtomicInteger sessionCount = new AtomicInteger(); @@ -149,7 +154,7 @@ protected LocalNode( Duration heartbeatPeriod, List factories, Secret registrationSecret, - String downloadsPath) { + boolean managedDownloadsEnabled) { super(tracer, new NodeId(UUID.randomUUID()), uri, registrationSecret); this.bus = Require.nonNull("Event bus", bus); @@ -166,7 +171,7 @@ protected LocalNode( this.sessionCount.set(drainAfterSessionCount); this.cdpEnabled = cdpEnabled; this.bidiEnabled = bidiEnabled; - this.downloadsPath = Optional.ofNullable(downloadsPath).orElse(""); + this.managedDownloadsEnabled = managedDownloadsEnabled; this.healthCheck = healthCheck == null ? () -> { @@ -176,7 +181,7 @@ protected LocalNode( String.format("%s is %s", uri, status.getAvailability())); } : healthCheck; - this.tempFileSystems = CacheBuilder.newBuilder() + this.uploadsTempFileSystem = CacheBuilder.newBuilder() .expireAfterAccess(sessionTimeout) .ticker(ticker) .removalListener((RemovalListener) notification -> { @@ -186,6 +191,26 @@ protected LocalNode( }) .build(); + this.downloadsTempFileSystem = CacheBuilder.newBuilder() + .expireAfterAccess(sessionTimeout) + .ticker(ticker) + .removalListener((RemovalListener) notification -> { + TemporaryFilesystem tempFS = notification.getValue(); + tempFS.deleteTemporaryFiles(); + tempFS.deleteBaseDir(); + }) + .build(); + + this.sessionToDownloadsDir = CacheBuilder.newBuilder() + .expireAfterAccess(sessionTimeout) + .ticker(ticker) + .removalListener((RemovalListener) notification -> { + if (notification.getValue() != null) { + this.downloadsTempFileSystem.invalidate(notification.getValue()); + } + }) + .build(); + this.currentSessions = CacheBuilder.newBuilder() .expireAfterAccess(sessionTimeout) .ticker(ticker) @@ -203,16 +228,27 @@ protected LocalNode( sessionCleanupNodeService.scheduleAtFixedRate( GuardedRunnable.guard(currentSessions::cleanUp), 30, 30, TimeUnit.SECONDS); - ScheduledExecutorService tempFileCleanupNodeService = + ScheduledExecutorService uploadTempFileCleanupNodeService = Executors.newSingleThreadScheduledExecutor( r -> { Thread thread = new Thread(r); thread.setDaemon(true); - thread.setName("TempFile Cleanup Node " + externalUri); + thread.setName("UploadTempFile Cleanup Node " + externalUri); return thread; }); - tempFileCleanupNodeService.scheduleAtFixedRate( - GuardedRunnable.guard(tempFileSystems::cleanUp), 30, 30, TimeUnit.SECONDS); + uploadTempFileCleanupNodeService.scheduleAtFixedRate( + GuardedRunnable.guard(uploadsTempFileSystem::cleanUp), 30, 30, TimeUnit.SECONDS); + + ScheduledExecutorService downloadTempFileCleanupNodeService = + Executors.newSingleThreadScheduledExecutor( + r -> { + Thread thread = new Thread(r); + thread.setDaemon(true); + thread.setName("DownloadTempFile Cleanup Node " + externalUri); + return thread; + }); + downloadTempFileCleanupNodeService.scheduleAtFixedRate( + GuardedRunnable.guard(downloadsTempFileSystem::cleanUp), 30, 30, TimeUnit.SECONDS); ScheduledExecutorService heartbeatNodeService = Executors.newSingleThreadScheduledExecutor( @@ -247,8 +283,10 @@ private void stopTimedOutSession(RemovalNotification not } // Attempt to stop the session slot.stop(); - // Invalidate temp file system - this.tempFileSystems.invalidate(id); + // Invalidate uploads temp file system + this.uploadsTempFileSystem.invalidate(id); + // Invalidate downloads temp file system + this.sessionToDownloadsDir.invalidate(id); // Decrement pending sessions if Node is draining if (this.isDraining()) { int done = pendingSessions.decrementAndGet(); @@ -283,6 +321,15 @@ public int getCurrentSessionCount() { return Math.toIntExact(currentSessions.size()); } + @VisibleForTesting + public UUID getDownloadsIdForSession(SessionId id) { + UUID uuid = sessionToDownloadsDir.getIfPresent(id); + if (uuid == null) { + throw new NoSuchSessionException("Cannot find session with id: " + id); + } + return uuid; + } + @ManagedAttribute(name = "MaxSessions") public int getMaxSessionCount() { return maxSessionCount; @@ -381,10 +428,21 @@ public Either newSession(CreateSessio new RetrySessionRequestException("No slot matched the requested capabilities.")); } + UUID uuidForSessionDownloads = UUID.randomUUID(); + Capabilities desiredCapabilities = sessionRequest.getDesiredCapabilities(); + if (managedDownloadsRequested(desiredCapabilities)) { + Capabilities enhanced = setDownloadsDirectory(uuidForSessionDownloads, desiredCapabilities); + enhanced = desiredCapabilities.merge(enhanced); + sessionRequest = new CreateSessionRequest(sessionRequest.getDownstreamDialects(), + enhanced, + sessionRequest.getMetadata()); + } + Either possibleSession = slotToUse.apply(sessionRequest); if (possibleSession.isRight()) { ActiveSession session = possibleSession.right(); + sessionToDownloadsDir.put(session.getId(), uuidForSessionDownloads); currentSessions.put(session.getId(), slotToUse); checkSessionCount(); @@ -407,7 +465,7 @@ public Either newSession(CreateSessio externalUri, slotToUse.isSupportingCdp(), slotToUse.isSupportingBiDi(), - sessionRequest.getDesiredCapabilities()); + desiredCapabilities); String sessionCreatedMessage = "Session created by the Node"; LOG.info(String.format("%s. Id: %s, Caps: %s", sessionCreatedMessage, sessionId, caps)); @@ -425,6 +483,49 @@ public Either newSession(CreateSessio } } + private boolean managedDownloadsRequested(Capabilities capabilities) { + Object downloadsEnabled = capabilities.getCapability("se:downloadsEnabled"); + return managedDownloadsEnabled && downloadsEnabled != null && + Boolean.parseBoolean(downloadsEnabled.toString()); + } + + private Capabilities setDownloadsDirectory(UUID uuid, Capabilities caps) { + File tempDir; + try { + TemporaryFilesystem tempFS = getDownloadsFilesystem(uuid); +// tempDir = tempFS.createTempDir("download", "file"); + tempDir = tempFS.createTempDir("download", ""); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + if (Browser.CHROME.is(caps) || Browser.EDGE.is(caps)) { + ImmutableMap map = ImmutableMap.of( + "download.prompt_for_download", false, + "download.default_directory", tempDir.getAbsolutePath()); + String optionsKey = Browser.CHROME.is(caps) ? "goog:chromeOptions" : "ms:edgeOptions"; + return appendPrefs(caps, optionsKey, map); + } + if (Browser.FIREFOX.is(caps)) { + ImmutableMap map = ImmutableMap.of( + "browser.download.folderList", 2, + "browser.download.dir", tempDir.getAbsolutePath()); + return appendPrefs(caps, "moz:firefoxOptions", map); + } + return caps; + } + + @SuppressWarnings("unchecked") + private Capabilities appendPrefs(Capabilities caps, + String optionsKey, Map map) { + Map currentOptions = (Map) Optional + .ofNullable(caps.getCapability(optionsKey)) + .orElse(new HashMap<>()); + + ((Map) currentOptions + .computeIfAbsent("prefs", k -> new HashMap<>())).putAll(map); + return caps; + } + @Override public boolean isSessionOwner(SessionId id) { Require.nonNull("Session ID", id); @@ -449,10 +550,20 @@ public Session getSession(SessionId id) throws NoSuchSessionException { } @Override - public TemporaryFilesystem getTemporaryFilesystem(SessionId id) throws IOException { + public TemporaryFilesystem getUploadsFilesystem(SessionId id) throws IOException { try { - return tempFileSystems.get(id, () -> TemporaryFilesystem.getTmpFsBasedOn( - TemporaryFilesystem.getDefaultTmpFS().createTempDir("session", id.toString()))); + return uploadsTempFileSystem.get(id, () -> TemporaryFilesystem.getTmpFsBasedOn( + TemporaryFilesystem.getDefaultTmpFS().createTempDir("session", id.toString()))); + } catch (ExecutionException e) { + throw new IOException(e); + } + } + + @Override + public TemporaryFilesystem getDownloadsFilesystem(UUID uuid) throws IOException { + try { + return downloadsTempFileSystem.get(uuid, () -> TemporaryFilesystem.getTmpFsBasedOn( + TemporaryFilesystem.getDefaultTmpFS().createTempDir("uuid", uuid.toString()))); } catch (ExecutionException e) { throw new IOException(e); } @@ -484,35 +595,36 @@ public HttpResponse downloadFile(HttpRequest req, SessionId 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"; + if (!this.managedDownloadsEnabled) { + String msg = "Please enable management of downloads via the command line arg " + + "[--enable-managed-downloads] 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)); + UUID uuid = sessionToDownloadsDir.getIfPresent(id); + if (uuid == null) { + throw new NoSuchSessionException("Cannot find session with id: " + id); } - if (!dir.isDirectory()) { - throw new WebDriverException(String.format("Invalid directory: %s.", downloadsPath)); + TemporaryFilesystem tempFS = downloadsTempFileSystem.getIfPresent(uuid); + if (tempFS == null) { + throw new WebDriverException("Cannot find downloads file system for session id: " + id); } + File downloadsDirectory = Optional + .ofNullable(tempFS.getBaseDir().listFiles()) + .orElse(new File[]{})[0]; if (req.getMethod().equals(HttpMethod.GET)) { //User wants to list files that can be downloaded List collected = Arrays.stream( - Optional.ofNullable(dir.listFiles()).orElse(new File[]{}) + Optional.ofNullable(downloadsDirectory.listFiles()).orElse(new File[]{}) ).map(File::getName).collect(Collectors.toList()); ImmutableMap data = ImmutableMap.of("names", collected); - ImmutableMap> result = ImmutableMap.of("value",data); + ImmutableMap> result = ImmutableMap.of("value", data); return new HttpResponse().setContent(asJson(result)); } - String raw = string(req); if (raw.isEmpty()) { throw new WebDriverException( "Please specify file to download in payload as {\"name\": \"fileToDownload\"}"); } - Map incoming = JSON.toType(raw, Json.MAP_TYPE); String filename = Optional.ofNullable(incoming.get("name")) .map(Object::toString) @@ -521,11 +633,13 @@ public HttpResponse downloadFile(HttpRequest req, SessionId id) { "Please specify file to download in payload as {\"name\": \"fileToDownload\"}")); try { File[] allFiles = Optional.ofNullable( - dir.listFiles((dir1, name) -> name.equals(filename)) + downloadsDirectory.listFiles((dir, 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)); + String.format("Cannot find file [%s] in directory %s.", + filename, + downloadsDirectory.getAbsolutePath())); } if (allFiles.length != 1) { throw new WebDriverException( @@ -556,8 +670,8 @@ public HttpResponse uploadFile(HttpRequest req, SessionId id) { File tempDir; try { - TemporaryFilesystem tempfs = getTemporaryFilesystem(id); - tempDir = tempfs.createTempDir("upload", "file"); + TemporaryFilesystem tempFS = getUploadsFilesystem(id); + tempDir = tempFS.createTempDir("upload", "file"); Zip.unzip((String) incoming.get("file"), tempDir); } catch (IOException e) { @@ -567,7 +681,7 @@ public HttpResponse uploadFile(HttpRequest req, SessionId id) { File[] allFiles = tempDir.listFiles(); if (allFiles == null) { throw new WebDriverException( - String.format("Cannot access temporary directory for uploaded files %s", tempDir)); + String.format("Cannot access temporary directory for uploaded files %s", tempDir)); } if (allFiles.length != 1) { throw new WebDriverException( @@ -622,20 +736,20 @@ private Session createExternalSession(ActiveSession other, } }); toUse = new PersistentCapabilities(cdpFiltered).setCapability("se:cdpEnabled", false); - } + } - // Add se:bidi if necessary to send the bidi url back - if ((isSupportingBiDi || toUse.getCapability("se:bidi") != null) && bidiEnabled) { - String bidiPath = String.format("/session/%s/se/bidi", other.getId()); - toUse = new PersistentCapabilities(toUse).setCapability("se:bidi", rewrite(bidiPath)); - } else { - // Remove any se:bidi* from the response, BiDi is not supported nor enabled - MutableCapabilities bidiFiltered = new MutableCapabilities(); - toUse.asMap().forEach((key, value) -> { - if (!key.startsWith("se:bidi")) { - bidiFiltered.setCapability(key, value); - } - }); + // Add se:bidi if necessary to send the bidi url back + if ((isSupportingBiDi || toUse.getCapability("se:bidi") != null) && bidiEnabled) { + String bidiPath = String.format("/session/%s/se/bidi", other.getId()); + toUse = new PersistentCapabilities(toUse).setCapability("se:bidi", rewrite(bidiPath)); + } else { + // Remove any se:bidi* from the response, BiDi is not supported nor enabled + MutableCapabilities bidiFiltered = new MutableCapabilities(); + toUse.asMap().forEach((key, value) -> { + if (!key.startsWith("se:bidi")) { + bidiFiltered.setCapability(key, value); + } + }); toUse = new PersistentCapabilities(bidiFiltered).setCapability("se:bidiEnabled", false); } @@ -772,7 +886,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 boolean managedDownloadsEnabled = false; private Builder( Tracer tracer, @@ -827,8 +941,8 @@ public Builder heartbeatPeriod(Duration heartbeatPeriod) { return this; } - public Builder downloadsPath(String path) { - this.downloadsPath = path; + public Builder enableManagedDownloads(boolean enable) { + this.managedDownloadsEnabled = enable; return this; } @@ -848,7 +962,7 @@ public LocalNode build() { heartbeatPeriod, factories.build(), registrationSecret, - downloadsPath); + managedDownloadsEnabled); } 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 6475845c593e7..6942f7a7aaf00 100644 --- a/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java +++ b/java/src/org/openqa/selenium/grid/node/local/LocalNodeFactory.java @@ -71,13 +71,12 @@ public static Node create(Config config) { .drainAfterSessionCount(nodeOptions.getDrainAfterSessionCount()) .enableCdp(nodeOptions.isCdpEnabled()) .enableBiDi(nodeOptions.isBiDiEnabled()) + .enableManagedDownloads(nodeOptions.isManagedDownloadsEnabled()) .heartbeatPeriod(nodeOptions.getHeartbeatPeriod()); 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/io/TemporaryFilesystem.java b/java/src/org/openqa/selenium/io/TemporaryFilesystem.java index 43c09c32f4e80..e7994bc90c262 100644 --- a/java/src/org/openqa/selenium/io/TemporaryFilesystem.java +++ b/java/src/org/openqa/selenium/io/TemporaryFilesystem.java @@ -29,10 +29,10 @@ /** * A wrapper around temporary filesystem behaviour. */ -public class -TemporaryFilesystem { +public class TemporaryFilesystem { private final Set temporaryFiles = new CopyOnWriteArraySet<>(); + private final File baseDir; // Thread safety reviewed private final Thread shutdownHook = new Thread(this::deleteTemporaryFiles); @@ -73,7 +73,7 @@ private TemporaryFilesystem(File baseDir) { if (!baseDir.exists()) { throw new UncheckedIOException( - new IOException("Unable to find tmp dir: " + baseDir.getAbsolutePath())); + new IOException("Unable to find tmp dir: " + baseDir.getAbsolutePath())); } if (!baseDir.canWrite()) { throw new UncheckedIOException( @@ -166,4 +166,8 @@ public boolean deleteBaseDir() { } return wasDeleted; } + + public File getBaseDir() { + return baseDir; + } } diff --git a/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java b/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java index 9468df855175d..006f47860596e 100644 --- a/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java +++ b/java/test/org/openqa/selenium/grid/data/DefaultSlotMatcherTest.java @@ -71,6 +71,43 @@ void matchesBrowser() { assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); } + @Test + void matchDownloadsForRegularTestMatchingAgainstADownloadAwareNode() { + Capabilities stereotype = new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, "chrome", + "se:downloadsEnabled", true + ); + Capabilities capabilities = new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, "chrome" + ); + assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); + } + + @Test + void matchDownloadsForAutoDownloadTestMatchingAgainstADownloadAwareNode() { + Capabilities stereotype = new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, "chrome", + "se:downloadsEnabled", true + ); + Capabilities capabilities = new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, "chrome", + "se:downloadsEnabled", true + ); + assertThat(slotMatcher.matches(stereotype, capabilities)).isTrue(); + } + + @Test + void ensureNoMatchFOrDownloadAwareTestMatchingAgainstOrdinaryNode() { + Capabilities stereotype = new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, "chrome" + ); + Capabilities capabilities = new ImmutableCapabilities( + CapabilityType.BROWSER_NAME, "chrome", + "se:downloadsEnabled", true + ); + assertThat(slotMatcher.matches(stereotype, capabilities)).isFalse(); + } + @Test void matchesEmptyBrowser() { Capabilities stereotype = new ImmutableCapabilities( diff --git a/java/test/org/openqa/selenium/grid/gridui/OverallGridTest.java b/java/test/org/openqa/selenium/grid/gridui/OverallGridTest.java index a0926ac89dc29..41a6a7e4c36a8 100644 --- a/java/test/org/openqa/selenium/grid/gridui/OverallGridTest.java +++ b/java/test/org/openqa/selenium/grid/gridui/OverallGridTest.java @@ -126,13 +126,13 @@ private Server createStandalone() { private void waitUntilReady(Server server) { try (HttpClient client = HttpClient.Factory.createDefault().createClient(server.getUrl())) { new FluentWait<>(client) - .withTimeout(Duration.ofSeconds(5)) - .until( - c -> { - HttpResponse response = c.execute(new HttpRequest(GET, "/status")); - Map status = Values.get(response, MAP_TYPE); - return status != null && Boolean.TRUE.equals(status.get("ready")); - }); + .withTimeout(Duration.ofSeconds(5)) + .until( + c -> { + HttpResponse response = c.execute(new HttpRequest(GET, "/status")); + Map status = Values.get(response, MAP_TYPE); + return status != null && Boolean.TRUE.equals(status.get("ready")); + }); } } diff --git a/java/test/org/openqa/selenium/grid/node/NodeTest.java b/java/test/org/openqa/selenium/grid/node/NodeTest.java index d51f290540efb..fc5d0feeb6e22 100644 --- a/java/test/org/openqa/selenium/grid/node/NodeTest.java +++ b/java/test/org/openqa/selenium/grid/node/NodeTest.java @@ -17,23 +17,9 @@ package org.openqa.selenium.grid.node; -import static java.time.Duration.ofSeconds; -import static java.util.concurrent.TimeUnit.SECONDS; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.assertj.core.api.InstanceOfAssertFactories.MAP; -import static org.openqa.selenium.json.Json.MAP_TYPE; -import static org.openqa.selenium.remote.http.Contents.string; -import static org.openqa.selenium.remote.http.HttpMethod.GET; -import static org.openqa.selenium.remote.http.HttpMethod.POST; - import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; -import java.nio.file.Path; -import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -85,17 +71,31 @@ import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.time.Clock; import java.time.Duration; import java.time.Instant; import java.time.ZoneId; import java.util.Collections; +import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import static java.time.Duration.ofSeconds; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.InstanceOfAssertFactories.MAP; +import static org.openqa.selenium.json.Json.MAP_TYPE; +import static org.openqa.selenium.remote.http.Contents.string; +import static org.openqa.selenium.remote.http.HttpMethod.GET; +import static org.openqa.selenium.remote.http.HttpMethod.POST; + class NodeTest { private Tracer tracer; @@ -117,12 +117,16 @@ public void setUp(TestInfo testInfo) throws URISyntaxException { bus = new GuavaEventBus(); registrationSecret = new Secret("sussex charmer"); + boolean isDownloadsTestCase = testInfo.getDisplayName().equalsIgnoreCase("DownloadsTestCase"); stereotype = new ImmutableCapabilities("browserName", "cheese"); caps = new ImmutableCapabilities("browserName", "cheese"); + if (isDownloadsTestCase) { + stereotype = new ImmutableCapabilities("browserName", "chrome", "se:downloadsEnabled", true); + caps = new ImmutableCapabilities("browserName", "chrome", "se:downloadsEnabled", true); + } 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) { @@ -135,13 +139,14 @@ public HttpResponse execute(HttpRequest req) throws UncheckedIOException { } } + Builder builder = LocalNode.builder(tracer, bus, uri, uri, registrationSecret) .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))) .maximumConcurrentSessions(2); - if (!testInfo.getDisplayName().equalsIgnoreCase("BootWithoutDownloadsDir")) { - builder = builder.downloadsPath(downloadsPath.getAbsolutePath()); + if (isDownloadsTestCase) { + builder = builder.enableManagedDownloads(true); } local = builder.build(); @@ -485,7 +490,7 @@ void canUploadAFile() throws IOException { req.setContent(() -> new ByteArrayInputStream(payload.getBytes())); node.execute(req); - File baseDir = getTemporaryFilesystemBaseDir(local.getTemporaryFilesystem(session.getId())); + File baseDir = getTemporaryFilesystemBaseDir(local.getUploadsFilesystem(session.getId())); assertThat(baseDir.listFiles()).hasSize(1); File uploadDir = baseDir.listFiles()[0]; assertThat(uploadDir.listFiles()).hasSize(1); @@ -496,6 +501,7 @@ void canUploadAFile() throws IOException { } @Test + @DisplayName("DownloadsTestCase") void canDownloadAFile() throws IOException { Either response = node.newSession(createSessionRequest(caps)); @@ -504,108 +510,181 @@ void canDownloadAFile() throws IOException { String hello = "Hello, world!"; HttpRequest req = new HttpRequest(POST, String.format("/session/%s/se/files", session.getId())); - File zip = createTmpFile(hello); - String payload = new Json().toJson(Collections.singletonMap("name", zip.getName())); + + //Let's simulate as if we downloaded a file via a test case + String zip = simulateFileDownload(session.getId(),hello); + + String payload = new Json().toJson(Collections.singletonMap("name", zip)); req.setContent(() -> new ByteArrayInputStream(payload.getBytes())); HttpResponse rsp = node.execute(req); Map raw = new Json().toType(string(rsp), Json.MAP_TYPE); - node.stop(session.getId()); - assertThat(raw).isNotNull(); - File baseDir = getTemporaryFilesystemBaseDir(TemporaryFilesystem.getDefaultTmpFS()); - Map map = Optional.ofNullable( - raw.get("value") - ).map(data -> (Map) data) - .orElseThrow(() -> new IllegalStateException("Could not find value attribute")); - 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); + try { + assertThat(raw).isNotNull(); + File baseDir = getTemporaryFilesystemBaseDir(TemporaryFilesystem.getDefaultTmpFS()); + Map map = Optional.ofNullable( + raw.get("value") + ).map(data -> (Map) data) + .orElseThrow(() -> new IllegalStateException("Could not find value attribute")); + 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); + } finally { + node.stop(session.getId()); + } + } + + @Test + @DisplayName("DownloadsTestCase") + void canDownloadMultipleFile() throws IOException { + Either response = + node.newSession(createSessionRequest(caps)); + assertThatEither(response).isRight(); + Session session = response.right().getSession(); + String hello = "Hello, world!"; + + HttpRequest req = new HttpRequest(POST, String.format("/session/%s/se/files", session.getId())); + + //Let's simulate as if we downloaded a file via a test case + String zip = simulateFileDownload(session.getId(), hello); + + // This file we are going to leave in the downloads directory of the session + // Just to check if we can clean up all the files for the session + simulateFileDownload(session.getId(),"Goodbye, world!"); + + String payload = new Json().toJson(Collections.singletonMap("name", zip)); + req.setContent(() -> new ByteArrayInputStream(payload.getBytes())); + HttpResponse rsp = node.execute(req); + Map raw = new Json().toType(string(rsp), Json.MAP_TYPE); + try { + assertThat(raw).isNotNull(); + File baseDir = getTemporaryFilesystemBaseDir(TemporaryFilesystem.getDefaultTmpFS()); + Map map = Optional.ofNullable( + raw.get("value") + ).map(data -> (Map) data) + .orElseThrow(() -> new IllegalStateException("Could not find value attribute")); + 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); + } finally { + UUID downloadsId = local.getDownloadsIdForSession(session.getId()); + File someDir = getTemporaryFilesystemBaseDir(local.getDownloadsFilesystem(downloadsId)); + node.stop(session.getId()); + assertThat(someDir).doesNotExist(); + } } @Test - void canListFilesToDownload() { + @DisplayName("DownloadsTestCase") + void canListFilesToDownload() throws IOException { Either response = node.newSession(createSessionRequest(caps)); assertThatEither(response).isRight(); Session session = response.right().getSession(); String hello = "Hello, world!"; - File zip = createTmpFile(hello); + String zip = simulateFileDownload(session.getId(), hello); HttpRequest req = new HttpRequest(GET, String.format("/session/%s/se/files", session.getId())); HttpResponse rsp = node.execute(req); Map raw = new Json().toType(string(rsp), Json.MAP_TYPE); - node.stop(session.getId()); - assertThat(raw).isNotNull(); - Map map = Optional.ofNullable( - raw.get("value") - ).map(data -> (Map) data) - .orElseThrow(() -> new IllegalStateException("Could not find value attribute")); - List files = (List) map.get("names"); - assertThat(files).contains(zip.getName()); + try { + assertThat(raw).isNotNull(); + Map map = Optional.ofNullable( + raw.get("value") + ).map(data -> (Map) data) + .orElseThrow(() -> new IllegalStateException("Could not find value attribute")); + List files = (List) map.get("names"); + assertThat(files).contains(zip); + } finally { + node.stop(session.getId()); + } } @Test - @DisplayName("BootWithoutDownloadsDir") void canDownloadFileThrowsErrorMsgWhenDownloadsDirNotSpecified() { Either response = node.newSession(createSessionRequest(caps)); assertThatEither(response).isRight(); Session session = response.right().getSession(); - createTmpFile("Hello, world!"); - - HttpRequest req = new HttpRequest(POST, String.format("/session/%s/se/files", session.getId())); - 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"; - assertThatThrownBy(() -> node.execute(req)) - .hasMessageContaining(msg); + try { + createTmpFile("Hello, world!"); + HttpRequest req = new HttpRequest(POST, + String.format("/session/%s/se/files", session.getId())); + String msg = "Please enable management of downloads via the command line arg " + + "[--enable-managed-downloads] and restart the node"; + assertThatThrownBy(() -> node.execute(req)) + .hasMessageContaining(msg); + } finally { + node.stop(session.getId()); + } } @Test + @DisplayName("DownloadsTestCase") void canDownloadFileThrowsErrorMsgWhenPayloadIsMissing() { Either response = node.newSession(createSessionRequest(caps)); assertThatEither(response).isRight(); Session session = response.right().getSession(); - createTmpFile("Hello, world!"); - - HttpRequest req = new HttpRequest(POST, String.format("/session/%s/se/files", session.getId())); - String msg = "Please specify file to download in payload as {\"name\": \"fileToDownload\"}"; - assertThatThrownBy(() -> node.execute(req)) - .hasMessageContaining(msg); + try { + createTmpFile("Hello, world!"); + + HttpRequest req = new HttpRequest(POST, + String.format("/session/%s/se/files", session.getId())); + String msg = "Please specify file to download in payload as {\"name\": \"fileToDownload\"}"; + assertThatThrownBy(() -> node.execute(req)) + .hasMessageContaining(msg); + } finally { + node.stop(session.getId()); + } } @Test + @DisplayName("DownloadsTestCase") void canDownloadFileThrowsErrorMsgWhenWrongPayloadIsGiven() { Either response = node.newSession(createSessionRequest(caps)); assertThatEither(response).isRight(); Session session = response.right().getSession(); - createTmpFile("Hello, world!"); - - HttpRequest req = new HttpRequest(POST, String.format("/session/%s/se/files", session.getId())); - String payload = new Json().toJson(Collections.singletonMap("my-file", "README.md")); - req.setContent(() -> new ByteArrayInputStream(payload.getBytes())); - - String msg = "Please specify file to download in payload as {\"name\": \"fileToDownload\"}"; - assertThatThrownBy(() -> node.execute(req)) - .hasMessageContaining(msg); + try { + createTmpFile("Hello, world!"); + + HttpRequest req = new HttpRequest(POST, + String.format("/session/%s/se/files", session.getId())); + String payload = new Json().toJson(Collections.singletonMap("my-file", "README.md")); + req.setContent(() -> new ByteArrayInputStream(payload.getBytes())); + + String msg = "Please specify file to download in payload as {\"name\": \"fileToDownload\"}"; + assertThatThrownBy(() -> node.execute(req)) + .hasMessageContaining(msg); + } finally { + node.stop(session.getId()); + } } @Test + @DisplayName("DownloadsTestCase") void canDownloadFileThrowsErrorMsgWhenFileNotFound() { Either response = node.newSession(createSessionRequest(caps)); assertThatEither(response).isRight(); Session session = response.right().getSession(); - createTmpFile("Hello, world!"); - - HttpRequest req = new HttpRequest(POST, String.format("/session/%s/se/files", session.getId())); - String payload = new Json().toJson(Collections.singletonMap("name", "README.md")); - req.setContent(() -> new ByteArrayInputStream(payload.getBytes())); - - String msg = "Cannot find file [README.md] in directory"; - assertThatThrownBy(() -> node.execute(req)) - .hasMessageContaining(msg); + try { + createTmpFile("Hello, world!"); + + HttpRequest req = new HttpRequest(POST, + String.format("/session/%s/se/files", session.getId())); + String payload = new Json().toJson(Collections.singletonMap("name", "README.md")); + req.setContent(() -> new ByteArrayInputStream(payload.getBytes())); + + String msg = "Cannot find file [README.md] in directory"; + assertThatThrownBy(() -> node.execute(req)) + .hasMessageContaining(msg); + } finally { + node.stop(session.getId()); + } } @Test @@ -708,6 +787,7 @@ private File createFile(String content, File directory) { } } + private File createTmpFile(String content) { try { File f = File.createTempFile("webdriver", "tmp"); @@ -728,9 +808,23 @@ private File getTemporaryFilesystemBaseDir(TemporaryFilesystem tempFS) { private CreateSessionRequest createSessionRequest(Capabilities caps) { return new CreateSessionRequest( - ImmutableSet.copyOf(Dialect.values()), - caps, - ImmutableMap.of()); + ImmutableSet.copyOf(Dialect.values()), + caps, + ImmutableMap.of()); + } + + private String simulateFileDownload(SessionId id, String text) throws IOException { + File zip = createTmpFile(text); + UUID downloadsId = local.getDownloadsIdForSession(id); + File someDir = getTemporaryFilesystemBaseDir(local.getDownloadsFilesystem(downloadsId)); + File downloadsDirectory = Optional.ofNullable(someDir.listFiles()).orElse(new File[]{})[0]; + File target = new File(downloadsDirectory, zip.getName()); + boolean renamed = zip.renameTo(target); + if (!renamed) { + throw new IllegalStateException("Could not move " + zip.getName() + " to directory " + + target.getParentFile().getAbsolutePath()); + } + return zip.getName(); } private static class MyClock extends Clock { diff --git a/java/test/org/openqa/selenium/grid/node/config/NodeOptionsTest.java b/java/test/org/openqa/selenium/grid/node/config/NodeOptionsTest.java index 32ff26648b4ea..abcaf4438132f 100644 --- a/java/test/org/openqa/selenium/grid/node/config/NodeOptionsTest.java +++ b/java/test/org/openqa/selenium/grid/node/config/NodeOptionsTest.java @@ -27,9 +27,12 @@ import org.openqa.selenium.Platform; import org.openqa.selenium.SessionNotCreatedException; import org.openqa.selenium.WebDriverException; +import org.openqa.selenium.WebDriverInfo; import org.openqa.selenium.chrome.ChromeDriverInfo; import org.openqa.selenium.chrome.ChromeOptions; +import org.openqa.selenium.edge.EdgeDriverInfo; import org.openqa.selenium.firefox.FirefoxOptions; +import org.openqa.selenium.firefox.GeckoDriverInfo; import org.openqa.selenium.grid.config.Config; import org.openqa.selenium.grid.config.ConfigException; import org.openqa.selenium.grid.config.MapConfig; @@ -37,9 +40,11 @@ import org.openqa.selenium.grid.data.CreateSessionRequest; import org.openqa.selenium.grid.node.ActiveSession; import org.openqa.selenium.grid.node.SessionFactory; +import org.openqa.selenium.ie.InternetExplorerDriverInfo; import org.openqa.selenium.internal.Either; import org.openqa.selenium.json.Json; import org.openqa.selenium.net.NetworkUtils; +import org.openqa.selenium.safari.SafariDriverInfo; import java.io.StringReader; import java.net.URI; @@ -87,6 +92,62 @@ void canConfigureNodeWithDriverDetection() { .orElseThrow(() -> new AssertionError("Unable to find Chrome info")); } + @Test + void ensureAutoDownloadsFlagIsAutoInjectedIntoChromeStereoCapabilitiesWhenEnabledForNode() { + boolean isEnabled = isDownloadEnabled(new ChromeDriverInfo(), "ChromeDriverInfo"); + assertThat(isEnabled).isTrue(); + } + + @Test + void ensureAutoDownloadsFlagIsAutoInjectedIntoFirefoxStereoCapabilitiesWhenEnabledForNode() { + boolean isEnabled = isDownloadEnabled(new GeckoDriverInfo(), "GeckoDriverInfo"); + assertThat(isEnabled).isTrue(); + } + + @Test + void ensureAutoDownloadsFlagIsAutoInjectedIntoEdgeStereoCapabilitiesWhenEnabledForNode() { + assumeTrue(Platform.getCurrent().is(Platform.WINDOWS)); + boolean isEnabled = isDownloadEnabled(new EdgeDriverInfo(), "EdgeDriverInfo"); + assertThat(isEnabled).isTrue(); + } + + @Test + void ensureAutoDownloadsFlagIsNOTAutoInjectedIntoIEStereoCapabilitiesWhenEnabledForNode() { + assumeTrue(Platform.getCurrent().is(Platform.WINDOWS)); + assumeFalse(Boolean.parseBoolean(System.getenv("GITHUB_ACTIONS")), + "We don't have driver servers in PATH when we run unit tests"); + boolean isEnabled = isDownloadEnabled(new InternetExplorerDriverInfo(), "InternetExplorerDriverInfo"); + assertThat(isEnabled).isFalse(); + } + + @Test + void ensureAutoDownloadsFlagIsNOTAutoInjectedIntoSafariStereoCapabilitiesWhenEnabledForNode() { + boolean isEnabled = isDownloadEnabled(new SafariDriverInfo(), "SafariDriverInfo"); + assertThat(isEnabled).isFalse(); + } + + boolean isDownloadEnabled(WebDriverInfo driver, String customMsg) { + assumeTrue(driver.isAvailable(), customMsg + " needs to be available"); + Config config = new MapConfig(singletonMap("node", ImmutableMap.of("detect-drivers", "true", + "selenium-manager", false, + "enable-manage-downloads", true))); + List reported = new ArrayList<>(); + new NodeOptions(config).getSessionFactories(caps -> { + reported.add(caps); + return Collections.singleton(HelperFactory.create(config, caps)); + }); + String expected = driver.getDisplayName(); + + Capabilities found = reported.stream() + .filter(driver::isSupporting) + .filter(caps -> expected.equalsIgnoreCase(caps.getBrowserName())) + .findFirst() + .orElseThrow(() -> new AssertionError("Unable to find " + customMsg + " info")); + return Optional.ofNullable(found.getCapability("se:downloadsEnabled")) + .map(value -> Boolean.parseBoolean(value.toString())) + .orElse(Boolean.FALSE); + } + @Test void shouldDetectCorrectDriversOnWindows() { assumeTrue(Platform.getCurrent().is(Platform.WINDOWS)); diff --git a/java/test/org/openqa/selenium/grid/router/BUILD.bazel b/java/test/org/openqa/selenium/grid/router/BUILD.bazel index 33c641069a8b6..900956ec6f62c 100644 --- a/java/test/org/openqa/selenium/grid/router/BUILD.bazel +++ b/java/test/org/openqa/selenium/grid/router/BUILD.bazel @@ -6,6 +6,7 @@ LARGE_TESTS = [ "DistributedCdpTest.java", "NewSessionCreationTest.java", "RemoteWebDriverBiDiTest.java", + "RemoteWebDriverDownloadTest.java", "StressTest.java", ] diff --git a/java/test/org/openqa/selenium/grid/router/RemoteWebDriverDownloadTest.java b/java/test/org/openqa/selenium/grid/router/RemoteWebDriverDownloadTest.java new file mode 100644 index 0000000000000..3eda408ad595b --- /dev/null +++ b/java/test/org/openqa/selenium/grid/router/RemoteWebDriverDownloadTest.java @@ -0,0 +1,160 @@ +// 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.router; + +import com.google.common.collect.ImmutableMap; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.openqa.selenium.By; +import org.openqa.selenium.Capabilities; +import org.openqa.selenium.PersistentCapabilities; +import org.openqa.selenium.environment.webserver.NettyAppServer; +import org.openqa.selenium.grid.config.TomlConfig; +import org.openqa.selenium.grid.router.DeploymentTypes.Deployment; +import org.openqa.selenium.grid.server.Server; +import org.openqa.selenium.io.Zip; +import org.openqa.selenium.json.Json; +import org.openqa.selenium.remote.RemoteWebDriver; +import org.openqa.selenium.remote.SessionId; +import org.openqa.selenium.remote.http.HttpClient; +import org.openqa.selenium.remote.http.HttpRequest; +import org.openqa.selenium.remote.http.HttpResponse; +import org.openqa.selenium.testing.Ignore; +import org.openqa.selenium.testing.Safely; +import org.openqa.selenium.testing.TearDownFixture; +import org.openqa.selenium.testing.drivers.Browser; + +import java.io.File; +import java.io.IOException; +import java.io.StringReader; +import java.net.URL; +import java.nio.file.Files; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.openqa.selenium.remote.http.Contents.asJson; +import static org.openqa.selenium.remote.http.Contents.string; +import static org.openqa.selenium.remote.http.HttpMethod.GET; +import static org.openqa.selenium.remote.http.HttpMethod.POST; +import static org.openqa.selenium.testing.drivers.Browser.IE; +import static org.openqa.selenium.testing.drivers.Browser.SAFARI; + +class RemoteWebDriverDownloadTest { + + private Server server; + private NettyAppServer appServer; + private Capabilities capabilities; + private final List tearDowns = new LinkedList<>(); + private final ExecutorService executor = + Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors() * 2); + + + @BeforeEach + public void setupServers() { + Browser browser = Browser.detect(); + assert browser != null; + capabilities = new PersistentCapabilities(browser.getCapabilities()) + .setCapability("se:downloadsEnabled", true); + + Deployment deployment = DeploymentTypes.STANDALONE.start( + browser.getCapabilities(), + new TomlConfig(new StringReader( + "[node]\n" + + "selenium-manager = true\n" + + "enable-managed-downloads = true\n" + + "driver-implementation = " + browser.displayName()))); + tearDowns.add(deployment); + + server = deployment.getServer(); + appServer = new NettyAppServer(); + tearDowns.add(() -> appServer.stop()); + appServer.start(); + } + + @AfterEach + public void tearDown() { + tearDowns.parallelStream().forEach(Safely::safelyCall); + executor.shutdownNow(); + } + + @Test + @Ignore(IE) + @Ignore(SAFARI) + void testCanListDownloadedFiles() throws InterruptedException { + URL gridUrl = server.getUrl(); + RemoteWebDriver driver = new RemoteWebDriver(gridUrl, capabilities); + driver.get(appServer.whereIs("downloads/download.html")); + driver.findElement(By.id("file-1")).click(); + driver.findElement(By.id("file-2")).click(); + SessionId sessionId = driver.getSessionId(); + + // Waiting for the file to be remotely downloaded + TimeUnit.SECONDS.sleep(3); + + HttpRequest request = new HttpRequest(GET, String.format("/session/%s/se/files", sessionId)); + try (HttpClient client = HttpClient.Factory.createDefault().createClient(gridUrl)) { + HttpResponse response = client.execute(request); + Map jsonResponse = new Json().toType(string(response), Json.MAP_TYPE); + @SuppressWarnings("unchecked") + Map value = (Map) jsonResponse.get("value"); + @SuppressWarnings("unchecked") + List names = (List) value.get("names"); + assertThat(names).contains("file_1.txt", "file_2.jpg"); + } finally { + driver.quit(); + } + } + + @Test + @Ignore(IE) + @Ignore(SAFARI) + void testCanDownloadFiles() throws InterruptedException, IOException { + URL gridUrl = server.getUrl(); + RemoteWebDriver driver = new RemoteWebDriver(gridUrl, capabilities); + driver.get(appServer.whereIs("downloads/download.html")); + driver.findElement(By.id("file-1")).click(); + SessionId sessionId = driver.getSessionId(); + + // Waiting for the file to be remotely downloaded + TimeUnit.SECONDS.sleep(3); + + HttpRequest request = new HttpRequest(POST, String.format("/session/%s/se/files", sessionId)); + request.setContent(asJson(ImmutableMap.of("name", "file_1.txt"))); + try (HttpClient client = HttpClient.Factory.createDefault().createClient(gridUrl)) { + HttpResponse response = client.execute(request); + Map jsonResponse = new Json().toType(string(response), Json.MAP_TYPE); + @SuppressWarnings("unchecked") + Map value = (Map) jsonResponse.get("value"); + String zippedContents = value.get("contents").toString(); + File downloadDir = Zip.unzipToTempDir(zippedContents, "download", ""); + File downloadedFile = Optional.ofNullable(downloadDir.listFiles()).orElse(new File[]{})[0]; + String fileContent = String.join("", Files.readAllLines(downloadedFile.toPath())); + assertThat(fileContent).isEqualTo("Hello, World!"); + } finally { + driver.quit(); + } + } +}