diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34366c57..41517248 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ "main" ] + branches: ["main"] pull_request: - branches: [ "main" ] + branches: ["main"] env: MIX_ENV: test @@ -14,60 +14,85 @@ permissions: jobs: test: + services: + postgres: + image: postgres + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: phoenix_test_test + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 runs-on: ubuntu-latest name: Test on OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} strategy: matrix: - otp: ['26.1.2'] - elixir: ['1.15.0', '1.16.0', '1.17.0'] + otp: ["26.1.2"] + elixir: ["1.15.0", "1.16.0", "1.17.0", "main"] steps: - - name: Set up Elixir - uses: erlef/setup-beam@v1 - with: - otp-version: ${{matrix.otp}} - elixir-version: ${{matrix.elixir}} - - - name: Checkout code - uses: actions/checkout@v3 - - - name: Cache deps - id: cache-deps - uses: actions/cache@v3 - env: - cache-name: cache-elixir-deps - with: - path: deps - key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} - restore-keys: | - ${{ runner.os }}-mix-${{ env.cache-name }}- - - - name: Cache compiled build - id: cache-build - uses: actions/cache@v3 - env: - cache-name: cache-compiled-build - with: - path: _build - key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} - restore-keys: | - ${{ runner.os }}-mix-${{ env.cache-name }}- - ${{ runner.os }}-mix- - - - name: Bust cache on job rerun to rule out incremental build as a source of flakiness - if: github.run_attempt != '1' - run: | - mix deps.clean --all - mix clean - shell: sh - - - name: Install dependencies - run: mix deps.get - - - name: Compiles without warnings - run: mix compile --warnings-as-errors - - - name: Check Formatting - run: mix format --check-formatted - - - name: Run tests - run: mix test + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + otp-version: ${{matrix.otp}} + elixir-version: ${{matrix.elixir}} + + - name: Checkout code + uses: actions/checkout@v3 + + - name: Cache deps + id: cache-deps + uses: actions/cache@v3 + env: + cache-name: cache-elixir-deps + with: + path: deps + key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-${{ env.cache-name }}- + + - name: Cache compiled build + id: cache-build + uses: actions/cache@v3 + env: + cache-name: cache-compiled-build + with: + path: _build + key: ${{ runner.os }}-mix-${{ env.cache-name }}-${{ hashFiles('**/mix.lock') }} + restore-keys: | + ${{ runner.os }}-mix-${{ env.cache-name }}- + ${{ runner.os }}-mix- + + - name: Bust cache on job rerun to rule out incremental build as a source of flakiness + if: github.run_attempt != '1' + run: | + mix deps.clean --all + mix clean + shell: sh + + - name: Install dependencies + run: mix deps.get + + - name: Install JS dependencies + run: npm ci --prefix priv/static/assets + + - name: Compiles without warnings + run: mix compile --warnings-as-errors + + - name: Check Formatting + run: mix format --check-formatted + + - name: Build assets for browser tests + run: mix do assets.setup, assets.build + + - name: Setup database + run: mix ecto.setup + + - name: Install playwright browsers + run: npm exec --prefix priv/static/assets playwright install --with-deps --only-shell + + - name: Run tests + run: "mix test || if [[ $? = 2 ]]; then mix test --failed; else false; fi" diff --git a/.gitignore b/.gitignore index 6bdf718f..727ba1a0 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,9 @@ phoenix_test-*.tar # Ignore assets that are produced by build tools. /priv/static/assets/ + +!/priv/static/assets/app.css +!/priv/static/assets/package.json +!/priv/static/assets/package-lock.json + +.envrc diff --git a/.tool-versions b/.tool-versions index d6b59dd2..a1073d9c 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ erlang 27.0 -elixir 1.17.2-otp-27 +elixir main diff --git a/config/test.exs b/config/test.exs index 68cbde9f..50c5387a 100644 --- a/config/test.exs +++ b/config/test.exs @@ -1,6 +1,14 @@ import Config -config :phoenix_test, :endpoint, PhoenixTest.Endpoint +config :phoenix_test, + endpoint: PhoenixTest.Endpoint, + ecto_repos: [PhoenixTest.Repo], + otp_app: :phoenix_test, + playwright: [ + browser: [browser: :chromium, headless: System.get_env("PLAYWRIGHT_HEADLESS", "t") in ~w(t true)], + cli: "priv/static/assets/node_modules/playwright/cli.js", + trace: System.get_env("PLAYWRIGHT_TRACE", "false") in ~w(t true) + ] config :logger, level: :warning @@ -20,3 +28,11 @@ config :esbuild, cd: Path.expand("../test/assets", __DIR__), env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} ] + +config :phoenix_test, PhoenixTest.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "phoenix_test_test#{System.get_env("MIX_TEST_PARTITION")}", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: 10 diff --git a/diff b/diff new file mode 100644 index 00000000..e69de29b diff --git a/lib/phoenix_test.ex b/lib/phoenix_test.ex index afcf2bfb..6dda4dc8 100644 --- a/lib/phoenix_test.ex +++ b/lib/phoenix_test.ex @@ -213,7 +213,7 @@ defmodule PhoenixTest do LiveView or a static view. You don't need to worry about which type of page you're visiting. """ - def visit(conn, path) do + def visit(%Plug.Conn{} = conn, path) do case get(conn, path) do %{assigns: %{live_module: _}} = conn -> PhoenixTest.Live.build(conn) @@ -230,6 +230,10 @@ defmodule PhoenixTest do end end + def visit(driver, path) when is_struct(driver) do + Driver.visit(driver, path) + end + defp all_headers(conn) do Enum.map(conn.req_headers, &elem(&1, 0)) end diff --git a/lib/phoenix_test/case.ex b/lib/phoenix_test/case.ex new file mode 100644 index 00000000..e68f38dd --- /dev/null +++ b/lib/phoenix_test/case.ex @@ -0,0 +1,120 @@ +defmodule PhoenixTest.Case do + @moduledoc """ + ExUnit case module to assist with browser based tests. + See `PhoenixTest.Playwright` for more information. + """ + + use ExUnit.CaseTemplate + + alias PhoenixTest.Case + + using opts do + quote do + import PhoenixTest + + setup do + [phoenix_test: unquote(opts)] + end + end + end + + @playwright_opts [ + browser: :chromium, + headless: true, + slowMo: 0 + ] + + setup_all context do + trace = Application.fetch_env!(:phoenix_test, :playwright)[:trace] + + case context do + %{playwright: true} -> + [browser_id: Case.Playwright.launch_browser(@playwright_opts), trace: trace] + + %{playwright: opts} when is_list(opts) -> + opts = Keyword.merge(@playwright_opts, opts) + [browser_id: Case.Playwright.launch_browser(opts), trace: trace] + + _ -> + :ok + end + end + + setup context do + case context do + %{playwright: p} when p != false -> + [conn: Case.Playwright.new_session(context)] + + _ -> + [conn: Phoenix.ConnTest.build_conn()] + end + end + + defmodule Playwright do + @moduledoc false + import PhoenixTest.Playwright.Connection + + alias PhoenixTest.Playwright.Browser + alias PhoenixTest.Playwright.BrowserContext + + @includes_ecto Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) && + Code.ensure_loaded?(Phoenix.Ecto.SQL.Sandbox) + + def launch_browser(opts) do + ensure_started() + browser = Keyword.fetch!(opts, :browser) + browser_id = launch_browser(browser, opts) + on_exit(fn -> post(guid: browser_id, method: "close") end) + browser_id + end + + def new_session(%{browser_id: browser_id} = context) do + context_id = Browser.new_context(browser_id) + subscribe(context_id) + + page_id = BrowserContext.new_page(context_id) + post(%{method: :updateSubscription, guid: page_id, params: %{event: "console", enabled: true}}) + frame_id = initializer(page_id).mainFrame.guid + on_exit(fn -> post(guid: context_id, method: "close") end) + + if context[:trace] do + BrowserContext.start_tracing(context_id) + + dir = :phoenix_test |> Application.fetch_env!(:playwright) |> Keyword.fetch!(:trace_dir) + File.mkdir_p!(dir) + + "Elixir." <> case = to_string(context.case) + session_id = System.unique_integer([:positive, :monotonic]) + file = String.replace("#{case}.#{context.test}_#{session_id}.zip", ~r/[^a-zA-Z0-9 \.]/, "_") + path = Path.join(dir, file) + + on_exit(fn -> BrowserContext.stop_tracing(context_id, path) end) + end + + PhoenixTest.Playwright.build(page_id, frame_id) + end + + if @includes_ecto do + def checkout_ecto_repos(async?) do + otp_app = Application.fetch_env!(:phoenix_test, :otp_app) + repos = Application.fetch_env!(otp_app, :ecto_repos) + + repos + |> Enum.map(&checkout_ecto_repo(&1, async?)) + |> Phoenix.Ecto.SQL.Sandbox.metadata_for(self()) + |> Phoenix.Ecto.SQL.Sandbox.encode_metadata() + end + + defp checkout_ecto_repo(repo, async?) do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(repo) + unless async?, do: Ecto.Adapters.SQL.Sandbox.mode(repo, {:shared, self()}) + + repo + end + else + def checkout_ecto_repos(_) do + nil + end + end + end +end diff --git a/lib/phoenix_test/driver.ex b/lib/phoenix_test/driver.ex index 59ddb17e..1d4cfcfd 100644 --- a/lib/phoenix_test/driver.ex +++ b/lib/phoenix_test/driver.ex @@ -1,5 +1,6 @@ defprotocol PhoenixTest.Driver do @moduledoc false + def visit(session, path) def render_page_title(session) def render_html(session) def click_link(session, selector, text) diff --git a/lib/phoenix_test/live.ex b/lib/phoenix_test/live.ex index 0b8f28fd..d2765651 100644 --- a/lib/phoenix_test/live.ex +++ b/lib/phoenix_test/live.ex @@ -367,6 +367,8 @@ defimpl PhoenixTest.Driver, for: PhoenixTest.Live do alias PhoenixTest.Assertions alias PhoenixTest.Live + def visit(_, _), do: raise(ArgumentError, message: "Unexpected: Call visit/1 with a %Plug.Conn{}.") + defdelegate render_page_title(session), to: Live defdelegate render_html(session), to: Live defdelegate click_link(session, selector, text), to: Live diff --git a/lib/phoenix_test/playwright.ex b/lib/phoenix_test/playwright.ex new file mode 100644 index 00000000..dd5fc7dd --- /dev/null +++ b/lib/phoenix_test/playwright.ex @@ -0,0 +1,398 @@ +defmodule PhoenixTest.Playwright do + @moduledoc """ + > #### Warning {: .warning} + > + > This feature is experimental. + > If you don't need browser based tests, see `m:PhoenixTest#module-usage` on regular usage. + + Run tests tests in a one or more browsers via [Playwright](https://playwright.dev/). + + + ## Setup + + 1. Install or vendor [playwright](https://www.npmjs.com/package/playwright) using your existing JS pipeline + 2. Install playwright browsers: `npm exec --prefix assets playwright install --with-deps` + 3. Add to `config/test.exs`: `config :phoenix_test, otp_app: :your_app, playwright: [cli: "assets/node_modules/playwright/cli.js"]` + 4. Add to `test/test_helpers.exs`: `Application.put_env(:phoenix_test, :base_url, YourAppWeb.Endpoint.url())` + + + ## Usage + ```ex + defmodule MyFeatureTest do + use PhoenixTest.Case, + async: true, + parameterize: [%{playwright: [browser: :chromium]}, %{playwright: [browser: :firefox]}] + + test "heading", %{conn: conn} do + conn + |> visit("/") + |> assert_has("h1", text: "Heading") + end + end + end + ``` + + As shown above, you can use `m:ExUnit.Case#module-parameterized-tests` parameterized tests + to run tests concurrently in different browsers. + + + ## Known limitations and inconsistencies + + - `PhoenixTest.select/4` option `exact_option` is not supported + - Playwright driver is less strict than `Live` and `Static` drivers. It does not raise errors + - when visiting a page that returns a `404` status + - when interactive elements such as forms and buttons are missing essential attributes (`phx-click`, `phx-submit`, `action`) + - A few small bugs + + See tests tagged with [`@tag playwright: false`](https://github.com/search?q=repo%3Agermsvel%2Fphoenix_test%20%22%40tag%20playwright%3A%20false%22&type=code) + for details. + + + ## Configuration + + In `config/test.exs`: + + ```elixir + config :phoenix_test, + playwright: [ + cli: "assets/node_modules/playwright/cli.js", + browser: [browser: :chromium, headless: System.get_env("PLAYWRIGHT_HEADLESS", "t") in ~w(t true)], + trace: System.get_env("PLAYWRIGHT_TRACE", "false") in ~w(t true), + trace_dir: "tmp" + ], + timeout_ms: 1000 + ``` + + ## Ecto SQL.Sandbox + + `PhoenixTest.Case` automatically takes care of this. + It passes a user agent referencing your Ecto repos. + This allows for [concurrent browser tests](https://hexdocs.pm/phoenix_ecto/main.html#concurrent-browser-tests). + + ```ex + defmodule MyTest do + use PhoenixTest.Case, async: true + ``` + """ + + alias PhoenixTest.Assertions + alias PhoenixTest.Element.Button + alias PhoenixTest.Element.Link + alias PhoenixTest.OpenBrowser + alias PhoenixTest.Playwright.Connection + alias PhoenixTest.Playwright.Frame + alias PhoenixTest.Playwright.Selector + alias PhoenixTest.Query + + require Logger + + defstruct [:page_id, :frame_id, :last_input_selector, within: :none] + + @endpoint Application.compile_env(:phoenix_test, :endpoint) + @default_timeout_ms 1000 + + def build(page_id, frame_id) do + %__MODULE__{page_id: page_id, frame_id: frame_id} + end + + def retry(fun, backoff_ms \\ [100, 250, 500, timeout()]) + def retry(fun, []), do: fun.() + + def retry(fun, [sleep_ms | backoff_ms]) do + fun.() + rescue + ExUnit.AssertionError -> + Process.sleep(sleep_ms) + retry(fun, backoff_ms) + end + + def visit(session, path) do + url = + case path do + "http://" <> _ -> path + "https://" <> _ -> path + _ -> Application.fetch_env!(:phoenix_test, :base_url) <> path + end + + Frame.goto(session.frame_id, url) + session + end + + def assert_has(session, "title") do + retry(fn -> Assertions.assert_has(session, "title") end) + end + + def assert_has(session, selector), do: assert_has(session, selector, []) + + def assert_has(session, "title", opts) do + retry(fn -> Assertions.assert_has(session, "title", opts) end) + end + + def assert_has(session, selector, opts) do + unless found?(session, selector, opts) do + Assertions.assert_has(session, selector, opts) + end + + session + end + + def refute_has(session, "title") do + retry(fn -> Assertions.refute_has(session, "title") end) + end + + def refute_has(session, selector), do: refute_has(session, selector, []) + + def refute_has(session, "title", opts) do + retry(fn -> Assertions.refute_has(session, "title", opts) end) + end + + def refute_has(session, selector, opts) do + if found?(session, selector, opts) do + Assertions.refute_has(session, selector, opts) + end + + session + end + + defp found?(session, selector, opts) do + if opts[:count] && opts[:at] do + raise ArgumentError, message: "Options `count` and `at` can not be used together." + end + + selector = + session + |> maybe_within() + |> Selector.concat(Selector.css(selector)) + |> Selector.concat(Selector.text(opts[:text], opts)) + |> Selector.concat(Selector.at(opts[:at])) + + if opts[:count] do + params = + %{ + expression: "to.have.count", + expectedNumber: opts[:count], + state: "attached", + isNot: false, + selector: selector, + timeout: timeout(opts) + } + + {:ok, found?} = Frame.expect(session.frame_id, params) + found? + else + params = + %{ + # Consistent with PhoenixTest: ignore visiblity + state: "attached", + selector: selector, + timeout: timeout(opts) + } + + case Frame.wait_for_selector(session.frame_id, params) do + {:ok, _} -> true + _ -> false + end + end + end + + def render_page_title(session) do + case Frame.title(session.frame_id) do + {:ok, ""} -> nil + {:ok, title} -> title + end + end + + def render_html(session) do + selector = maybe_within(session) + {:ok, html} = Frame.inner_html(session.frame_id, selector) + html + end + + def click_link(session, css_selector, text) do + selector = + session + |> maybe_within() + |> Selector.concat(Selector.css(css_selector)) + |> Selector.concat(Selector.text(text, exact: true)) + + session.frame_id + |> Frame.click(selector) + |> handle_response(fn -> Link.find!(render_html(session), css_selector, text) end) + + session + end + + def click_button(session, css_selector, text) do + selector = + session + |> maybe_within() + |> Selector.concat(Selector.css(css_selector)) + |> Selector.concat(Selector.text(text, exact: true)) + + session.frame_id + |> Frame.click(selector) + |> handle_response(fn -> Button.find!(render_html(session), css_selector, text) end) + + session + end + + def within(session, selector, fun) do + session + |> Map.put(:within, selector) + |> fun.() + |> Map.put(:within, :none) + end + + def fill_in(session, input_selector, label, opts) do + {value, opts} = Keyword.pop!(opts, :with) + fun = &Frame.fill(session.frame_id, &1, to_string(value), &2) + input(session, input_selector, label, opts, fun) + end + + def select(session, input_selector, option_labels, opts) do + if opts[:exact_option] != true, do: raise("exact_option not implemented") + + {label, opts} = Keyword.pop!(opts, :from) + options = option_labels |> List.wrap() |> Enum.map(&%{label: &1}) + fun = &Frame.select_option(session.frame_id, &1, options, &2) + input(session, input_selector, label, opts, fun) + end + + def check(session, input_selector, label, opts) do + fun = &Frame.check(session.frame_id, &1, &2) + input(session, input_selector, label, opts, fun) + end + + def uncheck(session, input_selector, label, opts) do + fun = &Frame.uncheck(session.frame_id, &1, &2) + input(session, input_selector, label, opts, fun) + end + + def choose(session, input_selector, label, opts) do + fun = &Frame.check(session.frame_id, &1, &2) + input(session, input_selector, label, opts, fun) + end + + def upload(session, input_selector, label, paths, opts) do + paths = paths |> List.wrap() |> Enum.map(&Path.expand/1) + fun = &Frame.set_input_files(session.frame_id, &1, paths, &2) + input(session, input_selector, label, opts, fun) + end + + defp input(session, input_selector, label, opts, fun) do + selector = + session + |> maybe_within() + |> Selector.concat(Selector.css(input_selector)) + |> Selector.and(Selector.label(label, opts)) + + selector + |> fun.(%{timeout: timeout(opts)}) + |> handle_response(fn -> Query.find_by_label!(render_html(session), input_selector, label, opts) end) + + %{session | last_input_selector: selector} + end + + defp maybe_within(session) do + case session.within do + :none -> "*" + selector -> "css=#{selector}" + end + end + + defp handle_response(result, error_fun) do + case result do + {:error, %{error: %{error: %{name: "TimeoutError"}}} = error} -> + Logger.error(error) + error_fun.() + raise ExUnit.AssertionError, message: "Could not find element." + + {:error, %{error: %{error: %{name: "Error", message: "Error: strict mode violation" <> _}}} = error} -> + Logger.error(error) + error_fun.() + raise ExUnit.AssertionError, message: "Found more than one element." + + {:error, %{error: %{error: %{name: "Error", message: "Clicking the checkbox did not change its state"}}}} -> + :ok + + {:ok, result} -> + result + end + end + + def submit(session) do + Frame.press(session.frame_id, session.last_input_selector, "Enter") + session + end + + def open_browser(session, open_fun \\ &OpenBrowser.open_with_system_cmd/1) do + {:ok, html} = Frame.content(session.frame_id) + + fixed_html = + html + |> Floki.parse_document!() + |> Floki.traverse_and_update(&OpenBrowser.prefix_static_paths(&1, @endpoint)) + |> Floki.raw_html() + + path = Path.join([System.tmp_dir!(), "phx-test#{System.unique_integer([:monotonic])}.html"]) + File.write!(path, fixed_html) + open_fun.(path) + + session + end + + def unwrap(session, fun) do + fun.(session.frame_id) + session + end + + def current_path(session) do + resp = + session.frame_id + |> Connection.received() + |> Enum.find(&match?(%{method: "navigated", params: %{url: _}}, &1)) + + if resp == nil, do: raise(ArgumentError, "Could not find current path.") + + uri = URI.parse(resp.params.url) + [uri.path, uri.query] |> Enum.reject(&is_nil/1) |> Enum.join("?") + end + + defp timeout(opts \\ []) do + default = Application.get_env(:phoenix_test, :timeout_ms, @default_timeout_ms) + Keyword.get(opts, :timeout, default) + end +end + +defimpl PhoenixTest.Driver, for: PhoenixTest.Playwright do + alias PhoenixTest.Assertions + alias PhoenixTest.Playwright + + defdelegate visit(session, path), to: Playwright + defdelegate render_page_title(session), to: Playwright + defdelegate render_html(session), to: Playwright + defdelegate click_link(session, selector, text), to: Playwright + defdelegate click_button(session, selector, text), to: Playwright + defdelegate within(session, selector, fun), to: Playwright + defdelegate fill_in(session, input_selector, label, opts), to: Playwright + defdelegate select(session, input_selector, option, opts), to: Playwright + defdelegate check(session, input_selector, label, opts), to: Playwright + defdelegate uncheck(session, input_selector, label, opts), to: Playwright + defdelegate choose(session, input_selector, label, opts), to: Playwright + defdelegate upload(session, input_selector, label, path, opts), to: Playwright + defdelegate submit(session), to: Playwright + defdelegate open_browser(session), to: Playwright + defdelegate open_browser(session, open_fun), to: Playwright + defdelegate unwrap(session, fun), to: Playwright + defdelegate current_path(session), to: Playwright + + defdelegate assert_has(session, selector), to: Playwright + defdelegate assert_has(session, selector, opts), to: Playwright + defdelegate refute_has(session, selector), to: Playwright + defdelegate refute_has(session, selector, opts), to: Playwright + + def assert_path(session, path), do: Playwright.retry(fn -> Assertions.assert_path(session, path) end) + def assert_path(session, path, opts), do: Playwright.retry(fn -> Assertions.assert_path(session, path, opts) end) + def refute_path(session, path), do: Playwright.retry(fn -> Assertions.refute_path(session, path) end) + def refute_path(session, path, opts), do: Playwright.retry(fn -> Assertions.refute_path(session, path, opts) end) +end diff --git a/lib/phoenix_test/playwright/browser.ex b/lib/phoenix_test/playwright/browser.ex new file mode 100644 index 00000000..f1bf10bf --- /dev/null +++ b/lib/phoenix_test/playwright/browser.ex @@ -0,0 +1,20 @@ +defmodule PhoenixTest.Playwright.Browser do + @moduledoc """ + Interact with a Playwright `Browser`. + + There is no official documentation, since this is considered Playwright internal. + + References: + - https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/client/browser.ts + """ + + import PhoenixTest.Playwright.Connection, only: [post: 1] + + @doc """ + Start a new browser context and return its `guid`. + """ + def new_context(browser_id) do + resp = post(guid: browser_id, method: "newContext") + resp.result.context.guid + end +end diff --git a/lib/phoenix_test/playwright/browser_context.ex b/lib/phoenix_test/playwright/browser_context.ex new file mode 100644 index 00000000..f79a8bb6 --- /dev/null +++ b/lib/phoenix_test/playwright/browser_context.ex @@ -0,0 +1,48 @@ +defmodule PhoenixTest.Playwright.BrowserContext do + @moduledoc """ + Interact with a Playwright `BrowserContext`. + + There is no official documentation, since this is considered Playwright internal. + + References: + - https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/client/browserContext.ts + """ + + import PhoenixTest.Playwright.Connection, only: [post: 1, initializer: 1] + + @doc """ + Open a new browser page and return its `guid`. + """ + def new_page(context_id) do + resp = post(guid: context_id, method: "newPage") + resp.result.page.guid + end + + @doc """ + Start tracing. The results can be retrieved via `stop_tracing/2`. + """ + def start_tracing(context_id, opts \\ []) do + opts = Keyword.validate!(opts, screenshots: true, snapshots: true, sources: true) + tracing_id = initializer(context_id).tracing.guid + post(method: :tracingStart, guid: tracing_id, params: Map.new(opts)) + post(method: :tracingStartChunk, guid: tracing_id) + :ok + end + + @doc """ + Stop tracing and write zip file to specified output path. + + Trace can be viewed via either + - `npx playwright show-trace trace.zip` + - https://trace.playwright.dev + """ + def stop_tracing(context_id, output_path) do + tracing_id = initializer(context_id).tracing.guid + resp = post(method: :tracingStopChunk, guid: tracing_id, params: %{mode: "archive"}) + zip_id = resp.result.artifact.guid + zip_path = initializer(zip_id).absolutePath + File.cp!(zip_path, output_path) + post(method: :tracingStop, guid: tracing_id) + :ok + end +end diff --git a/lib/phoenix_test/playwright/connection.ex b/lib/phoenix_test/playwright/connection.ex new file mode 100644 index 00000000..006a1cdc --- /dev/null +++ b/lib/phoenix_test/playwright/connection.ex @@ -0,0 +1,225 @@ +defmodule PhoenixTest.Playwright.Connection do + @moduledoc """ + Stateful, `GenServer` based connection to a Playwright node.js server. + The connection is established via `Playwright.Port`. + + You won't usually have to use this module directly. + `PhoenixTest.Case` uses this under the hood. + """ + use GenServer + + alias PhoenixTest.Playwright.Port, as: PlaywrightPort + + require Logger + + @default_timeout_ms 1000 + @playwright_timeout_grace_period_ms 100 + + defstruct [ + :port, + status: :pending, + awaiting_started: [], + initializers: %{}, + guid_ancestors: %{}, + guid_subscribers: %{}, + guid_received: %{}, + posts_in_flight: %{} + ] + + @name __MODULE__ + + def start_link(opts) do + GenServer.start_link(__MODULE__, opts, name: @name, timeout: timeout()) + end + + @doc """ + Lazy launch. Only start the playwright server if actually needed by a test. + """ + def ensure_started(opts \\ []) do + case Process.whereis(@name) do + nil -> start_link(opts) + pid -> {:ok, pid} + end + + GenServer.call(@name, :awaiting_started) + end + + @doc """ + Launch a browser and return its `guid`. + """ + def launch_browser(type, opts) do + types = initializer("Playwright") + type_id = Map.fetch!(types, type).guid + resp = post(guid: type_id, method: "launch", params: Map.new(opts)) + resp.result.browser.guid + end + + @doc """ + Subscribe to messages for a guid and its descendants. + """ + def subscribe(pid \\ self(), guid) do + GenServer.cast(@name, {:subscribe, {pid, guid}}) + end + + @doc """ + Post a message and await the response. + We wait for an additional grace period after the timeout that we pass to playwright. + """ + def post(msg) do + default = %{params: %{}, metadata: %{}} + msg = msg |> Enum.into(default) |> update_in(~w(params timeout)a, &(&1 || timeout())) + timeout = msg.params.timeout + timeout_with_grace_period = timeout + @playwright_timeout_grace_period_ms + GenServer.call(@name, {:post, msg}, timeout_with_grace_period) + end + + @doc """ + Get all past received messages for a playwright `guid` (e.g. a `Frame`). + The internal map used to track these messages is never cleaned, it will keep on growing. + Since we're dealing with (short-lived) tests, that should be fine. + """ + def received(guid) do + GenServer.call(@name, {:received, guid}) + end + + @doc """ + Get the initializer data for a channel. + """ + def initializer(guid) do + GenServer.call(@name, {:initializer, guid}) + end + + @impl GenServer + def init(config) do + port = PlaywrightPort.open(config) + msg = %{guid: "", params: %{sdkLanguage: "javascript"}, method: "initialize", metadata: %{}} + PlaywrightPort.post(port, msg) + + {:ok, %__MODULE__{port: port}} + end + + @impl GenServer + def handle_cast({:subscribe, {recipient, guid}}, state) do + subscribers = Map.update(state.guid_subscribers, guid, [recipient], &[recipient | &1]) + {:noreply, %{state | guid_subscribers: subscribers}} + end + + @impl GenServer + def handle_call({:post, msg}, from, state) do + msg_id = fn -> System.unique_integer([:positive, :monotonic]) end + msg = msg |> Map.new() |> Map.put_new_lazy(:id, msg_id) + PlaywrightPort.post(state.port, msg) + + {:noreply, Map.update!(state, :posts_in_flight, &Map.put(&1, msg.id, from))} + end + + def handle_call({:received, guid}, _from, state) do + {:reply, Map.get(state.guid_received, guid, []), state} + end + + def handle_call({:initializer, guid}, _from, state) do + {:reply, Map.get(state.initializers, guid), state} + end + + def handle_call(:awaiting_started, from, %{status: :pending} = state) do + {:noreply, Map.update!(state, :awaiting_started, &[from | &1])} + end + + def handle_call(:awaiting_started, _from, %{status: :started} = state) do + {:reply, :ok, state} + end + + @impl GenServer + def handle_info({_, {:data, _}} = raw_msg, state) do + {port, msgs} = PlaywrightPort.parse(state.port, raw_msg) + state = %{state | port: port} + state = Enum.reduce(msgs, state, &handle_recv/2) + + {:noreply, state} + end + + defp handle_recv(msg, state) do + state + |> log_js_error(msg) + |> log_console(msg) + |> add_guid_ancestors(msg) + |> add_initializer(msg) + |> add_received(msg) + |> handle_started(msg) + |> reply_in_flight(msg) + |> notify_subscribers(msg) + end + + defp log_js_error(state, %{method: "pageError"} = msg) do + Logger.error("Javascript error: #{inspect(msg.params.error)}") + state + end + + defp log_js_error(state, _), do: state + + defp log_console(state, %{method: "console"} = msg) do + level = + case msg.params.type do + "error" -> :error + "debug" -> :debug + _ -> :info + end + + Logger.log(level, "Javascript console: #{msg.params.text}") + state + end + + defp log_console(state, _), do: state + + defp handle_started(state, %{method: "__create__", params: %{type: "Playwright"}}) do + for from <- state.awaiting_started, do: GenServer.reply(from, :ok) + %{state | status: :started, awaiting_started: :none} + end + + defp handle_started(state, _), do: state + + defp add_guid_ancestors(state, %{method: "__create__"} = msg) do + child = msg.params.guid + parent = msg.guid + parent_ancestors = Map.get(state.guid_ancestors, parent, []) + + Map.update!(state, :guid_ancestors, &Map.put(&1, child, [parent | parent_ancestors])) + end + + defp add_guid_ancestors(state, _), do: state + + defp add_initializer(state, %{method: "__create__"} = msg) do + Map.update!(state, :initializers, &Map.put(&1, msg.params.guid, msg.params.initializer)) + end + + defp add_initializer(state, _), do: state + + defp reply_in_flight(%{posts_in_flight: in_flight} = state, msg) when is_map_key(in_flight, msg.id) do + {from, in_flight} = Map.pop(in_flight, msg.id) + GenServer.reply(from, msg) + + %{state | posts_in_flight: in_flight} + end + + defp reply_in_flight(state, _), do: state + + defp add_received(state, %{guid: guid} = msg) do + update_in(state.guid_received[guid], &[msg | &1 || []]) + end + + defp add_received(state, _), do: state + + defp notify_subscribers(state, %{guid: guid} = msg) do + for guid <- [guid | Map.get(state.guid_ancestors, guid, [])], pid <- Map.get(state.guid_subscribers, guid, []) do + send(pid, {:playwright, msg}) + end + + state + end + + defp notify_subscribers(state, _), do: state + + defp timeout do + Application.get_env(:phoenix_test, :timeout_ms, @default_timeout_ms) + end +end diff --git a/lib/phoenix_test/playwright/frame.ex b/lib/phoenix_test/playwright/frame.ex new file mode 100644 index 00000000..93e0885f --- /dev/null +++ b/lib/phoenix_test/playwright/frame.ex @@ -0,0 +1,125 @@ +defmodule PhoenixTest.Playwright.Frame do + @moduledoc """ + Interact with a Playwright `Frame` (usually the "main" frame of a browser page). + + There is no official documentation, since this is considered Playwright internal. + + References: + - https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/client/frame.ts + """ + + import PhoenixTest.Playwright.Connection, only: [post: 1] + + def goto(frame_id, url) do + params = %{url: url} + post(guid: frame_id, method: :goto, params: params) + :ok + end + + def url(frame_id) do + [guid: frame_id, method: :url, params: %{}] + |> post() + |> unwrap_response(& &1.result.value) + end + + def press(frame_id, selector, key) do + params = %{selector: selector, key: key} + post(guid: frame_id, method: :press, params: params) + :ok + end + + def title(frame_id) do + [guid: frame_id, method: :title] + |> post() + |> unwrap_response(& &1.result.value) + end + + def expect(frame_id, params) do + params = Enum.into(params, %{isNot: false}) + + [guid: frame_id, method: :expect, params: params] + |> post() + |> unwrap_response(& &1.result.matches) + end + + def wait_for_selector(frame_id, params) do + [guid: frame_id, method: :waitForSelector, params: params] + |> post() + |> unwrap_response(& &1.result.element) + end + + def inner_html(frame_id, selector) do + params = %{selector: selector} + + [guid: frame_id, method: :innerHTML, params: params] + |> post() + |> unwrap_response(& &1.result.value) + end + + def content(frame_id) do + [guid: frame_id, method: :content] + |> post() + |> unwrap_response(& &1.result.value) + end + + def fill(frame_id, selector, value, opts \\ []) do + params = %{selector: selector, value: value, strict: true} + params = Enum.into(opts, params) + + [guid: frame_id, method: :fill, params: params] + |> post() + |> unwrap_response(& &1) + end + + def select_option(frame_id, selector, options, opts \\ []) do + params = %{selector: selector, options: options, strict: true} + params = Enum.into(opts, params) + + [guid: frame_id, method: :selectOption, params: params] + |> post() + |> unwrap_response(& &1) + end + + def check(frame_id, selector, opts \\ []) do + params = %{selector: selector, strict: true} + params = Enum.into(opts, params) + + [guid: frame_id, method: :check, params: params] + |> post() + |> unwrap_response(& &1) + end + + def uncheck(frame_id, selector, opts \\ []) do + params = %{selector: selector, strict: true} + params = Enum.into(opts, params) + + [guid: frame_id, method: :uncheck, params: params] + |> post() + |> unwrap_response(& &1) + end + + def set_input_files(frame_id, selector, paths, opts \\ []) do + params = %{selector: selector, localPaths: paths, strict: true} + params = Enum.into(opts, params) + + [guid: frame_id, method: :setInputFiles, params: params] + |> post() + |> unwrap_response(& &1) + end + + def click(frame_id, selector, opts \\ []) do + params = %{selector: selector, waitUntil: "load", strict: true} + params = Enum.into(opts, params) + + [guid: frame_id, method: :click, params: params] + |> post() + |> unwrap_response(& &1) + end + + defp unwrap_response(response, fun) do + case response do + %{error: _} = error -> {:error, error} + _ -> {:ok, fun.(response)} + end + end +end diff --git a/lib/phoenix_test/playwright/message.ex b/lib/phoenix_test/playwright/message.ex new file mode 100644 index 00000000..b1705556 --- /dev/null +++ b/lib/phoenix_test/playwright/message.ex @@ -0,0 +1,45 @@ +defmodule PhoenixTest.Playwright.Message do + @moduledoc """ + Parse playwright messages. + A single `Port` message can contain multiple Playwright messages and/or a fraction of a message. + Such a message fraction is stored in `bufffer` and continued in the next `Port` message. + """ + + def parse(<>, 0, "", accumulated) do + %{ + buffer: "", + frames: accumulated, + remaining: head + } + end + + def parse(<>, 0, "", accumulated) do + parse(data, head, "", accumulated) + end + + def parse(<>, read_length, buffer, accumulated) when byte_size(data) == read_length do + %{ + buffer: "", + frames: accumulated ++ [buffer <> data], + remaining: 0 + } + end + + def parse(<>, read_length, buffer, accumulated) when byte_size(data) > read_length do + {message, tail} = bytewise_split(data, read_length) + parse(tail, 0, "", accumulated ++ [buffer <> message]) + end + + def parse(<>, read_length, buffer, accumulated) when byte_size(data) < read_length do + %{ + buffer: buffer <> data, + frames: accumulated, + remaining: read_length - byte_size(data) + } + end + + defp bytewise_split(input, offset) do + <> = input + {head, tail} + end +end diff --git a/lib/phoenix_test/playwright/port.ex b/lib/phoenix_test/playwright/port.ex new file mode 100644 index 00000000..cc07869d --- /dev/null +++ b/lib/phoenix_test/playwright/port.ex @@ -0,0 +1,77 @@ +defmodule PhoenixTest.Playwright.Port do + @moduledoc """ + Start a Playwright node.js server and communicate with it via a `Port`. + """ + + alias PhoenixTest.Playwright.Message + + defstruct [ + :port, + :remaining, + :buffer + ] + + def open(config \\ []) do + config = :phoenix_test |> Application.fetch_env!(:playwright) |> Keyword.merge(config) + cli = Keyword.fetch!(config, :cli) + + unless File.exists?(cli) do + msg = """ + Could not find playwright CLI at #{cli}. + + To resolve this please + 1. Install playwright, e.g. `npm i playwright` + 2. Configure the path correctly, e.g. in `config/text.exs`: `config :phoenix_test, playwright: [cli: "assets/node_modules/playwright/cli.js"]` + """ + + raise ArgumentError, msg + end + + cmd = "run-driver" + port = Port.open({:spawn, "#{cli} #{cmd}"}, [:binary]) + + %__MODULE__{port: port, remaining: 0, buffer: ""} + end + + def post(state, msg) do + frame = serialize(msg) + length = byte_size(frame) + padding = <> + Port.command(state.port, padding <> frame) + end + + def parse(%{port: port} = state, {port, {:data, data}}) do + parsed = Message.parse(data, state.remaining, state.buffer, []) + %{frames: frames, buffer: buffer, remaining: remaining} = parsed + state = %{state | buffer: buffer, remaining: remaining} + msgs = Enum.map(frames, &deserialize/1) + + {state, msgs} + end + + defp deserialize(json) do + case Jason.decode(json) do + {:ok, data} -> atom_keys(data) + error -> decode_error(json, error) + end + end + + defp decode_error(json, error) do + msg = "error: #{inspect(error)}; #{inspect(json: Enum.join(for <>, do: <>))}" + raise ArgumentError, message: msg + end + + defp serialize(message) do + Jason.encode!(message) + end + + defp atom_keys(map) when is_map(map) do + Map.new(map, fn + {k, v} when is_map(v) -> {String.to_atom(k), atom_keys(v)} + {k, list} when is_list(list) -> {String.to_atom(k), Enum.map(list, fn v -> atom_keys(v) end)} + {k, v} -> {String.to_atom(k), v} + end) + end + + defp atom_keys(other), do: other +end diff --git a/lib/phoenix_test/playwright/selector.ex b/lib/phoenix_test/playwright/selector.ex new file mode 100644 index 00000000..3449dbe7 --- /dev/null +++ b/lib/phoenix_test/playwright/selector.ex @@ -0,0 +1,42 @@ +defmodule PhoenixTest.Playwright.Selector do + @moduledoc """ + Playright supports different types of locators: CSS, XPath, internal. + + They can mixed and matched by chaining the together. + + Also, you can register [custom selector engines](https://playwright.dev/docs/extensibility#custom-selector-engines) + that run right in the browser (Javascript). + + There is no official documentation, since this is considered Playwright internal. + + References: + - https://playwright.dev/docs/other-locators + - https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/client/locator.ts + - https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/utils/isomorphic/locatorUtils.ts + """ + + def concat(left, :none), do: left + def concat(left, right), do: "#{left} >> #{right}" + + def unquote(:and)(left, :none), do: left + def unquote(:and)(left, right), do: concat(left, "internal:and=#{Jason.encode!(right)}") + defdelegate _and(left, right), to: __MODULE__, as: :and + + def text(nil, _opts), do: :none + def text(text, opts), do: "internal:text=\"#{text}\"#{exact_suffix(opts)}" + + def label(nil, _opts), do: :none + def label(label, opts), do: "internal:label=\"#{label}\"#{exact_suffix(opts)}" + + def at(nil), do: :none + def at(at), do: "nth=#{at}" + + def css(nil), do: :none + def css([]), do: :none + def css(selector) when is_binary(selector), do: css([selector]) + def css(selectors) when is_list(selectors), do: "css=#{Enum.join(selectors, ",")}" + + defp exact_suffix(opts) when is_list(opts), do: opts |> Keyword.get(:exact, false) |> exact_suffix() + defp exact_suffix(true), do: "s" + defp exact_suffix(false), do: "i" +end diff --git a/lib/phoenix_test/static.ex b/lib/phoenix_test/static.ex index 01cdc089..67a35fea 100644 --- a/lib/phoenix_test/static.ex +++ b/lib/phoenix_test/static.ex @@ -292,6 +292,8 @@ defimpl PhoenixTest.Driver, for: PhoenixTest.Static do alias PhoenixTest.Assertions alias PhoenixTest.Static + def visit(_, _), do: raise(ArgumentError, message: "Unexpected: Call visit/1 with a %Plug.Conn{}.") + defdelegate render_page_title(session), to: Static defdelegate render_html(session), to: Static defdelegate click_link(session, selector, text), to: Static diff --git a/mix.exs b/mix.exs index af2aad37..39d17173 100644 --- a/mix.exs +++ b/mix.exs @@ -50,6 +50,10 @@ defmodule PhoenixTest.MixProject do {:mime, ">= 1.0.0", optional: true}, {:phoenix, "~> 1.7.10"}, {:phoenix_live_view, "~> 0.20.1"}, + {:ecto, "~> 3.12", optional: true}, + {:ecto_sql, "~> 3.12", optional: true}, + {:phoenix_ecto, "~> 4.6", optional: true}, + {:postgrex, "~> 0.19.2", option: true}, {:plug_cowboy, "~> 2.7", only: :test, runtime: false}, {:styler, "~> 0.11", only: [:dev, :test], runtime: false} ] @@ -77,7 +81,10 @@ defmodule PhoenixTest.MixProject do defp aliases do [ - setup: ["deps.get", "assets.setup", "assets.build"], + setup: ["deps.get", "ecto.setup", "assets.setup", "assets.build"], + "ecto.setup": ["ecto.create", "ecto.migrate"], + "ecto.reset": ["ecto.drop", "ecto.setup"], + test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"], "assets.setup": ["esbuild.install --if-missing"], "assets.build": ["esbuild default"] ] diff --git a/mix.lock b/mix.lock index 0685833d..87a35c8b 100644 --- a/mix.lock +++ b/mix.lock @@ -3,7 +3,11 @@ "cowboy": {:hex, :cowboy, "2.12.0", "f276d521a1ff88b2b9b4c54d0e753da6c66dd7be6c9fca3d9418b561828a3731", [:make, :rebar3], [{:cowlib, "2.13.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "8a7abe6d183372ceb21caa2709bec928ab2b72e18a3911aa1771639bef82651e"}, "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.13.0", "db8f7505d8332d98ef50a3ef34b34c1afddec7506e4ee4dd4a3a266285d282ca", [:make, :rebar3], [], "hexpm", "e1e1284dc3fc030a64b1ad0d8382ae7e99da46c3246b815318a4b848873800a4"}, + "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, + "decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"}, "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, + "ecto": {:hex, :ecto, "3.12.4", "267c94d9f2969e6acc4dd5e3e3af5b05cdae89a4d549925f3008b2b7eb0b93c3", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ef04e4101688a67d061e1b10d7bc1fbf00d1d13c17eef08b71d070ff9188f747"}, + "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, "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.32.1", "21e40f939515373bcdc9cffe65f3b3543f05015ac6c3d01d991874129d173420", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.1", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "5142c9db521f106d61ff33250f779807ed2a88620e472ac95dc7d59c380113da"}, "floki": {:hex, :floki, "0.35.2", "87f8c75ed8654b9635b311774308b2760b47e9a579dabf2e4d5f1e1d42c39e0b", [:mix], [], "hexpm", "6b05289a8e9eac475f644f09c2e4ba7e19201fd002b89c28c1293e7bd16773d9"}, @@ -17,13 +21,15 @@ "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, "phoenix": {:hex, :phoenix, "1.7.10", "02189140a61b2ce85bb633a9b6fd02dff705a5f1596869547aeb2b2b95edd729", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "cf784932e010fd736d656d7fead6a584a4498efefe5b8227e9f383bf15bb79d0"}, - "phoenix_html": {:hex, :phoenix_html, "4.0.0", "4857ec2edaccd0934a923c2b0ba526c44a173c86b847e8db725172e9e51d11d6", [:mix], [], "hexpm", "cee794a052f243291d92fa3ccabcb4c29bb8d236f655fb03bcbdc3a8214b8d13"}, + "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, + "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.3", "8b6406bc0a451f295407d7acff7f234a6314be5bbe0b3f90ed82b07f50049878", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a8e4385e05618b424779f894ed2df97d3c7518b7285fcd11979077ae6226466b"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.15.2", "94cf1fa375526f30ff8770837cb804798e0045fd97185f0bb9e5fcd858c792a3", [: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", "02731fa0c2dcb03d8d21a1d941bdbbe99c2946c0db098eee31008e04c6283615"}, "plug_cowboy": {:hex, :plug_cowboy, "2.7.1", "87677ffe3b765bc96a89be7960f81703223fe2e21efa42c125fcd0127dd9d6b2", [: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", "02dbd5f9ab571b864ae39418db7811618506256f6d13b4a45037e5fe78dc5de3"}, "plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"}, + "postgrex": {:hex, :postgrex, "0.19.2", "34d6884a332c7bf1e367fc8b9a849d23b43f7da5c6e263def92784d03f9da468", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "618988886ab7ae8561ebed9a3c7469034bf6a88b8995785a3378746a4b9835ec"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "styler": {:hex, :styler, "0.11.9", "2595393b94e660cd6e8b582876337cc50ff047d184ccbed42fdad2bfd5d78af5", [:mix], [], "hexpm", "8b7806ba1fdc94d0a75127c56875f91db89b75117fcc67572661010c13e1f259"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, diff --git a/priv/repo/migrations/.formatter.exs b/priv/repo/migrations/.formatter.exs new file mode 100644 index 00000000..49f9151e --- /dev/null +++ b/priv/repo/migrations/.formatter.exs @@ -0,0 +1,4 @@ +[ + import_deps: [:ecto_sql], + inputs: ["*.exs"] +] diff --git a/priv/static/assets/app.css b/priv/static/assets/app.css new file mode 100644 index 00000000..e69de29b diff --git a/priv/static/assets/package-lock.json b/priv/static/assets/package-lock.json new file mode 100644 index 00000000..205bdcb6 --- /dev/null +++ b/priv/static/assets/package-lock.json @@ -0,0 +1,57 @@ +{ + "name": "phoenix_test", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "phoenix_test", + "dependencies": { + "playwright": "^1.48.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.0.tgz", + "integrity": "sha512-eKpmys0UFDnfNb3vfsf8Vx2LEOtflgRebl0Im2eQQnYMA4Aqd+Zw8bEOB+7ZKvN76901mRnqdsiOGKxzVTbi7A==", + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.49.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.49.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.0.tgz", + "integrity": "sha512-R+3KKTQF3npy5GTiKH/T+kdhoJfJojjHESR1YEWhYuEKRVfVaxH3+4+GvXE5xyCngCxhxnykk0Vlah9v8fs3jA==", + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/priv/static/assets/package.json b/priv/static/assets/package.json new file mode 100644 index 00000000..c3d2d5ec --- /dev/null +++ b/priv/static/assets/package.json @@ -0,0 +1,6 @@ +{ + "name": "phoenix_test", + "dependencies": { + "playwright": "^1.48.1" + } +} diff --git a/priv/static/driver.js b/priv/static/driver.js new file mode 120000 index 00000000..4f62e658 --- /dev/null +++ b/priv/static/driver.js @@ -0,0 +1 @@ +assets/node_modules/playwright/cli.js \ No newline at end of file diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico new file mode 100644 index 00000000..e69de29b diff --git a/test/phoenix_test/assertions_test.exs b/test/phoenix_test/assertions_test.exs index c11d8025..9aa7bd62 100644 --- a/test/phoenix_test/assertions_test.exs +++ b/test/phoenix_test/assertions_test.exs @@ -1,17 +1,12 @@ defmodule PhoenixTest.AssertionsTest do - use ExUnit.Case, async: true + use PhoenixTest.Case, async: true, parameterize: [%{playwright: false}, %{playwright: true}] - import PhoenixTest import PhoenixTest.Locators import PhoenixTest.TestHelpers alias ExUnit.AssertionError alias PhoenixTest.Live - setup do - %{conn: Phoenix.ConnTest.build_conn()} - end - describe "assert_has/2" do test "succeeds if single element is found with CSS selector", %{conn: conn} do conn @@ -47,6 +42,7 @@ defmodule PhoenixTest.AssertionsTest do |> assert_has("li") end + @tag playwright: false, reason: {:not_implemented, :locators} test "takes in input helper in assertion", %{conn: conn} do conn |> visit("/page/index") @@ -282,6 +278,10 @@ defmodule PhoenixTest.AssertionsTest do end end + # Different semantics: + # - PhoenixTest: Assert second li has text is "Legolas" + # - Playwright: Assert two li with text "Legolas" exist + @tag playwright: false, reason: :known_inconsistency test "accepts an `at` option to assert on a specific element", %{conn: conn} do conn |> visit("/page/index") @@ -553,6 +553,10 @@ defmodule PhoenixTest.AssertionsTest do |> refute_has("#multiple-items li", at: 2, text: "Aragorn") end + # Different semantics: + # - PhoenixTest: Assert second li has text is "Legolas" + # - Playwright: Assert two li with text "Legolas" exist + @tag playwright: false, reason: :known_inconsistency test "raises if it finds element at `at` position", %{conn: conn} do msg = ignore_whitespace(""" diff --git a/test/phoenix_test/live_test.exs b/test/phoenix_test/live_test.exs index 38135d03..d9b7f93b 100644 --- a/test/phoenix_test/live_test.exs +++ b/test/phoenix_test/live_test.exs @@ -1,42 +1,28 @@ defmodule PhoenixTest.LiveTest do - use ExUnit.Case, async: true - - import PhoenixTest - import PhoenixTest.Locators + use PhoenixTest.Case, async: true, parameterize: [%{playwright: false}, %{playwright: true}] alias PhoenixTest.Driver - setup do - %{conn: Phoenix.ConnTest.build_conn()} - end - - describe "render_page_title/1" do + describe "assert_has/2 title" do test "renders the page title", %{conn: conn} do - title = - conn - |> visit("/live/index") - |> PhoenixTest.Driver.render_page_title() - - assert title == "PhoenixTest is the best!" + conn + |> visit("/live/index") + |> assert_has("title", text: "PhoenixTest is the best!") end test "renders updated page title", %{conn: conn} do - title = - conn - |> visit("/live/index") - |> click_button("Change page title") - |> PhoenixTest.Driver.render_page_title() - - assert title == "Title changed!" + conn + |> visit("/live/index") + |> click_button("Change page title") + |> assert_has("title", text: "Title changed!") end + end + describe "refute_has/1 title" do test "returns nil if page title isn't found", %{conn: conn} do - title = - conn - |> visit("/live/index_no_layout") - |> PhoenixTest.Driver.render_page_title() - - assert title == nil + conn + |> visit("/live/index_no_layout") + |> refute_has("title") end end @@ -59,6 +45,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("h1", text: "LiveView main page") end + @tag playwright: false, reason: :irrelevant test "preserves headers across redirects", %{conn: conn} do conn |> Plug.Conn.put_req_header("x-custom-header", "Some-Value") @@ -69,6 +56,7 @@ defmodule PhoenixTest.LiveTest do end) end + @tag playwright: false, reason: :known_inconsistency test "raises error if route doesn't exist", %{conn: conn} do assert_raise ArgumentError, ~r/404/, fn -> visit(conn, "/live/non_route") @@ -112,6 +100,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("h1", text: "Main page") end + @tag playwright: false, reason: :irrelevant test "preserves headers across navigation", %{conn: conn} do conn |> Plug.Conn.put_req_header("x-custom-header", "Some-Value") @@ -123,6 +112,8 @@ defmodule PhoenixTest.LiveTest do end) end + # Playwright: Errors with PhoenixTest message (not Phoenix.LiveViewTest message) + @tag playwright: false, reason: :different_error_message test "raises error when there are multiple links with same text", %{conn: conn} do assert_raise ArgumentError, ~r/2 of them matched the text filter/, fn -> conn @@ -131,6 +122,8 @@ defmodule PhoenixTest.LiveTest do end end + # Playwright: Errors with PhoenixTest message (not Phoenix.LiveViewTest message) + @tag playwright: false, reason: :different_error_message test "raises an error when link element can't be found with given text", %{conn: conn} do assert_raise ArgumentError, ~r/elements but none matched the text filter "No link"/, fn -> conn @@ -139,6 +132,8 @@ defmodule PhoenixTest.LiveTest do end end + # Playwright: Errors with PhoenixTest message (not Phoenix.LiveViewTest message) + @tag playwright: false, reason: :different_error_message test "raises an error when there are no links on the page", %{conn: conn} do assert_raise ArgumentError, ~r/selector "a" did not return any element/, fn -> conn @@ -156,6 +151,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("#tab", text: "Tab title") end + @tag playwright: false, reason: :irrevelant test "does not remove active form if button isn't form's submit button", %{conn: conn} do session = conn @@ -166,6 +162,7 @@ defmodule PhoenixTest.LiveTest do assert PhoenixTest.ActiveForm.active?(session.active_form) end + @tag playwright: false, reason: :irrevelant test "resets active form if it is form's submit button", %{conn: conn} do session = conn @@ -199,7 +196,9 @@ defmodule PhoenixTest.LiveTest do test "can click button that does not submit form after filling form", %{conn: conn} do conn |> visit("/live/index") - |> fill_in("Email", with: "some@example.com") + |> within("#email-form", fn session -> + fill_in(session, "Email", with: "some@example.com") + end) |> click_button("Save Nested Form") |> refute_has("#form-data", text: "email: some@example.com") end @@ -227,7 +226,7 @@ defmodule PhoenixTest.LiveTest do conn |> visit("/live/index") |> within("#redirect-form-to-static", &fill_in(&1, "Name", with: "Aragorn")) - |> click_button("#redirect-form-to-static-submit", "Save Redirect to Static") + |> click_button("#redirect-form-to-static-submit", "Save Redirect to Static Form") |> assert_has("h1", text: "Main page") end @@ -239,6 +238,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("#form-data", text: "name: Aragorn") end + @tag playwright: false, reason: :known_inconsistency test "raises an error if form doesn't have a `phx-submit` or `action`", %{conn: conn} do msg = ~r/to have a `phx-submit` or `action` defined/ @@ -258,6 +258,7 @@ defmodule PhoenixTest.LiveTest do end end + @tag playwright: false, reason: :known_inconsistency test "raises an error if button is not part of form and has no phx-submit", %{conn: conn} do msg = ~r/to have a valid `phx-click` attribute or belong to a `form` element/ @@ -299,7 +300,7 @@ defmodule PhoenixTest.LiveTest do |> within("#email-form", fn session -> fill_in(session, "Email", with: "someone@example.com") end) - |> assert_has(input(label: "Email", value: "someone@example.com")) + |> assert_has("#form-data", text: "email: someone@example.com") end test "raises when data is not in scoped HTML", %{conn: conn} do @@ -317,8 +318,10 @@ defmodule PhoenixTest.LiveTest do test "fills in a single text field based on the label", %{conn: conn} do conn |> visit("/live/index") - |> fill_in("Email", with: "someone@example.com") - |> assert_has(input(label: "Email", value: "someone@example.com")) + |> within("#email-form", fn session -> + fill_in(session, "Email", with: "someone@example.com") + end) + |> assert_has("#form-data", text: "email: someone@example.com") end test "can fill input with `nil` to override existing value", %{conn: conn} do @@ -373,7 +376,9 @@ defmodule PhoenixTest.LiveTest do test "can be used to submit form", %{conn: conn} do conn |> visit("/live/index") - |> fill_in("Email", with: "someone@example.com") + |> within("#email-form", fn session -> + fill_in(session, "Email", with: "someone@example.com") + end) |> click_button("Save Email") |> assert_has("#form-data", text: "email: someone@example.com") end @@ -381,7 +386,9 @@ defmodule PhoenixTest.LiveTest do test "can be combined with other forms' fill_ins (without pollution)", %{conn: conn} do conn |> visit("/live/index") - |> fill_in("Email", with: "frodo@example.com") + |> within("#email-form", fn session -> + fill_in(session, "Email", with: "frodo@example.com") + end) |> fill_in("Comments", with: "Hobbit") |> assert_has("#form-data", text: "comments: Hobbit") |> refute_has("#form-data", text: "email: frodo@example.com") @@ -466,41 +473,24 @@ defmodule PhoenixTest.LiveTest do |> assert_has("#form-data", text: "[elf, dwarf]") end - test "works with phx-click outside of forms", %{conn: conn} do - conn - |> visit("/live/index") - |> within("#not-a-form", fn session -> - select(session, "Dog", from: "Choose a pet:") - end) - |> assert_has("#form-data", text: "selected: [dog]") - end - - test "works with phx-click and multi-select", %{conn: conn} do - conn - |> visit("/live/index") - |> within("#not-a-form", fn session -> - select(session, ["Dog", "Cat"], from: "Choose a pet:") - end) - |> assert_has("#form-data", text: "selected: [dog, cat]") - end - test "can target a label with exact: false", %{conn: conn} do conn |> visit("/live/index") |> within("#complex-labels", fn session -> - select(session, "Dog", from: "Choose a pet:", exact: false) + select(session, "Cat", from: "Choose a pet:", exact: false) end) - |> assert_has("#form-data", text: "pet: dog") + |> assert_has("#form-data", text: "pet: cat") end + @tag playwright: false, reason: :not_implemented, not_implemented: :exact_option test "can target an option's text with exact_option: false", %{conn: conn} do conn |> visit("/live/index") |> within("#full-form", fn session -> - select(session, "Hum", from: "Race", exact_option: false) + select(session, "Dwa", from: "Race", exact_option: false) end) |> submit() - |> assert_has("#form-data", text: "race: human") + |> assert_has("#form-data", text: "race: dwarf") end test "can target option with selector if multiple labels have same text", %{conn: conn} do @@ -512,6 +502,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("#form-data", text: "favorite-character: Frodo") end + @tag playwright: false, reason: :known_inconsistency test "raises an error if select option is neither in a form nor has a phx-click", %{conn: conn} do session = visit(conn, "/live/index") @@ -582,6 +573,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("#form-data", text: "like-elixir: yes") end + @tag playwright: false, reason: :known_inconsistency test "raises error if checkbox doesn't have phx-click or belong to form", %{conn: conn} do session = visit(conn, "/live/index") @@ -652,6 +644,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("#form-data", text: "like-elixir: no") end + @tag playwright: false, reason: :known_inconsistency test "raises error if checkbox doesn't have phx-click or belong to form", %{conn: conn} do session = visit(conn, "/live/index") @@ -681,9 +674,9 @@ defmodule PhoenixTest.LiveTest do conn |> visit("/live/index") |> within("#not-a-form", fn session -> - choose(session, "Huey") + choose(session, "Dewey") end) - |> assert_has("#form-data", text: "value: huey") + |> assert_has("#form-data", text: "value: dewey") end test "can target a label with exact: false", %{conn: conn} do @@ -704,6 +697,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("#form-data", text: "elixir-yes: yes") end + @tag playwright: false, reason: :known_inconsistency test "raises an error if radio is neither in a form nor has a phx-click", %{conn: conn} do session = visit(conn, "/live/index") @@ -747,6 +741,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("#form-data", text: "main_avatar: elixir.jpg") end + @tag playwright: false, reason: :known_inconsistency test "upload (without other form actions) does not work with submit (matches browser behavior)", %{conn: conn} do session = conn @@ -795,7 +790,9 @@ defmodule PhoenixTest.LiveTest do test "submits a pre-filled form via phx-submit", %{conn: conn} do conn |> visit("/live/index") - |> fill_in("Email", with: "some@example.com") + |> within("#email-form", fn session -> + fill_in(session, "Email", with: "some@example.com") + end) |> submit() |> assert_has("#form-data", text: "email: some@example.com") end @@ -848,6 +845,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("h1", text: "Main page") end + @tag playwright: false, reason: :irrelevant test "preserves headers after form submission and redirect", %{conn: conn} do conn |> Plug.Conn.put_req_header("x-custom-header", "Some-Value") @@ -876,6 +874,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("#form-data", text: "button: save") end + @tag playwright: false, reason: :known_inconsistency test "raises an error if there's no active form", %{conn: conn} do message = ~r/There's no active form. Fill in a form with `fill_in`, `select`, etc./ @@ -886,6 +885,7 @@ defmodule PhoenixTest.LiveTest do end end + @tag playwright: false, reason: :known_inconsistency test "raises an error if form doesn't have a `phx-submit` or `action`", %{conn: conn} do msg = ~r/to have a `phx-submit` or `action` defined/ @@ -910,6 +910,7 @@ defmodule PhoenixTest.LiveTest do %{open_fun: open_fun} end + @tag playwright: false, reason: :irrelevant test "opens the browser", %{conn: conn, open_fun: open_fun} do conn |> visit("/live/index") @@ -919,6 +920,7 @@ defmodule PhoenixTest.LiveTest do end describe "unwrap" do + @tag playwright: false, reason: :irrelevant test "provides an escape hatch that gives access to the underlying view", %{conn: conn} do conn |> visit("/live/index") @@ -930,6 +932,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("#form-data", text: "name: Legolas") end + @tag playwright: false, reason: :irrelevant test "follows redirects after unwrap action", %{conn: conn} do conn |> visit("/live/index") @@ -942,62 +945,52 @@ defmodule PhoenixTest.LiveTest do end end - describe "current_path" do + describe "assert_path" do test "it is set on visit", %{conn: conn} do - session = visit(conn, "/live/index") - - assert PhoenixTest.Driver.current_path(session) == "/live/index" + conn + |> visit("/live/index") + |> assert_path("/live/index") end test "it is set on visit with query string", %{conn: conn} do - session = visit(conn, "/live/index?foo=bar") - - assert PhoenixTest.Driver.current_path(session) == "/live/index?foo=bar" + conn + |> visit("/live/index?foo=bar") + |> assert_path("/live/index", query_params: %{foo: "bar"}) end test "it is updated on href navigation", %{conn: conn} do - session = - conn - |> visit("/live/index") - |> click_link("Navigate to non-liveview") - - assert PhoenixTest.Driver.current_path(session) == "/page/index?details=true&foo=bar" + conn + |> visit("/live/index") + |> click_link("Navigate to non-liveview") + |> assert_path("/page/index", query_params: %{details: "true", foo: "bar"}) end test "it is updated on live navigation", %{conn: conn} do - session = - conn - |> visit("/live/index") - |> click_link("Navigate link") - - assert PhoenixTest.Driver.current_path(session) == "/live/page_2?details=true&foo=bar" + conn + |> visit("/live/index") + |> click_link("Navigate link") + |> assert_path("/live/page_2", query_params: %{details: "true", foo: "bar"}) end test "it is updated on live patching", %{conn: conn} do - session = - conn - |> visit("/live/index") - |> click_link("Patch link") - - assert PhoenixTest.Driver.current_path(session) == "/live/index?details=true&foo=bar" + conn + |> visit("/live/index") + |> click_link("Patch link") + |> assert_path("/live/index", query_params: %{details: "true", foo: "bar"}) end test "it is updated on push navigation", %{conn: conn} do - session = - conn - |> visit("/live/index") - |> click_button("Button with push navigation") - - assert PhoenixTest.Driver.current_path(session) == "/live/page_2?foo=bar" + conn + |> visit("/live/index") + |> click_button("Button with push navigation") + |> assert_path("/live/page_2", query_params: %{foo: "bar"}) end test "it is updated on push patch", %{conn: conn} do - session = - conn - |> visit("/live/index") - |> click_button("Button with push patch") - - assert PhoenixTest.Driver.current_path(session) == "/live/index?foo=bar" + conn + |> visit("/live/index") + |> click_button("Button with push patch") + |> assert_path("/live/index", query_params: %{foo: "bar"}) end end @@ -1005,17 +998,25 @@ defmodule PhoenixTest.LiveTest do test "triggers phx-change validations", %{conn: conn} do conn |> visit("/live/index") - |> fill_in("Email", with: nil) + |> within("#email-form", fn session -> + session + |> fill_in("Email", with: "email") + |> fill_in("Email", with: nil) + end) |> assert_has("#form-errors", text: "Errors present") end test "sends _target with phx-change events", %{conn: conn} do conn |> visit("/live/index") - |> fill_in("Email", with: "frodo@example.com") + |> within("#email-form", fn session -> + fill_in(session, "Email", with: "frodo@example.com") + end) |> assert_has("#form-data", text: "_target: [email]") end + # Playwright: Unstable data-phx-ids + @tag playwright: false, reason: :flaky_test test "does not trigger phx-change event if one isn't present", %{conn: conn} do session = visit(conn, "/live/index") @@ -1049,6 +1050,7 @@ defmodule PhoenixTest.LiveTest do |> assert_has("#form-data", text: "hidden_race: hobbit") end + @tag playwright: false, reason: :known_inconsistency test "raises an error if field doesn't have a `name` attribute", %{conn: conn} do assert_raise ArgumentError, ~r/Field is missing a `name` attribute/, fn -> conn diff --git a/test/phoenix_test/playwright_test.exs b/test/phoenix_test/playwright_test.exs new file mode 100644 index 00000000..312c9a81 --- /dev/null +++ b/test/phoenix_test/playwright_test.exs @@ -0,0 +1,26 @@ +defmodule PhoenixTest.PlaywrightTest do + use PhoenixTest.Case, + async: true, + parameterize: Enum.map(~w(chromium firefox)a, &%{playwright: [browser: &1]}) + + describe "render_page_title/1" do + unless System.version() in ~w(1.15.0 1.16.0 1.17.0) do + test "runs in multiple browsers via ExUnit `parameterize`", %{conn: conn} do + session = visit(conn, "/live/index") + assert %PhoenixTest.Playwright{} = session + + title = PhoenixTest.Driver.render_page_title(session) + assert title == "PhoenixTest is the best!" + end + end + + @tag playwright: false + test "'@tag playwright: false' forces live driver", %{conn: conn} do + session = visit(conn, "/live/index") + assert %PhoenixTest.Live{} = session + + title = PhoenixTest.Driver.render_page_title(session) + assert title == "PhoenixTest is the best!" + end + end +end diff --git a/test/phoenix_test/static_test.exs b/test/phoenix_test/static_test.exs index 6b741e12..3e4356a8 100644 --- a/test/phoenix_test/static_test.exs +++ b/test/phoenix_test/static_test.exs @@ -1,13 +1,8 @@ defmodule PhoenixTest.StaticTest do - use ExUnit.Case, async: true + use PhoenixTest.Case, async: true, parameterize: [%{playwright: false}, %{playwright: true}] - import PhoenixTest import PhoenixTest.TestHelpers - setup do - %{conn: Phoenix.ConnTest.build_conn()} - end - describe "render_page_title/1" do test "renders the page title", %{conn: conn} do title = @@ -41,6 +36,7 @@ defmodule PhoenixTest.StaticTest do |> assert_has("h1", text: "Main page") end + @tag playwright: false, reason: :irrelevant test "preserves headers across redirects", %{conn: conn} do conn |> Plug.Conn.put_req_header("x-custom-header", "Some-Value") @@ -51,6 +47,7 @@ defmodule PhoenixTest.StaticTest do end) end + @tag playwright: false, reason: :known_inconsistency test "raises error if route doesn't exist", %{conn: conn} do assert_raise ArgumentError, ~r/404/, fn -> visit(conn, "/non_route") @@ -80,6 +77,7 @@ defmodule PhoenixTest.StaticTest do |> assert_has("h1", text: "Page 2") end + @tag playwright: false, reason: :irrelevant test "preserves headers across navigation", %{conn: conn} do conn |> Plug.Conn.put_req_header("x-custom-header", "Some-Value") @@ -98,6 +96,8 @@ defmodule PhoenixTest.StaticTest do |> assert_has("h1", text: "LiveView main page") end + # Playwright: case insensitive when using exact=false semantics to find link by substring. + @tag playwright: false, reason: :known_inconsistency test "handles form submission via `data-method` & `data-to` attributes", %{conn: conn} do conn |> visit("/page/index") @@ -105,6 +105,7 @@ defmodule PhoenixTest.StaticTest do |> assert_has("h1", text: "Record deleted") end + @tag playwright: false, reason: :known_inconsistency test "raises error if trying to submit via `data-` attributes but incomplete", %{conn: conn} do msg = ignore_whitespace(""" @@ -229,6 +230,7 @@ defmodule PhoenixTest.StaticTest do |> assert_has("h1", text: "Record deleted") end + @tag playwright: false, reason: :irrelevant test "does not remove active form if button isn't form's submit button", %{conn: conn} do session = conn @@ -239,6 +241,7 @@ defmodule PhoenixTest.StaticTest do assert PhoenixTest.ActiveForm.active?(session.active_form) end + @tag playwright: false, reason: :irrelevant test "resets active form if it is form's submit button", %{conn: conn} do session = conn @@ -277,6 +280,7 @@ defmodule PhoenixTest.StaticTest do |> refute_has("#form-data", text: "disabled_textarea:") end + @tag playwright: false, reason: :known_inconsistency test "raises error if trying to submit via `data-` attributes but incomplete", %{conn: conn} do msg = ~r/Tried submitting form via `data-method` but some data attributes/ @@ -307,6 +311,7 @@ defmodule PhoenixTest.StaticTest do end end + @tag playwright: false, reason: :known_inconsistency test "raises an error if button is not part of form", %{conn: conn} do msg = ~r/Could not find "form" for an element with selector/ @@ -496,6 +501,7 @@ defmodule PhoenixTest.StaticTest do |> refute_has("#form-data", text: "race_2") end + @tag playwright: false, reason: :flaky_test test "can target a label with exact: false", %{conn: conn} do conn |> visit("/page/index") @@ -506,6 +512,7 @@ defmodule PhoenixTest.StaticTest do |> assert_has("#form-data", text: "pet: dog") end + @tag playwright: false, reason: :not_implemented, not_implemented: :exact_option test "can target an option's text with exact_option: false", %{conn: conn} do conn |> visit("/page/index") @@ -516,6 +523,7 @@ defmodule PhoenixTest.StaticTest do |> assert_has("#form-data", text: "race: human") end + @tag playwright: false, reason: :flaky_test test "can target option with selector if multiple labels have same text", %{conn: conn} do conn |> visit("/page/index") @@ -652,6 +660,8 @@ defmodule PhoenixTest.StaticTest do |> assert_has("#form-data", text: "book-or-movie: book") end + # Playwright: Can't find with `exact: true` + @tag playwright: false, reason: :bug test "can specify input selector when multiple options have same label in same form", %{conn: conn} do conn |> visit("/page/index") @@ -676,6 +686,8 @@ defmodule PhoenixTest.StaticTest do |> assert_has("#form-data", text: "avatar: elixir.jpg") end + # Playwright: Can't find second input field (order of `upload` calls seems to matter, why?) + @tag playwright: false, reason: :bug test "uploads image list", %{conn: conn} do conn |> visit("/page/index") @@ -705,6 +717,8 @@ defmodule PhoenixTest.StaticTest do |> assert_has("#form-data", text: "avatar: elixir.jpg") end + # Playwright: 'Enter' key on file input oppens file picker dialog + @tag playwright: false, reason: :bug test "can specify input selector when multiple inputs have same label", %{conn: conn} do conn |> visit("/page/index") @@ -803,6 +817,7 @@ defmodule PhoenixTest.StaticTest do |> assert_has("h1", text: "LiveView main page") end + @tag playwright: false, reason: :irrelevant test "preserves headers after form submission and redirect", %{conn: conn} do conn |> Plug.Conn.put_req_header("x-custom-header", "Some-Value") @@ -836,6 +851,7 @@ defmodule PhoenixTest.StaticTest do |> assert_has("h1", text: "Record deleted") end + @tag playwright: false, reason: :known_inconsistency test "raises an error if there's no active form", %{conn: conn} do msg = ~r/There's no active form. Fill in a form with `fill_in`, `select`, etc./ @@ -883,6 +899,7 @@ defmodule PhoenixTest.StaticTest do @endpoint Application.compile_env(:phoenix_test, :endpoint) + @tag playwright: false, reason: :irrelevant test "provides an escape hatch that gives access to the underlying conn", %{conn: conn} do conn |> visit("/page/index") @@ -894,6 +911,7 @@ defmodule PhoenixTest.StaticTest do end) end + @tag playwright: false, reason: :irrelevant test "follows redirects after unwrap action", %{conn: conn} do conn |> visit("/page/page_2") @@ -904,39 +922,36 @@ defmodule PhoenixTest.StaticTest do end end - describe "current_path" do + describe "assert_path" do test "it is set on visit", %{conn: conn} do - session = visit(conn, "/page/index") - - assert PhoenixTest.Driver.current_path(session) == "/page/index" + conn + |> visit("/page/index") + |> assert_path("/page/index") end test "it includes query string if available", %{conn: conn} do - session = visit(conn, "/page/index?foo=bar") - - assert PhoenixTest.Driver.current_path(session) == "/page/index?foo=bar" + conn + |> visit("/page/index?foo=bar") + |> assert_path("/page/index", query_params: %{foo: "bar"}) end test "it is updated on href navigation", %{conn: conn} do - session = - conn - |> visit("/page/index") - |> click_link("Page 2") - - assert PhoenixTest.Driver.current_path(session) == "/page/page_2?foo=bar" + conn + |> visit("/page/index") + |> click_link("Page 2") + |> assert_path("/page/page_2", query_params: %{foo: "bar"}) end test "it is updated on redirects", %{conn: conn} do - session = - conn - |> visit("/page/index") - |> click_link("Navigate away and redirect back") - - assert PhoenixTest.Driver.current_path(session) == "/page/index" + conn + |> visit("/page/index") + |> click_link("Navigate away and redirect back") + |> assert_path("/page/index") end end describe "shared form helpers behavior" do + @tag playwright: false, reason: :known_inconsistency test "raises an error if field doesn't have a `name` attribute", %{conn: conn} do assert_raise ArgumentError, ~r/Field is missing a `name` attribute/, fn -> conn diff --git a/test/support/endpoint.ex b/test/support/endpoint.ex index a6d3f5bc..af7eb8e4 100644 --- a/test/support/endpoint.ex +++ b/test/support/endpoint.ex @@ -12,4 +12,6 @@ defmodule PhoenixTest.Endpoint do plug Plug.MethodOverride plug PhoenixTest.Router + + plug Phoenix.Ecto.SQL.Sandbox end diff --git a/test/support/index_live.ex b/test/support/index_live.ex index c6bcbda0..5e24b6cb 100644 --- a/test/support/index_live.ex +++ b/test/support/index_live.ex @@ -39,7 +39,7 @@ defmodule PhoenixTest.IndexLive do -
+ @@ -127,26 +127,26 @@ defmodule PhoenixTest.IndexLive do
-
+ - + - + - + - + - @@ -162,7 +162,7 @@ defmodule PhoenixTest.IndexLive do -
+
Please select your preferred contact method:
@@ -175,7 +175,7 @@ defmodule PhoenixTest.IndexLive do
- @@ -258,7 +258,7 @@ defmodule PhoenixTest.IndexLive do -
+ @@ -268,7 +268,7 @@ defmodule PhoenixTest.IndexLive do Human * - + - + Do you like Erlang @@ -406,12 +406,6 @@ defmodule PhoenixTest.IndexLive do
- - -
Select to get second breakfast: @@ -422,6 +416,7 @@ defmodule PhoenixTest.IndexLive do id="second-breakfast" name="second-breakfast" value="second-breakfast" + phx-update="ignore" /> @@ -466,6 +461,7 @@ defmodule PhoenixTest.IndexLive do |> allow_upload(:avatar, accept: ~w(.jpg .jpeg)) |> allow_upload(:main_avatar, accept: ~w(.jpg .jpeg)) |> allow_upload(:backup_avatar, accept: ~w(.jpg .jpeg)) + |> allow_upload(:complex_avatar, accept: ~w(.jpg .jpeg)) } end @@ -477,6 +473,15 @@ defmodule PhoenixTest.IndexLive do {:noreply, assign(socket, :show_tab, true)} end + def handle_event("validate-form", form_data, socket) do + { + :noreply, + socket + |> assign(:form_saved, true) + |> assign(:form_data, form_data) + } + end + def handle_event("save-form", form_data, socket) do avatars = consume_uploaded_entries(socket, :avatar, fn _, %{client_name: name} -> @@ -486,10 +491,14 @@ defmodule PhoenixTest.IndexLive do main_avatars = consume_uploaded_entries(socket, :main_avatar, fn _, %{client_name: name} -> {:ok, name} end) + complex_avatars = + consume_uploaded_entries(socket, :complex_avatar, fn _, %{client_name: name} -> {:ok, name} end) + form_data = form_data |> Map.put("avatar", List.first(avatars)) |> Map.put("main_avatar", List.first(main_avatars)) + |> Map.put("complex_avatar", List.first(complex_avatars)) { :noreply, @@ -562,19 +571,6 @@ defmodule PhoenixTest.IndexLive do |> then(&{:noreply, &1}) end - def handle_event("select-pet", %{"value" => value}, socket) do - form_data = - case socket.assigns.form_data do - %{selected: values} -> %{selected: values ++ [value]} - %{} -> %{selected: [value]} - end - - socket - |> assign(:form_saved, true) - |> assign(:form_data, form_data) - |> then(&{:noreply, &1}) - end - def handle_event("toggle-second-breakfast", params, socket) do socket |> assign(:form_saved, true) diff --git a/test/support/repo.ex b/test/support/repo.ex new file mode 100644 index 00000000..dbc2c052 --- /dev/null +++ b/test/support/repo.ex @@ -0,0 +1,5 @@ +defmodule PhoenixTest.Repo do + use Ecto.Repo, + otp_app: :phoenix_test, + adapter: Ecto.Adapters.Postgres +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 3dd79ff0..c3442c95 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,4 +1,9 @@ ExUnit.start() {:ok, _} = Supervisor.start_link([{Phoenix.PubSub, name: PhoenixTest.PubSub}], strategy: :one_for_one) +{:ok, _} = PhoenixTest.Repo.start_link() {:ok, _} = PhoenixTest.Endpoint.start_link() + +Application.put_env(:phoenix_test, :base_url, PhoenixTest.Endpoint.url()) + +Ecto.Adapters.SQL.Sandbox.mode(PhoenixTest.Repo, :manual)