Resource library

QA How-To

Selenium 4 BiDi Download Progress Testing Tutorial (2026)

Selenium 4 BiDi download progress testing tutorial with runnable Python code for lifecycle events, byte sampling, completion checks, and CI-safe cleanup.

19 min read | 2,485 words

TL;DR

Negotiate `webSocketUrl`, subscribe to `browsingContext.downloadWillBegin` and `browsingContext.downloadEnd`, and collect those lifecycle events in a thread-safe queue. Selenium BiDi does not expose a portable per-byte progress percentage, so measure intermediate growth in a test-owned download directory and validate the final artifact separately.

Key Takeaways

  • Use BiDi lifecycle events for download start and finish, then sample the controlled directory for intermediate byte progress.
  • Subscribe before clicking because a fast download can begin before a listener is ready.
  • Match the download by its suggested filename and test-owned URL instead of assuming the newest file belongs to the test.
  • Require monotonic growth, a nonzero intermediate sample, and a successful end event without depending on an exact transfer rate.
  • Validate file size, SHA-256 digest, and content after completion because a successful browser event does not prove business correctness.
  • Give every test its own temporary directory and always close the BiDi socket and WebDriver session.

The selenium 4 bidi download progress testing tutorial below builds a real browser test that detects when a download starts, records intermediate byte growth, waits for the standardized completion event, and verifies the final file. It uses Python, pytest, Chrome, and Selenium 4 with a WebDriver BiDi connection.

There is an important API boundary. WebDriver BiDi supplies portable lifecycle events, but it does not promise a cross-browser stream of transferred-byte percentages. The reliable design combines BiDi start and end evidence with filesystem samples from a unique download directory. If BiDi is new to you, the Selenium getting-started guide provides the WebDriver foundation.

The example serves a deterministic 4 MiB file slowly from localhost. You control the payload, chunk timing, filename, expected length, and SHA-256 digest, which makes failures explainable instead of dependent on an arbitrary public download site.

What You Will Build

You will create one pytest module that can:

  • Start Chrome with a negotiated WebDriver BiDi WebSocket.
  • Subscribe to browsingContext.downloadWillBegin and browsingContext.downloadEnd before the user action.
  • Download a 4 MiB fixture from a local server in 64 KiB chunks.
  • sample the download directory and prove that bytes increased before completion.
  • Correlate lifecycle events by the BiDi navigation identifier.
  • Verify the finished filename, byte count, SHA-256 digest, and payload pattern.
  • Tear down the socket, server, browser, and temporary files predictably.

This separation answers three different questions. The start event proves the browser accepted a download. Directory samples prove observable progress occurred. Content assertions prove the delivered artifact is correct.

Prerequisites

Use Python 3.12, Selenium 4.46.0, pytest 8.3.5, websocket-client 1.8.0, and a current stable Chrome installation. Selenium Manager resolves a compatible ChromeDriver in normal local and CI environments. Pin the browser image separately in CI so a browser rollout cannot silently change the test matrix.

Create the project:

mkdir selenium-bidi-download
cd selenium-bidi-download
python -m venv .venv
source .venv/bin/activate
python -m pip install selenium==4.46.0 pytest==8.3.5 websocket-client==1.8.0

Windows PowerShell users can activate with .venv\Scripts\Activate.ps1. Create test_download_progress.py; each step adds to that file. If your organization uses Grid, first confirm the remote endpoint returns webSocketUrl and supports browser downloads. The Docker Selenium Grid guide explains remote session capacity and artifact concerns.

Verification: Run python --version, python -m pip show selenium websocket-client, and python -m pytest --version. Confirm the exact package versions and Python 3.12.x before writing browser code.

Step 1: Serve a Slow, Deterministic Download

Start with a local HTTP fixture. The server sends a repeated byte pattern in 64 KiB pieces and pauses 30 milliseconds after each write. A 4 MiB artifact therefore lasts long enough to produce several samples without making the suite painfully slow.

import hashlib
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

import pytest

CHUNK = b"QAJobFit-download-fixture\n" * 2520
CHUNK = (CHUNK + b"x" * 65536)[:65536]
PAYLOAD = CHUNK * 64
EXPECTED_SHA256 = hashlib.sha256(PAYLOAD).hexdigest()
FILENAME = "bidi-report.bin"


class DownloadHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == "/":
            body = (
                b"<!doctype html><title>Download fixture</title>"
                b"<a id='download' href='/report'>Download report</a>"
            )
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.end_headers()
            self.wfile.write(body)
            return

        if self.path != "/report":
            self.send_error(404)
            return

        self.send_response(200)
        self.send_header("Content-Type", "application/octet-stream")
        self.send_header("Content-Disposition", f'attachment; filename="{FILENAME}"')
        self.send_header("Content-Length", str(len(PAYLOAD)))
        self.end_headers()
        for offset in range(0, len(PAYLOAD), len(CHUNK)):
            self.wfile.write(PAYLOAD[offset:offset + len(CHUNK)])
            self.wfile.flush()
            time.sleep(0.03)

    def log_message(self, format, *args):
        pass


@pytest.fixture(scope="module")
def site_url():
    server = ThreadingHTTPServer(("127.0.0.1", 0), DownloadHandler)
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    host, port = server.server_address
    yield f"http://{host}:{port}"
    server.shutdown()
    server.server_close()
    thread.join(timeout=2)

Content-Length gives the browser an expected total, while Content-Disposition turns navigation into a download with a known suggested name. Port zero avoids clashes between local processes. The server is test infrastructure, not a simulation of browser events.

Verification: Temporarily add a test that calls urllib.request.urlopen(site_url + '/report').read(). Assert its length equals len(PAYLOAD) and its digest equals EXPECTED_SHA256, then remove that temporary test. This isolates fixture errors before a browser enters the diagnosis.

Step 2: Start Chrome With BiDi and an Isolated Directory

Configure a unique directory before constructing Chrome. Request the BiDi endpoint by enabling BiDi on the options object, then fail immediately if negotiation did not return webSocketUrl.

from pathlib import Path

from selenium import webdriver


@pytest.fixture
def browser(tmp_path):
    download_dir = tmp_path / "downloads"
    download_dir.mkdir()

    options = webdriver.ChromeOptions()
    options.enable_bidi = True
    options.add_experimental_option(
        "prefs",
        {
            "download.default_directory": str(download_dir.resolve()),
            "download.prompt_for_download": False,
            "download.directory_upgrade": True,
            "safebrowsing.enabled": True,
        },
    )
    options.add_argument("--headless=new")

    driver = webdriver.Chrome(options=options)
    driver.set_page_load_timeout(10)
    assert driver.capabilities.get("webSocketUrl"), (
        "The session did not negotiate WebDriver BiDi"
    )
    yield driver, download_dir
    driver.quit()

A per-test path prevents stale files from making a test pass and stops parallel workers from racing over one filename. Chrome preferences are Chromium-specific setup, but lifecycle observation uses standardized BiDi event names. Firefox needs its own download preferences; validate it in a separate capability fixture instead of mixing browser branches into one test.

Do not log the complete WebSocket URL in shared CI output. It identifies a live automation endpoint. Log only whether BiDi was available, plus the browser name and version.

Verification: Add def test_session(browser): driver, path = browser; assert path.is_dir(); assert driver.capabilities['browserName'] == 'chrome'. Run python -m pytest -q -k session; it should pass and close Chrome without leaving a profile process.

Step 3: Build a Small BiDi Event Client

Selenium exposes the negotiated endpoint in capabilities. The following helper opens it, subscribes to two browsing-context download events, and places incoming events in a thread-safe queue. It also waits for command acknowledgements so subscription failures cannot hide behind later timeouts.

import json
import queue

import websocket


class DownloadEvents:
    def __init__(self, websocket_url):
        self.messages = queue.Queue()
        self.socket = websocket.create_connection(websocket_url, timeout=5)
        self.next_id = 1
        self.closed = threading.Event()
        self.reader = threading.Thread(target=self._read, daemon=True)
        self.reader.start()

    def _read(self):
        while not self.closed.is_set():
            try:
                message = json.loads(self.socket.recv())
            except (websocket.WebSocketException, json.JSONDecodeError):
                return
            self.messages.put(message)

    def command(self, method, params):
        command_id = self.next_id
        self.next_id += 1
        self.socket.send(json.dumps({
            "id": command_id, "method": method, "params": params
        }))
        deadline = time.monotonic() + 5
        deferred = []
        while time.monotonic() < deadline:
            message = self.messages.get(timeout=max(0.01, deadline - time.monotonic()))
            if message.get("id") == command_id:
                for item in deferred:
                    self.messages.put(item)
                if "error" in message:
                    raise RuntimeError(f"BiDi command failed: {message}")
                return message.get("result", {})
            deferred.append(message)
        raise TimeoutError(f"No BiDi response for {method}")

    def subscribe(self):
        self.command("session.subscribe", {
            "events": [
                "browsingContext.downloadWillBegin",
                "browsingContext.downloadEnd",
            ]
        })

    def wait_for(self, method, timeout=10, predicate=lambda params: True):
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            message = self.messages.get(timeout=max(0.01, deadline - time.monotonic()))
            if message.get("method") == method:
                params = message.get("params", {})
                if predicate(params):
                    return params
        raise TimeoutError(f"No matching {method} event")

    def close(self):
        self.closed.set()
        self.socket.close()
        self.reader.join(timeout=2)

The queue matters because WebSocket callbacks and pytest assertions run on different threads. The helper does not assume events arrive in a single hard-coded sequence beyond the lifecycle correlation performed later.

Verification: Construct DownloadEvents(driver.capabilities['webSocketUrl']), call subscribe(), and close it in finally. The subscription command must return without an unknown event error. If it fails, the browser, driver, or Grid endpoint does not implement the required event set.

Step 4: Subscribe Before the Click and Identify the Download

Open the page first, create the event client, and subscribe before clicking. A localhost response can reach the browser before code placed after click() attaches a listener.

from selenium.webdriver.common.by import By


def test_download_starts(browser, site_url):
    driver, download_dir = browser
    driver.get(site_url)
    events = DownloadEvents(driver.capabilities["webSocketUrl"])
    try:
        events.subscribe()
        driver.find_element(By.ID, "download").click()
        started = events.wait_for(
            "browsingContext.downloadWillBegin",
            predicate=lambda p: p.get("suggestedFilename") == FILENAME,
        )
        assert started["url"] == site_url + "/report"
        assert started.get("navigation")
        assert download_dir.is_dir()
    finally:
        events.close()

The filename predicate discards unrelated activity, and the exact URL assertion proves the intended endpoint initiated the artifact. Preserve started['navigation']; the completion event uses that identifier to connect the end to the same download. Do not correlate by event order when a test can start several downloads.

Some applications redirect before sending the attachment. In that case assert the final download URL allowed by the test environment, not necessarily the link's original href. Keep the allowlist explicit.

Verification: Run python -m pytest -q -k download_starts. The test should receive a start event with bidi-report.bin. Change the predicate to wrong.bin and confirm it fails with a bounded timeout rather than hanging.

Step 5: Measure Intermediate Download Progress

There is no standardized Selenium BiDi event that reports a portable percent-complete value on every browser. Measure progress from the isolated directory. Chromium commonly writes an in-progress file with a temporary suffix, so sum every regular file in that directory instead of depending on the suffix name.

def directory_bytes(path: Path) -> int:
    return sum(item.stat().st_size for item in path.iterdir() if item.is_file())


def collect_progress(path: Path, expected: int, timeout=8):
    deadline = time.monotonic() + timeout
    samples = []
    while time.monotonic() < deadline:
        current = directory_bytes(path)
        if not samples or current != samples[-1]:
            samples.append(current)
        if current >= expected:
            return samples
        time.sleep(0.02)
    raise TimeoutError(f"Only observed {samples[-1] if samples else 0} bytes")

Assert properties, not timing. A useful progress trace begins below the final length, contains at least one positive intermediate value, never decreases, and eventually reaches the expected byte count. Do not require exactly 64 samples. Browser buffering, filesystem caching, scheduler load, and antivirus scanning can combine server chunks into fewer writes.

Directory size can briefly include a temporary file and a renamed final file on some filesystems. Because this fixture watches one browser-specific implementation, verify the monotonic assumption in your required CI image. A more defensive cross-browser collector should track the single file associated with the suggested name and browser-specific temporary naming policy.

Verification: Unit-test collect_progress with a thread that appends three chunks to a temporary file. Confirm the returned sequence is ordered and ends at the expected length. That check distinguishes sampling logic from browser behavior.

Step 6: Complete the Selenium 4 BiDi Download Progress Testing Tutorial Test

Combine lifecycle and filesystem assertions in one scenario. Start progress collection immediately after the start event, then match the end event by navigation identifier.

def test_download_progress_and_completion(browser, site_url):
    driver, download_dir = browser
    driver.get(site_url)
    events = DownloadEvents(driver.capabilities["webSocketUrl"])

    try:
        events.subscribe()
        driver.find_element(By.ID, "download").click()

        started = events.wait_for(
            "browsingContext.downloadWillBegin",
            predicate=lambda p: p.get("suggestedFilename") == FILENAME,
        )
        samples = collect_progress(download_dir, len(PAYLOAD))
        ended = events.wait_for(
            "browsingContext.downloadEnd",
            predicate=lambda p: p.get("navigation") == started["navigation"],
        )

        assert ended["status"] == "complete"
        assert samples == sorted(samples)
        assert any(0 < value < len(PAYLOAD) for value in samples)
        assert samples[-1] == len(PAYLOAD)
    finally:
        events.close()

The event and directory checks are complementary. If the server disconnects, the directory may contain bytes, but status should not report complete. If the browser reports completion but no file reaches the configured directory, the session or node has an artifact-routing problem.

Use >= inside collection to avoid hanging if environmental files appear, but require equality in the isolated-directory assertion. An unexpected extra file should fail the test and prompt investigation.

Verification: Run the test three times with python -m pytest -q -k progress_and_completion --count=3 if pytest-repeat is installed, or run the command three times manually. Each run must show at least one positive intermediate sample and exactly 4 MiB at completion.

Step 7: Validate the Finished Artifact

Browser completion proves transfer state, not content correctness. Wait for the final filename, reject leftover partial files, calculate a digest, and inspect a meaningful prefix.

def wait_for_final_file(path: Path, timeout=5) -> Path:
    target = path / FILENAME
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if target.is_file() and target.stat().st_size == len(PAYLOAD):
            return target
        time.sleep(0.02)
    raise TimeoutError(f"Final file did not appear: {target}")


def assert_report_file(download_dir: Path):
    report = wait_for_final_file(download_dir)
    assert report.stat().st_size == len(PAYLOAD)
    assert hashlib.sha256(report.read_bytes()).hexdigest() == EXPECTED_SHA256
    assert report.read_bytes()[:24] == PAYLOAD[:24]
    unexpected = [p.name for p in download_dir.iterdir() if p != report]
    assert unexpected == []

Call assert_report_file(download_dir) after the assertions in Step 6. For a PDF, parse the document and assert page text or metadata. For CSV, check headers, row count, encoding, and key values. A digest is excellent for an immutable fixture, but dynamic reports need semantic assertions because dates and identifiers legitimately change bytes.

Avoid calling read_bytes() twice for large production artifacts. Stream the digest in 1 MiB blocks and parse only the fields required by the acceptance criterion. The small fixture keeps the tutorial readable.

Verification: Change one byte in EXPECTED_SHA256 and make sure the test fails at the digest assertion. Restore it, then truncate the server payload and observe that either the end status or final length exposes the defect.

Step 8: Make Failure and CI Behavior Explicit

A useful suite also covers interruption. Add a server route that advertises the full length, writes several chunks, and closes the connection. Click that link and assert the matching downloadEnd status is not complete; also assert the final expected file is absent. Do not accept only a timeout, because a timeout cannot distinguish a missing subscription from a correctly detected failed download.

For CI, keep these boundaries:

Concern Reliable assertion Fragile alternative
Start Matching BiDi start event File exists after a fixed sleep
Progress Positive, monotonic intermediate samples Exact bytes at exact milliseconds
Finish Correlated end event with complete Temporary suffix disappeared
Integrity Expected length and semantic content or digest Filename alone
Isolation New temporary directory per test Cleaning a shared global folder

On Selenium Grid, the download directory lives on the browser node, not necessarily on the runner. Filesystem sampling works when the runner and browser share the mounted artifact directory. Otherwise use the Grid download API after completion for retrieval, and treat intermediate progress as node-side telemetry. The running tests on Selenium Grid in Kubernetes guide helps place this storage boundary.

Record sanitized diagnostics on failure: browser name, browser version, whether webSocketUrl existed, lifecycle method, navigation identifier, status, sample count, first sample, and last sample. Never publish the WebSocket endpoint, authenticated URLs, cookies, or downloaded customer data.

Verification: Run in headless CI with two pytest workers only after confirming each worker receives a unique tmp_path. Both downloads must complete without filename collisions or cross-test samples.

Troubleshooting

Problem: webSocketUrl is absent -> Set options.enable_bidi = True before creating the session. Upgrade the Selenium client, browser, driver, and Grid node as a compatible set. A remote provider must forward the negotiated BiDi WebSocket, not merely accept classic WebDriver commands.

Problem: session.subscribe returns unknown event -> The endpoint does not implement the required download lifecycle events. Keep the failure as a capability gate, check the supported browser matrix, and do not silently replace the assertion with a sleep.

Problem: no intermediate sample is recorded -> Increase fixture size or server delay, subscribe before clicking, and shorten the sampling interval moderately. Never force an exact sample count because local buffering and CI scheduling differ.

Problem: the end event arrives but the final file is missing -> Confirm the absolute download directory, Chrome preferences, container mount, and node permissions. On remote Grid, remember that the file is created inside the node unless a shared volume or download retrieval feature moves it.

Problem: directory bytes decrease during rename -> Track the browser's active temporary file and final filename as one logical artifact, or calculate the maximum observed size. Validate that policy on each supported browser rather than assuming Chrome's suffix applies everywhere.

Problem: the test passes with a corrupt report -> Add length, digest, format parsing, and business-field checks after completion. Lifecycle status describes transport outcome, not the meaning of the downloaded bytes.

Interview Questions and Answers

Q: Why combine BiDi events and filesystem observation?

BiDi establishes browser lifecycle facts, including which download began and how it ended. Filesystem observation supplies intermediate byte evidence that the standardized lifecycle events do not provide portably. Content assertions then cover correctness, so each signal has one responsibility.

Q: Why subscribe before clicking the download link?

Event delivery can begin as soon as the click triggers navigation. Registering afterward creates a race that appears most often with small local files. Subscription acknowledgement should complete before the user action.

Q: How do you correlate start and end events?

Capture the navigation identifier from downloadWillBegin and require the same identifier on downloadEnd. Suggested filename helps select the intended start, but filenames alone are not unique when several downloads run together.

Q: Why avoid a fixed sleep?

A sleep guesses how long transfer and filesystem work will take. A bounded wait exits immediately when its condition is true and fails with evidence when the deadline is exceeded. It is both faster in the normal case and clearer under load.

Q: What changes on Selenium Grid?

The file normally exists on the browser node, while the test runner may be another machine. Intermediate sampling needs shared storage or node telemetry, and retrieval can occur after completion through supported Grid download facilities. The BiDi connection must also be forwarded by the remote endpoint.

The structured interviewQnA entries below provide additional concise model answers. Practice explaining the boundary between browser state, transfer progress, and artifact correctness.

Best Practices

  • Own the download endpoint and payload used by the test.
  • Subscribe and verify acknowledgement before the action.
  • Select events using an exact URL and suggested filename.
  • Correlate the finish event with the start navigation identifier.
  • Use bounded condition waits with failure diagnostics.
  • Assert monotonic properties instead of precise transfer rates.
  • Give every browser session a unique artifact directory.
  • Verify payload semantics after transport completion.
  • Close the event socket in finally, then quit WebDriver.
  • Keep secrets and customer downloads out of test reports.

If you are organizing these helpers into a larger suite, compare the Selenium Java framework tutorial even when your implementation language is Python; its fixture and ownership principles transfer directly. For broader tool selection, see the best QA automation tools for 2026.

Where To Go Next

Turn the tutorial into a reusable DownloadObserver fixture with explicit start, samples, end, and artifact results. Add a failed-transfer fixture, then run the same acceptance contract in every browser and Grid environment your team supports.

Next, strengthen your core setup with the Selenium Python framework from scratch guide, practice realistic automation exercises in the QA practice workspace, or evaluate your resume evidence in the QAJobFit resume workspace. Keep the event contract strict: if an environment cannot provide the required BiDi lifecycle events, report it as an unsupported capability instead of weakening the test.

A durable download test proves start, meaningful progress, successful completion, and correct content independently. That structure survives faster machines, slower CI agents, browser buffering changes, and remote execution far better than watching for a filename after an arbitrary delay.

Interview Questions and Answers

How would you design a reliable Selenium download progress test?

I subscribe to BiDi download lifecycle events before the action, identify the intended download by URL and suggested filename, and correlate completion by navigation ID. I sample a unique download directory for monotonic intermediate growth. Finally, I assert successful completion and validate the artifact's size and content.

What is the difference between download completion and file correctness?

Completion says the browser considers the transfer finished. It does not prove the server delivered the right report or that its data is valid. I parse the completed artifact or compare an expected digest to cover business correctness.

Why is Thread.sleep a poor download synchronization strategy?

It encodes a timing guess rather than an observable condition. It wastes time when the file is fast and fails when CI is slower. Bounded waits around lifecycle events, byte growth, and final content give faster execution and actionable failure evidence.

How do you test several simultaneous downloads?

I retain the navigation identifier from each start event and keep independent state keyed by that identifier. I also use unique suggested filenames or separate directories where possible. End events and artifacts are matched to their own state instead of relying on arrival order.

What would you log when a BiDi download test fails?

I log browser and driver versions, BiDi availability, sanitized URL path, suggested filename, navigation ID, end status, and the first and last byte samples. I exclude the WebSocket URL, credentials, cookies, and sensitive artifact contents.

How does remote Grid execution affect download assertions?

The browser writes to the node filesystem, not automatically to the runner. Progress sampling therefore needs a shared mount or node-side telemetry, while a supported Grid download API can retrieve completed files. The remote session must also negotiate a reachable BiDi WebSocket.

Frequently Asked Questions

Can Selenium BiDi report download percentage directly?

WebDriver BiDi provides standardized download lifecycle events, but a portable per-byte percentage stream is not guaranteed across browsers. Measure intermediate growth in a controlled download directory or use approved node-side telemetry, then use BiDi to prove start and completion.

Which BiDi events are used for Selenium download testing?

Subscribe to `browsingContext.downloadWillBegin` before triggering the action and wait for `browsingContext.downloadEnd`. Correlate them using the navigation identifier and assert the final status is `complete`.

How do I avoid flaky Selenium download waits?

Replace fixed sleeps with bounded waits for a matching start event, observable byte growth, a correlated end event, and the final artifact. Make the directory unique per test so stale files and parallel sessions cannot satisfy those conditions.

How should I verify a downloaded file in Selenium?

Check the final filename, exact expected size, and meaningful content. Use SHA-256 for an immutable fixture; for a dynamic CSV or PDF, parse it and assert required headers, values, pages, or metadata.

Does this download progress approach work on Selenium Grid?

BiDi lifecycle events work only when the Grid negotiates and forwards the WebSocket capability. Filesystem progress requires access to the node's artifact directory, usually through a mounted volume or node-side collector; runner-side retrieval commonly happens only after completion.

Why is a temporary download directory important?

It prevents a file from an earlier run from producing a false pass and isolates parallel workers. It also makes the expected total directory size meaningful and enables automatic cleanup after the test.

Related Guides