Skip to content

Commit 4eadeaf

Browse files
authored
Custom browser dialog handling (#30)
* Add with_dialog and with_event_listener * closes #28 * Record navigate events in separate GenServer, not Connection * Configure via accept_dialogs
1 parent ec68acc commit 4eadeaf

File tree

10 files changed

+258
-77
lines changed

10 files changed

+258
-77
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99
### Added
10-
- Accept browser dialogs automatically (`alert()`, `confirm()`, `prompt()`)
10+
- Dialog handling
11+
- Config option `accept_dialogs` (default: `true`)
12+
- `PhoenixTest.Playwright.with_dialog/3` for conditional handling
1113

1214
## [0.6.3] 2025-05-05
1315
### Added

lib/phoenix_test/playwright.ex

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -236,15 +236,14 @@ defmodule PhoenixTest.Playwright do
236236
conn
237237
end
238238
239+
# In your test, first call `|> tap(&Connection.subscribe(&1.page_id))`
239240
def assert_download(conn, name, contains: content) do
240241
assert_receive({:playwright, %{method: :download} = download_msg}, 2000)
241-
artifact_guid = download_msg.params.artifact.guid
242-
assert_receive({:playwright, %{method: :__create__, params: %{guid: ^artifact_guid}} = artifact_msg}, 2000)
243-
download_path = artifact_msg.params.initializer.absolute_path
244-
wait_for_file(download_path)
242+
path = Connection.initializer(download_msg.params.artifact.guid).absolute_path
243+
wait_for_file(path)
245244
246245
assert download_msg.params.suggested_filename =~ name
247-
assert File.read!(download_path) =~ content
246+
assert File.read!(path) =~ content
248247
249248
conn
250249
end
@@ -334,15 +333,25 @@ defmodule PhoenixTest.Playwright do
334333
alias PhoenixTest.OpenBrowser
335334
alias PhoenixTest.Playwright.BrowserContext
336335
alias PhoenixTest.Playwright.Config
337-
alias PhoenixTest.Playwright.Connection
338336
alias PhoenixTest.Playwright.CookieArgs
337+
alias PhoenixTest.Playwright.Dialog
338+
alias PhoenixTest.Playwright.EventListener
339+
alias PhoenixTest.Playwright.EventRecorder
339340
alias PhoenixTest.Playwright.Frame
340341
alias PhoenixTest.Playwright.Page
341342
alias PhoenixTest.Playwright.Selector
342343

343344
require Logger
344345

345-
defstruct [:context_id, :page_id, :frame_id, :last_input_selector, within: :none]
346+
defstruct [
347+
:context_id,
348+
:page_id,
349+
:frame_id,
350+
:navigate_recorder_pid,
351+
:dialog_listener_pid,
352+
:last_input_selector,
353+
within: :none
354+
]
346355

347356
@opaque t :: %__MODULE__{}
348357
@type css_selector :: String.t()
@@ -355,8 +364,26 @@ defmodule PhoenixTest.Playwright do
355364
@endpoint Application.compile_env(:phoenix_test, :endpoint)
356365

357366
@doc false
358-
def build(context_id, page_id, frame_id) do
359-
%__MODULE__{context_id: context_id, page_id: page_id, frame_id: frame_id}
367+
def build(%{context_id: context_id, page_id: page_id, frame_id: frame_id, config: config}) do
368+
%__MODULE__{
369+
context_id: context_id,
370+
page_id: page_id,
371+
frame_id: frame_id,
372+
navigate_recorder_pid: start_navigate_recorder(frame_id),
373+
dialog_listener_pid: start_dialog_listener(page_id, config[:accept_dialogs])
374+
}
375+
end
376+
377+
defp start_navigate_recorder(frame_id) do
378+
args = %{guid: frame_id, filter: &match?(%{method: :navigated}, &1)}
379+
ExUnit.Callbacks.start_supervised!({EventRecorder, args}, id: "#{frame_id}-navigate-recorder")
380+
end
381+
382+
defp start_dialog_listener(page_id, auto_accept?) do
383+
filter = &match?(%{method: :__create__, params: %{type: "Dialog"}}, &1)
384+
callback = &if(auto_accept?, do: {:ok, _} = Dialog.accept(&1.params.guid))
385+
args = %{guid: page_id, filter: filter, callback: callback}
386+
ExUnit.Callbacks.start_supervised!({EventListener, args}, id: "#{page_id}-dialog-listener")
360387
end
361388

362389
@doc false
@@ -624,6 +651,55 @@ defmodule PhoenixTest.Playwright do
624651
end
625652
end
626653

654+
@doc """
655+
Handle browser dialogs (`alert()`, `confirm()`, `prompt()`) while executing the inner function.
656+
657+
*Note:* Add `@tag accept_dialogs: false` before tests that call this function.
658+
Otherwise, all dialogs are accepted by default.
659+
660+
## Callback return values
661+
The callback may return one of these values:
662+
- `:accept` -> accepts confirmation dialog
663+
- `{:accept, prompt_text}` -> accepts prompt dialog with text
664+
- `:dismiss` -> dismisses dialog
665+
- Any other value will ignore the dialog
666+
667+
## Examples
668+
@tag accept_dialogs: false
669+
test "conditionally handle dialog", %{conn: conn} do
670+
conn
671+
|> visit("/")
672+
|> with_dialog(
673+
fn
674+
%{message: "Are you sure?"} -> :accept
675+
%{message: "Enter the magic number"} -> {:accept, "42"}
676+
%{message: "Self destruct?"} -> :dismiss
677+
end,
678+
fn conn ->
679+
conn
680+
|> click_button("Delete")
681+
|> assert_has(".flash", text: "Deleted")
682+
end
683+
end)
684+
end
685+
"""
686+
def with_dialog(session, callback, fun) when is_function(callback, 1) and is_function(fun, 1) do
687+
event_callback = fn %{params: %{guid: guid, initializer: %{message: message}}} ->
688+
{:ok, _} =
689+
case callback.(%{guid: guid, message: message}) do
690+
:accept -> Dialog.accept(guid)
691+
{:accept, prompt_text} -> Dialog.accept(guid, prompt_text: prompt_text)
692+
:dismiss -> Dialog.dismiss(guid)
693+
_ -> {:ok, :ignore}
694+
end
695+
end
696+
697+
session
698+
|> tap(&EventListener.push_callback(&1.dialog_listener_pid, event_callback))
699+
|> fun.()
700+
|> tap(&EventListener.pop_callback(&1.dialog_listener_pid))
701+
end
702+
627703
@doc false
628704
def render_page_title(conn) do
629705
case Frame.title(conn.frame_id) do
@@ -889,14 +965,8 @@ defmodule PhoenixTest.Playwright do
889965

890966
@doc false
891967
def current_path(conn) do
892-
resp =
893-
conn.frame_id
894-
|> Connection.received()
895-
|> Enum.find(&match?(%{method: :navigated, params: %{url: _}}, &1))
896-
897-
if resp == nil, do: raise(ArgumentError, "Could not find current path.")
898-
899-
uri = URI.parse(resp.params.url)
968+
[event | _] = EventRecorder.events(conn.navigate_recorder_pid)
969+
uri = URI.parse(event.params.url)
900970
[uri.path, uri.query] |> Enum.reject(&is_nil/1) |> Enum.join("?")
901971
end
902972

lib/phoenix_test/playwright/case.ex

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ defmodule PhoenixTest.Playwright.Case do
3535
screenshot: 2,
3636
screenshot: 3,
3737
type: 3,
38-
type: 4
38+
type: 4,
39+
with_dialog: 3
3940
]
4041

4142
import PhoenixTest.Playwright.Case
@@ -82,7 +83,6 @@ defmodule PhoenixTest.Playwright.Case do
8283
}
8384

8485
browser_context_id = Browser.new_context(context.browser_id, browser_context_opts)
85-
subscribe(browser_context_id)
8686

8787
page_id = BrowserContext.new_page(browser_context_id)
8888
Page.update_subscription(page_id, event: :console, enabled: true)
@@ -94,7 +94,12 @@ defmodule PhoenixTest.Playwright.Case do
9494
if config[:trace], do: trace(browser_context_id, config, context)
9595
if config[:screenshot], do: screenshot(page_id, config, context)
9696

97-
PhoenixTest.Playwright.build(browser_context_id, page_id, frame_id)
97+
PhoenixTest.Playwright.build(%{
98+
context_id: browser_context_id,
99+
page_id: page_id,
100+
frame_id: frame_id,
101+
config: config
102+
})
98103
end
99104

100105
defp trace(browser_context_id, config, context) do

lib/phoenix_test/playwright/config.ex

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,16 @@ schema =
5555
trace_dir: [
5656
default: "traces",
5757
type: :string
58+
],
59+
accept_dialogs: [
60+
default: true,
61+
type: :boolean,
62+
doc: "Accept browser dialogs (`alert()`, `confirm()`, `prompt()`"
5863
]
5964
)
6065

6166
setup_all_keys = ~w(browser browser_launch_timeout headless slow_mo)a
62-
setup_keys = ~w(screenshot trace)a
67+
setup_keys = ~w(accept_dialogs screenshot trace)a
6368

6469
defmodule PhoenixTest.Playwright.Config do
6570
@moduledoc """

lib/phoenix_test/playwright/connection.ex

Lines changed: 4 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,6 @@ defmodule PhoenixTest.Playwright.Connection do
33
Stateful, `GenServer` based connection to a Playwright node.js server.
44
The connection is established via `PhoenixTest.Playwright.Port`.
55
6-
The state is ever-growing and never pruned.
7-
Since we're dealing with (short-lived) tets, that is considered good enough for now.
8-
9-
The state includes
10-
- a tree of Playwright artifact guids,
11-
- all received messages,
12-
- sent message for which we're expecting a response.
13-
146
You won't usually have to use this module directly.
157
`PhoenixTest.Playwright` uses this under the hood.
168
"""
@@ -29,9 +21,7 @@ defmodule PhoenixTest.Playwright.Connection do
2921
status: :pending,
3022
awaiting_started: [],
3123
initializers: %{},
32-
guid_ancestors: %{},
3324
guid_subscribers: %{},
34-
guid_received: %{},
3525
posts_in_flight: %{}
3626
]
3727

@@ -108,13 +98,6 @@ defmodule PhoenixTest.Playwright.Connection do
10898
GenServer.call(@name, {:post, msg}, call_timeout)
10999
end
110100

111-
@doc """
112-
Get all past received messages for a playwright `guid` (e.g. a `PhoenixTest.Playwright.Frame`).
113-
"""
114-
def received(guid) do
115-
GenServer.call(@name, {:received, guid})
116-
end
117-
118101
@doc """
119102
Get the initializer data for a channel.
120103
"""
@@ -146,10 +129,6 @@ defmodule PhoenixTest.Playwright.Connection do
146129
{:noreply, Map.update!(state, :posts_in_flight, &Map.put(&1, msg.id, from))}
147130
end
148131

149-
def handle_call({:received, guid}, _from, state) do
150-
{:reply, Map.get(state.guid_received, guid, []), state}
151-
end
152-
153132
def handle_call({:initializer, guid}, _from, state) do
154133
{:reply, Map.get(state.initializers, guid), state}
155134
end
@@ -175,13 +154,10 @@ defmodule PhoenixTest.Playwright.Connection do
175154
state
176155
|> log_js_error(msg)
177156
|> log_console(msg)
178-
|> accept_dialog(msg)
179-
|> add_guid_ancestors(msg)
180157
|> add_initializer(msg)
181-
|> add_received(msg)
182158
|> handle_started(msg)
183159
|> reply_in_flight(msg)
184-
|> notify_subscribers(msg)
160+
|> send_to_subscribers(msg)
185161
end
186162

187163
defp log_js_error(state, %{method: :page_error} = msg) do
@@ -218,31 +194,13 @@ defmodule PhoenixTest.Playwright.Connection do
218194

219195
defp log_console(state, _), do: state
220196

221-
defp accept_dialog(state, %{method: :__create__, params: %{type: "Dialog", guid: guid}}) do
222-
msg = %{guid: guid, method: :accept, params: %{}, metadata: %{}}
223-
PlaywrightPort.post(state.port, msg)
224-
state
225-
end
226-
227-
defp accept_dialog(state, _), do: state
228-
229197
defp handle_started(state, %{method: :__create__, params: %{type: "Playwright"}}) do
230198
for from <- state.awaiting_started, do: GenServer.reply(from, :ok)
231199
%{state | status: :started, awaiting_started: :none}
232200
end
233201

234202
defp handle_started(state, _), do: state
235203

236-
defp add_guid_ancestors(state, %{method: :__create__} = msg) do
237-
child = msg.params.guid
238-
parent = msg.guid
239-
parent_ancestors = Map.get(state.guid_ancestors, parent, [])
240-
241-
Map.update!(state, :guid_ancestors, &Map.put(&1, child, [parent | parent_ancestors]))
242-
end
243-
244-
defp add_guid_ancestors(state, _), do: state
245-
246204
defp add_initializer(state, %{method: :__create__} = msg) do
247205
Map.update!(state, :initializers, &Map.put(&1, msg.params.guid, msg.params.initializer))
248206
end
@@ -258,20 +216,13 @@ defmodule PhoenixTest.Playwright.Connection do
258216

259217
defp reply_in_flight(state, _), do: state
260218

261-
defp add_received(state, %{guid: guid} = msg) do
262-
update_in(state.guid_received[guid], &[msg | &1 || []])
263-
end
264-
265-
defp add_received(state, _), do: state
266-
267-
defp notify_subscribers(state, %{guid: guid} = msg) do
268-
for guid <- [guid | Map.get(state.guid_ancestors, guid, [])],
269-
pid <- Map.get(state.guid_subscribers, guid, []) do
219+
defp send_to_subscribers(state, %{guid: guid} = msg) do
220+
for pid <- Map.get(state.guid_subscribers, guid, []) do
270221
send(pid, {:playwright, msg})
271222
end
272223

273224
state
274225
end
275226

276-
defp notify_subscribers(state, _), do: state
227+
defp send_to_subscribers(state, _), do: state
277228
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
defmodule PhoenixTest.Playwright.Dialog do
2+
@moduledoc """
3+
Interact with a Playwright `Dialog`.
4+
5+
There is no official documentation, since this is considered Playwright internal.
6+
7+
References:
8+
- https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/client/dialog.ts
9+
"""
10+
11+
import PhoenixTest.Playwright.Connection, only: [post: 1]
12+
13+
def accept(dialog_id, opts \\ []) do
14+
[guid: dialog_id, method: :accept, params: Map.new(opts)]
15+
|> post()
16+
|> unwrap_response(& &1)
17+
end
18+
19+
def dismiss(dialog_id, opts \\ []) do
20+
[guid: dialog_id, method: :dismiss, params: Map.new(opts)]
21+
|> post()
22+
|> unwrap_response(& &1)
23+
end
24+
25+
defp unwrap_response(response, fun) do
26+
case response do
27+
%{error: _} = error -> {:error, error}
28+
_ -> {:ok, fun.(response)}
29+
end
30+
end
31+
end

0 commit comments

Comments
 (0)