defmodule PhoenixTest.Playwright do @moduledoc ~S""" Playwright driver for [PhoenixTest](https://hexdocs.pm/phoenix_test). This module implements the PhoenixTest driver protocol, running tests in a real browser via Playwright. The `conn` in tests is not a `Plug.Conn` but a `%PhoenixTest.Playwright{}` struct holding the Playwright session state (page, frame, browser context, etc.). It also provides browser-specific functions beyond the standard PhoenixTest API, such as `screenshot/3`, `evaluate/2`, `type/3`, `press/3`, and `drag/3`. See the [README](README.md) for getting started, configuration, and troubleshooting. ## Missing Playwright features See the [README](README.md#missing-playwright-features). """ import ExUnit.Assertions alias PhoenixTest.Assertions alias PhoenixTest.OpenBrowser alias PhoenixTest.Playwright.Config alias PhoenixTest.Playwright.CookieArgs alias PhoenixTest.Playwright.EventListener alias PhoenixTest.Playwright.EventRecorder alias PlaywrightEx.Artifact alias PlaywrightEx.BrowserContext alias PlaywrightEx.Dialog alias PlaywrightEx.Frame alias PlaywrightEx.Page alias PlaywrightEx.Selector alias PlaywrightEx.Tracing require Logger defstruct [ :context_id, :page_id, :frame_id, :tracing_id, :dialog_listener_pid, :download_recorder_pid, :last_input_selector, within: :none ] @opaque t :: %__MODULE__{} @type css_selector :: String.t() @type playwright_selector :: String.t() @type selector :: playwright_selector() | css_selector() @timeout_opt [type: :non_neg_integer, doc: "Maximum wait time in milliseconds. Defaults to the configured timeout."] @exact_opt_schema [type: :boolean, default: false, doc: "Exact or substring text match."] @exact_opts_schema [exact: @exact_opt_schema, timeout: @timeout_opt] @endpoint Application.compile_env(:phoenix_test, :endpoint) @doc false def build(%{context_id: context_id, page_id: page_id, frame_id: frame_id, tracing_id: tracing_id, config: config}) do %__MODULE__{ context_id: context_id, page_id: page_id, frame_id: frame_id, tracing_id: tracing_id, dialog_listener_pid: start_dialog_listener(page_id, config[:accept_dialogs]), download_recorder_pid: start_download_recorder(page_id) } end defp start_dialog_listener(page_id, auto_accept?) do filter = &match?(%{method: :__create__, params: %{type: "Dialog"}}, &1) callback = &if(auto_accept?, do: {:ok, _} = Dialog.accept(&1.params.guid, timeout: timeout())) args = %{guid: page_id, filter: filter, callback: callback} ExUnit.Callbacks.start_supervised!({EventListener, args}, id: "#{page_id}-dialog-listener") end defp start_download_recorder(page_id) do filter = &match?(%{method: :download}, &1) args = %{guid: page_id, filter: filter} ExUnit.Callbacks.start_supervised!({EventRecorder, args}, id: "#{page_id}-download-recorder") end @retry_interval to_timeout(millisecond: 10) @doc false def retry(fun, remaining) when remaining <= 0, do: fun.() def retry(fun, remaining) do fun.() rescue ExUnit.AssertionError -> Process.sleep(@retry_interval) retry(fun, remaining - @retry_interval) end @doc """ Label a step in the Playwright trace. This is useful for marking custom helper functions or complex multi-step operations so they appear as distinct steps in the trace viewer for easier debugging. Steps can be nested. Their source location is noted in the trace. ## Examples def complete_checkout(conn, user_email) do conn |> sign_in_as(user_email) |> step("Check out", fn conn -> conn |> step("Fill shipping information", fn conn -> conn |> fill_in("Address", with: "123 Main St") |> fill_in("City", with: "Portland") |> click_button("Continue") end) |> step("Fill payment information", fn conn -> conn |> fill_in("Card number", with: "4242424242424242") |> fill_in("CVV", with: "123") |> click_button("Place order") end) end) end defp sign_in_as(conn, user_email) do conn |> step("Sign in as \#{user_email}", fn conn -> conn |> fill_in("Email", with: user_email) |> fill_in("Password", with: "password123") |> click_button("Sign In") end) end """ defmacro step(conn, title, fun) do caller_location = [file: Path.absname(__CALLER__.file), line: __CALLER__.line] quote bind_quoted: [conn: conn, title: title, fun: fun, caller_location: caller_location] do Tracing.group( conn.tracing_id, [ name: title, location: caller_location, timeout: Config.global(:timeout) ], fn -> fun.(conn) end ) conn end end @doc false def visit(conn, path), do: visit(conn, path, []) @visit_opts_schema [ timeout: @timeout_opt, referer: [ type: :string, doc: "Referer header value. If provided, takes preference over the referer header set via the `extra_http_headers` option to `PlaywrightEx.Browser.new_context/2`." ], wait_until: [ type: {:in, ["load", "domcontentloaded", "networkidle", "commit"]}, default: "load", doc: """ When to consider the operation succeeded. One of: - `"load"` (default) — fires when the `load` event is fired. - `"domcontentloaded"` — fires when the `DOMContentLoaded` event is fired. - `"networkidle"` — **discouraged** — fires when there are no network connections for at least 500 ms. Rely on web assertions instead for testing readiness. - `"commit"` — fires when the network response is received and the document has started loading. """ ] ] @doc """ Like `PhoenixTest.visit/2`, but with a custom `timeout`. ## Options #{NimbleOptions.docs(@visit_opts_schema)} """ @spec visit(t(), String.t(), [unquote(NimbleOptions.option_typespec(@visit_opts_schema))]) :: t() def visit(conn, path, opts) do opts = NimbleOptions.validate!(opts, @visit_opts_schema) tap( conn, &case Frame.goto(&1.frame_id, opts |> ensure_timeout() |> Keyword.put(:url, path)) do {:ok, _} -> :ok {:error, %{error: %{message: "Download is starting"}}} -> :ok end ) end @reload_page_opts_schema [timeout: @timeout_opt] @doc false def reload_page(conn, opts \\ []) do opts = NimbleOptions.validate!(opts, @reload_page_opts_schema) tap(conn, &({:ok, _} = Page.reload(&1.page_id, ensure_timeout(opts)))) end @doc """ Add cookies to the browser context, using `Plug.Conn.put_resp_cookie/3` Note that for signed cookies the signing salt is **not** configurable. As such, this function is not appropriate for signed `Plug.Session` cookies. For signed session cookies, use `add_session_cookie/3` A cookie's value must be a binary unless the cookie is signed/encrypted ## Cookie fields | key | type | description | | ----------- | ----------- | ----------- | | `:name` | `binary()` | | | `:value` | `binary()` | | | `:url` | `binary()` | *(optional)* either url or domain / path are required | | `:domain` | `binary()` | *(optional)* either url or domain / path are required | | `:path` | `binary()` | *(optional)* either url or domain / path are required | | `:max_age` | `float()` | *(optional)* The cookie max age, in seconds. | | `:http_only` | `boolean()` | *(optional)* | | `:secure` | `boolean()` | *(optional)* | | `:encrypt` | `boolean()` | *(optional)* | | `:sign` | `boolean()` | *(optional)* | | `:same_site` | `binary()` | *(optional)* one of "Strict", "Lax", "None" | Two of the cookie fields mean nothing to Playwright. These are: 1. `:encrypt` 2. `:sign` The `:max_age` cookie field means the same thing as documented in `Plug.Conn.put_resp_cookie/4`. The `:max_age` value is used to infer the correct `expires` value that Playwright requires. See https://playwright.dev/docs/api/class-browsercontext#browser-context-add-cookies """ def add_cookies(conn, cookies) do cookies = Enum.map(cookies, &CookieArgs.from_cookie/1) tap(conn, &({:ok, _} = BrowserContext.add_cookies(&1.context_id, cookies: cookies, timeout: timeout()))) end @clear_cookies_opts_schema [timeout: @timeout_opt] @doc """ Removes all cookies from the context. ## Options #{NimbleOptions.docs(@clear_cookies_opts_schema)} """ @spec clear_cookies(t(), [unquote(NimbleOptions.option_typespec(@clear_cookies_opts_schema))]) :: t() def clear_cookies(conn, opts \\ []) do opts = NimbleOptions.validate!(opts, @clear_cookies_opts_schema) tap(conn, &({:ok, _} = BrowserContext.clear_cookies(&1.context_id, ensure_timeout(opts)))) end @doc """ Add a `Plug.Session` cookie to the browser context. This is useful for emulating a logged-in user. Note that that the cookie `:value` must be a map, since we are using `Plug.Conn.put_session/3` to write each of value's key-value pairs to the cookie. The `session_options` are exactly the same as the opts used when writing `plug Plug.Session` in your router/endpoint module. ## Examples |> add_session_cookie( [value: %{user_token: Accounts.generate_user_session_token(user)}], MyAppWeb.Endpoint.session_options() ) """ def add_session_cookie(conn, cookie, session_options) do cookie = CookieArgs.from_session_options(cookie, session_options) tap(conn, &({:ok, _} = BrowserContext.add_cookies(&1.context_id, cookies: [cookie], timeout: timeout()))) end @screenshot_opts_schema [ full_page: [ type: :boolean, default: true ], omit_background: [ type: :boolean, default: false, doc: "Only applicable to .png images." ], timeout: @timeout_opt ] @doc """ Takes a screenshot of the current page and saves it to the given file path. The file type will be inferred from the file extension on the path you provide. The file is saved in `:screenshot_dir`, see `PhoenixTest.Playwright.Config`. ## Options #{NimbleOptions.docs(@screenshot_opts_schema)} ## Examples |> screenshot("my-screenshot.png") |> screenshot("my-test/my-screenshot.jpg") """ @spec screenshot(t(), String.t(), [ unquote(NimbleOptions.option_typespec(@screenshot_opts_schema)) ]) :: t() def screenshot(conn, file_path, opts \\ []) do opts = NimbleOptions.validate!(opts, @screenshot_opts_schema) dir = Config.global(:screenshot_dir) File.mkdir_p!(dir) path = Path.join(dir, file_path) {:ok, binary_img} = Page.screenshot(conn.page_id, ensure_timeout(opts)) File.write!(path, Base.decode64!(binary_img)) conn end @assert_screenshot_opts_schema [ full_page: [ type: :boolean, doc: "When true, takes a screenshot of the full scrollable page, instead of the currently visible viewport. Defaults to `false`." ], omit_background: [ type: :boolean, doc: "Hides default white background and allows capturing screenshots with transparency. Not applicable to `jpeg` images. Defaults to `false`." ], animations: [ type: {:in, ["disabled", "allow"]}, doc: ~s{When set to `"disabled"`, stops CSS animations, CSS transitions and Web Animations. Animations get different treatment depending on their duration: finite animations are fast-forwarded to completion, so they'll fire `transitionend` event. Infinite animations are canceled to initial state, and then played over after the screenshot. Defaults to `"disabled"`.} ], caret: [ type: {:in, ["hide", "initial"]}, doc: ~s{When set to `"hide"`, screenshot will hide text caret. When set to `"initial"`, text caret behavior will not be changed. Defaults to `"hide"`.} ], clip: [ type: :map, doc: "An object which specifies clipping of the resulting image. `%{x: float, y: float, width: float, height: float}`." ], selector: [type: :string, doc: "Scope the screenshot to a CSS or Playwright selector instead of the full page."], max_diff_pixels: [ type: :non_neg_integer, doc: "An acceptable amount of pixels that could be different. Unset by default." ], max_diff_pixel_ratio: [ type: :float, doc: "An acceptable ratio of pixels that are different to the total amount of pixels, between `0` and `1`. Unset by default." ], threshold: [ type: :float, doc: "An acceptable perceived color difference in the YIQ color space between the same pixel in compared images, between zero (strict) and one (lax). Defaults to `0.2`." ], scale: [ type: {:in, ["css", "device"]}, doc: ~s{When set to `"css"`, screenshot will have a single pixel per each css pixel on the page. For high-dpi devices, this will keep screenshots small. Using `"device"` option will produce a single pixel per each device pixel. Defaults to `"css"`.} ], mask: [ type: {:list, :string}, doc: "Specify selectors that should be masked when the screenshot is taken. Masked elements will be overlaid with a pink box `#FF00FF` (customized by `mask_color`) that completely covers its bounding box." ], mask_color: [ type: :string, doc: "Specify the color of the overlay box for masked elements, in CSS color format. Default color is pink `#FF00FF`." ], snapshot_dir: [type: :string, doc: "Override the global `:snapshot_dir` config for this call."], timeout: @timeout_opt ] @doc """ Visual regression assertion: compares a screenshot of the current page against a stored baseline. `name` is a relative path within `:snapshot_dir` (see `PhoenixTest.Playwright.Config`), including the file extension — for example `"home.png"` or `"auth/login.png"`. Subdirectories are created automatically. On first run (no baseline exists), the screenshot is saved as the new baseline and the test passes. On subsequent runs, the live screenshot is compared pixel-by-pixel against the baseline. On mismatch, a diff image is saved to `/__diff__/` and the test fails. To update a baseline, delete the snapshot file and re-run the test. ## Options #{NimbleOptions.docs(@assert_screenshot_opts_schema)} ## Examples |> assert_screenshot("home.png") |> assert_screenshot("auth/login.png", full_page: false) |> assert_screenshot("hero.png", selector: ".hero-section") """ @spec assert_screenshot(t(), String.t(), [unquote(NimbleOptions.option_typespec(@assert_screenshot_opts_schema))]) :: t() def assert_screenshot(conn, name, opts \\ []) do opts = NimbleOptions.validate!(opts, @assert_screenshot_opts_schema) {snapshot_dir, opts} = Keyword.pop(opts, :snapshot_dir, Config.global(:snapshot_dir)) snapshot_path = Path.join(snapshot_dir, name) opts = opts |> maybe_add_expected(snapshot_path) |> ensure_timeout() |> maybe_convert_selector(conn.frame_id) |> maybe_convert_mask(conn.frame_id) case Page.expect_screenshot(conn.page_id, opts) do {:ok, nil} -> conn {:ok, actual_b64} when is_binary(actual_b64) -> write_file!(snapshot_path, actual_b64) conn {:error, %{diff: diff, custom_error_message: msg}} when is_binary(diff) -> write_file!(Path.join([snapshot_dir, "__diff__", name]), diff) flunk("Screenshot mismatch for #{name}: #{msg}") {:error, %{custom_error_message: msg}} -> flunk(msg) end end defp maybe_add_expected(opts, snapshot_path) do if File.exists?(snapshot_path) do Keyword.put(opts, :expected, snapshot_path |> File.read!() |> Base.encode64()) else opts end end defp maybe_convert_selector(opts, frame_id) do case Keyword.pop(opts, :selector) do {nil, opts} -> opts {s, opts} -> Keyword.put(opts, :locator, %{frame: %{guid: frame_id}, selector: s}) end end defp maybe_convert_mask(opts, frame_id) do case Keyword.pop(opts, :mask) do {nil, opts} -> opts {selectors, opts} -> masks = Enum.map(selectors, &%{frame: %{guid: frame_id}, selector: &1}) Keyword.put(opts, :mask, masks) end end defp write_file!(path, base64) do File.mkdir_p!(Path.dirname(path)) File.write!(path, Base.decode64!(base64)) end @type_opts_schema [ delay: [ type: :non_neg_integer, default: 0, doc: "Time to wait between key presses in milliseconds." ], timeout: @timeout_opt ] @doc """ Focuses the matching element and simulates user typing. In most cases, you should use `PhoenixTest.fill_in/4` instead. ## Options #{NimbleOptions.docs(@type_opts_schema)} ## Examples |> type("#id", "some text") |> type(Selector.role("heading", "Untitled"), "New title") """ @spec type(t(), selector(), String.t(), [ unquote(NimbleOptions.option_typespec(@type_opts_schema)) ]) :: t() def type(conn, selector, text, opts \\ []) when is_binary(text) do opts = opts |> NimbleOptions.validate!(@type_opts_schema) |> Keyword.merge(selector: selector, text: text) |> ensure_timeout() conn.frame_id |> Frame.type(opts) |> handle_response(selector) conn end @press_opts_schema [ delay: [ type: :non_neg_integer, default: 0, doc: "Time to wait between keydown and keyup in milliseconds." ], timeout: @timeout_opt ] @doc """ Focuses the matching element and presses a combination of the keyboard keys. Use `type/4` if you don't need to press special keys. Examples of [supported keys](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values): `F1 - F12, Digit0- Digit9, KeyA- KeyZ, Backquote, Minus, Equal, Backslash, Backspace, Tab, Delete, Escape, ArrowDown, End, Enter, Home, Insert, PageDown, PageUp, ArrowRight, ArrowUp` Modifiers are also supported: `Shift, Control, Alt, Meta, ShiftLeft, ControlOrMeta` Combinations are also supported: `Control+o, Control++, Control+Shift+T` ## Options #{NimbleOptions.docs(@press_opts_schema)} ## Examples |> press("#id", "Control+Shift+T") |> press(Selector.button("Submit"), "Enter") """ @spec press(t(), selector(), String.t(), [ unquote(NimbleOptions.option_typespec(@press_opts_schema)) ]) :: t() def press(conn, selector, key, opts \\ []) when is_binary(key) do opts = opts |> NimbleOptions.validate!(@press_opts_schema) |> Keyword.merge(selector: selector, key: key) |> ensure_timeout() conn.frame_id |> Frame.press(opts) |> handle_response(selector) conn end @drag_opts_schema [ to: [ type_spec: quote(do: selector()), type_doc: "`t:selector/0`", required: true, doc: "The target selector." ], playwright: [ type: :keyword_list, default: [], doc: "Additional options passed to [frame.dragAndDrop](https://playwright.dev/docs/api/class-frame#frame-drag-and-drop)." ], timeout: @timeout_opt ] @doc """ Drag and drop a source element to a target element. ## Options #{NimbleOptions.docs(@drag_opts_schema)} ## Examples |> drag("#source", to: "#target") |> drag(Selector.text("Draggable"), to: Selector.text("Target")) """ @spec drag(t(), selector(), [ unquote(NimbleOptions.option_typespec(@drag_opts_schema)) ]) :: t() def drag(conn, source_selector, opts) do {target_selector, opts} = opts |> NimbleOptions.validate!(@drag_opts_schema) |> Keyword.pop!(:to) source_selector = conn |> maybe_within() |> Selector.concat(source_selector) target_selector = conn |> maybe_within() |> Selector.concat(target_selector) opts = Keyword.merge( [source: source_selector, target: target_selector, timeout: timeout(opts)], Keyword.fetch!(opts, :playwright) ) conn.frame_id |> Frame.drag_and_drop(opts) |> handle_response(source_selector) conn end @path_assertion_opts_schema [ query_params: [ type: {:map, :any, :any}, doc: "Map of query params to match exactly." ], timeout: @timeout_opt ] @doc false def assert_path(conn, path, opts \\ []) do opts = NimbleOptions.validate!(opts, @path_assertion_opts_schema) if opts[:query_params] do retry(fn -> Assertions.assert_path(conn, path, opts) end, timeout(opts)) else retry(fn -> Assertions.assert_path(conn, path) end, timeout(opts)) end end @doc false def refute_path(conn, path, opts \\ []) do opts = NimbleOptions.validate!(opts, @path_assertion_opts_schema) if opts[:query_params] do retry(fn -> Assertions.refute_path(conn, path, opts) end, timeout(opts)) else retry(fn -> Assertions.refute_path(conn, path) end, timeout(opts)) end end @doc false def assert_has(conn, "title") do if not has_title?(conn, text: ""), do: flunk("Page does not have a title") conn end def assert_has(conn, selector), do: assert_has(conn, selector, []) @doc false def assert_has(conn, "title", opts) do if not has_title?(conn, opts), do: flunk("Page title does not match") conn end def assert_has(conn, selector, opts) do if not found?(conn, selector, opts), do: flunk("Could not find element \"#{selector}\" #{inspect(opts)}") conn end @doc false def refute_has(conn, "title") do if not has_title?(conn, text: ""), do: flunk("Page has a title") conn end def refute_has(conn, selector), do: refute_has(conn, selector, []) @doc false def refute_has(conn, "title", opts) do if not has_title?(conn, opts, is_not: true), do: flunk("Page title matches") conn end def refute_has(conn, selector, opts) do if found?(conn, selector, opts, is_not: true), do: flunk("Found element \"#{selector}\" #{inspect(opts)}") conn end @title_assertion_opts_schema [ text: [ type: :any, required: true ], exact: @exact_opt_schema, timeout: @timeout_opt ] @assertion_opts_schema [ text: [ type: :any ], value: [ type: :any ], selected: [ type: :any ], checked: [ type: :any ], label: [ type: :string ], exact: @exact_opt_schema, count: [ type: :non_neg_integer ], at: [ type: :integer ], timeout: @timeout_opt ] @content_assertion_opts [:text, :value, :selected, :checked] defp has_title?(conn, opts, params \\ []) do {text, opts} = opts |> NimbleOptions.validate!(@title_assertion_opts_schema) |> Keyword.pop!(:text) params = Keyword.merge( [ expression: "to.have.title", expected_text: [%{string: text, match_substring: not opts[:exact]}], timeout: timeout(opts) ], params ) {:ok, matches?} = Frame.expect(conn.frame_id, params) matches? end defp found?(conn, selector, opts, other_opts \\ []) do opts = validate_assertion_opts!(opts) other_opts = Keyword.validate!(other_opts, is_not: false) selector = conn |> maybe_within() |> Selector.concat(Selector.css(selector)) |> Selector.and(Selector.label(opts[:label], exact: true)) |> Selector.and(checked_selector(opts[:checked])) |> selector_has(selected_selector(opts[:selected])) |> Selector.concat("visible=true") |> Selector.concat(Selector.text(opts[:text], opts)) |> Selector.concat(Selector.value(opts[:value])) params = case Map.new(opts) do %{count: _, at: _} -> raise(ArgumentError, message: "Options `count` and `at` can not be used together") %{count: count} -> [expression: "to.have.count", expected_number: count, selector: Selector.build(selector)] _ -> selector = Selector.concat(selector, Selector.at(opts[:at] || 0)) [expression: "to.be.visible", selector: selector] end params = [timeout: timeout(opts)] |> Keyword.merge(other_opts) |> Keyword.merge(params) {:ok, found?} = Frame.expect(conn.frame_id, params) found? end defp validate_assertion_opts!(opts) do opts = NimbleOptions.validate!(opts, @assertion_opts_schema) case Keyword.get(opts, :checked) do checked when is_boolean(checked) or is_nil(checked) -> :ok checked -> raise ArgumentError, "Expected :checked to be true or false, got #{inspect(checked)}" end if Keyword.has_key?(opts, :text) and Keyword.has_key?(opts, :label) do raise ArgumentError, "Cannot provide :label with :text to assertions" end content_opts = Enum.filter(@content_assertion_opts, &Keyword.has_key?(opts, &1)) if length(content_opts) > 1 do raise ArgumentError, "Cannot pass more than one of options :text, :value, :selected, :checked to assertions" end opts end defp checked_selector(nil), do: :none defp checked_selector(true), do: Selector.css(":checked") defp checked_selector(false), do: Selector.css(":not(:checked)") defp selected_selector(nil), do: :none defp selected_selector(selected) do "option:checked" |> Selector.css() |> Selector.concat(Selector.text(to_string(selected), exact: true)) end defp selector_has(selector, :none), do: selector defp selector_has(selector, has_selector), do: Selector.has(selector, has_selector) def assert_download(conn, filename_or_fun) do download = case EventRecorder.pop(conn.download_recorder_pid, timeout()) do {:ok, %{params: params}} -> path = download_artifact(params.artifact.guid) %PhoenixTest.FileDownload{ mime_type: MIME.from_path(params.suggested_filename), name: params.suggested_filename, content: File.read!(path) } {:error, :timeout} -> flunk("No download detected") end case filename_or_fun do filename when is_binary(filename) -> assert download.name == filename fun when is_function(fun, 1) -> fun.(download) end conn end defp download_artifact(artifact_guid) do path = Path.join(System.tmp_dir!(), "phoenix-test-download-#{System.unique_integer([:positive])}") case Artifact.save_as(artifact_guid, path, timeout: timeout()) do :ok -> path {:error, error} -> raise ExUnit.AssertionError, message: "Download failed: #{download_error_message(error)}" end end defp download_error_message(%{message: message}), do: message defp download_error_message(error), do: inspect(error) @doc """ Handle browser dialogs (`alert()`, `confirm()`, `prompt()`) while executing the inner function. *Note:* Add `@tag accept_dialogs: false` before tests that call this function. Otherwise, all dialogs are accepted by default. ## Callback return values The callback may return one of these values: - `:accept` -> accepts confirmation dialog - `{:accept, prompt_text}` -> accepts prompt dialog with text - `:dismiss` -> dismisses dialog - Any other value will ignore the dialog ## Examples @tag accept_dialogs: false test "conditionally handle dialog", %{conn: conn} do conn |> visit("/") |> with_dialog( fn %{message: "Are you sure?"} -> :accept %{message: "Enter the magic number"} -> {:accept, "42"} %{message: "Self destruct?"} -> :dismiss end, fn conn -> conn |> click_button("Delete") |> assert_has(".flash", text: "Deleted") end end) end """ def with_dialog(session, callback, fun) when is_function(callback, 1) and is_function(fun, 1) do event_callback = fn %{params: %{guid: guid, initializer: %{message: message}}} -> {:ok, _} = case callback.(%{guid: guid, message: message}) do :accept -> Dialog.accept(guid, timeout: timeout()) {:accept, prompt_text} -> Dialog.accept(guid, prompt_text: prompt_text, timeout: timeout()) :dismiss -> Dialog.dismiss(guid, timeout: timeout()) _ -> {:ok, :ignore} end end session |> tap(&EventListener.push_callback(&1.dialog_listener_pid, event_callback)) |> fun.() |> tap(&EventListener.pop_callback(&1.dialog_listener_pid)) end @doc false def render_page_title(conn) do case Frame.title(conn.frame_id, timeout: timeout()) do {:ok, ""} -> nil {:ok, title} -> title end end @doc false def render_html(conn) do selector = conn |> maybe_within() |> Selector.build() {:ok, html} = Frame.inner_html(conn.frame_id, selector: selector, timeout: timeout()) LazyHTML.from_document(html) end @doc """ See `click/4`. """ @spec click(t(), selector()) :: t() def click(conn, selector) do conn.frame_id |> Frame.click(selector: selector, timeout: timeout()) |> handle_response(selector) conn end @doc """ Click an element that is not a link or button. Otherwise, use `click_link/4` and `click_button/4`. ## Options #{NimbleOptions.docs(@exact_opts_schema)} ## Examples |> click(Selector.menuitem("Edit")) |> click("summary", "(expand)", exact: false) """ @spec click(t(), selector(), String.t(), [ unquote(NimbleOptions.option_typespec(@exact_opts_schema)) ]) :: t() def click(conn, selector, text, opts \\ []) do opts = NimbleOptions.validate!(opts, @exact_opts_schema) selector = conn |> maybe_within() |> Selector.concat(selector) |> Selector.concat(Selector.text(text, opts)) conn.frame_id |> Frame.click(selector: selector, timeout: timeout(opts)) |> handle_response(selector) conn end @doc """ Like `PhoenixTest.click_link/3`, but allows exact text match. ## Options #{NimbleOptions.docs(@exact_opts_schema)} """ def click_link(conn, selector \\ nil, text, opts \\ []) do opts = NimbleOptions.validate!(opts, @exact_opts_schema) selector = conn |> maybe_within() |> Selector.concat( case selector do nil -> Selector.link(text, opts) css -> css |> Selector.css() |> Selector.concat(Selector.text(text, opts)) end ) |> Selector.build() conn.frame_id |> Frame.click(selector: selector, timeout: timeout(opts)) |> handle_response(selector) conn end @doc """ Like `PhoenixTest.click_button/3`, but allows exact text match. ## Options #{NimbleOptions.docs(@exact_opts_schema)} """ def click_button(conn, selector \\ nil, text, opts \\ []) do opts = NimbleOptions.validate!(opts, @exact_opts_schema) selector = conn |> maybe_within() |> Selector.concat( case selector do nil -> Selector.button(text, opts) css -> css |> Selector.css() |> Selector.concat(Selector.text(text, opts)) end ) |> Selector.build() conn.frame_id |> Frame.click(selector: selector, timeout: timeout(opts)) |> handle_response(selector) conn end @fill_in_opts_schema [ with: [ type: :any, required: true, doc: "Value to fill into the input." ], exact: @exact_opt_schema, timeout: @timeout_opt ] @form_field_opts_schema [exact: @exact_opt_schema, timeout: @timeout_opt] @select_opts_schema [ from: [ type: :string, required: true, doc: "Label of the select field." ], exact: @exact_opt_schema, exact_option: [ type: :boolean, default: true, doc: "Whether to match option text exactly." ], timeout: @timeout_opt ] @doc false def fill_in(conn, css_selector \\ nil, label, opts) do opts = NimbleOptions.validate!(opts, @fill_in_opts_schema) {value, opts} = Keyword.pop!(opts, :with) fun = &Frame.fill(conn.frame_id, selector: &1, value: to_string(value), timeout: timeout(opts)) input(conn, css_selector, label, opts, fun) end @doc false def select(conn, css_selector \\ nil, option_labels, opts) do # PhoenixTest.select/3 passes the option as `option_labels` and leaves this duplicate key in opts. opts = Keyword.delete(opts, :option) opts = NimbleOptions.validate!(opts, @select_opts_schema) if opts[:exact_option] != true, do: raise("exact_option not implemented") {label, opts} = Keyword.pop!(opts, :from) options = option_labels |> List.wrap() |> Enum.map(&%{label: &1}) fun = &Frame.select_option(conn.frame_id, selector: &1, options: options, timeout: timeout(opts)) input(conn, css_selector, label, opts, fun) end @doc false def check(conn, css_selector \\ nil, label, opts) do opts = NimbleOptions.validate!(opts, @form_field_opts_schema) fun = &Frame.check(conn.frame_id, selector: &1, timeout: timeout(opts)) input(conn, css_selector, label, opts, fun) end @doc false def uncheck(conn, css_selector \\ nil, label, opts) do opts = NimbleOptions.validate!(opts, @form_field_opts_schema) fun = &Frame.uncheck(conn.frame_id, selector: &1, timeout: timeout(opts)) input(conn, css_selector, label, opts, fun) end @doc false def choose(conn, css_selector \\ nil, label, opts) do opts = NimbleOptions.validate!(opts, @form_field_opts_schema) fun = &Frame.check(conn.frame_id, selector: &1, timeout: timeout(opts)) input(conn, css_selector, label, opts, fun) end @doc false def upload(conn, css_selector \\ nil, label, paths, opts) do opts = NimbleOptions.validate!(opts, @form_field_opts_schema) paths = paths |> List.wrap() |> Enum.map(&Path.expand/1) fun = &Frame.set_input_files(conn.frame_id, selector: &1, local_paths: paths, timeout: timeout(opts)) input(conn, css_selector, label, opts, fun) end defp input(conn, css_selector, label, opts, fun) do selector = conn |> maybe_within() |> Selector.concat( case css_selector do nil -> Selector.label(label, opts) css -> css |> Selector.css() |> Selector.and(Selector.label(label, opts)) end ) |> Selector.concat("visible=true") |> Selector.build() selector |> fun.() |> handle_response(selector) # trigger phx-change if phx-debounce="blur" Frame.blur(conn.frame_id, selector: selector, timeout: timeout(opts)) %{conn | last_input_selector: selector} end defp maybe_within(conn) do case conn.within do :none -> Selector.none() selector -> selector end end defp handle_response(result, debug_selector) do checkbox_msg = "Clicking the checkbox did not change its state" case result do {:error, %{error: %{name: "TimeoutError"}} = error} -> raise ArgumentError, "Could not find element with selector \"#{debug_selector}\"#{timeout_suffix(error)}\n" <> more_info(error) {:error, %{error: %{message: "Error: strict mode violation: " <> _ = message}}} -> short_message = String.replace(message, "Error: strict mode violation: ", "") raise ArgumentError, "Found more than one element matching selector \"#{debug_selector}\":\n#{short_message}" {:error, %{error: %{name: "Error", message: ^checkbox_msg}}} -> :ok {:ok, result} -> result end end defp timeout_suffix(error) do case Regex.scan(~r/Timeout (\d+)ms exceeded./, error.error[:message] || "") do [[_, timeout]] -> " within #{String.to_integer(timeout)}ms" _ -> "" end end defp more_info(error) do case error[:log] do log when is_list(log) -> "Playwright log:\n" <> Enum.join(log, "\n") _ -> inspect(error, pretty: true) end end @doc false def submit(conn) do conn.frame_id |> Frame.focus(selector: conn.last_input_selector, timeout: timeout()) |> handle_response(conn.last_input_selector) case Frame.evaluate(conn.frame_id, expression: """ () => { const element = document.activeElement; const form = element && element.form; if (!form) { throw new Error("Last interacted element is not associated with a form"); } const submitter = form.querySelector( 'button:not([type]):not(:disabled), button[type="submit"]:not(:disabled), input[type="submit"]:not(:disabled), input[type="image"]:not(:disabled)' ); form.requestSubmit(submitter || undefined); } """, is_function: true, timeout: timeout() ) do {:ok, _} -> :ok {:error, error} -> raise ExUnit.AssertionError, message: "Could not submit form for selector \"#{conn.last_input_selector}\":\n#{more_info(error)}" end conn end @doc false def open_browser(conn, open_fun \\ &OpenBrowser.open_with_system_cmd/1) do path = Path.join([System.tmp_dir!(), "phx-test#{System.unique_integer([:monotonic])}.html"]) # Await any pending navigation Process.sleep(100) {:ok, raw_html} = Frame.content(conn.frame_id, timeout: timeout()) fixed_html = raw_html |> PhoenixTest.Html.parse_document() |> PhoenixTest.Html.postwalk(&OpenBrowser.prefix_static_paths(&1, @endpoint)) |> PhoenixTest.Html.raw() File.write!(path, fixed_html) open_fun.(path) conn end @evaluate_opts_schema [ timeout: @timeout_opt, is_function: [ type: :boolean, default: false, doc: "Whether the expression is a function." ], arg: [ type: :any, default: nil, doc: "Optional argument to pass to the function." ] ] @doc """ Evaluates a JavaScript expression in the page context. When a callback function is given, it receives the JavaScript result. This is useful for assertions or side effects without breaking the pipe chain. ## Options #{NimbleOptions.docs(@evaluate_opts_schema)} ## Examples conn |> evaluate("window.scrollTo(0, document.body.scrollHeight)") conn |> evaluate("selectors => selectors.forEach(s => document.querySelector(s).remove())", is_function: true, arg: ["h1"]) conn |> evaluate("document.title", & assert &1 =~ "Dashboard") """ @spec evaluate(t(), String.t()) :: t() @spec evaluate(t(), String.t(), [unquote(NimbleOptions.option_typespec(@evaluate_opts_schema))] | (any() -> any())) :: t() @spec evaluate(t(), String.t(), [unquote(NimbleOptions.option_typespec(@evaluate_opts_schema))], (any() -> any())) :: t() def evaluate(conn, expression), do: evaluate(conn, expression, [], & &1) def evaluate(conn, expression, fun) when is_function(fun, 1), do: evaluate(conn, expression, [], fun) def evaluate(conn, expression, opts) when is_list(opts), do: evaluate(conn, expression, opts, & &1) def evaluate(conn, expression, opts, fun) when is_list(opts) and is_function(fun, 1) do args = opts |> NimbleOptions.validate!(@evaluate_opts_schema) |> ensure_timeout() |> Keyword.put(:expression, expression) tap(conn, fn conn -> case Frame.evaluate(conn.frame_id, args) do {:ok, value} -> fun.(value) {:error, error} -> raise ExUnit.AssertionError, message: "JavaScript evaluation failed: #{inspect(expression)}\n\n#{inspect(error)}" end end) end @doc """ See `PhoenixTest.unwrap/2`. Invokes `fun` with various Playwright IDs. These can be used to interact with the Playwright [`BrowserContext`](`PlaywrightEx.BrowserContext`), [`Page`](`PlaywrightEx.Page`) and [`Frame`](`PlaywrightEx.Frame`). ## Examples |> unwrap(fn %{page_id: page_id} -> PlaywrightEx.subscribe(page_id) end) """ @spec unwrap(t(), (%{context_id: any(), page_id: any(), frame_id: any()} -> any())) :: t() def unwrap(conn, fun) do tap(conn, &fun.(Map.take(&1, ~w(context_id page_id frame_id)a))) end @doc false def current_path(conn) do case Frame.evaluate(conn.frame_id, expression: "window.location.pathname + window.location.search", timeout: timeout() ) do {:ok, path} -> path {:error, _} -> raise ExUnit.AssertionError, message: "Could not read current path (page may be navigating)" end end defp timeout, do: Config.global(:timeout) defp timeout(opts), do: Keyword.get_lazy(opts, :timeout, &timeout/0) defp ensure_timeout(opts), do: Keyword.put_new_lazy(opts, :timeout, &timeout/0) end defimpl PhoenixTest.Driver, for: PhoenixTest.Playwright do alias PhoenixTest.Playwright defdelegate visit(conn, path), to: Playwright defdelegate reload_page(conn), to: Playwright defdelegate render_page_title(conn), to: Playwright defdelegate render_html(conn), to: Playwright defdelegate within(conn, selector, fun), to: PhoenixTest.SessionHelpers defdelegate click_link(conn, text), to: Playwright defdelegate click_link(conn, selector, text), to: Playwright defdelegate click_button(conn, text), to: Playwright defdelegate click_button(conn, selector, text), to: Playwright defdelegate fill_in(conn, label, opts), to: Playwright defdelegate fill_in(conn, selector, label, opts), to: Playwright defdelegate select(conn, selector, option, opts), to: Playwright defdelegate select(conn, option, opts), to: Playwright defdelegate check(conn, selector, label, opts), to: Playwright defdelegate check(conn, label, opts), to: Playwright defdelegate uncheck(conn, selector, label, opts), to: Playwright defdelegate uncheck(conn, label, opts), to: Playwright defdelegate choose(conn, selector, label, opts), to: Playwright defdelegate choose(conn, label, opts), to: Playwright defdelegate upload(conn, selector, label, path, opts), to: Playwright defdelegate upload(conn, label, path, opts), to: Playwright defdelegate submit(conn), to: Playwright defdelegate open_browser(conn), to: Playwright defdelegate open_browser(conn, open_fun), to: Playwright defdelegate unwrap(conn, fun), to: Playwright defdelegate current_path(conn), to: Playwright defdelegate assert_has(conn, selector), to: Playwright defdelegate assert_has(conn, selector, opts), to: Playwright defdelegate refute_has(conn, selector), to: Playwright defdelegate refute_has(conn, selector, opts), to: Playwright defdelegate assert_download(conn, filename_or_fun), to: Playwright defdelegate assert_path(conn, path), to: Playwright defdelegate assert_path(conn, path, opts), to: Playwright defdelegate refute_path(conn, path), to: Playwright defdelegate refute_path(conn, path, opts), to: Playwright end