Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down Expand Up @@ -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
Expand Down
101 changes: 101 additions & 0 deletions lib/playwright_ex/channels/download.ex
Original file line number Diff line number Diff line change
@@ -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
50 changes: 50 additions & 0 deletions lib/playwright_ex/channels/page.ex
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ defmodule PlaywrightEx.Page do

alias PlaywrightEx.ChannelResponse
alias PlaywrightEx.Connection
alias PlaywrightEx.DownloadWaiter
alias PlaywrightEx.Frame
alias PlaywrightEx.Serialization

Expand Down Expand Up @@ -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
Expand Down
102 changes: 102 additions & 0 deletions lib/playwright_ex/processes/download_waiter.ex
Original file line number Diff line number Diff line change
@@ -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
34 changes: 34 additions & 0 deletions test/playwright_ex/download_test.exs
Original file line number Diff line number Diff line change
@@ -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