Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Playwright driver (no deps) #145

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 79 additions & 54 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ name: CI

on:
push:
branches: [ "main" ]
branches: ["main"]
pull_request:
branches: [ "main" ]
branches: ["main"]

env:
MIX_ENV: test
Expand All @@ -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"
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
erlang 27.0
elixir 1.17.2-otp-27
elixir main
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need the 1.18 parameterized tests!

18 changes: 17 additions & 1 deletion config/test.exs
Original file line number Diff line number Diff line change
@@ -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)],
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 nice to have this out of the box.

cli: "priv/static/assets/node_modules/playwright/cli.js",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What version of playwright are we using/supporting? I imagine we're testing with a specific version?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. I honestly have no idea how stable the playwright protocol is.
Since its considered internal, there probably are no strong guarantees, so its difficult to say.

trace: System.get_env("PLAYWRIGHT_TRACE", "false") in ~w(t true)
]

config :logger, level: :warning

Expand All @@ -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
Empty file added diff
Empty file.
6 changes: 5 additions & 1 deletion lib/phoenix_test.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -230,6 +230,10 @@ defmodule PhoenixTest do
end
end

def visit(driver, path) when is_struct(driver) do
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like the easiest, extensible way of adding custom drivers.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, seems sensible to me. 👍

Driver.visit(driver, path)
end

defp all_headers(conn) do
Enum.map(conn.req_headers, &elem(&1, 0))
end
Expand Down
120 changes: 120 additions & 0 deletions lib/phoenix_test/case.ex
Original file line number Diff line number Diff line change
@@ -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]
Comment on lines +27 to +36
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is in a setup_all. Does this mean that we'll launch Playwright browser once so long as any test uses playwright?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, exactly. The browser is only launched once.
And then one session within that browser per test (think new user profile + window).

And Connection.ensure_started provides lazy launching of the playwright node.js server, so it is only started if actually required by a test.


_ ->
: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
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't have to change, but might be easier to follow the code below if we alias Connection instead of importing it here.


alias PhoenixTest.Playwright.Browser
alias PhoenixTest.Playwright.BrowserContext

@includes_ecto Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) &&
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

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)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice. I love to see this.

I know we're working with a port to do this. Have you seen any zombie OS processes being left open if we crash in a weird way during the tests?

I'm thinking of this https://hexdocs.pm/elixir/Port.html#module-zombie-operating-system-processes

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
1 change: 1 addition & 0 deletions lib/phoenix_test/driver.ex
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
2 changes: 2 additions & 0 deletions lib/phoenix_test/live.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading