From bc5a8b97436d8579d2b12e8e49d76932a113902d Mon Sep 17 00:00:00 2001 From: Tristan Peralta Date: Sat, 17 Jan 2026 17:33:26 +0800 Subject: [PATCH 01/52] Upgrade to Elixir 1.18.4, Erlang 27.3.4.6, Node.js 22.21.1 - Upgrade Elixir from 1.16.2-otp-26 to 1.18.4-otp-27 - Upgrade Erlang from 26.2.5 to 27.3.4.6 - Upgrade Node.js from 22.2.0 to 22.21.1 - Migrate from asdf (.tool-versions) to mise (.mise.toml) - Update CI workflow to use new versions - Fix `mix playwright.install` to support Arch Linux and Ubuntu - Add OS detection to install system dependencies appropriately - Install both Chromium and Firefox browsers explicitly The playwright install task now detects the OS and: - On Arch Linux: Installs dependencies via pacman, then browsers - On Ubuntu/Debian: Uses --with-deps flag as before - On unknown OS: Installs browsers without system dependencies --- .github/workflows/ci.yml | 4 +-- .mise.toml | 4 +++ .tool-versions | 3 -- lib/playwright/sdk/cli.ex | 71 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 .mise.toml delete mode 100644 .tool-versions diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d2ddf78..45139a34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,8 +18,8 @@ jobs: - name: Set up Elixir uses: erlef/setup-beam@v1 with: - elixir-version: '1.16.2' - otp-version: '26.2.5' + elixir-version: '1.18.4' + otp-version: '27.3.4.6' - name: Install Elixir dependencies run: mix deps.get - name: Install Playwright dependencies (e.g., browsers) diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 00000000..797a684e --- /dev/null +++ b/.mise.toml @@ -0,0 +1,4 @@ +[tools] +elixir = "1.18.4-otp-27" +erlang = "27.3.4.6" +nodejs = "22.21.1" diff --git a/.tool-versions b/.tool-versions deleted file mode 100644 index 31fe85c1..00000000 --- a/.tool-versions +++ /dev/null @@ -1,3 +0,0 @@ -elixir 1.16.2-otp-26 -erlang 26.2.5 -nodejs 22.2.0 diff --git a/lib/playwright/sdk/cli.ex b/lib/playwright/sdk/cli.ex index c80881b2..66db185a 100644 --- a/lib/playwright/sdk/cli.ex +++ b/lib/playwright/sdk/cli.ex @@ -8,14 +8,79 @@ defmodule Playwright.SDK.CLI do def install do Logger.info("Installing playwright browsers and dependencies") cli_path = config_cli() || default_cli() - {result, exit_status} = System.cmd(cli_path, ["install", "--with-deps"]) - Logger.info(result) - if exit_status != 0, do: raise("Failed to install playwright browsers") + + case detect_os() do + :arch_linux -> + Logger.info("Detected Arch Linux, installing dependencies via pacman") + install_arch_dependencies() + {result, exit_status} = System.cmd(cli_path, ["install", "chromium", "firefox"]) + Logger.info(result) + if exit_status != 0, do: raise("Failed to install playwright browsers") + + :ubuntu -> + Logger.info("Detected Ubuntu/Debian, using --with-deps") + {result, exit_status} = System.cmd(cli_path, ["install", "--with-deps", "chromium", "firefox"]) + Logger.info(result) + if exit_status != 0, do: raise("Failed to install playwright browsers") + + :unknown -> + Logger.warning("Unknown OS, attempting install without system dependencies") + {result, exit_status} = System.cmd(cli_path, ["install", "chromium", "firefox"]) + Logger.info(result) + if exit_status != 0, do: raise("Failed to install playwright browsers") + end end # private # ---------------------------------------------------------------------------- + defp detect_os do + cond do + File.exists?("/etc/arch-release") -> :arch_linux + File.exists?("/etc/debian_version") or File.exists?("/etc/lsb-release") -> :ubuntu + true -> :unknown + end + end + + defp install_arch_dependencies do + # Playwright browser dependencies for Arch Linux + packages = [ + "nss", + "nspr", + "atk", + "at-spi2-atk", + "cups", + "dbus", + "libxkbcommon", + "libxcomposite", + "libxdamage", + "libxrandr", + "mesa", + "pango", + "cairo", + "alsa-lib", + "libxshmfence" + ] + + Logger.info("Installing system dependencies: #{Enum.join(packages, ", ")}") + + # Check if running as root or with sudo + {result, exit_status} = + if System.get_env("EUID") == "0" or System.get_env("SUDO_USER") do + System.cmd("pacman", ["-S", "--needed", "--noconfirm" | packages], stderr_to_stdout: true) + else + Logger.warning("Not running as root, attempting with sudo") + System.cmd("sudo", ["pacman", "-S", "--needed", "--noconfirm" | packages], stderr_to_stdout: true) + end + + Logger.info(result) + + if exit_status != 0 do + Logger.error("Failed to install system dependencies") + Logger.warning("You may need to install these packages manually: sudo pacman -S #{Enum.join(packages, " ")}") + end + end + defp config_cli do Application.get_env(:playwright, LaunchOptions)[:driver_path] end From 92c7efe2113804a3e22d8a6d67237c9f6d15b5a0 Mon Sep 17 00:00:00 2001 From: Tristan Peralta Date: Sat, 17 Jan 2026 17:41:24 +0800 Subject: [PATCH 02/52] Upgrade Hex dependencies to latest compatible versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - credo: 1.6 → 1.7 - dialyxir: 1.1 → 1.4 - esbuild: 0.8.1 → 0.10 - ex_doc: 0.34 → 0.39 - mix_audit: 1.0 → 2.1 - recase: 0.7 → 0.9 Note: cowlib and gun cannot be upgraded due to playwright_assets constraints requiring cowlib ~> 2.7.3 --- mix.exs | 12 ++++++------ mix.lock | 30 +++++++++++++++--------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/mix.exs b/mix.exs index ad7cadae..3a764c52 100644 --- a/mix.exs +++ b/mix.exs @@ -47,15 +47,15 @@ defmodule Playwright.MixProject do defp deps do [ {:cowlib, "~> 2.7.0"}, - {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.1", only: [:dev, :test], runtime: false}, - {:esbuild, "~> 0.8.1", runtime: Mix.env() == :dev}, - {:ex_doc, "~> 0.34", only: :dev, runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, + {:ex_doc, "~> 0.39", only: :dev, runtime: false}, {:gun, "~> 1.3.3"}, {:jason, "~> 1.4"}, - {:mix_audit, "~> 1.0", only: [:dev, :test], runtime: false}, + {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:playwright_assets, "1.49.1", only: [:test]}, - {:recase, "~> 0.7"}, + {:recase, "~> 0.9"}, {:elixir_uuid, "~> 1.2"} ] end diff --git a/mix.lock b/mix.lock index b0b2aba4..01389a7f 100644 --- a/mix.lock +++ b/mix.lock @@ -3,29 +3,29 @@ "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e5580029080f3f1ad17436fb97b0d5ed2ed4e4815a96bac36b5a992e20f58db6"}, "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm", "1e1a3d176d52daebbecbbcdfd27c27726076567905c2a9d7398c54da9d225761"}, - "credo": {:hex, :credo, "1.7.6", "b8f14011a5443f2839b04def0b252300842ce7388f3af177157c86da18dfbeea", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "146f347fb9f8cbc5f7e39e3f22f70acbef51d441baa6d10169dd604bfbc55296"}, - "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, - "ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"}, - "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, + "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "gun": {:hex, :gun, "1.3.3", "cf8b51beb36c22b9c8df1921e3f2bc4d2b1f68b49ad4fbc64e91875aa14e16b4", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "3106ce167f9c9723f849e4fb54ea4a4d814e3996ae243a1c828b256e749041e0"}, - "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, - "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, - "mix_audit": {:hex, :mix_audit, "1.0.1", "9dd114408961b8db214f42fee40b2f632ecd7e4fd29500403068c82c77db8361", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.8.0", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "65066bb7757078aa49faaa2f7c1e2d52f56ff6fe6cff01723dbaf5be2a75771b"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, + "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "playwright_assets": {:hex, :playwright_assets, "1.49.1", "22f633af14bf2c16a4dcf64c9e08c21fe6e16750705ac7767f07797faf4d5756", [:mix], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:plug, "~> 1.12", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.1.3", [hex: :plug_cowboy, repo: "hexpm", optional: false]}], "hexpm", "688727f5bcbf8d6b8b83f5febc207130c83fb7e00e83853ab8c9d75c9a1642d4"}, "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, "plug_cowboy": {:hex, :plug_cowboy, "2.1.3", "38999a3e85e39f0e6bdfdf820761abac61edde1632cfebbacc445cdcb6ae1333", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "056f41f814dbb38ea44613e0f613b3b2b2f2c6afce64126e252837669eba84db"}, "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, - "recase": {:hex, :recase, "0.8.0", "ec9500abee5d493d41e3cbfd7d51a4e10957a164570be0c805d5c6661b8cdbae", [:mix], [], "hexpm", "0d4b67b81e7897af77552bd1e6d6148717a4b45ec5c7b014a48b0ba9a28946b5"}, + "recase": {:hex, :recase, "0.9.1", "82d2e2e2d4f9e92da1ce5db338ede2e4f15a50ac1141fc082b80050b9f49d96e", [:mix], [], "hexpm", "19ba03ceb811750e6bec4a015a9f9e45d16a8b9e09187f6d72c3798f454710f3"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, - "yaml_elixir": {:hex, :yaml_elixir, "2.8.0", "c7ff0034daf57279c2ce902788ce6fdb2445532eb4317e8df4b044209fae6832", [:mix], [{:yamerl, "~> 0.8", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "4b674bd881e373d1ac6a790c64b2ecb69d1fd612c2af3b22de1619c15473830b"}, + "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, } From 63d2d0caaa3fd9c59b5904f2d3977a2dc11bc2c2 Mon Sep 17 00:00:00 2001 From: Tristan Peralta Date: Sat, 17 Jan 2026 18:00:16 +0800 Subject: [PATCH 03/52] Upgrade cowlib, gun, and related dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use forked playwright_assets (github: tristanperalta/playwright-assets) to unblock dependency upgrades - Upgrade cowlib 2.7.3 → 2.16.0, gun 1.3.3 → 2.2.0, cowboy 2.6.3 → 2.14.2 - Fix gun 2.x API: ws_send/2 → ws_send/3 (requires stream_ref) - Fix regex route patterns: use camelCase keys (regexSource/regexFlags) and convert Elixir regex opts to JS flags - Rename unit_test.ex → unit_case.ex to silence ExUnit warning --- lib/playwright/sdk/helpers/route_handler.ex | 16 ++++++++++++++-- lib/playwright/sdk/transport/websocket.ex | 12 +++++++----- mix.exs | 6 +++--- mix.lock | 21 +++++++++++---------- test/support/{unit_test.ex => unit_case.ex} | 0 5 files changed, 35 insertions(+), 20 deletions(-) rename test/support/{unit_test.ex => unit_case.ex} (100%) diff --git a/lib/playwright/sdk/helpers/route_handler.ex b/lib/playwright/sdk/helpers/route_handler.ex index e06da5a2..4bbb9afa 100644 --- a/lib/playwright/sdk/helpers/route_handler.ex +++ b/lib/playwright/sdk/helpers/route_handler.ex @@ -40,8 +40,20 @@ defmodule Playwright.SDK.Helpers.RouteHandler do defp prepare_matcher(%URLMatcher{regex: %Regex{} = regex}) do %{ - regex_source: Regex.source(regex), - regex_flags: Regex.opts(regex) + regexSource: Regex.source(regex), + regexFlags: regex_opts_to_flags(Regex.opts(regex)) } end + + defp regex_opts_to_flags(opts) do + opts + |> Enum.map(fn + :caseless -> "i" + :multiline -> "m" + :dotall -> "s" + :unicode -> "u" + _ -> "" + end) + |> Enum.join() + end end diff --git a/lib/playwright/sdk/transport/websocket.ex b/lib/playwright/sdk/transport/websocket.ex index 69dcff3d..f9131abf 100644 --- a/lib/playwright/sdk/transport/websocket.ex +++ b/lib/playwright/sdk/transport/websocket.ex @@ -5,7 +5,8 @@ defmodule Playwright.SDK.Transport.WebSocket do defstruct([ :process, - :monitor + :monitor, + :stream_ref ]) # module API @@ -16,21 +17,22 @@ defmodule Playwright.SDK.Transport.WebSocket do with {:ok, process} <- :gun.open(to_charlist(uri.host), port(uri), %{connect_timeout: 30_000}), {:ok, _protocol} <- :gun.await_up(process, :timer.seconds(5)), - {:ok, _stream_ref} <- ws_upgrade(process, uri.path), + {:ok, stream_ref} <- ws_upgrade(process, uri.path), :ok <- wait_for_ws_upgrade() do monitor = Process.monitor(process) %__MODULE__{ process: process, - monitor: monitor + monitor: monitor, + stream_ref: stream_ref } else error -> error end end - def post(message, %{process: process}) do - :gun.ws_send(process, {:text, message}) + def post(message, %{process: process, stream_ref: stream_ref}) do + :gun.ws_send(process, stream_ref, {:text, message}) end def parse({:gun_ws, _process, _stream_ref, {:text, message}}, state) do diff --git a/mix.exs b/mix.exs index 3a764c52..83b3ef11 100644 --- a/mix.exs +++ b/mix.exs @@ -46,15 +46,15 @@ defmodule Playwright.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:cowlib, "~> 2.7.0"}, + {:cowlib, "~> 2.16"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, {:esbuild, "~> 0.10", runtime: Mix.env() == :dev}, {:ex_doc, "~> 0.39", only: :dev, runtime: false}, - {:gun, "~> 1.3.3"}, + {:gun, "~> 2.2"}, {:jason, "~> 1.4"}, {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, - {:playwright_assets, "1.49.1", only: [:test]}, + {:playwright_assets, github: "tristanperalta/playwright-assets", only: [:test]}, {:recase, "~> 0.9"}, {:elixir_uuid, "~> 1.2"} ] diff --git a/mix.lock b/mix.lock index 01389a7f..5d6d0dff 100644 --- a/mix.lock +++ b/mix.lock @@ -1,8 +1,9 @@ %{ "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "castore": {:hex, :castore, "1.0.7", "b651241514e5f6956028147fe6637f7ac13802537e895a724f90bf3e36ddd1dd", [:mix], [], "hexpm", "da7785a4b0d2a021cd1292a60875a784b6caef71e76bf4917bdee1f390455cf5"}, - "cowboy": {:hex, :cowboy, "2.6.3", "99aa50e94e685557cad82e704457336a453d4abcb77839ad22dbe71f311fcc06", [:rebar3], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e5580029080f3f1ad17436fb97b0d5ed2ed4e4815a96bac36b5a992e20f58db6"}, - "cowlib": {:hex, :cowlib, "2.7.3", "a7ffcd0917e6d50b4d5fb28e9e2085a0ceb3c97dea310505f7460ff5ed764ce9", [:rebar3], [], "hexpm", "1e1a3d176d52daebbecbbcdfd27c27726076567905c2a9d7398c54da9d225761"}, + "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, + "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, + "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, "credo": {:hex, :credo, "1.7.15", "283da72eeb2fd3ccf7248f4941a0527efb97afa224bcdef30b4b580bc8258e1c", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "291e8645ea3fea7481829f1e1eb0881b8395db212821338e577a90bf225c5607"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, @@ -11,21 +12,21 @@ "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "ex_doc": {:hex, :ex_doc, "0.39.3", "519c6bc7e84a2918b737aec7ef48b96aa4698342927d080437f61395d361dcee", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "0590955cf7ad3b625780ee1c1ea627c28a78948c6c0a9b0322bd976a079996e1"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, - "gun": {:hex, :gun, "1.3.3", "cf8b51beb36c22b9c8df1921e3f2bc4d2b1f68b49ad4fbc64e91875aa14e16b4", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "3106ce167f9c9723f849e4fb54ea4a4d814e3996ae243a1c828b256e749041e0"}, + "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, - "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, - "playwright_assets": {:hex, :playwright_assets, "1.49.1", "22f633af14bf2c16a4dcf64c9e08c21fe6e16750705ac7767f07797faf4d5756", [:mix], [{:cowlib, "~> 2.7.3", [hex: :cowlib, repo: "hexpm", optional: false]}, {:plug, "~> 1.12", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.1.3", [hex: :plug_cowboy, repo: "hexpm", optional: false]}], "hexpm", "688727f5bcbf8d6b8b83f5febc207130c83fb7e00e83853ab8c9d75c9a1642d4"}, - "plug": {:hex, :plug, "1.16.0", "1d07d50cb9bb05097fdf187b31cf087c7297aafc3fed8299aac79c128a707e47", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbf53aa1f5c4d758a7559c0bd6d59e286c2be0c6a1fac8cc3eee2f638243b93e"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.1.3", "38999a3e85e39f0e6bdfdf820761abac61edde1632cfebbacc445cdcb6ae1333", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "056f41f814dbb38ea44613e0f613b3b2b2f2c6afce64126e252837669eba84db"}, - "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, - "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, + "playwright_assets": {:git, "https://github.com/tristanperalta/playwright-assets.git", "4ca3cda19af20672d987189e3ac82a3e7c2364eb", []}, + "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.7.5", "261f21b67aea8162239b2d6d3b4c31efde4daa22a20d80b19c2c0f21b34b270e", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "20884bf58a90ff5a5663420f5d2c368e9e15ed1ad5e911daf0916ea3c57f77ac"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "recase": {:hex, :recase, "0.9.1", "82d2e2e2d4f9e92da1ce5db338ede2e4f15a50ac1141fc082b80050b9f49d96e", [:mix], [], "hexpm", "19ba03ceb811750e6bec4a015a9f9e45d16a8b9e09187f6d72c3798f454710f3"}, - "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, "yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"}, } diff --git a/test/support/unit_test.ex b/test/support/unit_case.ex similarity index 100% rename from test/support/unit_test.ex rename to test/support/unit_case.ex From 82d4d573c7f94e72735060095d464f9dd0812aee Mon Sep 17 00:00:00 2001 From: Tristan Peralta Date: Sat, 17 Jan 2026 18:09:44 +0800 Subject: [PATCH 04/52] Fix credo --strict issues and add contributing guide Credo fixes: - route_handler.ex: Use Enum.map_join instead of Enum.map |> Enum.join - config.ex: Remove parentheses from zero-arity function definitions - page.ex: Alphabetize alias groups - channel.ex: Extract async_evaluate/2 to reduce nesting depth - frame.ex: Split select_option_values into pattern-matched clauses - .credo.exs: Disable PredicateFunctionNames (is_* matches Playwright API) Documentation: - Add man/guides/contributing.md documenting known issues and improvements - Add contributing guide to mix.exs docs config --- .credo.exs | 3 +- lib/playwright/frame.ex | 28 +--- lib/playwright/page.ex | 3 +- lib/playwright/sdk/channel.ex | 10 +- lib/playwright/sdk/config.ex | 6 +- lib/playwright/sdk/helpers/route_handler.ex | 4 +- man/guides/contributing.md | 175 ++++++++++++++++++++ mix.exs | 1 + 8 files changed, 197 insertions(+), 33 deletions(-) create mode 100644 man/guides/contributing.md diff --git a/.credo.exs b/.credo.exs index dfa26a9c..1f43a5a8 100644 --- a/.credo.exs +++ b/.credo.exs @@ -103,7 +103,8 @@ {Credo.Check.Readability.ModuleNames, []}, {Credo.Check.Readability.ParenthesesInCondition, []}, {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, - {Credo.Check.Readability.PredicateFunctionNames, []}, + # Disabled: is_* functions match Playwright's JavaScript API for consistency + {Credo.Check.Readability.PredicateFunctionNames, false}, {Credo.Check.Readability.PreferImplicitTry, []}, {Credo.Check.Readability.RedundantBlankLines, []}, {Credo.Check.Readability.Semicolons, []}, diff --git a/lib/playwright/frame.ex b/lib/playwright/frame.ex index e635054c..3602f063 100644 --- a/lib/playwright/frame.ex +++ b/lib/playwright/frame.ex @@ -996,26 +996,16 @@ defmodule Playwright.Frame do select_option_values([values]) end + defp select_option_values([]) do + %{} + end + + defp select_option_values([%ElementHandle{} | _] = values) do + %{elements: Enum.map(values, &select_option_value/1)} + end + defp select_option_values(values) when is_list(values) do - if Enum.empty?(values) do - %{} - else - if is_struct(List.first(values), ElementHandle) do - elements = - Enum.into(values, [], fn value -> - select_option_value(value) - end) - - %{elements: elements} - else - options = - Enum.into(values, [], fn value -> - select_option_value(value) - end) - - %{options: options} - end - end + %{options: Enum.map(values, &select_option_value/1)} end defp select_option_value(value) when is_binary(value) do diff --git a/lib/playwright/page.ex b/lib/playwright/page.ex index 0d27158a..3369f26f 100644 --- a/lib/playwright/page.ex +++ b/lib/playwright/page.ex @@ -40,9 +40,8 @@ defmodule Playwright.Page do """ use Playwright.SDK.ChannelOwner - alias Playwright.SDK.Channel alias Playwright.{BrowserContext, ElementHandle, Frame, Page, Response} - alias Playwright.SDK.{ChannelOwner, Helpers} + alias Playwright.SDK.{Channel, ChannelOwner, Helpers} @property :bindings @property :is_closed diff --git a/lib/playwright/sdk/channel.ex b/lib/playwright/sdk/channel.ex index 5c168a61..df42f60f 100644 --- a/lib/playwright/sdk/channel.ex +++ b/lib/playwright/sdk/channel.ex @@ -82,11 +82,7 @@ defmodule Playwright.SDK.Channel do if predicate do with_timeout(options, fn timeout -> - task = - Task.async(fn -> - evaluate(predicate, event.target, event) - end) - + task = async_evaluate(predicate, event) Task.await(task, timeout) end) else @@ -94,6 +90,10 @@ defmodule Playwright.SDK.Channel do end end + defp async_evaluate(predicate, event) do + Task.async(fn -> evaluate(predicate, event.target, event) end) + end + defp evaluate(predicate, resource, event) do case predicate.(resource, event) do false -> diff --git a/lib/playwright/sdk/config.ex b/lib/playwright/sdk/config.ex index a0ba5485..b20aa389 100644 --- a/lib/playwright/sdk/config.ex +++ b/lib/playwright/sdk/config.ex @@ -239,20 +239,20 @@ defmodule Playwright.SDK.Config do @doc false @spec connect_options() :: connect_options - def connect_options() do + def connect_options do config_for(ConnectOptions, %Types.ConnectOptions{}) || %{} end @doc false @spec launch_options() :: map() - def launch_options() do + def launch_options do config_for(LaunchOptions, %Types.LaunchOptions{}) || %{} # |> clean() end @doc false @spec playwright_test() :: Types.PlaywrightTest - def playwright_test() do + def playwright_test do config_for(PlaywrightTest, %Types.PlaywrightTest{}) # |> Map.from_struct() end diff --git a/lib/playwright/sdk/helpers/route_handler.ex b/lib/playwright/sdk/helpers/route_handler.ex index 4bbb9afa..16e09b2e 100644 --- a/lib/playwright/sdk/helpers/route_handler.ex +++ b/lib/playwright/sdk/helpers/route_handler.ex @@ -46,14 +46,12 @@ defmodule Playwright.SDK.Helpers.RouteHandler do end defp regex_opts_to_flags(opts) do - opts - |> Enum.map(fn + Enum.map_join(opts, "", fn :caseless -> "i" :multiline -> "m" :dotall -> "s" :unicode -> "u" _ -> "" end) - |> Enum.join() end end diff --git a/man/guides/contributing.md b/man/guides/contributing.md new file mode 100644 index 00000000..ca4b6da5 --- /dev/null +++ b/man/guides/contributing.md @@ -0,0 +1,175 @@ +# Contributing + +This document outlines known issues, improvement opportunities, and areas where contributions are welcome. + +## Critical Issues + +These should be addressed with priority: + +### Error Handling in `Browser.new_page/2` + +**File:** `lib/playwright/browser.ex:155` + +The `new_page/2` function doesn't handle errors from `new_context/2` or `BrowserContext.new_page/1`: + +```elixir +def new_page(%Browser{session: session} = browser, options) do + context = new_context(browser, options) + page = BrowserContext.new_page(context) + # crashes if context or page is an error tuple +end +``` + +**Fix:** Wrap in `with` statement to handle error tuples. + +### Unsafe Atom Creation in `Page.on/3` + +**File:** `lib/playwright/page.ex:501` + +```elixir +def on(%Page{} = page, event, callback) when is_binary(event) do + on(page, String.to_atom(event), callback) +end +``` + +**Risk:** Arbitrary string-to-atom conversion can exhaust atom table. + +**Fix:** Validate against known event atoms or use `String.to_existing_atom/1`. + +### Module Resolution Exit + +**File:** `lib/playwright/sdk/channel_owner.ex:133` + +```elixir +defp module(%{type: type}) do + String.to_existing_atom("Elixir.Playwright.#{type}") +rescue + ArgumentError -> + exit("ChannelOwner of type #{inspect(type)} is not yet defined") +end +``` + +**Fix:** Return `{:error, reason}` instead of calling `exit/1`. + +## Code Quality Improvements + +### Naive Glob Implementation + +**File:** `lib/playwright/sdk/helpers/url_matcher.ex:50` + +Current implementation only handles `**` patterns: + +```elixir +defp glob_to_regex(pattern) do + String.replace(pattern, ~r/\*{2,}/, ".*") +end +``` + +**Missing:** `*` (single segment), `?` (single char), `[abc]` (character classes). + +**Suggestion:** Use a proper glob library like [path_glob](https://github.com/jonleighton/path_glob). + +### Duplicate Channel.find Call + +**File:** `lib/playwright/sdk/channel_owner.ex:108` + +```elixir +defp with_latest(subject, task) do + Channel.find(subject.session, {:guid, subject.guid}) |> task.() + Channel.find(subject.session, {:guid, subject.guid}) # Called twice +end +``` + +**Fix:** Store result of first call and return it. + +### HACK Comments + +These indicate fragile code that may break with Playwright updates: + +| File | Line | Description | +|------|------|-------------| +| `lib/playwright/route.ex` | 24, 47 | Workaround for v1.33.0 changes | +| `lib/playwright/page.ex` | 513 | Event name conversion hack | + +## Dead Code + +These modules are empty stubs and can be removed: + +| File | Notes | +|------|-------| +| `lib/playwright/local_utils.ex` | Marked "obsolete?" - 6 lines | +| `lib/playwright/fetch_request.ex` | Marked "obsolete?" - 6 lines | + +## Unimplemented Features + +### Config Options + +**File:** `lib/playwright/sdk/config.ex` + +These options are documented but silently ignored: + +- `env` - Environment variables for browser process +- `downloads_path` - Custom downloads directory + +### Skipped Tests + +| File | Reason | +|------|--------| +| `test/api/page/accessibility_test.exs` | Needs `Page.wait_for_function` implementation | +| `test/api/browser_context/expect_test.exs` | Multiple tests unreachable | + +## TODO/FIXME Items + +| File | Line | Comment | +|------|------|---------| +| `lib/playwright/route.ex` | 22 | "figure out what's up with is_fallback" | +| `lib/playwright/browser.ex` | 159 | "handle the following, for page" | +| `lib/playwright/frame.ex` | 934 | FIXME: incorrect documentation | +| `lib/playwright/sdk/helpers/url_matcher.ex` | 49 | Replace with proper glob library | +| `lib/playwright/api_request_context.ex` | 66 | "move to APIResponse.body, probably" | +| `lib/playwright/sdk/channel/event.ex` | 14 | "consider promoting params as top-level fields" | + +## Refactoring Candidates + +Large files that could benefit from splitting: + +| File | Lines | Suggestion | +|------|-------|------------| +| `lib/playwright/locator.ex` | 1365 | Split into Locator.Input, Locator.Navigation, etc. | +| `lib/playwright/frame.ex` | 1044 | Extract common patterns | +| `lib/playwright/page.ex` | 778 | Well-organized but large | + +## Documentation Gaps + +- ~516 public functions lack `@doc` annotations +- Many SDK modules have `@moduledoc false` but could use internal docs +- Commented-out function stubs (527 total) indicate unimplemented API surface + +## Running Tests + +```bash +# Run all tests +mix test + +# Run with browser visible +PLAYWRIGHT_HEADLESS=false mix test + +# Run specific test file +mix test test/api/page_test.exs +``` + +## Code Style + +The project uses: + +- `mix format` for formatting +- `mix credo` for linting +- `mix dialyzer` for type checking + +Run all checks before submitting PRs: + +```bash +mix format --check-formatted +mix credo --strict +mix dialyzer +``` diff --git a/mix.exs b/mix.exs index 83b3ef11..48985f22 100644 --- a/mix.exs +++ b/mix.exs @@ -84,6 +84,7 @@ defmodule Playwright.MixProject do "man/guides/browsers.md": [filename: "guides-browsers"], "man/guides/chrome-extensions.md": [filename: "guides-chrome-extensions"], "man/guides/command-line-tools.md": [filename: "guides-command-line-tools"], + "man/guides/contributing.md": [filename: "guides-contributing"], "man/guides/dialogs.md": [filename: "guides-dialogs"], "man/guides/downloads.md": [filename: "guides-downloads"], "man/guides/emulation.md": [filename: "guides-emulation"], From 553cf26c6d94626f4100ccc37ef67bd6f3264747 Mon Sep 17 00:00:00 2001 From: Tristan Peralta Date: Sat, 17 Jan 2026 18:16:22 +0800 Subject: [PATCH 05/52] Add precommit task and fix dialyzer errors Dialyzer fixes: - Fix @spec for BrowserContext.route/4, unroute/3, Page.route/4 - Return type changed from :ok to t() | {:error, Channel.Error.t()} - Added Regex.t() to pattern parameter types New precommit task (mix precommit): - compile --warnings-as-errors - format --check-formatted - credo --strict - dialyzer - test Also: - Fix trailing blank lines (mix format) --- lib/playwright/browser_context.ex | 4 ++-- lib/playwright/locator.ex | 5 +++-- lib/playwright/page.ex | 2 +- lib/playwright/page/coverage.ex | 1 - lib/playwright/page/download.ex | 1 - lib/playwright/page/file_chooser.ex | 1 - lib/playwright/page/mouse.ex | 1 - lib/playwright/page/touchscreen.ex | 1 - lib/playwright/page/video.ex | 1 - mix.exs | 9 ++++++++- test/api/locator_test.exs | 3 +++ 11 files changed, 17 insertions(+), 12 deletions(-) diff --git a/lib/playwright/browser_context.ex b/lib/playwright/browser_context.ex index 4324ef5b..033e49dc 100644 --- a/lib/playwright/browser_context.ex +++ b/lib/playwright/browser_context.ex @@ -533,7 +533,7 @@ defmodule Playwright.BrowserContext do Channel.list(context.session, {:guid, context.guid}, "Page") end - @spec route(t(), binary(), function(), map()) :: :ok + @spec route(t(), binary() | Regex.t(), function(), map()) :: t() | {:error, Channel.Error.t()} def route(context, pattern, handler, options \\ %{}) def route(%BrowserContext{session: session} = context, pattern, handler, _options) do @@ -593,7 +593,7 @@ defmodule Playwright.BrowserContext do # --- - @spec unroute(t(), binary(), function() | nil) :: :ok + @spec unroute(t(), binary() | Regex.t(), function() | nil) :: t() | {:error, Channel.Error.t()} def unroute(%BrowserContext{session: session} = context, pattern, callback \\ nil) do with_latest(context, fn context -> remaining = diff --git a/lib/playwright/locator.ex b/lib/playwright/locator.ex index 99eca22c..9bc502fc 100644 --- a/lib/playwright/locator.ex +++ b/lib/playwright/locator.ex @@ -907,10 +907,11 @@ defmodule Playwright.Locator do This implements the `or` function for locators, but `or` is not an allowed function name in elixir. """ - @spec or_(Locator.t(), Locator.t()) :: Locator.t() + @spec or_(Locator.t(), Locator.t()) :: Locator.t() def or_(%Locator{frame: frame} = locator, %Locator{frame: frame} = other) do - new(frame, locator.selector <> ">> internal:or=" <> Jason.encode!(other.selector)) + new(frame, locator.selector <> ">> internal:or=" <> Jason.encode!(other.selector)) end + def or_(_, _) do raise ArgumentError, "Locators must belong to the same frame" end diff --git a/lib/playwright/page.ex b/lib/playwright/page.ex index 3369f26f..1765980e 100644 --- a/lib/playwright/page.ex +++ b/lib/playwright/page.ex @@ -590,7 +590,7 @@ defmodule Playwright.Page do |> List.first() end - @spec route(t(), binary(), function(), map()) :: :ok + @spec route(t(), binary() | Regex.t(), function(), map()) :: t() | {:error, Channel.Error.t()} def route(page, pattern, handler, options \\ %{}) def route(%Page{session: session} = page, pattern, handler, _options) do diff --git a/lib/playwright/page/coverage.ex b/lib/playwright/page/coverage.ex index 824377ca..7a1ac7ff 100644 --- a/lib/playwright/page/coverage.ex +++ b/lib/playwright/page/coverage.ex @@ -12,5 +12,4 @@ defmodule Playwright.Coverage do # @spec stop_js_coverage(t()) :: result() # def stop_js_coverage(coverage) - end diff --git a/lib/playwright/page/download.ex b/lib/playwright/page/download.ex index 5ac99516..67f136a2 100644 --- a/lib/playwright/page/download.ex +++ b/lib/playwright/page/download.ex @@ -27,5 +27,4 @@ defmodule Playwright.Download do # @spec url(t()) :: binary() # def url(download) - end diff --git a/lib/playwright/page/file_chooser.ex b/lib/playwright/page/file_chooser.ex index c6e25648..e864366e 100644 --- a/lib/playwright/page/file_chooser.ex +++ b/lib/playwright/page/file_chooser.ex @@ -12,5 +12,4 @@ defmodule Playwright.FileChooser do # @spec set_files(t(), any(), options()) :: :ok # def cancel(file_chooser, files, options \\ %{}) - end diff --git a/lib/playwright/page/mouse.ex b/lib/playwright/page/mouse.ex index 2e9dd318..1fc0e93f 100644 --- a/lib/playwright/page/mouse.ex +++ b/lib/playwright/page/mouse.ex @@ -18,5 +18,4 @@ defmodule Playwright.Mouse do # @spec wheel(t(), number(), number()) :: :ok # def wheel(mouse, delta_x, delta_y) - end diff --git a/lib/playwright/page/touchscreen.ex b/lib/playwright/page/touchscreen.ex index 4ec7c2a5..5a40675a 100644 --- a/lib/playwright/page/touchscreen.ex +++ b/lib/playwright/page/touchscreen.ex @@ -3,5 +3,4 @@ defmodule Playwright.Touchscreen do # @spec tap(t(), number(), number()) :: :ok # def tap(touchscreen, x, y) - end diff --git a/lib/playwright/page/video.ex b/lib/playwright/page/video.ex index e51a6b42..e7151a44 100644 --- a/lib/playwright/page/video.ex +++ b/lib/playwright/page/video.ex @@ -9,5 +9,4 @@ defmodule Playwright.Video do # @spec save_as(t(), binary()) :: :ok # def save_as(video, path) - end diff --git a/mix.exs b/mix.exs index 48985f22..12e947c2 100644 --- a/mix.exs +++ b/mix.exs @@ -17,7 +17,7 @@ defmodule Playwright.MixProject do elixirc_paths: elixirc_paths(Mix.env()), homepage_url: @source_url, package: package(), - preferred_cli_env: [credo: :test, dialyzer: :test, docs: :docs], + preferred_cli_env: [credo: :test, dialyzer: :test, docs: :docs, precommit: :test], source_url: @source_url, start_permanent: Mix.env() == :prod, version: "1.49.1-alpha.2" @@ -160,6 +160,13 @@ defmodule Playwright.MixProject do [ "assets.build": [ "cmd echo 'NOT IMPLEMENTED'" + ], + precommit: [ + "compile --warnings-as-errors", + "format --check-formatted", + "credo --strict", + "dialyzer", + "test" ] ] end diff --git a/test/api/locator_test.exs b/test/api/locator_test.exs index 34a0dc7d..4b860b9a 100644 --- a/test/api/locator_test.exs +++ b/test/api/locator_test.exs @@ -678,12 +678,15 @@ defmodule Playwright.LocatorTest do on_exit(:ok, fn -> Playwright.Page.close(other_page) end) + page |> Page.set_content("
") div_locator = Page.locator(page, "div") + other_page |> Page.set_content("") + span_locator = Page.locator(other_page, "span") assert_raise ArgumentError, "Locators must belong to the same frame", fn -> From 5aee2e42b878a66fd33b438daf1d54d39c2c6ef1 Mon Sep 17 00:00:00 2001 From: Tristan Peralta Date: Sat, 17 Jan 2026 18:39:41 +0800 Subject: [PATCH 06/52] fix!: Add error handling to Browser.new_page/2 and fix related issues Browser.new_page/2 now returns {:ok, Page.t()} instead of Page.t() Changes: - Browser.new_page/2 now returns {:ok, page} tuple with proper error handling - Page.on/3 validates events against whitelist to prevent atom exhaustion - ChannelOwner.module/1 uses reraise instead of exit() for proper error handling - Page.close/1 uses latest channel state for owned_context check - Page.request/1 fetches fresh page state for owned_context access - Remove dead code: FetchRequest module (not in modern Playwright API) - Fix with_latest/2 to explicitly discard first find result Test updates: - Update all tests to use {:ok, page} pattern matching - Use Page.owned_context(page) instead of page.owned_context field access - Set async: false for browser/context tests needing exclusive access --- lib/playwright/browser.ex | 18 +++++++-------- lib/playwright/fetch_request.ex | 6 ----- lib/playwright/page.ex | 27 ++++++++++++++++++----- lib/playwright/sdk/channel_owner.ex | 8 +++---- lib/playwright_test/case.ex | 6 ++--- test/api/browser_context/cookies_test.exs | 2 +- test/api/browser_context_test.exs | 4 ++-- test/api/browser_test.exs | 4 ++-- test/api/locator_test.exs | 2 +- test/api/page_test.exs | 10 ++++----- test/playwright_test.exs | 24 +++++++++----------- 11 files changed, 58 insertions(+), 53 deletions(-) delete mode 100644 lib/playwright/fetch_request.ex diff --git a/lib/playwright/browser.ex b/lib/playwright/browser.ex index e741ba7a..e3be4344 100644 --- a/lib/playwright/browser.ex +++ b/lib/playwright/browser.ex @@ -149,19 +149,17 @@ defmodule Playwright.Browser do `Playwright.BrowserContext.new_page/2`, given the new context, to manage resource lifecycles. """ - @spec new_page(t(), options()) :: Page.t() + @spec new_page(t(), options()) :: {:ok, Page.t()} | {:error, Channel.Error.t()} def new_page(browser, options \\ %{}) def new_page(%Browser{session: session} = browser, options) do - context = new_context(browser, options) - page = BrowserContext.new_page(context) - - # TODO: handle the following, for `page`: - # ** (KeyError) key :guid not found in: {:error, %Playwright.Channel.Error{message: "Target closed"}} - - # establish co-dependency - Channel.patch(session, {:guid, context.guid}, %{owner_page: page}) - Channel.patch(session, {:guid, page.guid}, %{owned_context: context}) + with context when is_struct(context) <- new_context(browser, options), + page when is_struct(page) <- BrowserContext.new_page(context) do + # establish co-dependency + Channel.patch(session, {:guid, context.guid}, %{owner_page: page}) + Channel.patch(session, {:guid, page.guid}, %{owned_context: context}) + {:ok, page} + end end # --- diff --git a/lib/playwright/fetch_request.ex b/lib/playwright/fetch_request.ex deleted file mode 100644 index 655a4f9b..00000000 --- a/lib/playwright/fetch_request.ex +++ /dev/null @@ -1,6 +0,0 @@ -defmodule Playwright.FetchRequest do - @moduledoc false - use Playwright.SDK.ChannelOwner - - # obsolete? -end diff --git a/lib/playwright/page.ex b/lib/playwright/page.ex index 1765980e..1c4fccff 100644 --- a/lib/playwright/page.ex +++ b/lib/playwright/page.ex @@ -49,6 +49,13 @@ defmodule Playwright.Page do @property :owned_context @property :routes + @valid_events ~w( + close console crash dialog domcontentloaded download + filechooser frameattached framedetached framenavigated + load pageerror popup request response request_finished + request_failed websocket worker + )a + # --- # @property :coverage # @property :keyboard @@ -201,13 +208,14 @@ defmodule Playwright.Page do # A call to `close` will remove the item from the catalog. `Catalog.find` # here ensures that we do not `post` a 2nd `close`. case Channel.find(session, {:guid, page.guid}, %{timeout: 10}) do - %Page{} -> + %Page{} = latest_page -> Channel.post(session, {:guid, page.guid}, :close, options) # NOTE: this *might* prefer to be done on `__dispose__` # ...OR, `.on(_, "close", _)` - if page.owned_context do - context(page) |> BrowserContext.close() + # Use latest_page to get patched owned_context field + if latest_page.owned_context do + context(latest_page) |> BrowserContext.close() end :ok @@ -498,7 +506,13 @@ defmodule Playwright.Page do # - worker def on(%Page{} = page, event, callback) when is_binary(event) do - on(page, String.to_atom(event), callback) + atom = String.to_atom(event) + + if atom in @valid_events do + on(page, atom, callback) + else + {:error, %ArgumentError{message: "Invalid Page event: #{event}"}} + end end # NOTE: These events will be recv'd from Playwright server with the parent @@ -586,7 +600,10 @@ defmodule Playwright.Page do @spec request(t()) :: Playwright.APIRequestContext.t() def request(%Page{session: session} = page) do - Channel.list(session, {:guid, page.owned_context.browser.guid}, "APIRequestContext") + # Fetch latest page state to get patched owned_context field + fresh_page = Channel.find(session, {:guid, page.guid}) + + Channel.list(session, {:guid, fresh_page.owned_context.browser.guid}, "APIRequestContext") |> List.first() end diff --git a/lib/playwright/sdk/channel_owner.ex b/lib/playwright/sdk/channel_owner.ex index 924da9a3..0c67c9a4 100644 --- a/lib/playwright/sdk/channel_owner.ex +++ b/lib/playwright/sdk/channel_owner.ex @@ -111,7 +111,8 @@ defmodule Playwright.SDK.ChannelOwner do end defp with_latest(subject, task) do - Channel.find(subject.session, {:guid, subject.guid}) |> task.() + # First call passes fresh state to task, second returns updated state + _ = Channel.find(subject.session, {:guid, subject.guid}) |> task.() Channel.find(subject.session, {:guid, subject.guid}) end end @@ -132,9 +133,8 @@ defmodule Playwright.SDK.ChannelOwner do defp module(%{type: type}) do String.to_existing_atom("Elixir.Playwright.#{type}") rescue - ArgumentError -> - message = "ChannelOwner of type #{inspect(type)} is not yet defined" - exit(message) + e in ArgumentError -> + reraise %{e | message: "ChannelOwner of type #{inspect(type)} is not yet defined"}, __STACKTRACE__ end # ChannelOwner macros diff --git a/lib/playwright_test/case.ex b/lib/playwright_test/case.ex index 19ae1118..49cac536 100644 --- a/lib/playwright_test/case.ex +++ b/lib/playwright_test/case.ex @@ -25,9 +25,7 @@ defmodule PlaywrightTest.Case do describe "features w/out `page` context" do @tag exclude: [:page] test "goes to a page", %{browser: browser} do - page = - browser - |> Playwright.Browser.new_page() + {:ok, page} = Playwright.Browser.new_page(browser) text = page @@ -67,7 +65,7 @@ defmodule PlaywrightTest.Case do context false -> - page = Playwright.Browser.new_page(context.browser) + {:ok, page} = Playwright.Browser.new_page(context.browser) on_exit(:ok, fn -> Playwright.Page.close(page) diff --git a/test/api/browser_context/cookies_test.exs b/test/api/browser_context/cookies_test.exs index 8332827b..4feea13b 100644 --- a/test/api/browser_context/cookies_test.exs +++ b/test/api/browser_context/cookies_test.exs @@ -4,7 +4,7 @@ defmodule Playwright.BrowserContext.CookiesTest do describe "BrowserContext.cookies/1" do test "retrieves no cookies from a pristine context", %{page: page} do - cookies = BrowserContext.cookies(page.owned_context) + cookies = BrowserContext.cookies(Page.owned_context(page)) assert cookies == [] end diff --git a/test/api/browser_context_test.exs b/test/api/browser_context_test.exs index c383ab0a..505dd5e0 100644 --- a/test/api/browser_context_test.exs +++ b/test/api/browser_context_test.exs @@ -21,7 +21,7 @@ defmodule Playwright.BrowserContextTest do test "creates and binds a new context", %{browser: browser} do assert Browser.contexts(browser) == [] - page = Browser.new_page(browser) + {:ok, page} = Browser.new_page(browser) assert [%BrowserContext{} = context] = Browser.contexts(browser) assert context.browser == browser @@ -283,7 +283,7 @@ defmodule Playwright.BrowserContextTest do end test "can be set via new_page", %{browser: browser} do - page = Browser.new_page(browser, %{"userAgent" => "Mozzies"}) + {:ok, page} = Browser.new_page(browser, %{"userAgent" => "Mozzies"}) assert Page.evaluate(page, "window.navigator.userAgent") == "Mozzies" diff --git a/test/api/browser_test.exs b/test/api/browser_test.exs index d601e507..077a9534 100644 --- a/test/api/browser_test.exs +++ b/test/api/browser_test.exs @@ -16,10 +16,10 @@ defmodule Playwright.BrowserTest do test "builds a new Page, incl. context", %{browser: browser} do assert [] = Browser.contexts(browser) - page1 = Browser.new_page(browser) + {:ok, page1} = Browser.new_page(browser) assert [%BrowserContext{}] = Browser.contexts(browser) - page2 = Browser.new_page(browser) + {:ok, page2} = Browser.new_page(browser) assert [%BrowserContext{}, %BrowserContext{}] = Browser.contexts(browser) Page.close(page1) diff --git a/test/api/locator_test.exs b/test/api/locator_test.exs index 4b860b9a..a2f4e730 100644 --- a/test/api/locator_test.exs +++ b/test/api/locator_test.exs @@ -673,7 +673,7 @@ defmodule Playwright.LocatorTest do end test "raises an error when the given locators don't share a frame", %{page: page, browser: browser} do - other_page = Playwright.Browser.new_page(browser) + {:ok, other_page} = Playwright.Browser.new_page(browser) on_exit(:ok, fn -> Playwright.Page.close(other_page) diff --git a/test/api/page_test.exs b/test/api/page_test.exs index d539f4dc..14d3ce52 100644 --- a/test/api/page_test.exs +++ b/test/api/page_test.exs @@ -79,7 +79,7 @@ defmodule Playwright.PageTest do describe "Page.on/3" do @tag exclude: [:page] test "on :close (atom)", %{browser: browser} do - page = Browser.new_page(browser) + {:ok, page} = Browser.new_page(browser) this = self() guid = page.guid @@ -93,7 +93,7 @@ defmodule Playwright.PageTest do @tag exclude: [:page] test "on 'close' (string)", %{browser: browser} do - page = Browser.new_page(browser) + {:ok, page} = Browser.new_page(browser) this = self() guid = page.guid @@ -111,8 +111,8 @@ defmodule Playwright.PageTest do test "on 'close' of one Page does not affect another", %{browser: browser} do this = self() - %{guid: guid_one} = page_one = Browser.new_page(browser) - %{guid: guid_two} = page_two = Browser.new_page(browser) + {:ok, %{guid: guid_one} = page_one} = Browser.new_page(browser) + {:ok, %{guid: guid_two} = page_two} = Browser.new_page(browser) Page.on(page_one, "close", fn %{target: target} -> send(this, target.guid) @@ -351,7 +351,7 @@ defmodule Playwright.PageTest do describe "Page.close/1" do @tag without: [:page] test "removes the Page", %{browser: browser} do - page = Browser.new_page(browser) + {:ok, page} = Browser.new_page(browser) assert %Page{} = Channel.find(page.session, {:guid, page.guid}) page |> Page.close() diff --git a/test/playwright_test.exs b/test/playwright_test.exs index 29517947..ec3d5b46 100644 --- a/test/playwright_test.exs +++ b/test/playwright_test.exs @@ -6,9 +6,8 @@ defmodule Playwright.PlaywrightTest do describe "Playwright.connect/2" do @tag :ws test "with :chromium" do - with {:ok, browser} <- Playwright.connect(:chromium) do - page = Browser.new_page(browser) - + with {:ok, browser} <- Playwright.connect(:chromium), + {:ok, page} <- Browser.new_page(browser) do assert page |> Page.goto("https://www.whatsmybrowser.org") |> Response.ok() @@ -19,9 +18,8 @@ defmodule Playwright.PlaywrightTest do @tag :ws test "with :firefox" do - with {:ok, browser} <- Playwright.connect(:firefox) do - page = Browser.new_page(browser) - + with {:ok, browser} <- Playwright.connect(:firefox), + {:ok, page} <- Browser.new_page(browser) do assert page |> Page.goto("https://www.whatsmybrowser.org") |> Response.ok() @@ -32,9 +30,8 @@ defmodule Playwright.PlaywrightTest do @tag :ws test "with :webkit" do - with {:ok, browser} <- Playwright.connect(:webkit) do - page = Browser.new_page(browser) - + with {:ok, browser} <- Playwright.connect(:webkit), + {:ok, page} <- Browser.new_page(browser) do assert page |> Page.goto("https://www.whatsmybrowser.org") |> Response.ok() @@ -47,9 +44,9 @@ defmodule Playwright.PlaywrightTest do describe "Playwright.launch/2" do test "launches and returns an instance of the requested Browser" do {:ok, browser} = Playwright.launch(:chromium) + {:ok, page} = Browser.new_page(browser) - assert browser - |> Browser.new_page() + assert page |> Page.goto("http://example.com") |> Response.ok() end @@ -57,8 +54,9 @@ defmodule Playwright.PlaywrightTest do describe "PlaywrightTest.Case context" do test "using `:browser`", %{browser: browser} do - assert browser - |> Browser.new_page() + {:ok, page} = Browser.new_page(browser) + + assert page |> Page.goto("http://example.com") |> Response.ok() end From 45b8a7227ea6b48dfaf9d17a757c7d771b4ef945 Mon Sep 17 00:00:00 2001 From: Tristan Peralta Date: Sat, 17 Jan 2026 18:55:17 +0800 Subject: [PATCH 07/52] Add feature parity tracking document and ignore .claude directory - Add man/guides/feature_parity.md documenting implementation status of all Playwright features compared to TypeScript client - Add .claude/ to .gitignore - Add feature_parity.md to mix.exs docs config --- .gitignore | 3 +- man/guides/feature_parity.md | 676 +++++++++++++++++++++++++++++++++++ mix.exs | 1 + 3 files changed, 679 insertions(+), 1 deletion(-) create mode 100644 man/guides/feature_parity.md diff --git a/.gitignore b/.gitignore index 5c4953a9..c97992c0 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,5 @@ playwright-*.tar .doctor.out .vscode *.iml -/.idea/ \ No newline at end of file +/.idea/ +.claude/ diff --git a/man/guides/feature_parity.md b/man/guides/feature_parity.md new file mode 100644 index 00000000..85650226 --- /dev/null +++ b/man/guides/feature_parity.md @@ -0,0 +1,676 @@ +# Playwright Feature Parity Tracking + +This document tracks the implementation status of Playwright features in playwright-elixir compared to the official TypeScript client. + +**Reference:** `/home/tristan/sources/playwright/packages/playwright-core/src/client/` + +**Legend:** +- [x] Implemented +- [~] Partially implemented / stubbed +- [ ] Not implemented +- [!] Priority implementation candidate + +--- + +## Page Module + +**File:** `lib/playwright/page.ex` +**Reference:** `page.ts` + +### Navigation & Loading + +| Method | Status | Notes | +|--------|--------|-------| +| `goto(url, options)` | [x] | | +| `reload(options)` | [x] | | +| `goBack(options)` | [ ] | !Priority | +| `goForward(options)` | [ ] | !Priority | +| `waitForLoadState(state, options)` | [x] | | +| `waitForNavigation(options)` | [ ] | !Priority - commonly needed | +| `waitForURL(url, options)` | [ ] | !Priority | +| `waitForRequest(urlOrPredicate, options)` | [ ] | | +| `waitForResponse(urlOrPredicate, options)` | [ ] | | +| `waitForEvent(event, options)` | [~] | As `expect_event` | +| `bringToFront()` | [ ] | | + +### Content & State + +| Method | Status | Notes | +|--------|--------|-------| +| `url()` | [x] | | +| `title()` | [x] | | +| `content()` | [ ] | Get page HTML | +| `setContent(html, options)` | [x] | | +| `setViewportSize(size)` | [x] | | +| `viewportSize()` | [ ] | | +| `isClosed()` | [~] | Via `is_closed` field | +| `close(options)` | [x] | | +| `opener()` | [ ] | | + +### Frames + +| Method | Status | Notes | +|--------|--------|-------| +| `mainFrame()` | [~] | Via `main_frame` field | +| `frames()` | [x] | | +| `frame(selector)` | [ ] | Get frame by name/url | +| `frameLocator(selector)` | [ ] | !Priority | + +### Locators (getBy* methods) + +| Method | Status | Notes | +|--------|--------|-------| +| `locator(selector, options)` | [x] | | +| `getByText(text, options)` | [x] | | +| `getByRole(role, options)` | [ ] | !Priority - accessibility testing | +| `getByTestId(testId)` | [ ] | !Priority - common pattern | +| `getByLabel(text, options)` | [ ] | !Priority - form testing | +| `getByPlaceholder(text, options)` | [ ] | | +| `getByAltText(text, options)` | [ ] | | +| `getByTitle(text, options)` | [ ] | | + +### Actions (selector-based) + +| Method | Status | Notes | +|--------|--------|-------| +| `click(selector, options)` | [x] | | +| `dblclick(selector, options)` | [x] | | +| `tap(selector, options)` | [~] | Stubbed | +| `fill(selector, value, options)` | [x] | | +| `type(selector, text, options)` | [ ] | Deprecated, use fill | +| `press(selector, key, options)` | [x] | | +| `hover(selector, options)` | [x] | | +| `focus(selector, options)` | [x] | | +| `selectOption(selector, values, options)` | [x] | | +| `check(selector, options)` | [ ] | | +| `uncheck(selector, options)` | [ ] | | +| `setChecked(selector, checked, options)` | [ ] | | +| `setInputFiles(selector, files, options)` | [ ] | | +| `dragAndDrop(source, target, options)` | [x] | | +| `dispatchEvent(selector, type, eventInit, options)` | [x] | | + +### Query Methods + +| Method | Status | Notes | +|--------|--------|-------| +| `textContent(selector, options)` | [x] | | +| `innerText(selector, options)` | [ ] | | +| `innerHTML(selector, options)` | [ ] | | +| `getAttribute(selector, name, options)` | [x] | | +| `inputValue(selector, options)` | [ ] | | +| `isChecked(selector, options)` | [ ] | | +| `isDisabled(selector, options)` | [ ] | | +| `isEditable(selector, options)` | [ ] | | +| `isEnabled(selector, options)` | [ ] | | +| `isHidden(selector, options)` | [ ] | | +| `isVisible(selector, options)` | [ ] | | +| `waitForSelector(selector, options)` | [x] | | + +### JavaScript Evaluation + +| Method | Status | Notes | +|--------|--------|-------| +| `evaluate(expression, arg)` | [x] | | +| `evaluateHandle(expression, arg)` | [x] | | +| `evalOnSelector(selector, expression, arg)` | [x] | | +| `evalOnSelectorAll(selector, expression, arg)` | [ ] | | +| `exposeFunction(name, callback)` | [x] | | +| `exposeBinding(name, callback, options)` | [x] | | +| `addInitScript(script, arg)` | [x] | | +| `addScriptTag(options)` | [ ] | | +| `addStyleTag(options)` | [ ] | | + +### Routing & Network + +| Method | Status | Notes | +|--------|--------|-------| +| `route(url, handler, options)` | [x] | | +| `unroute(url, handler)` | [x] | | +| `unrouteAll(options)` | [ ] | | +| `routeFromHAR(har, options)` | [ ] | | +| `routeWebSocket(url, handler)` | [ ] | | +| `setExtraHTTPHeaders(headers)` | [ ] | | + +### Screenshots & Media + +| Method | Status | Notes | +|--------|--------|-------| +| `screenshot(options)` | [x] | | +| `pdf(options)` | [ ] | Chromium only | +| `video()` | [ ] | | +| `emulateMedia(options)` | [ ] | | + +### Events + +| Method | Status | Notes | +|--------|--------|-------| +| `on(event, callback)` | [x] | With event validation | +| `waitForEvent(event, options)` | [~] | As `expect_event` | +| `consoleMessages()` | [ ] | | +| `pageErrors()` | [ ] | | + +### Locator Handlers + +| Method | Status | Notes | +|--------|--------|-------| +| `addLocatorHandler(locator, handler, options)` | [ ] | For auto-dismiss dialogs | +| `removeLocatorHandler(locator)` | [ ] | | + +### Timeouts + +| Method | Status | Notes | +|--------|--------|-------| +| `setDefaultTimeout(timeout)` | [ ] | | +| `setDefaultNavigationTimeout(timeout)` | [ ] | | + +### Other + +| Method | Status | Notes | +|--------|--------|-------| +| `context()` | [x] | | +| `pause()` | [ ] | Inspector | +| `requestGC()` | [ ] | | + +--- + +## Locator Module + +**File:** `lib/playwright/locator.ex` +**Reference:** `locator.ts` + +### Creation & Chaining + +| Method | Status | Notes | +|--------|--------|-------| +| `locator(selector, options)` | [x] | | +| `first()` | [x] | | +| `last()` | [x] | | +| `nth(index)` | [x] | | +| `filter(options)` | [ ] | !Priority - filter by text/hasText | +| `and(locator)` | [ ] | Combine locators | +| `or(locator)` | [x] | As `or_` | +| `getByText(text, options)` | [x] | | +| `getByRole(role, options)` | [ ] | !Priority | +| `getByTestId(testId)` | [ ] | !Priority | +| `getByLabel(text, options)` | [ ] | !Priority | +| `getByPlaceholder(text, options)` | [ ] | | +| `getByAltText(text, options)` | [ ] | | +| `getByTitle(text, options)` | [ ] | | +| `frameLocator(selector)` | [ ] | | +| `contentFrame()` | [ ] | | +| `owner()` | [ ] | | + +### Actions + +| Method | Status | Notes | +|--------|--------|-------| +| `click(options)` | [x] | | +| `dblclick(options)` | [x] | | +| `tap(options)` | [x] | | +| `fill(value, options)` | [x] | | +| `clear(options)` | [x] | | +| `type(text, options)` | [x] | Deprecated | +| `pressSequentially(text, options)` | [ ] | Replaces type | +| `press(key, options)` | [x] | | +| `hover(options)` | [x] | | +| `focus(options)` | [x] | | +| `blur(options)` | [x] | | +| `check(options)` | [x] | | +| `uncheck(options)` | [x] | | +| `setChecked(checked, options)` | [x] | | +| `selectOption(values, options)` | [x] | | +| `selectText(options)` | [x] | | +| `setInputFiles(files, options)` | [x] | | +| `dragTo(target, options)` | [x] | | +| `scrollIntoViewIfNeeded(options)` | [~] | As `scroll_into_view` | +| `dispatchEvent(type, eventInit, options)` | [x] | | +| `highlight()` | [ ] | | + +### Query Methods + +| Method | Status | Notes | +|--------|--------|-------| +| `count()` | [x] | | +| `all()` | [x] | | +| `textContent(options)` | [x] | | +| `innerText(options)` | [x] | | +| `innerHTML(options)` | [x] | | +| `getAttribute(name, options)` | [x] | | +| `inputValue(options)` | [x] | | +| `boundingBox(options)` | [x] | | +| `allTextContents()` | [x] | | +| `allInnerTexts()` | [x] | | + +### State Checks + +| Method | Status | Notes | +|--------|--------|-------| +| `isChecked(options)` | [x] | | +| `isDisabled(options)` | [x] | | +| `isEditable(options)` | [x] | | +| `isEnabled(options)` | [x] | | +| `isHidden(options)` | [x] | | +| `isVisible(options)` | [x] | | + +### Evaluation + +| Method | Status | Notes | +|--------|--------|-------| +| `evaluate(expression, arg, options)` | [x] | | +| `evaluateAll(expression, arg)` | [x] | | +| `evaluateHandle(expression, arg, options)` | [x] | | + +### Screenshots & Handles + +| Method | Status | Notes | +|--------|--------|-------| +| `screenshot(options)` | [x] | | +| `elementHandle(options)` | [x] | | +| `elementHandles()` | [x] | | +| `ariaSnapshot(options)` | [ ] | | + +### Waiting + +| Method | Status | Notes | +|--------|--------|-------| +| `waitFor(options)` | [x] | | + +### Other + +| Method | Status | Notes | +|--------|--------|-------| +| `page()` | [ ] | | +| `describe(description)` | [ ] | | + +--- + +## BrowserContext Module + +**File:** `lib/playwright/browser_context.ex` +**Reference:** `browserContext.ts` + +### Pages + +| Method | Status | Notes | +|--------|--------|-------| +| `newPage()` | [x] | | +| `pages()` | [x] | | +| `browser()` | [x] | | + +### Cookies + +| Method | Status | Notes | +|--------|--------|-------| +| `cookies(urls)` | [x] | | +| `addCookies(cookies)` | [x] | | +| `clearCookies(options)` | [x] | | + +### Permissions + +| Method | Status | Notes | +|--------|--------|-------| +| `grantPermissions(permissions, options)` | [x] | | +| `clearPermissions()` | [x] | | + +### Settings + +| Method | Status | Notes | +|--------|--------|-------| +| `setGeolocation(geolocation)` | [ ] | !Priority | +| `setExtraHTTPHeaders(headers)` | [ ] | | +| `setOffline(offline)` | [x] | | +| `setHTTPCredentials(credentials)` | [ ] | | +| `setDefaultTimeout(timeout)` | [ ] | | +| `setDefaultNavigationTimeout(timeout)` | [ ] | | + +### Scripts & Bindings + +| Method | Status | Notes | +|--------|--------|-------| +| `addInitScript(script, arg)` | [x] | | +| `exposeBinding(name, callback, options)` | [x] | | +| `exposeFunction(name, callback)` | [x] | | + +### Routing + +| Method | Status | Notes | +|--------|--------|-------| +| `route(url, handler, options)` | [x] | | +| `unroute(url, handler)` | [x] | | +| `unrouteAll(options)` | [ ] | | +| `routeFromHAR(har, options)` | [ ] | | +| `routeWebSocket(url, handler)` | [ ] | | + +### State + +| Method | Status | Notes | +|--------|--------|-------| +| `storageState(options)` | [ ] | !Priority - session persistence | +| `close(options)` | [x] | | + +### Events + +| Method | Status | Notes | +|--------|--------|-------| +| `on(event, callback)` | [x] | | +| `waitForEvent(event, options)` | [~] | As `expect_event` | + +### Workers + +| Method | Status | Notes | +|--------|--------|-------| +| `backgroundPages()` | [ ] | | +| `serviceWorkers()` | [ ] | | + +### CDP + +| Method | Status | Notes | +|--------|--------|-------| +| `newCDPSession(page)` | [x] | | + +--- + +## Browser Module + +**File:** `lib/playwright/browser.ex` +**Reference:** `browser.ts` + +| Method | Status | Notes | +|--------|--------|-------| +| `newContext(options)` | [x] | | +| `newPage(options)` | [x] | Returns `{:ok, page}` | +| `contexts()` | [x] | | +| `close()` | [x] | | +| `isConnected()` | [ ] | | +| `browserType()` | [ ] | | +| `version` | [x] | Property | +| `name` | [x] | Property | +| `newBrowserCDPSession()` | [ ] | | +| `startTracing(page, options)` | [ ] | | +| `stopTracing()` | [ ] | | + +--- + +## Frame Module + +**File:** `lib/playwright/frame.ex` +**Reference:** `frame.ts` + +### Navigation + +| Method | Status | Notes | +|--------|--------|-------| +| `goto(url, options)` | [x] | | +| `waitForNavigation(options)` | [ ] | | +| `waitForURL(url, options)` | [ ] | | +| `waitForLoadState(state, options)` | [x] | | +| `url()` | [x] | | +| `name()` | [ ] | | +| `title()` | [x] | | + +### Content + +| Method | Status | Notes | +|--------|--------|-------| +| `content()` | [x] | | +| `setContent(html, options)` | [x] | | + +### Hierarchy + +| Method | Status | Notes | +|--------|--------|-------| +| `page()` | [ ] | | +| `parentFrame()` | [ ] | | +| `childFrames()` | [ ] | | +| `isDetached()` | [ ] | | +| `frameElement()` | [ ] | | +| `frameLocator(selector)` | [ ] | | + +### Locators + +| Method | Status | Notes | +|--------|--------|-------| +| `locator(selector, options)` | [x] | | +| `getByText(text, options)` | [x] | | +| `getByRole(role, options)` | [ ] | | +| `getByTestId(testId)` | [ ] | | +| `getByLabel(text, options)` | [ ] | | +| `getByPlaceholder(text, options)` | [ ] | | +| `getByAltText(text, options)` | [ ] | | +| `getByTitle(text, options)` | [ ] | | + +### Actions + +| Method | Status | Notes | +|--------|--------|-------| +| `click(selector, options)` | [x] | | +| `dblclick(selector, options)` | [x] | | +| `tap(selector, options)` | [x] | | +| `fill(selector, value, options)` | [x] | | +| `type(selector, text, options)` | [x] | | +| `press(selector, key, options)` | [x] | | +| `hover(selector, options)` | [x] | | +| `focus(selector, options)` | [x] | | +| `check(selector, options)` | [x] | | +| `uncheck(selector, options)` | [x] | | +| `selectOption(selector, values, options)` | [x] | | +| `setInputFiles(selector, files, options)` | [x] | | +| `dragAndDrop(source, target, options)` | [x] | | +| `dispatchEvent(selector, type, eventInit, options)` | [x] | | + +### Query Methods + +| Method | Status | Notes | +|--------|--------|-------| +| `textContent(selector, options)` | [x] | | +| `innerText(selector, options)` | [x] | | +| `innerHTML(selector, options)` | [x] | | +| `getAttribute(selector, name, options)` | [x] | | +| `inputValue(selector, options)` | [x] | | +| `isChecked(selector, options)` | [x] | | +| `isDisabled(selector, options)` | [x] | | +| `isEditable(selector, options)` | [x] | | +| `isEnabled(selector, options)` | [x] | | +| `isHidden(selector, options)` | [x] | | +| `isVisible(selector, options)` | [x] | | +| `waitForSelector(selector, options)` | [x] | | +| `querySelector(selector)` | [x] | As `query_selector` | +| `querySelectorAll(selector)` | [x] | As `query_selector_all` | + +### Evaluation + +| Method | Status | Notes | +|--------|--------|-------| +| `evaluate(expression, arg)` | [x] | | +| `evaluateHandle(expression, arg)` | [x] | | +| `evalOnSelector(selector, expression, arg)` | [x] | | +| `evalOnSelectorAll(selector, expression, arg)` | [x] | | + +--- + +## Completely Stubbed/Empty Modules + +These modules exist but have no implemented methods (all commented out): + +### Mouse (`lib/playwright/page/mouse.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `click(x, y, options)` | [ ] | | +| `dblclick(x, y, options)` | [ ] | | +| `down(options)` | [ ] | | +| `up(options)` | [ ] | | +| `move(x, y, options)` | [ ] | | +| `wheel(deltaX, deltaY)` | [ ] | | + +### Touchscreen (`lib/playwright/page/touchscreen.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `tap(x, y)` | [ ] | | + +### Dialog (`lib/playwright/dialog.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `accept(promptText)` | [ ] | !Priority | +| `dismiss()` | [ ] | !Priority | +| `message()` | [ ] | | +| `defaultValue()` | [ ] | | +| `type()` | [ ] | | +| `page()` | [ ] | | + +### Download (`lib/playwright/page/download.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `cancel()` | [ ] | | +| `delete()` | [ ] | | +| `failure()` | [ ] | | +| `page()` | [ ] | | +| `path()` | [ ] | !Priority | +| `saveAs(path)` | [ ] | !Priority | +| `suggestedFilename()` | [ ] | | +| `url()` | [ ] | | + +### Coverage (`lib/playwright/page/coverage.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `startJSCoverage(options)` | [ ] | | +| `stopJSCoverage()` | [ ] | | +| `startCSSCoverage(options)` | [ ] | | +| `stopCSSCoverage()` | [ ] | | + +### Tracing (`lib/playwright/tracing.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `start(options)` | [ ] | | +| `stop(options)` | [ ] | | +| `group(name, options)` | [ ] | | +| `groupEnd()` | [ ] | | + +### FrameLocator (`lib/playwright/page/frame_locator.ex`) + +| Method | Status | Notes | +|--------|--------|-------| +| `first()` | [ ] | | +| `last()` | [ ] | | +| `nth(index)` | [ ] | | +| `frameLocator(selector)` | [ ] | | +| `locator(selector)` | [ ] | | +| `getByText(text, options)` | [ ] | | +| `getByRole(role, options)` | [ ] | | +| `getByTestId(testId)` | [ ] | | +| `getByLabel(text, options)` | [ ] | | +| `getByPlaceholder(text, options)` | [ ] | | +| `getByAltText(text, options)` | [ ] | | +| `getByTitle(text, options)` | [ ] | | +| `owner()` | [ ] | | + +--- + +## Missing Modules (Not Yet Created) + +### FileChooser + +| Method | Status | Notes | +|--------|--------|-------| +| `element()` | [ ] | | +| `isMultiple()` | [ ] | | +| `page()` | [ ] | | +| `setFiles(files, options)` | [ ] | !Priority | + +### Clock + +| Method | Status | Notes | +|--------|--------|-------| +| `install(options)` | [ ] | | +| `fastForward(ticks)` | [ ] | | +| `pauseAt(time)` | [ ] | | +| `resume()` | [ ] | | +| `runFor(ticks)` | [ ] | | +| `setFixedTime(time)` | [ ] | | +| `setSystemTime(time)` | [ ] | | + +### Video + +| Method | Status | Notes | +|--------|--------|-------| +| `delete()` | [ ] | | +| `path()` | [ ] | | +| `saveAs(path)` | [ ] | | + +--- + +## Priority Implementation Roadmap + +### Phase 1: Core Navigation & Waiting (High Impact) +1. `Page.goBack()` / `Page.goForward()` +2. `Page.waitForNavigation()` +3. `Page.waitForURL()` +4. `Dialog.accept()` / `Dialog.dismiss()` + +### Phase 2: Modern Locators (Developer Experience) +1. `*.getByRole()` +2. `*.getByTestId()` +3. `*.getByLabel()` +4. `Locator.filter()` + +### Phase 3: Session & State (Testing Infrastructure) +1. `BrowserContext.storageState()` +2. `BrowserContext.setGeolocation()` +3. `Download.saveAs()` / `Download.path()` + +### Phase 4: Advanced Features +1. `Mouse` module +2. `FrameLocator` module +3. `Page.pdf()` +4. `Tracing` module + +### Phase 5: Completeness +1. Remaining Page query methods +2. `FileChooser` module +3. `Clock` module +4. `Video` module + +--- + +## Implementation Notes + +### Event Names (from Playwright events.ts) + +```typescript +Page: { + AgentTurn: 'agentturn', + Close: 'close', + Crash: 'crash', + Console: 'console', + Dialog: 'dialog', + Download: 'download', + FileChooser: 'filechooser', + DOMContentLoaded: 'domcontentloaded', + PageError: 'pageerror', + Request: 'request', + Response: 'response', + RequestFailed: 'requestfailed', + RequestFinished: 'requestfinished', + FrameAttached: 'frameattached', + FrameDetached: 'framedetached', + FrameNavigated: 'framenavigated', + Load: 'load', + Popup: 'popup', + WebSocket: 'websocket', + Worker: 'worker', +} +``` + +### Channel Commands Reference + +When implementing new methods, refer to the protocol definitions in: +- `/home/tristan/sources/playwright/packages/protocol/src/channels.ts` + +### Testing Patterns + +Each new feature should include tests following the existing pattern in `test/api/`. diff --git a/mix.exs b/mix.exs index 12e947c2..5fbdeeac 100644 --- a/mix.exs +++ b/mix.exs @@ -85,6 +85,7 @@ defmodule Playwright.MixProject do "man/guides/chrome-extensions.md": [filename: "guides-chrome-extensions"], "man/guides/command-line-tools.md": [filename: "guides-command-line-tools"], "man/guides/contributing.md": [filename: "guides-contributing"], + "man/guides/feature_parity.md": [filename: "guides-feature-parity"], "man/guides/dialogs.md": [filename: "guides-dialogs"], "man/guides/downloads.md": [filename: "guides-downloads"], "man/guides/emulation.md": [filename: "guides-emulation"], From 41853d333756b20198a36a7159a7afcd5e44c4be Mon Sep 17 00:00:00 2001 From: Tristan Peralta Date: Sat, 17 Jan 2026 19:05:00 +0800 Subject: [PATCH 08/52] feat: Add navigation methods go_back, go_forward, wait_for_url Implement Page and Frame navigation methods: - Page.go_back/2 - Navigate to previous page in history - Page.go_forward/2 - Navigate to next page in history - Page.wait_for_url/3 - Wait for URL to match pattern - Frame.wait_for_url/3 - Same for frames wait_for_url supports: - Glob patterns (e.g., "**/login") - Regex patterns (e.g., ~r/dashboard$/) - Function predicates (e.g., fn url -> ... end) Implementation uses polling approach since wait_for_navigation is not yet implemented. --- lib/playwright/frame.ex | 69 ++++++++++++++++++++++++- lib/playwright/page.ex | 83 ++++++++++++++++++++++++++++--- man/guides/feature_parity.md | 10 ++-- test/api/page/navigation_test.exs | 64 ++++++++++++++++++++++++ 4 files changed, 213 insertions(+), 13 deletions(-) create mode 100644 test/api/page/navigation_test.exs diff --git a/lib/playwright/frame.ex b/lib/playwright/frame.ex index 3602f063..5d24cc13 100644 --- a/lib/playwright/frame.ex +++ b/lib/playwright/frame.ex @@ -943,8 +943,73 @@ defmodule Playwright.Frame do # @spec wait_for_timeout(Frame.t(), number()) :: :ok # def wait_for_timeout(frame, timeout) - # @spec wait_for_url(Frame.t(), binary(), options()) :: :ok - # def wait_for_url(frame, url, options \\ %{}) + @doc """ + Wait until the frame URL matches the given pattern. + + The pattern can be: + - A string with glob patterns (e.g., `"**/login"`) + - A regex (e.g., `~r/\\/login$/`) + - A function that receives URL and returns boolean + + ## Options + + - `:timeout` - Maximum time in milliseconds. Defaults to 30000 (30 seconds). + - `:wait_until` - When to consider navigation succeeded. Defaults to `"load"`. + + ## Examples + + Frame.wait_for_url(frame, "**/login") + Frame.wait_for_url(frame, ~r/\\/dashboard$/) + Frame.wait_for_url(frame, fn url -> String.contains?(url, "success") end) + + ## Returns + + - `Frame.t()` - The frame after URL matches + - `{:error, term()}` - If timeout occurs + """ + @spec wait_for_url(t(), binary() | Regex.t() | function(), options()) :: t() | {:error, term()} + def wait_for_url(%Frame{} = frame, url_pattern, options \\ %{}) do + alias Playwright.SDK.Helpers.URLMatcher + + matcher = URLMatcher.new(url_pattern) + current_url = url(frame) + + if URLMatcher.matches(matcher, current_url) do + # URL already matches, just wait for load state + wait_until = Map.get(options, :wait_until, "load") + wait_for_load_state(frame, wait_until, options) + else + # Poll for URL change + timeout = Map.get(options, :timeout, 30_000) + poll_interval = 100 + deadline = System.monotonic_time(:millisecond) + timeout + + wait_for_url_loop(frame, matcher, deadline, poll_interval, options) + end + end + + defp wait_for_url_loop(frame, matcher, deadline, poll_interval, options) do + alias Playwright.SDK.Channel + alias Playwright.SDK.Helpers.URLMatcher + + # Get fresh frame state + fresh_frame = Channel.find(frame.session, {:guid, frame.guid}) + current_url = url(fresh_frame) + + cond do + URLMatcher.matches(matcher, current_url) -> + # URL matches, wait for load state and return + wait_until = Map.get(options, :wait_until, "load") + wait_for_load_state(fresh_frame, wait_until, options) + + System.monotonic_time(:millisecond) > deadline -> + {:error, %Playwright.SDK.Channel.Error{type: "TimeoutError", message: "Timeout waiting for URL to match pattern"}} + + true -> + Process.sleep(poll_interval) + wait_for_url_loop(frame, matcher, deadline, poll_interval, options) + end + end # --- diff --git a/lib/playwright/page.ex b/lib/playwright/page.ex index 1c4fccff..6b3978d7 100644 --- a/lib/playwright/page.ex +++ b/lib/playwright/page.ex @@ -444,11 +444,53 @@ defmodule Playwright.Page do # @spec get_by_title(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil # def get_by_title(page, text, options \\ %{}) - # @spec go_back(t(), options()) :: Response.t() | nil - # def go_back(page, options \\ %{}) + @doc """ + Navigate to the previous page in history. + + ## Options + + - `:timeout` - Maximum time in milliseconds. Defaults to 30000 (30 seconds). + - `:wait_until` - When to consider navigation succeeded. Defaults to `"load"`. + - `"load"` - wait for the load event + - `"domcontentloaded"` - wait for DOMContentLoaded event + - `"networkidle"` - wait until no network connections for 500ms + - `"commit"` - wait for network response and document started loading + + ## Returns + + - `Playwright.Response.t()` - Response of the main resource + - `nil` - if navigation did not happen (e.g., no previous page) + """ + @spec go_back(t(), options()) :: Response.t() | nil + def go_back(%Page{session: session} = page, options \\ %{}) do + case Channel.post(session, {:guid, page.guid}, :goBack, options) do + %{response: nil} -> nil + %{response: %{guid: _} = response} -> Channel.find(session, {:guid, response.guid}) + other -> other + end + end + + @doc """ + Navigate to the next page in history. + + ## Options + + - `:timeout` - Maximum time in milliseconds. Defaults to 30000 (30 seconds). + - `:wait_until` - When to consider navigation succeeded. Defaults to `"load"`. + + ## Returns - # @spec go_forward(t(), options()) :: Response.t() | nil - # def go_forward(page, options \\ %{}) + - `Playwright.Response.t()` - Response of the main resource + - `nil` - if navigation did not happen (e.g., no next page) + """ + @spec go_forward(t(), options()) :: Response.t() | nil + def go_forward(%Page{session: session} = page, options \\ %{}) do + case Channel.post(session, {:guid, page.guid}, :goForward, options) do + %{response: nil} -> nil + %{response: %{guid: _} = response} -> Channel.find(session, {:guid, response.guid}) + other -> other + end + end # --- @@ -749,8 +791,37 @@ defmodule Playwright.Page do # --- - # @spec wait_for_url(Page.t(), binary(), options()) :: :ok - # def wait_for_url(page, url, options \\ %{}) + @doc """ + Wait until the page URL matches the given pattern. + + The pattern can be: + - A string with glob patterns (e.g., `"**/login"`) + - A regex (e.g., `~r/\\/login$/`) + - A function that receives URL and returns boolean + + ## Options + + - `:timeout` - Maximum time in milliseconds. Defaults to 30000 (30 seconds). + - `:wait_until` - When to consider navigation succeeded. Defaults to `"load"`. + + ## Examples + + Page.wait_for_url(page, "**/login") + Page.wait_for_url(page, ~r/\\/dashboard$/) + Page.wait_for_url(page, fn url -> String.contains?(url, "success") end) + + ## Returns + + - `Page.t()` - The page after URL matches + - `{:error, term()}` - If timeout occurs + """ + @spec wait_for_url(t(), binary() | Regex.t() | function(), options()) :: t() | {:error, term()} + def wait_for_url(%Page{} = page, url_pattern, options \\ %{}) do + case main_frame(page) |> Frame.wait_for_url(url_pattern, options) do + {:error, _} = error -> error + _frame -> page + end + end # @spec workers(t()) :: [Worker.t()] # def workers(page) diff --git a/man/guides/feature_parity.md b/man/guides/feature_parity.md index 85650226..b893f9ab 100644 --- a/man/guides/feature_parity.md +++ b/man/guides/feature_parity.md @@ -23,11 +23,11 @@ This document tracks the implementation status of Playwright features in playwri |--------|--------|-------| | `goto(url, options)` | [x] | | | `reload(options)` | [x] | | -| `goBack(options)` | [ ] | !Priority | -| `goForward(options)` | [ ] | !Priority | +| `goBack(options)` | [x] | | +| `goForward(options)` | [x] | | | `waitForLoadState(state, options)` | [x] | | | `waitForNavigation(options)` | [ ] | !Priority - commonly needed | -| `waitForURL(url, options)` | [ ] | !Priority | +| `waitForURL(url, options)` | [x] | Polling-based implementation | | `waitForRequest(urlOrPredicate, options)` | [ ] | | | `waitForResponse(urlOrPredicate, options)` | [ ] | | | `waitForEvent(event, options)` | [~] | As `expect_event` | @@ -607,9 +607,9 @@ These modules exist but have no implemented methods (all commented out): ## Priority Implementation Roadmap ### Phase 1: Core Navigation & Waiting (High Impact) -1. `Page.goBack()` / `Page.goForward()` +1. ~~`Page.goBack()` / `Page.goForward()`~~ DONE 2. `Page.waitForNavigation()` -3. `Page.waitForURL()` +3. ~~`Page.waitForURL()`~~ DONE 4. `Dialog.accept()` / `Dialog.dismiss()` ### Phase 2: Modern Locators (Developer Experience) diff --git a/test/api/page/navigation_test.exs b/test/api/page/navigation_test.exs new file mode 100644 index 00000000..aeab4288 --- /dev/null +++ b/test/api/page/navigation_test.exs @@ -0,0 +1,64 @@ +defmodule Playwright.Page.NavigationTest do + use Playwright.TestCase, async: true + alias Playwright.Page + + describe "Page.go_back/2" do + test "navigates back in history", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + Page.goto(page, assets.dom) + + assert String.ends_with?(Page.url(page), "/dom.html") + + Page.go_back(page) + assert String.ends_with?(Page.url(page), "/empty.html") + end + + test "returns nil when no history", %{page: page} do + assert Page.go_back(page) == nil + end + end + + describe "Page.go_forward/2" do + test "navigates forward in history", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + Page.goto(page, assets.dom) + Page.go_back(page) + + assert String.ends_with?(Page.url(page), "/empty.html") + + Page.go_forward(page) + assert String.ends_with?(Page.url(page), "/dom.html") + end + + test "returns nil when no forward history", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + assert Page.go_forward(page) == nil + end + end + + describe "Page.wait_for_url/3" do + test "resolves immediately if URL already matches", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + result = Page.wait_for_url(page, "**/empty.html") + assert %Page{} = result + end + + test "matches with regex pattern", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + result = Page.wait_for_url(page, ~r/empty\.html$/) + assert %Page{} = result + end + + test "matches with function predicate", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + result = Page.wait_for_url(page, fn url -> String.contains?(url, "empty") end) + assert %Page{} = result + end + + test "times out when URL does not match", %{assets: assets, page: page} do + Page.goto(page, assets.empty) + result = Page.wait_for_url(page, "**/nonexistent.html", %{timeout: 100}) + assert {:error, %{message: "Timeout waiting for URL to match pattern"}} = result + end + end +end From def4f1243ebcdf4eef776188a7cdf27b7fc2adff Mon Sep 17 00:00:00 2001 From: Tristan Peralta Date: Sat, 17 Jan 2026 19:13:43 +0800 Subject: [PATCH 09/52] Implement Dialog module for browser dialog handling Add support for handling browser dialogs (alert, confirm, prompt, beforeunload): - Dialog.accept/1 and accept/2 for accepting dialogs with optional prompt text - Dialog.dismiss/1 for dismissing/canceling dialogs - Dialog.message/1, type/1, default_value/1, page/1 property accessors Note: Dialog handlers should spawn a Task to avoid deadlock with Connection GenServer when calling Dialog.accept/dismiss from within event handlers. --- lib/playwright/dialog.ex | 111 +++++++++++++++++++++--- man/guides/feature_parity.md | 12 +-- test/api/dialog_test.exs | 161 +++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 19 deletions(-) create mode 100644 test/api/dialog_test.exs diff --git a/lib/playwright/dialog.ex b/lib/playwright/dialog.ex index d19b916a..017c55ef 100644 --- a/lib/playwright/dialog.ex +++ b/lib/playwright/dialog.ex @@ -1,22 +1,107 @@ defmodule Playwright.Dialog do - @moduledoc false + @moduledoc """ + `Playwright.Dialog` instances are dispatched by page and handled via + `Playwright.Page.on/3` for the `:dialog` event type. + + ## Dialog Types + + - `"alert"` - Alert dialog with OK button + - `"confirm"` - Confirm dialog with OK and Cancel buttons + - `"prompt"` - Prompt dialog with text input + - `"beforeunload"` - Before unload dialog + + ## Example + + Page.on(page, :dialog, fn dialog -> + IO.puts("Dialog message: \#{Dialog.message(dialog)}") + Dialog.accept(dialog) + end) + + # For prompts with input: + Page.on(page, :dialog, fn dialog -> + Dialog.accept(dialog, "my input") + end) + """ use Playwright.SDK.ChannelOwner + alias Playwright.SDK.{Channel, ChannelOwner} + + @property :default_value + @property :message + @property :dialog_type + + # callbacks + # --------------------------------------------------------------------------- + + @impl ChannelOwner + def init(dialog, initializer) do + {:ok, + %{ + dialog + | default_value: initializer.default_value, + message: initializer.message, + dialog_type: initializer.type + }} + end + + @doc """ + Get the dialog type. + + Returns one of: `"alert"`, `"confirm"`, `"prompt"`, `"beforeunload"`. + """ + @spec type(t()) :: binary() + def type(dialog) do + dialog_type(dialog) + end + + # API + # --------------------------------------------------------------------------- + + @doc """ + Accept the dialog. + + For prompt dialogs, optionally provide text input. + + ## Arguments + + - `prompt_text` - Text to enter in prompt dialog (optional) + + ## Examples - # @spec accept(Dialog.t(), binary()) :: :ok - # def accept(dialog, prompt \\ "") + Dialog.accept(dialog) + Dialog.accept(dialog, "my input") + """ + @spec accept(t(), binary() | nil) :: :ok + def accept(dialog, prompt_text \\ nil) - # @spec default_value(Dialog.t()) :: binary() - # def default_value(dialog) + def accept(%__MODULE__{session: session} = dialog, nil) do + Channel.post(session, {:guid, dialog.guid}, :accept, %{}) + :ok + end - # @spec dismiss(Dialog.t()) :: :ok - # def dismiss(dialog) + def accept(%__MODULE__{session: session} = dialog, prompt_text) when is_binary(prompt_text) do + Channel.post(session, {:guid, dialog.guid}, :accept, %{promptText: prompt_text}) + :ok + end - # @spec message(Dialog.t()) :: binary() - # def message(dialog) + @doc """ + Dismiss the dialog (click Cancel or close). + """ + @spec dismiss(t()) :: :ok + def dismiss(%__MODULE__{session: session} = dialog) do + Channel.post(session, {:guid, dialog.guid}, :dismiss, %{}) + :ok + end - # @spec page(Dialog.t()) :: nil | Page.t() - # def page(dialog) + @doc """ + Get the page that initiated the dialog. - # @spec type(Dialog.t()) :: binary() - # def type(dialog) + Returns `nil` if dialog was triggered by a different context. + """ + @spec page(t()) :: Playwright.Page.t() | nil + def page(%__MODULE__{session: session, parent: parent}) do + case parent do + %{guid: guid} -> Channel.find(session, {:guid, guid}) + _ -> nil + end + end end diff --git a/man/guides/feature_parity.md b/man/guides/feature_parity.md index b893f9ab..128da3e7 100644 --- a/man/guides/feature_parity.md +++ b/man/guides/feature_parity.md @@ -513,12 +513,12 @@ These modules exist but have no implemented methods (all commented out): | Method | Status | Notes | |--------|--------|-------| -| `accept(promptText)` | [ ] | !Priority | -| `dismiss()` | [ ] | !Priority | -| `message()` | [ ] | | -| `defaultValue()` | [ ] | | -| `type()` | [ ] | | -| `page()` | [ ] | | +| `accept(promptText)` | [x] | | +| `dismiss()` | [x] | | +| `message()` | [x] | | +| `defaultValue()` | [x] | `default_value/1` | +| `type()` | [x] | | +| `page()` | [x] | | ### Download (`lib/playwright/page/download.ex`) diff --git a/test/api/dialog_test.exs b/test/api/dialog_test.exs new file mode 100644 index 00000000..498cf1e5 --- /dev/null +++ b/test/api/dialog_test.exs @@ -0,0 +1,161 @@ +defmodule Playwright.DialogTest do + use Playwright.TestCase, async: true + alias Playwright.{Dialog, Page} + alias Playwright.SDK.Channel.Event + + describe "Dialog.accept/1" do + test "accepts an alert dialog", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + # Spawn a task to handle dialog to avoid deadlock with Connection GenServer + Task.start(fn -> + send(test_pid, {:dialog_type, Dialog.type(dialog)}) + send(test_pid, {:dialog_message, Dialog.message(dialog)}) + Dialog.accept(dialog) + send(test_pid, :dialog_handled) + end) + end) + + Page.evaluate(page, "() => alert('Hello!')") + assert_receive({:dialog_type, "alert"}, 5000) + assert_receive({:dialog_message, "Hello!"}, 5000) + assert_receive(:dialog_handled, 5000) + end + + test "accepts a confirm dialog returning true", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + send(test_pid, {:dialog_type, Dialog.type(dialog)}) + Dialog.accept(dialog) + send(test_pid, :dialog_handled) + end) + end) + + task = + Task.async(fn -> + Page.evaluate(page, "() => confirm('Accept?')") + end) + + assert_receive({:dialog_type, "confirm"}, 5000) + assert_receive(:dialog_handled, 5000) + result = Task.await(task) + assert result == true + end + end + + describe "Dialog.dismiss/1" do + test "dismisses a confirm dialog returning false", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + Dialog.dismiss(dialog) + send(test_pid, :dialog_handled) + end) + end) + + task = + Task.async(fn -> + Page.evaluate(page, "() => confirm('Accept?')") + end) + + assert_receive(:dialog_handled, 5000) + result = Task.await(task) + assert result == false + end + end + + describe "Dialog.accept/2 with prompt" do + test "accepts a prompt with text", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + send(test_pid, {:dialog_type, Dialog.type(dialog)}) + send(test_pid, {:default_value, Dialog.default_value(dialog)}) + Dialog.accept(dialog, "my input") + send(test_pid, :dialog_handled) + end) + end) + + task = + Task.async(fn -> + Page.evaluate(page, "() => prompt('Enter:', 'default')") + end) + + assert_receive({:dialog_type, "prompt"}, 5000) + assert_receive({:default_value, "default"}, 5000) + assert_receive(:dialog_handled, 5000) + result = Task.await(task) + assert result == "my input" + end + + test "accepts a prompt without text uses empty string", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + Dialog.accept(dialog) + send(test_pid, :dialog_handled) + end) + end) + + task = + Task.async(fn -> + Page.evaluate(page, "() => prompt('Enter:', 'default value')") + end) + + assert_receive(:dialog_handled, 5000) + result = Task.await(task) + # When accepting without text, Playwright uses empty string, not default + assert result == "" + end + end + + describe "Dialog properties" do + test "message/1 returns dialog message", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + send(test_pid, {:message, Dialog.message(dialog)}) + Dialog.accept(dialog) + end) + end) + + Page.evaluate(page, "() => alert('Test message')") + assert_receive({:message, "Test message"}, 5000) + end + + test "type/1 returns dialog type", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + send(test_pid, {:type, Dialog.type(dialog)}) + Dialog.accept(dialog) + end) + end) + + Page.evaluate(page, "() => alert('Test')") + assert_receive({:type, "alert"}, 5000) + end + + test "default_value/1 returns prompt default", %{page: page} do + test_pid = self() + + Page.on(page, :dialog, fn %Event{params: %{dialog: dialog}} -> + Task.start(fn -> + send(test_pid, {:default_value, Dialog.default_value(dialog)}) + Dialog.accept(dialog) + end) + end) + + Page.evaluate(page, "() => prompt('Enter:', 'default value')") + assert_receive({:default_value, "default value"}, 5000) + end + end +end From bb248ca66ef4660f99f36e0aeb86a205c94b3e49 Mon Sep 17 00:00:00 2001 From: Tristan Peralta Date: Sat, 17 Jan 2026 19:24:15 +0800 Subject: [PATCH 10/52] Add getByRole, getByTestId, getByLabel locator methods Implement modern locator methods for Page, Frame, and Locator modules: - get_by_role/3 with full options support (name, checked, disabled, etc.) - get_by_test_id/2 for data-testid attribute selection - get_by_label/3 for label-based element selection All methods follow the existing three-layer delegation pattern and support chaining. --- lib/playwright/frame.ex | 55 ++++++++++-- lib/playwright/locator.ex | 113 +++++++++++++++++++++++- lib/playwright/page.ex | 55 ++++++++++-- man/guides/feature_parity.md | 26 +++--- test/api/get_by_test.exs | 161 +++++++++++++++++++++++++++++++++++ 5 files changed, 381 insertions(+), 29 deletions(-) create mode 100644 test/api/get_by_test.exs diff --git a/lib/playwright/frame.ex b/lib/playwright/frame.ex index 5d24cc13..2d1f7c96 100644 --- a/lib/playwright/frame.ex +++ b/lib/playwright/frame.ex @@ -441,17 +441,60 @@ defmodule Playwright.Frame do # @spec get_by_alt_text(Frame.t(), binary(), options()) :: Playwright.Locator.t() | nil # def get_by_alt_text(frame, text, options \\ %{}) - # @spec get_by_label(Frame.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_label(frame, text, options \\ %{}) + @doc """ + Allows locating elements by their associated label text. + + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ---------- | ----------- | + | `text` | param | `binary()` | Label text to locate. | + | `:exact` | option | `boolean()`| Whether to find an exact match. Default to false. | + """ + @spec get_by_label(t(), binary(), %{optional(:exact) => boolean()}) :: Playwright.Locator.t() + def get_by_label(frame, text, options \\ %{}) when is_binary(text) do + locator(frame, Locator.get_by_label_selector(text, options)) + end # @spec get_by_placeholder(Frame.t(), binary(), options()) :: Playwright.Locator.t() | nil # def get_by_placeholder(frame, text, options \\ %{}) - # @spec get_by_role(Frame.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_role(frame, text, options \\ %{}) + @doc """ + Allows locating elements by ARIA role. + + ## Arguments + + | key/name | type | | description | + | ---------------- | ------ | ---------- | ----------- | + | `role` | param | `binary()` | ARIA role (e.g., "button", "heading"). | + | `:name` | option | `binary()` | Filter by accessible name. | + | `:exact` | option | `boolean()`| Exact name match. Default to false. | + | `:checked` | option | `boolean()`| Filter by checked state. | + | `:disabled` | option | `boolean()`| Filter by disabled state. | + | `:expanded` | option | `boolean()`| Filter by expanded state. | + | `:include_hidden`| option | `boolean()`| Include hidden elements. | + | `:level` | option | `integer()`| Heading level (1-6). | + | `:pressed` | option | `boolean()`| Filter by pressed state. | + | `:selected` | option | `boolean()`| Filter by selected state. | + """ + @spec get_by_role(t(), binary(), map()) :: Playwright.Locator.t() + def get_by_role(frame, role, options \\ %{}) when is_binary(role) do + locator(frame, Locator.get_by_role_selector(role, options)) + end + + @doc """ + Allows locating elements by their test id attribute (data-testid by default). - # @spec get_by_test_id(Frame.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_test_id(frame, text, options \\ %{}) + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ---------- | ----------- | + | `test_id` | param | `binary()` | The test id to locate. | + """ + @spec get_by_test_id(t(), binary()) :: Playwright.Locator.t() + def get_by_test_id(frame, test_id) when is_binary(test_id) do + locator(frame, Locator.get_by_test_id_selector(test_id)) + end @doc """ Allows locating elements that contain given text. diff --git a/lib/playwright/locator.ex b/lib/playwright/locator.ex index 9bc502fc..bb10b15b 100644 --- a/lib/playwright/locator.ex +++ b/lib/playwright/locator.ex @@ -660,14 +660,60 @@ defmodule Playwright.Locator do # @spec get_by_alt_text(Locator.t(), binary(), options()) :: Locator.t() # def get_by_alt_text(locator, text, options \\ %{}) - # @spec get_by_label(Locator.t(), binary(), options()) :: Locator.t() - # def get_by_label(locator, text, options \\ %{}) + @doc """ + Allows locating elements by their associated label text. + + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ---------- | ----------- | + | `text` | param | `binary()` | Label text to locate. | + | `:exact` | option | `boolean()`| Whether to find an exact match: case-sensitive and whole-string. Default to false. | + """ + @spec get_by_label(t(), binary(), %{optional(:exact) => boolean()}) :: t() + def get_by_label(locator, text, options \\ %{}) when is_binary(text) do + locator |> Locator.locator(get_by_label_selector(text, options)) + end # @spec get_by_placeholder(Locator.t(), binary(), options()) :: Locator.t() # def get_by_placeholder(locator, text, options \\ %{}) - # @spec get_by_test_id(Locator.t(), binary(), options()) :: Locator.t() - # def get_by_test_id(locator, text, options \\ %{}) + @doc """ + Allows locating elements by ARIA role. + + ## Arguments + + | key/name | type | | description | + | ---------------- | ------ | ---------- | ----------- | + | `role` | param | `binary()` | ARIA role (e.g., "button", "heading"). | + | `:name` | option | `binary()` | Filter by accessible name. | + | `:exact` | option | `boolean()`| Exact name match. Default to false. | + | `:checked` | option | `boolean()`| Filter by checked state. | + | `:disabled` | option | `boolean()`| Filter by disabled state. | + | `:expanded` | option | `boolean()`| Filter by expanded state. | + | `:include_hidden`| option | `boolean()`| Include hidden elements. | + | `:level` | option | `integer()`| Heading level (1-6). | + | `:pressed` | option | `boolean()`| Filter by pressed state. | + | `:selected` | option | `boolean()`| Filter by selected state. | + """ + @spec get_by_role(t(), binary(), map()) :: t() + def get_by_role(locator, role, options \\ %{}) when is_binary(role) do + locator |> Locator.locator(get_by_role_selector(role, options)) + end + + @doc """ + Allows locating elements by their test id attribute (data-testid by default). + + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ---------- | ----------- | + | `test_id` | param | `binary()` | The test id to locate. | + """ + @spec get_by_test_id(t(), binary()) :: t() + def get_by_test_id(locator, test_id) when is_binary(test_id) do + locator |> Locator.locator(get_by_test_id_selector(test_id)) + end @doc """ Allows locating elements that contain given text. @@ -703,6 +749,65 @@ defmodule Playwright.Locator do "internal:text=\"#{text}\"" <> selector_suffix end + @doc false + def get_by_test_id_selector(test_id) do + escaped = escape_for_attribute_selector(test_id, true) + "internal:testid=[data-testid=#{escaped}]" + end + + @doc false + def get_by_label_selector(text, options \\ %{}) do + exact = Map.get(options, :exact, false) + escaped = escape_for_text_selector(text, exact) + "internal:label=#{escaped}" + end + + @doc false + def get_by_role_selector(role, options \\ %{}) do + props = + [] + |> maybe_add_prop(options, :checked) + |> maybe_add_prop(options, :disabled) + |> maybe_add_prop(options, :selected) + |> maybe_add_prop(options, :expanded) + |> maybe_add_prop(options, :include_hidden, "include-hidden") + |> maybe_add_prop(options, :level) + |> maybe_add_prop(options, :pressed) + + props = + if Map.has_key?(options, :name) do + exact = Map.get(options, :exact, false) + escaped = escape_for_attribute_selector(options.name, exact) + props ++ [["name", escaped]] + else + props + end + + props_str = Enum.map_join(props, "", fn [n, v] -> "[#{n}=#{v}]" end) + "internal:role=#{role}#{props_str}" + end + + defp maybe_add_prop(props, options, key, attr_name \\ nil) do + attr_name = attr_name || Atom.to_string(key) + + if Map.has_key?(options, key) do + props ++ [[attr_name, "#{Map.get(options, key)}"]] + else + props + end + end + + defp escape_for_text_selector(text, exact) do + suffix = if exact, do: "s", else: "i" + "\"#{text}\"#{suffix}" + end + + defp escape_for_attribute_selector(value, exact) do + escaped = value |> String.replace("\\", "\\\\") |> String.replace("\"", "\\\"") + suffix = if exact, do: "s", else: "i" + "\"#{escaped}\"#{suffix}" + end + # @spec get_by_title(Locator.t(), binary(), options()) :: Locator.t() # def get_by_title(locator, text, options \\ %{}) diff --git a/lib/playwright/page.ex b/lib/playwright/page.ex index 6b3978d7..4f06aa1f 100644 --- a/lib/playwright/page.ex +++ b/lib/playwright/page.ex @@ -414,17 +414,60 @@ defmodule Playwright.Page do # @spec get_by_alt_text(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil # def get_by_alt_text(page, text, options \\ %{}) - # @spec get_by_label(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_label(page, text, options \\ %{}) + @doc """ + Allows locating elements by their associated label text. + + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ---------- | ----------- | + | `text` | param | `binary()` | Label text to locate. | + | `:exact` | option | `boolean()`| Whether to find an exact match. Default to false. | + """ + @spec get_by_label(t(), binary(), %{optional(:exact) => boolean()}) :: Playwright.Locator.t() + def get_by_label(page, text, options \\ %{}) when is_binary(text) do + main_frame(page) |> Frame.get_by_label(text, options) + end # @spec get_by_placeholder(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil # def get_by_placeholder(page, text, options \\ %{}) - # @spec get_by_role(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_role(page, text, options \\ %{}) + @doc """ + Allows locating elements by ARIA role. + + ## Arguments + + | key/name | type | | description | + | ---------------- | ------ | ---------- | ----------- | + | `role` | param | `binary()` | ARIA role (e.g., "button", "heading"). | + | `:name` | option | `binary()` | Filter by accessible name. | + | `:exact` | option | `boolean()`| Exact name match. Default to false. | + | `:checked` | option | `boolean()`| Filter by checked state. | + | `:disabled` | option | `boolean()`| Filter by disabled state. | + | `:expanded` | option | `boolean()`| Filter by expanded state. | + | `:include_hidden`| option | `boolean()`| Include hidden elements. | + | `:level` | option | `integer()`| Heading level (1-6). | + | `:pressed` | option | `boolean()`| Filter by pressed state. | + | `:selected` | option | `boolean()`| Filter by selected state. | + """ + @spec get_by_role(t(), binary(), map()) :: Playwright.Locator.t() + def get_by_role(page, role, options \\ %{}) when is_binary(role) do + main_frame(page) |> Frame.get_by_role(role, options) + end + + @doc """ + Allows locating elements by their test id attribute (data-testid by default). - # @spec get_by_test_id(Page.t(), binary(), options()) :: Playwright.Locator.t() | nil - # def get_by_test_id(page, text, options \\ %{}) + ## Arguments + + | key/name | type | | description | + | ---------- | ------ | ---------- | ----------- | + | `test_id` | param | `binary()` | The test id to locate. | + """ + @spec get_by_test_id(t(), binary()) :: Playwright.Locator.t() + def get_by_test_id(page, test_id) when is_binary(test_id) do + main_frame(page) |> Frame.get_by_test_id(test_id) + end @doc """ Allows locating elements that contain given text. diff --git a/man/guides/feature_parity.md b/man/guides/feature_parity.md index 128da3e7..211eef8a 100644 --- a/man/guides/feature_parity.md +++ b/man/guides/feature_parity.md @@ -62,9 +62,9 @@ This document tracks the implementation status of Playwright features in playwri |--------|--------|-------| | `locator(selector, options)` | [x] | | | `getByText(text, options)` | [x] | | -| `getByRole(role, options)` | [ ] | !Priority - accessibility testing | -| `getByTestId(testId)` | [ ] | !Priority - common pattern | -| `getByLabel(text, options)` | [ ] | !Priority - form testing | +| `getByRole(role, options)` | [x] | | +| `getByTestId(testId)` | [x] | | +| `getByLabel(text, options)` | [x] | | | `getByPlaceholder(text, options)` | [ ] | | | `getByAltText(text, options)` | [ ] | | | `getByTitle(text, options)` | [ ] | | @@ -190,9 +190,9 @@ This document tracks the implementation status of Playwright features in playwri | `and(locator)` | [ ] | Combine locators | | `or(locator)` | [x] | As `or_` | | `getByText(text, options)` | [x] | | -| `getByRole(role, options)` | [ ] | !Priority | -| `getByTestId(testId)` | [ ] | !Priority | -| `getByLabel(text, options)` | [ ] | !Priority | +| `getByRole(role, options)` | [x] | | +| `getByTestId(testId)` | [x] | | +| `getByLabel(text, options)` | [x] | | | `getByPlaceholder(text, options)` | [ ] | | | `getByAltText(text, options)` | [ ] | | | `getByTitle(text, options)` | [ ] | | @@ -432,9 +432,9 @@ This document tracks the implementation status of Playwright features in playwri |--------|--------|-------| | `locator(selector, options)` | [x] | | | `getByText(text, options)` | [x] | | -| `getByRole(role, options)` | [ ] | | -| `getByTestId(testId)` | [ ] | | -| `getByLabel(text, options)` | [ ] | | +| `getByRole(role, options)` | [x] | | +| `getByTestId(testId)` | [x] | | +| `getByLabel(text, options)` | [x] | | | `getByPlaceholder(text, options)` | [ ] | | | `getByAltText(text, options)` | [ ] | | | `getByTitle(text, options)` | [ ] | | @@ -610,12 +610,12 @@ These modules exist but have no implemented methods (all commented out): 1. ~~`Page.goBack()` / `Page.goForward()`~~ DONE 2. `Page.waitForNavigation()` 3. ~~`Page.waitForURL()`~~ DONE -4. `Dialog.accept()` / `Dialog.dismiss()` +4. ~~`Dialog.accept()` / `Dialog.dismiss()`~~ DONE ### Phase 2: Modern Locators (Developer Experience) -1. `*.getByRole()` -2. `*.getByTestId()` -3. `*.getByLabel()` +1. ~~`*.getByRole()`~~ DONE +2. ~~`*.getByTestId()`~~ DONE +3. ~~`*.getByLabel()`~~ DONE 4. `Locator.filter()` ### Phase 3: Session & State (Testing Infrastructure) diff --git a/test/api/get_by_test.exs b/test/api/get_by_test.exs new file mode 100644 index 00000000..3399fc81 --- /dev/null +++ b/test/api/get_by_test.exs @@ -0,0 +1,161 @@ +defmodule Playwright.GetByTest do + use Playwright.TestCase, async: true + alias Playwright.{Locator, Page} + + describe "get_by_test_id/2" do + test "locates element by data-testid", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_test_id(page, "submit-btn") + assert Locator.text_content(locator) == "Submit" + end + + test "locates element with special characters in testid", %{page: page} do + Page.set_content(page, ~s|
Profile
|) + locator = Page.get_by_test_id(page, "user-profile-123") + assert Locator.text_content(locator) == "Profile" + end + end + + describe "get_by_label/3" do + test "locates input by label text (implicit association)", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_label(page, "Email") + assert Locator.count(locator) == 1 + end + + test "locates input by label text (explicit for association)", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_label(page, "Email") + assert Locator.count(locator) == 1 + end + + test "with exact option matches only exact text", %{page: page} do + Page.set_content( + page, + ~s|| + ) + + locator = Page.get_by_label(page, "Email", %{exact: true}) + assert Locator.count(locator) == 1 + assert Locator.get_attribute(locator, "id") == "short" + end + + test "without exact option matches partial text", %{page: page} do + Page.set_content( + page, + ~s|| + ) + + locator = Page.get_by_label(page, "Email") + assert Locator.count(locator) == 2 + end + end + + describe "get_by_role/3" do + test "locates element by role", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_role(page, "button") + assert Locator.text_content(locator) == "Click me" + end + + test "filters by name option", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_role(page, "button", %{name: "OK"}) + assert Locator.count(locator) == 1 + assert Locator.text_content(locator) == "OK" + end + + test "filters by disabled state", %{page: page} do + Page.set_content(page, ~s||) + locator = Page.get_by_role(page, "button", %{disabled: true}) + assert Locator.text_content(locator) == "Cancel" + end + + test "locates headings by level", %{page: page} do + Page.set_content(page, ~s|

Title

Subtitle

Section

|) + locator = Page.get_by_role(page, "heading", %{level: 2}) + assert Locator.text_content(locator) == "Subtitle" + end + + test "locates link by name", %{page: page} do + Page.set_content(page, ~s|HomeAbout|) + locator = Page.get_by_role(page, "link", %{name: "About"}) + assert Locator.get_attribute(locator, "href") == "/about" + end + + test "filters by checked state", %{page: page} do + Page.set_content( + page, + ~s|| + ) + + locator = Page.get_by_role(page, "checkbox", %{checked: true}) + assert Locator.get_attribute(locator, "id") == "b" + end + end + + describe "chaining getBy methods" do + test "chains getBy methods on Page then Locator", %{page: page} do + Page.set_content(page, ~s| +
+ + +
+
+ +
+ |) + + locator = + page + |> Page.get_by_test_id("form") + |> Locator.get_by_role("button", %{name: "Submit"}) + + assert Locator.text_content(locator) == "Submit" + end + + test "chains multiple getBy methods on Locator", %{page: page} do + Page.set_content(page, ~s| +
+
+ +
+
+ +
+
+ |) + + locator = + page + |> Page.get_by_test_id("users") + |> Locator.get_by_test_id("user-1") + |> Locator.get_by_role("button") + + assert Locator.count(locator) == 1 + end + end + + describe "Frame.get_by_* methods" do + test "get_by_test_id works on frame", %{page: page} do + Page.set_content(page, ~s|Frame content|) + frame = Page.main_frame(page) + locator = Playwright.Frame.get_by_test_id(frame, "main") + assert Locator.text_content(locator) == "Frame content" + end + + test "get_by_label works on frame", %{page: page} do + Page.set_content(page, ~s||) + frame = Page.main_frame(page) + locator = Playwright.Frame.get_by_label(frame, "Username") + assert Locator.count(locator) == 1 + end + + test "get_by_role works on frame", %{page: page} do + Page.set_content(page, ~s||) + frame = Page.main_frame(page) + locator = Playwright.Frame.get_by_role(frame, "navigation") + assert Locator.count(locator) == 1 + end + end +end From a7b9fc0c95c8d855e769405c5960fc36c1f8cf0e Mon Sep 17 00:00:00 2001 From: Tristan Peralta Date: Sat, 17 Jan 2026 19:27:23 +0800 Subject: [PATCH 11/52] Remove broken documentation cross-references in Locator module - Replace references to undefined set_default_timeout/2 functions with generic text - Replace references to hidden Channel.Error module with generic error types - Use term() in @spec for error returns to avoid hidden module references --- lib/playwright/locator.ex | 76 +++++++++++++++++++-------------------- 1 file changed, 38 insertions(+), 38 deletions(-) diff --git a/lib/playwright/locator.ex b/lib/playwright/locator.ex index bb10b15b..a948b566 100644 --- a/lib/playwright/locator.ex +++ b/lib/playwright/locator.ex @@ -194,7 +194,7 @@ defmodule Playwright.Locator do | key/name | type | | description | | ---------- | ------ | ---------- | ----------- | - | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via `Playwright.BrowserContext.set_default_timeout/2` or `Playwright.Page.set_default_timeout/2`. `(default: 30 seconds)` | + | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via BrowserContext or Page timeout settings. `(default: 30 seconds)` | """ @spec bounding_box(t(), options()) :: map() | nil def bounding_box(%Locator{} = locator, options \\ %{}) do @@ -232,7 +232,7 @@ defmodule Playwright.Locator do | `:force` | option | `boolean()` | Whether to bypass the actionability checks. `(default: false)` | | `:no_wait_after` | option | `boolean()` | Actions that initiate navigations are waiting for these navigations to happen and for pages to start loading. You can opt out of waiting via setting this flag. You would only need this option in the exceptional cases such as navigating to inaccessible pages. `(default: false)` | | `:position` | option | `%{x: x, y: y}` | A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of the element. | - | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via `Playwright.BrowserContext.set_default_timeout/2` or `Playwright.Page.set_default_timeout/2`. `(default: 30 seconds)` | + | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via BrowserContext or Page timeout settings. `(default: 30 seconds)` | | `:trial` | option | `boolean()` | When set, this call only performs the actionability checks and skips the action. Useful to wait until the element is ready for the action without performing it. `(default: false)` | """ @spec check(t(), options()) :: :ok @@ -262,7 +262,7 @@ defmodule Playwright.Locator do If the element is detached from the DOM at any moment during the action, this method throws. When all steps combined have not finished during the specified timeout, this method throws a - `Playwright.SDK.Channel.Error.t()`. Passing `0` timeout disables this. + timeout error. Passing `0` timeout disables this. ## Returns @@ -279,7 +279,7 @@ defmodule Playwright.Locator do | `:modifiers` | option | `[:alt, :control, :meta, :shift]` | Modifier keys to press. Ensures that only these modifiers are pressed during the operation, and then restores current modifiers back. If not specified, currently pressed modifiers are used. | | `:no_wait_after` | option | `boolean()` | Actions that initiate navigations are waiting for these navigations to happen and for pages to start loading. You can opt out of waiting via setting this flag. You would only need this option in the exceptional cases such as navigating to inaccessible pages. `(default: false)` | | `:position` | option | `%{x: x, y: y}` | A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of the element. | - | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via `Playwright.BrowserContext.set_default_timeout/2` or `Playwright.Page.set_default_timeout/2`. `(default: 30 seconds)` | + | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via BrowserContext or Page timeout settings. `(default: 30 seconds)` | | `:trial` | option | `boolean()` | When set, this call only performs the actionability checks and skips the action. Useful to wait until the element is ready for the action without performing it. `(default: false)` | """ @spec click(t(), options_click()) :: :ok @@ -342,7 +342,7 @@ defmodule Playwright.Locator do | `:modifiers` | option | `[:alt, :control, :meta, :shift]` | Modifier keys to press. Ensures that only these modifiers are pressed during the operation, and then restores current modifiers back. If not specified, currently pressed modifiers are used. | | `:no_wait_after` | option | `boolean()` | Actions that initiate navigations are waiting for these navigations to happen and for pages to start loading. You can opt out of waiting via setting this flag. You would only need this option in the exceptional cases such as navigating to inaccessible pages. `(default: false)` | | `:position` | option | `%{x: x, y: y}` | A point to use relative to the top-left corner of element padding box. If not specified, uses some visible point of the element. | - | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed by using the `Playwright.BrowserContext.set_default_timeout/2` or `Playwright.Page.set_default_timeout/2` functions. `(default: 30 seconds)` | + | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via BrowserContext or Page timeout settings. `(default: 30 seconds)` | | `:trial` | option | `boolean()` | When set, this call only performs the actionability checks and skips the action. Useful to wait until the element is ready for the action without performing it. `(default: false)` | """ @spec dblclick(t(), options()) :: :ok @@ -395,7 +395,7 @@ defmodule Playwright.Locator do | ---------------- | ------ | ----------------------- | ----------- | | `type` | param | `atom()` or `binary()` | DOM event type: `:click`, `:dragstart`, etc. | | `event_init` | param | `evaluation_argument()` | Optional event-specific initialization properties. | - | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed by using the `Playwright.BrowserContext.set_default_timeout/2` or `Playwright.Page.set_default_timeout/2` functions. `(default: 30 seconds)` | + | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via BrowserContext or Page timeout settings. `(default: 30 seconds)` | """ @spec dispatch_event(t(), atom() | binary(), Frame.evaluation_argument(), options()) :: :ok def dispatch_event(%Locator{} = locator, type, event_init \\ nil, options \\ %{}) do @@ -420,16 +420,16 @@ defmodule Playwright.Locator do ## Returns - `Playwright.ElementHandle.t()` - - `{:error, Playwright.SDK.Channel.Error.t()}` + - `{:error, error}` ## Arguments | key/name | type | | description | | ---------- | ------ | ---------- | ----------- | - | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed by using the `Playwright.BrowserContext.set_default_timeout/2` or `Playwright.Page.set_default_timeout/2` functions. `(default: 30 seconds)` | + | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via BrowserContext or Page timeout settings. `(default: 30 seconds)` | """ @doc deprecated: "Discouraged: Prefer using Locators and web assertions over ElementHandles because latter are inherently racy." - @spec element_handle(t(), options()) :: ElementHandle.t() | {:error, Channel.Error.t()} + @spec element_handle(t(), options()) :: ElementHandle.t() | {:error, term()} def element_handle(%Locator{} = locator, options \\ %{}) do options = Map.merge(%{strict: true, state: "attached"}, options) @@ -466,7 +466,7 @@ defmodule Playwright.Locator do | ------------ | ------ | ---------- | ----------- | | `expression` | param | `binary()` | JavaScript expression to be evaluated in the browser context. If it looks like a function declaration, it is interpreted as a function. Otherwise, evaluated as an expression. | | `arg` | param | `any()` | Argument to pass to `expression` `(optional)` | - | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed by using the `Playwright.BrowserContext.set_default_timeout/2` or `Playwright.Page.set_default_timeout/2` functions. `(default: 30 seconds)` | + | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via BrowserContext or Page timeout settings. `(default: 30 seconds)` | """ @spec evaluate(t(), binary(), any(), options()) :: serializable() def evaluate(locator, expression, arg \\ nil, options \\ %{}) @@ -527,7 +527,7 @@ defmodule Playwright.Locator do ## Returns - `Playwright.ElementHandle.t()` - - `{:error, Playwright.SDK.Channel.Error.t()}` + - `{:error, error}` ## Arguments @@ -535,9 +535,9 @@ defmodule Playwright.Locator do | ------------ | ------ | ---------- | ----------- | | `expression` | param | `binary()` | JavaScript expression to be evaluated in the browser context. If it looks like a function declaration, it is interpreted as a function. Otherwise, evaluated as an expression. | | `arg` | param | `any()` | Argument to pass to `expression` `(optional)` | - | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed by using the `Playwright.BrowserContext.set_default_timeout/2` or `Playwright.Page.set_default_timeout/2` functions. `(default: 30 seconds)` | + | `:timeout` | option | `number()` | Maximum time in milliseconds. Pass `0` to disable timeout. The default value can be changed via BrowserContext or Page timeout settings. `(default: 30 seconds)` | """ - @spec evaluate_handle(t(), binary(), any(), options()) :: ElementHandle.t() | {:error, Channel.Error.t()} + @spec evaluate_handle(t(), binary(), any(), options()) :: ElementHandle.t() | {:error, term()} def evaluate_handle(locator, expression, arg \\ nil, options \\ %{}) # NOTE: need to do all of the map-like things before a plain `map()`, @@ -596,7 +596,7 @@ defmodule Playwright.Locator do | `value` | param | `binary()` | Value to fill for the ``, `