PRACTICAL GUIDE / Selenium Grid remote download directory verification
Verify the file your remote browser actually downloaded
Retrieve Grid-managed downloads through the owning session, wait for the right file, inspect its bytes, and diagnose missing or misleading artifacts.
In this guide6 sections
What you will learn
- Follow the file across the remote session boundary
- Retrieve bytes through Remote WebDriver
- Read the evidence when the local directory stays empty
- Work three failures that share the same symptom
Chrome shows “Download complete” in the Grid recording, but the test runner's ./downloads directory is empty. The file was written on a remote Node while the assertion looked on a different machine. Increasing the local polling timeout cannot bridge that filesystem boundary.
Follow the file across the remote session boundary
A local WebDriver test and a Grid test can execute identical browser commands while putting downloaded bytes in different places. With a local driver, the browser and test process normally share a machine or container filesystem. With Remote WebDriver, the browser uses the Node's filesystem. A path such as /tmp/downloads refers to the Node when configured in browser preferences, not to /tmp/downloads on the CI runner.
Mounting one shared directory into every runner and Node can make files visible, but it creates a new ownership problem. Concurrent sessions may use the same filename, partial files become visible across tests, and stale artifacts can satisfy later assertions. Network storage also changes browser download timing. That design is occasionally justified for large files, but it should not be the default way to make a remote browser behave like a local one.
Selenium Grid's managed-download feature gives the session an explicit transfer path. The Node must start with --enable-managed-downloads true. The client must request download management using the se:downloadsEnabled capability, which current bindings expose through browser options. Once both sides agree, the active session can list remote files, download a named file to the client, and delete its remote downloads.
The wire behavior explains several surprises. Listing uses GET /session/{sessionId}/se/files. Downloading uses POST to the same route with the requested name. The response carries base64-encoded contents of a zip archive. The client binding decodes and extracts that archive into the target directory. Retrieval therefore consumes Node CPU, Router bandwidth, client bandwidth, and local disk in addition to the original browser download.
The directory is session-scoped and removed when the session ends or times out. That is helpful isolation, but it creates a strict ordering rule:
- trigger the browser download;
- establish that the application finished producing it;
- wait until the managed file appears;
- retrieve and inspect the local copy;
- retain any required test artifact;
- quit the WebDriver session.
Putting driver.quit() in a finally block is still correct. The verification must run before control reaches it.
Enable the server side with a documented Grid option. This standalone command is suitable for a disposable local proof; use the same option on Nodes in a distributed deployment.
#!/usr/bin/env bash
set -euo pipefail
SELENIUM_JAR="$1"
java -jar "$SELENIUM_JAR" standalone \
--port 4444 \
--enable-managed-downloads true \
--session-timeout 300Ask the exact server artifact for standalone --help or node --help during deployment validation. That catches a version mismatch before a suite fails with an unsupported command.
Retrieve bytes through Remote WebDriver
The shortest correct test enables downloads in ChromeOptions, starts a remote session, clicks a download, waits for the expected name, transfers it to a client-side temporary directory, and asserts its contents. Selenium's public download sample exposes two small files, so it is useful for verifying Grid wiring without involving your application.
from pathlib import Path
from tempfile import TemporaryDirectory
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
GRID_URL = "http://localhost:4444"
DOWNLOAD_PAGE = "https://www.selenium.dev/selenium/web/downloads/download.html"
options = Options()
options.add_argument("--headless=new")
options.enable_downloads = True
driver = webdriver.Remote(command_executor=GRID_URL, options=options)
try:
driver.get(DOWNLOAD_PAGE)
driver.find_element(By.ID, "file-1").click()
WebDriverWait(driver, 10).until(
lambda current: "file_1.txt" in current.get_downloadable_files()
)
with TemporaryDirectory(prefix="grid-download-") as target:
driver.download_file("file_1.txt", target)
downloaded = Path(target, "file_1.txt")
assert downloaded.read_text(encoding="utf-8").strip() == "Hello, World!"
driver.delete_downloadable_files()
assert driver.get_downloadable_files() == []
finally:
driver.quit()That example uses three real Python binding methods on the driver: get_downloadable_files(), download_file(), and delete_downloadable_files(). The fourth line that looks like part of the same family is not a method at all. options.enable_downloads is a property on the browser options object, and assigning True to it is what puts the se:downloadsEnabled capability into the session request. The distinction is worth keeping straight when you read a failure. An AttributeError on enable_downloads points at the bindings version on the client, while an error raised by download_file() points at the session, the Node flag, or the file itself. The browser option maps to the Selenium capability rather than to a Chrome preference that names a Node-local path.
The wait only proves that the filename appeared in the managed directory. Selenium explicitly describes the list as an immediate snapshot and does not promise that listing waits for completion. Small files often finish before the next poll, which can hide the distinction. A production test should use an application signal where one exists: a toast that reports export completion, an enabled link after server generation, or an API status correlated with the export ID.
Content validation supplies the second line of defense. If the file can be parsed only after it is complete, retry retrieval into a fresh temporary directory until the validator succeeds. Keep the retry bounded and report the last content error. Do not repeatedly overwrite one path because a prior partial file can confuse the result.
This helper waits for a specifically named CSV, retrieves each attempt into its own directory, and accepts the result only when required columns and a minimum number of records are present. It returns the verified bytes so the caller can attach them to its test report.
import csv
import io
import tempfile
import time
from pathlib import Path
from selenium.common.exceptions import WebDriverException
def download_verified_csv(
driver,
file_name: str,
required_columns: set[str],
minimum_rows: int,
timeout_seconds: int = 30,
) -> bytes:
deadline = time.monotonic() + timeout_seconds
last_error = "file did not appear"
while time.monotonic() < deadline:
names = driver.get_downloadable_files()
if file_name not in names:
time.sleep(0.5)
continue
try:
with tempfile.TemporaryDirectory(prefix="csv-attempt-") as target:
driver.download_file(file_name, target)
payload = Path(target, file_name).read_bytes()
text = payload.decode("utf-8-sig")
reader = csv.DictReader(io.StringIO(text))
fields = set(reader.fieldnames or [])
rows = list(reader)
missing = required_columns - fields
if missing:
raise ValueError(f"missing CSV columns: {sorted(missing)}")
if len(rows) < minimum_rows:
raise ValueError(
f"expected at least {minimum_rows} rows, got {len(rows)}"
)
return payload
except (OSError, UnicodeError, ValueError, WebDriverException) as error:
last_error = str(error)
time.sleep(0.5)
raise AssertionError(
f"{file_name!r} was not a valid completed CSV within "
f"{timeout_seconds}s: {last_error}"
)Retrying a parse costs extra transfers. For a 500 MB export, this approach can move gigabytes while waiting. Prefer a definitive application completion signal for large files, then retrieve once. If the feature under test is server-side export generation rather than browser delivery, verify the export through an application API and keep one small browser test for the download interaction.
Read the evidence when the local directory stays empty
First, print the session ID and the effective capability. In Python, driver.capabilities.get("se:downloadsEnabled") should reflect whether the session accepted managed downloads. If it is absent or false, determine whether the client option was omitted, the Grid version lacks the feature, or the selected browser does not support it.
Record both requested and returned capabilities. A driver factory can set the option correctly and still route to an older Grid or an unsupported Node pool. The returned capability describes the session that actually exists. Pair it with the Node URI from Grid session details when only one browser pool fails. If every session lacks the capability, inspect shared client configuration and server startup. If one pool lacks it, inspect that pool's version and Node arguments.
Do not log the entire capability document without review. Proxy credentials, extension options, or environment-specific data may be present. Extract the browser identity, browser version, platform, session ID, and download capability into a small incident record.
Second, call the managed-files endpoint while the session is active. This bypasses assumptions in a test helper and shows the raw Grid response. The script below lists names, downloads one file through the documented route, decodes the returned zip, and extracts it into an empty evidence directory.
#!/usr/bin/env bash
set -euo pipefail
: "$SELENIUM_GRID_URL"
: "$SESSION_ID"
: "$FILE_NAME"
work_dir="artifacts/remote-download-$SESSION_ID"
mkdir -p "$work_dir"
curl --fail --silent --show-error \
"$SELENIUM_GRID_URL/session/$SESSION_ID/se/files" |
tee "$work_dir/list.json"
curl --fail --silent --show-error \
-H 'Content-Type: application/json' \
--data "{\"name\":\"$FILE_NAME\"}" \
"$SELENIUM_GRID_URL/session/$SESSION_ID/se/files" \
> "$work_dir/download-response.json"
python3 - "$work_dir/download-response.json" "$work_dir" <<'PY'
import base64
import json
import pathlib
import sys
import zipfile
response_path = pathlib.Path(sys.argv[1])
target = pathlib.Path(sys.argv[2])
document = json.loads(response_path.read_text(encoding="utf-8"))
archive_path = target / "download.zip"
archive_path.write_bytes(base64.b64decode(document["value"]["contents"]))
with zipfile.ZipFile(archive_path) as archive:
destination = target.resolve()
for member in archive.infolist():
resolved = (destination / member.filename).resolve()
if destination not in resolved.parents and resolved != destination:
raise RuntimeError(f"unsafe archive entry: {member.filename}")
archive.extractall(destination)
PYA successful list response has names under value.names. An empty list means the session directory has no visible file at that moment. It does not mean the runner's local directory was inspected. An error explaining that downloads are not enabled points back to the Node flag or session capability. An invalid-session response means retrieval happened after timeout or quit.
Third, distinguish browser navigation from download behavior. A server can return a PDF or image with headers that cause the browser to display it in a tab instead of saving it. In that case, the page visibly contains the document and the managed list remains empty. Inspect the response and application behavior. Forcing a browser preference may test a different user experience than production.
Fourth, verify the expected filename. Browsers can rename duplicate downloads, applications can include a date, and Content-Disposition can override the name shown in the link. Capture the complete managed list. If report.csv and report (1).csv both exist, a test that selects “the first CSV” can retrieve yesterday's action from the same session.
Avoid that ambiguity by deleting managed files before the action or by generating a unique export name tied to the test run. Deletion removes useful failure clues, so do it at the start of a test, not in the middle of investigating a failure. After the action, assert the exact set of expected names when the feature promises exact names.
Fifth, inspect bytes rather than extensions. A server error page can be downloaded as customers.csv. A proxy can return a login page with HTTP 200. A zero-byte file can have the correct name. CSV parsing, archive entry checks, file signatures, and domain assertions expose those cases.
For a PDF, check at least the %PDF- signature, a sensible minimum size, and domain content using a PDF parser if the document contract matters. For a zip, enumerate entries and reject path traversal before extraction. For a signed installer or fixture, compare a published SHA-256 digest. Pick assertions that correspond to the user risk.
Work three failures that share the same symptom
In the first case, the test runs locally for months and fails on Grid after migration. Browser options set a download path to /home/runner/work/downloads, and the test polls that path on the CI worker. On Grid, Chrome interprets the preference on the Node. The recording shows the download, but the runner sees nothing.
The decisive evidence is a managed list containing the file while the local runner directory remains empty. The fix is to enable managed downloads and retrieve through the active session. The cost is an additional transfer and local copy. For sensitive files, it also creates another location that must follow retention rules.
In the second case, the filename appears, retrieval succeeds, and CSV parsing intermittently reports a truncated final row. The suite treated get_downloadable_files() as a completion wait. A larger report made the race visible. The application still showed “Preparing export” when the name entered the directory.
Wait on the application's completion state, then retrieve and parse. If no completion signal exists, use bounded content validation with fresh target directories. That workaround adds repeated I/O and may conceal a product defect in how completion is communicated. Ask the product team for an observable export state instead of making the browser test guess forever.
In the third case, a session downloads invoice.pdf, quits in teardown, and an after-test reporter then tries to attach the remote file. The report says “invalid session id,” while screenshots make the download look complete. The remote directory disappeared with the session.
Move retrieval and validation inside the test lifecycle, before quit. Pass the verified local path or bytes to the reporter. The trade-off is that report attachment code now runs while the browser slot is still occupied. Keep it quick, and avoid uploading large artifacts synchronously before releasing the session.
A nearby failure has nothing to do with filesystem location. The application endpoint returns a 500 error page named invoice.pdf. Both local and remote directories contain a file, and the test passes because it asserts only existence. Managed downloads cannot fix that weak oracle. Validate the file signature and business content.
Parallel exports expose a different ownership bug. Ten sessions request customers.csv at once, and a shared-directory helper waits for the first matching path. Nine tests can inspect the first session's result. Every assertion may pass when fixtures contain identical data, then fail unpredictably when accounts differ.
Managed directories isolate the sessions, but the client must keep that isolation after transfer. Use a unique client target per session or test ID. Include a run-specific value inside the export where the product permits it, then assert that value after retrieval. A single global artifacts/downloads/customers.csv path recreates the collision on the runner even though Grid separated the remote files.
The cost is artifact sprawl. Unique directories consume more disk and make reports noisier. Retain failed outputs, summarize successful hashes and sizes, and clean successful temporary files. That policy preserves the one artifact needed for diagnosis without keeping every export from every shard.
Password-protected or encrypted downloads need another decision. A successful transfer cannot prove the payload is usable unless the test has an authorized way to open it. Keep secrets out of command lines and logs, validate only the contract the test account may access, and prefer a domain-specific library over checking that encrypted bytes merely exist.
Another near-miss is upload behavior. Selenium's local file detector transfers a file from the client to a remote Node for an input type=file element. That direction is the reverse of managed downloads and uses different APIs. Enabling downloads does not repair a remote upload test, and setting a file detector does not retrieve browser output.
One false twin deserves a separate branch in the investigation. The export can exist in the managed directory while the transfer from Grid to the runner fails. A helper that catches every retrieval exception and eventually reports “expected local file not found” makes this look like an export that was never generated. The application recording, completion message, and final local directory can look the same in both incidents, yet changing the export wait fixes only the generation case.
Take one managed-list snapshot immediately before retrieval. In a healthy run, the effective download capability is true, value.names contains the exact expected name, the download request returns its archive container, and the resolved client target contains the extracted file. In a generation failure, value.names never gains that name. In a transfer failure, the name is present before the request, but the request fails, its archive cannot be decoded or extracted, or the target remains empty. Keep the original transfer exception. Replacing it with the later file-existence assertion destroys the evidence that separates these paths.
The value.contents field in a successful raw download response is transport data, not a content verdict. A nonempty value should decode to the zip Grid returned, but its character count is not the downloaded file size because base64 encoding and zip compression both change the number. Treating that count as a healthy file length is misleading. Read the extracted file's byte count, signature, and domain validator result instead. For a normal successful attempt, those local observations describe one file with the expected name and usable content. A response that cannot be decoded, an archive that cannot be opened, or a target with no extracted entry is broken even though the earlier list was healthy.
This distinction also narrows the next experiment. If a tiny known download transfers through the same active session while a realistic large export repeatedly fails after appearing in value.names, export generation is no longer the leading explanation. Preserve the raw response status, transfer exception, file name, session identity, and timestamps, then ask the Grid platform owner to inspect the Router, Node, and any intermediary on the response path. That comparison is diagnostic evidence, not proof of a particular size limit. A network interruption or session lifecycle event can produce the same boundary, so the platform logs still decide the final cause.
Migrate a suite without hiding download regressions
Inventory tests that inspect local paths after browser actions. Separate true browser downloads from files created directly by test code, API clients, report libraries, or the application under test. Only browser-owned output needs the managed session path.
Enable the Node flag on one canary browser pool. Confirm the deployed Grid version, browser support, disk capacity, and cleanup behavior. A Node now stores per-session downloads until deletion, quit, or timeout, so disk monitoring matters when sessions are long-lived or files are large.
Centralize session options in the driver factory. Set download enablement for tests that need it, or for the whole pool if the extra Node behavior is acceptable. After session creation, log the session ID and effective se:downloadsEnabled value. That turns a future capability mismatch into a direct diagnosis.
Replace generic waitForFile(downloadDir) helpers with an interface that receives the active driver, an expected name, a deadline, and a validator. Do not keep both paths under an automatic fallback. A fallback from remote retrieval to a shared local directory can make a broken Grid configuration pass because stale files happen to exist.
Migrate one file type at a time. Text and CSV are easy to inspect. Archives need safe entry checks. PDFs need a parser or at least signature and domain assertions. Images may need dimensions, format, and content checks. Executables and large binary packages often need a digest supplied by the product.
Make artifact retention explicit. The local verified copy may contain customer or test data. Upload only what helps diagnosis, redact where necessary, set a retention period, and delete temporary directories. The remote session directory's automatic cleanup does not govern the CI artifact store.
A CI job can preserve the verified client-side artifact even though the browser ran elsewhere. This example assumes the test writes only validated files to artifacts/downloads.
name: remote-download-check
on:
workflow_dispatch:
jobs:
verify-download:
runs-on: ubuntu-latest
timeout-minutes: 15
env:
SELENIUM_GRID_URL: ${{ secrets.SELENIUM_GRID_URL }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: python3 -m pip install --requirement requirements.txt
- run: pytest tests/downloads/test_customer_export.py -q
- uses: actions/upload-artifact@v4
if: failure()
with:
name: verified-download-evidence
path: artifacts/downloads
retention-days: 7Roll back by moving tests to the previous pool, not by reintroducing local directory polling. If managed downloads fail on one browser version, preserve the session capability, raw endpoint response, Node log, and browser version. That evidence can distinguish an unsupported combination from an application regression.
Measure migration success through fewer false “file missing” failures, but also watch transfer duration, Node disk use, session duration, and artifact size. Retrieving bytes before quit extends slot occupancy. For hundreds of large exports, that extra occupancy can become a capacity issue.
Set separate limits for generation and transfer. An export may be allowed five minutes to become ready but only thirty seconds to move from Node to runner. Combining those into one ten-minute wait makes a network regression look like slow application generation. Emit timestamps for click, application completion, managed-list appearance, transfer completion, and validation. Those five points identify which owner needs the failure.
Canary a large realistic file as well as Selenium's tiny sample. Small text proves the feature is enabled; it does not expose reverse-proxy body limits, transfer timeouts, Node disk pressure, or CI artifact quotas. Use synthetic non-sensitive data and remove it after the check.
Land the migration in dependency order. First, make content validators accept bytes or an explicit client path so they can run against the current local transport. Next, change failure reporting to accept an already retrieved artifact and validator error while the session is alive. Extend custom driver wrappers and test doubles before the factory begins returning download-enabled remote sessions. Then enable the canary Nodes, request the capability only for tests routed there, and migrate a small file type. Expand to larger artifacts and additional supported browsers only after the canary produces the full healthy evidence chain. Remove the old path-polling helper last, after its remaining callers are accounted for.
Expect custom wrappers and teardown hooks to expose the first integration break. A wrapper may not forward the managed-download operations, while a reporter may assume it can fetch evidence after quit(). Test doubles can also pass unit tests while omitting the session lifecycle entirely. Supporting both transports during migration costs maintenance in concrete places: two fixture paths, two sets of wrapper behavior, and two reporter inputs must stay compatible until the final caller moves. Keep that overlap time-boxed, and never let the legacy path act as a fallback for a failed managed transfer.
Assign initial ownership at the first divergent boundary. The application team owns an export that never reaches its documented ready state or yields the wrong business data. The Grid platform team owns a session whose returned capability or managed-file transfer disagrees with the deployed pool's contract. The test-framework team owns use of the wrong session, premature teardown, an incorrect client target, or a validator that rejects a valid contract. A handoff should contain the expected artifact contract, exact event chronology, returned capability, managed names immediately before transfer, the preserved transfer exception or response status, the resolved target listing, and the validator error. Include a digest and byte count instead of attaching sensitive content when that is enough to compare attempts.
The change is working when a canary failure lands with one of those boundaries intact, not merely when the pass rate rises. A download control can still be inaccessible to keyboard users even when a pointer click produces perfect bytes. Managed retrieval does not test focus order, accessible naming, or keyboard activation. Cover that user-facing failure separately.
Know when managed downloads are the wrong tool
Use the ordinary filesystem for a local WebDriver session when the browser and test genuinely share it. Adding a remote transfer layer to a local test creates work without crossing a boundary.
Prefer an application API test when the requirement is export generation rather than browser delivery. The browser test should prove that a user action initiates the correct download. API-level checks can cover large files, many data combinations, and detailed schema validation faster.
Avoid moving multi-gigabyte artifacts through the Grid Router unless browser delivery itself is the risk. The base64 and zip path adds memory, CPU, network traffic, and a second full copy. Object storage with a signed URL or a dedicated artifact channel may be more appropriate.
Do not use a Node-wide shared directory merely to preserve a local helper. Shared state creates collisions and weakens session isolation. If policy requires shared storage, namespace it by session ID and test run, make completion atomic, and clean it independently.
Do not call managed-download methods after the session ends. The lifecycle cannot be worked around with retries because ownership is gone. Retrieve first or redesign the report hook to receive a local artifact.
Do not treat a file's presence as proof of its correctness. Managed downloads solve location and transfer. They do not validate server data, authentication, content headers, encoding, truncation, or business rules.
The clean solution costs more than Files.exists. You must configure both ends, keep the session alive through retrieval, transfer bytes, validate content, and manage local retention. In return, the test asserts against the file created by its own remote browser instead of whichever file happens to be visible on the runner.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Why is my download folder empty when Selenium runs on Grid?
The browser writes to the remote Node's filesystem, while your assertion reads the CI runner's filesystem. Enable managed downloads on the Node and in the session, then retrieve the file through Remote WebDriver before quitting.
Does get_downloadable_files wait until a download is complete?
No. Selenium documents the list as an immediate snapshot of names in the session directory. Wait for an application completion signal and validate the retrieved bytes instead of treating a filename as proof.
Which browsers support Selenium managed downloads?
Current Selenium documentation lists Chrome, Edge, and Firefox for this Grid feature. Test the exact browser and Grid version you deploy before migrating a large cross-browser suite.
Can I download the file after calling driver.quit?
Retrieval requires an active session because the file belongs to that session's managed directory. Copy and verify it first; ending the session normally removes the remote download directory.
Should a test compare only the downloaded filename?
A name proves almost nothing about the server response. Check a property that matters to the user, such as CSV headers and records, archive entries, a cryptographic digest, or a file signature and minimum size.
RELATED GUIDES
Continue the learning route
GUIDE 01
Force Browser Downloads with Selenium Manager
A practical guide to Selenium Manager force browser download, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 02
Selenium Java File Upload and Download Verification
Selenium Java file upload download testing: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation engineers.
GUIDE 03
Prevent Browser Downloads with Selenium Manager
A practical guide to Selenium Manager avoid browser download, with implementation examples, debugging workflows, CI evidence, security controls, and release gates.
GUIDE 04
Remote File Uploads with LocalFileDetector on Selenium Grid
Use Selenium LocalFileDetector upload correctly on Grid, trace local-to-node file transfer, and diagnose path, input, and server failures.
GUIDE 05
Drain Selenium Grid Nodes for Zero-Downtime Browser Upgrades
Upgrade Selenium Grid browser nodes without dropping active sessions by draining capacity, monitoring slots, replacing images, and verifying registration.