From 298df31099d87e8b772bbb13ea52b26c69d9fc69 Mon Sep 17 00:00:00 2001 From: Tyler Hunt Date: Thu, 16 Apr 2026 10:08:15 -0400 Subject: [PATCH] Add support for file downloads Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 2 + lib/playwright_ex/channels/download.ex | 101 +++++++++++++++++ lib/playwright_ex/channels/page.ex | 50 +++++++++ .../processes/download_waiter.ex | 102 ++++++++++++++++++ test/playwright_ex/download_test.exs | 34 ++++++ 5 files changed, 289 insertions(+) create mode 100644 lib/playwright_ex/channels/download.ex create mode 100644 lib/playwright_ex/processes/download_waiter.ex create mode 100644 test/playwright_ex/download_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed90eb..8855b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Regex support in argument serialization/deserialization using protocol-native `{r: %{p, f}}` values. - `PlaywrightEx.Page.reload/2` to reload current page. - `PlaywrightEx.Page.bring_to_front/2` to activate a page tab. #29 +- `PlaywrightEx.Page`: `expect_download/2` and `await_download/1`, `PlaywrightEx.Download`, and `PlaywrightEx.DownloadWaiter` to support file downloads. [@tylerhunt] ### Fixed - `PlaywrightEx.Frame.wait_for_selector/2`: crash when `state` is `"hidden"` or `"detached"` (result has no element). #22 - `PlaywrightEx.BrowserContext.add_init_script/2` and `PlaywrightEx.Page.add_init_script/2`: use `source` parameter name required by Playwright protocol (instead of `content`). @@ -65,6 +66,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). [@nathanl]: https://github.com/nathanl [@carsoncall]: https://github.com/carsoncall [@oliver-kriska]: https://github.com/oliver-kriska +[@tylerhunt]: https://github.com/tylerhunt [530e362]: https://github.com/ftes/playwright_ex/commit/530e36 [fecf965]: https://github.com/ftes/playwright_ex/commit/fecf965 diff --git a/lib/playwright_ex/channels/download.ex b/lib/playwright_ex/channels/download.ex new file mode 100644 index 0000000..2e81dcf --- /dev/null +++ b/lib/playwright_ex/channels/download.ex @@ -0,0 +1,101 @@ +defmodule PlaywrightEx.Download do + @moduledoc """ + Interact with a Playwright `Download`. + + Reference: https://playwright.dev/docs/api/class-download + """ + + alias PlaywrightEx.ChannelResponse + alias PlaywrightEx.Connection + + schema = + NimbleOptions.new!( + connection: PlaywrightEx.Channel.connection_opt(), + timeout: PlaywrightEx.Channel.timeout_opt() + ) + + @doc """ + Returns the path to the downloaded file. + + Only available for local (non-remote) connections. + + ## Options + #{NimbleOptions.docs(schema)} + """ + @schema schema + @type path_opt :: unquote(NimbleOptions.option_typespec(schema)) + @spec path(PlaywrightEx.guid(), [path_opt() | PlaywrightEx.unknown_opt()]) :: + {:ok, String.t()} | {:error, any()} + def path(artifact_guid, opts \\ []) do + {connection, opts} = opts |> PlaywrightEx.Channel.validate_known!(@schema) |> Keyword.pop!(:connection) + {timeout, _opts} = Keyword.pop!(opts, :timeout) + + connection + |> Connection.send(%{guid: artifact_guid, method: :path_after_finished, params: %{}}, timeout) + |> ChannelResponse.unwrap(& &1.value) + end + + @doc """ + Streams the downloaded file to a local temp path. + + Used for remote (WebSocket) connections where the file lives on the remote + Playwright server and must be transferred over the protocol. + + ## Options + #{NimbleOptions.docs(schema)} + """ + @schema schema + @type save_as_opt :: unquote(NimbleOptions.option_typespec(schema)) + @spec save_as(PlaywrightEx.guid(), [save_as_opt() | PlaywrightEx.unknown_opt()]) :: + {:ok, String.t()} | {:error, any()} + def save_as(artifact_guid, opts \\ []) do + {connection, opts} = opts |> PlaywrightEx.Channel.validate_known!(@schema) |> Keyword.pop!(:connection) + {timeout, _opts} = Keyword.pop!(opts, :timeout) + + local_path = Path.join(System.tmp_dir!(), "playwright-download-#{System.unique_integer([:positive])}") + + with {:ok, %{stream: %{guid: stream_guid}}} <- + connection + |> Connection.send(%{guid: artifact_guid, method: :save_as_stream, params: %{}}, timeout) + |> ChannelResponse.unwrap(& &1), + :ok <- stream_to_file(connection, stream_guid, timeout, local_path), + {:ok, _} <- close_stream(connection, stream_guid, timeout), + {:ok, _} <- delete_artifact(connection, artifact_guid, timeout) do + {:ok, local_path} + end + end + + defp stream_to_file(connection, stream_guid, timeout, path) do + File.open!(path, [:write, :binary], fn file -> + read_stream_to_file(connection, stream_guid, timeout, file) + end) + end + + defp read_stream_to_file(connection, stream_guid, timeout, file) do + case connection + |> Connection.send(%{guid: stream_guid, method: :read, params: %{size: 1_048_576}}, timeout) + |> ChannelResponse.unwrap(& &1) do + {:ok, %{binary: ""}} -> + :ok + + {:ok, %{binary: chunk}} -> + IO.binwrite(file, Base.decode64!(chunk)) + read_stream_to_file(connection, stream_guid, timeout, file) + + {:error, _} = error -> + error + end + end + + defp close_stream(connection, stream_guid, timeout) do + connection + |> Connection.send(%{guid: stream_guid, method: :close, params: %{}}, timeout) + |> ChannelResponse.unwrap(& &1) + end + + defp delete_artifact(connection, artifact_guid, timeout) do + connection + |> Connection.send(%{guid: artifact_guid, method: :delete, params: %{}}, timeout) + |> ChannelResponse.unwrap(& &1) + end +end diff --git a/lib/playwright_ex/channels/page.ex b/lib/playwright_ex/channels/page.ex index 64c7dce..62b46ed 100644 --- a/lib/playwright_ex/channels/page.ex +++ b/lib/playwright_ex/channels/page.ex @@ -9,6 +9,7 @@ defmodule PlaywrightEx.Page do alias PlaywrightEx.ChannelResponse alias PlaywrightEx.Connection + alias PlaywrightEx.DownloadWaiter alias PlaywrightEx.Frame alias PlaywrightEx.Serialization @@ -379,6 +380,55 @@ defmodule PlaywrightEx.Page do |> ChannelResponse.unwrap(& &1) end + schema = + NimbleOptions.new!( + connection: PlaywrightEx.Channel.connection_opt(), + timeout: PlaywrightEx.Channel.timeout_opt() + ) + + @doc group: :composed + @doc """ + Starts listening for a download event on the page. + + Must be called **before** the action that triggers the download. Returns a + reference that can be passed to `await_download/1` after triggering the + download action. + + Reference: https://playwright.dev/docs/api/class-page#page-wait-for-event-event-download + + ## Options + #{NimbleOptions.docs(schema)} + """ + @schema schema + @type expect_download_opt :: unquote(NimbleOptions.option_typespec(schema)) + @spec expect_download(PlaywrightEx.guid(), [expect_download_opt() | PlaywrightEx.unknown_opt()]) :: + {:ok, pid()} | {:error, any()} + def expect_download(page_id, opts \\ []) do + {connection, opts} = opts |> PlaywrightEx.Channel.validate_known!(@schema) |> Keyword.pop!(:connection) + {timeout, _opts} = Keyword.pop!(opts, :timeout) + + DownloadWaiter.start(connection, page_id, timeout) + end + + @doc group: :composed + @doc """ + Waits for a download to complete and returns the local file path. + + Pass the reference returned by `expect_download/2`. Blocks until the download + finishes or the timeout (set in `expect_download/2`) elapses. + + ## Example + + {:ok, download_ref} = Page.expect_download(page_id, timeout: 30_000) + {:ok, _} = Frame.click(frame_id, selector: "a#export", timeout: 5_000) + {:ok, path} = Page.await_download(download_ref) + {:ok, content} = File.read(path) + """ + @spec await_download(pid()) :: {:ok, String.t()} | {:error, map()} + def await_download(download_ref) do + DownloadWaiter.await(download_ref) + end + defp main_frame_id!(connection, page_id) do page_initializer = Connection.initializer!(connection, page_id) page_initializer.main_frame.guid diff --git a/lib/playwright_ex/processes/download_waiter.ex b/lib/playwright_ex/processes/download_waiter.ex new file mode 100644 index 0000000..593c48d --- /dev/null +++ b/lib/playwright_ex/processes/download_waiter.ex @@ -0,0 +1,102 @@ +defmodule PlaywrightEx.DownloadWaiter do + @moduledoc false + use GenServer + + alias PlaywrightEx.Connection + alias PlaywrightEx.Download + + @page_closed_error "Download failed because page was closed!" + @page_crashed_error "Download failed because page crashed!" + + defstruct connection: nil, + page_id: nil, + timeout: nil, + waiter_from: nil, + result: nil, + timer_ref: nil + + @spec start(atom(), PlaywrightEx.guid(), timeout()) :: {:ok, pid()} | {:error, any()} + def start(connection, page_id, timeout) do + GenServer.start(__MODULE__, %{connection: connection, page_id: page_id, timeout: timeout}) + end + + @spec await(pid()) :: {:ok, String.t()} | {:error, map()} + def await(pid) do + GenServer.call(pid, :await, :infinity) + catch + :exit, {:noproc, _} -> {:error, %{message: "Download waiter is no longer running"}} + :exit, reason -> {:error, %{message: Exception.format_exit(reason)}} + end + + @impl true + def init(%{connection: connection, page_id: page_id, timeout: timeout}) do + Connection.subscribe(connection, self(), page_id) + timer_ref = Process.send_after(self(), :waiter_timeout, timeout) + + state = %__MODULE__{ + connection: connection, + page_id: page_id, + timeout: timeout, + timer_ref: timer_ref + } + + {:ok, state} + end + + @impl true + def handle_call(:await, _from, %{result: result} = state) when not is_nil(result) do + {:stop, :normal, result, state} + end + + def handle_call(:await, from, state) do + {:noreply, %{state | waiter_from: from}} + end + + @impl true + def handle_info({:playwright_msg, %{method: :download, params: params}}, state) do + artifact_guid = params.artifact.guid + + result = + if Connection.remote?(state.connection) do + Download.save_as(artifact_guid, connection: state.connection, timeout: state.timeout) + else + Download.path(artifact_guid, connection: state.connection, timeout: state.timeout) + end + + cancel_timer(state.timer_ref) + resolve(state, result) + end + + def handle_info({:playwright_msg, %{guid: page_id, method: :close}}, %{page_id: page_id} = state) do + cancel_timer(state.timer_ref) + resolve(state, {:error, %{message: @page_closed_error}}) + end + + def handle_info({:playwright_msg, %{guid: page_id, method: :__dispose__}}, %{page_id: page_id} = state) do + cancel_timer(state.timer_ref) + resolve(state, {:error, %{message: @page_closed_error}}) + end + + def handle_info({:playwright_msg, %{guid: page_id, method: :crash}}, %{page_id: page_id} = state) do + cancel_timer(state.timer_ref) + resolve(state, {:error, %{message: @page_crashed_error}}) + end + + def handle_info(:waiter_timeout, state) do + resolve(state, {:error, %{message: "Timeout #{state.timeout}ms exceeded."}}) + end + + def handle_info(_msg, state), do: {:noreply, state} + + defp resolve(%{waiter_from: nil} = state, result) do + {:noreply, %{state | result: result, timer_ref: nil}} + end + + defp resolve(state, result) do + GenServer.reply(state.waiter_from, result) + {:stop, :normal, %{state | result: result, timer_ref: nil}} + end + + defp cancel_timer(nil), do: :ok + defp cancel_timer(timer_ref), do: _ = Process.cancel_timer(timer_ref, async: true, info: false) +end diff --git a/test/playwright_ex/download_test.exs b/test/playwright_ex/download_test.exs new file mode 100644 index 0000000..43b5455 --- /dev/null +++ b/test/playwright_ex/download_test.exs @@ -0,0 +1,34 @@ +defmodule PlaywrightEx.DownloadTest do + use PlaywrightExCase, async: true + + alias PlaywrightEx.Frame + alias PlaywrightEx.Page + + describe "expect_download/2 + await_download/1" do + test "returns an error when no download is triggered", %{page: page} do + {:ok, download_ref} = Page.expect_download(page.guid, timeout: 100) + assert {:error, %{message: "Timeout 100ms exceeded."}} = Page.await_download(download_ref) + end + + test "returns the path of a downloaded file", %{page: page, frame: frame} do + {:ok, _} = Frame.goto(frame.guid, url: "about:blank", timeout: @timeout) + {:ok, download_ref} = Page.expect_download(page.guid, timeout: @timeout) + + {:ok, _} = + eval(frame.guid, """ + () => { + const blob = new Blob(['hello world'], {type: 'text/plain'}); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'test.txt'; + document.body.appendChild(a); + a.click(); + } + """) + + assert {:ok, path} = Page.await_download(download_ref) + assert {:ok, "hello world"} = File.read(path) + end + end +end