Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
<!-- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -->

## [Unreleased]
### Changed
- Ecto sandbox ownership: Use a separate sandbox owner process instead of the test process. This reduces ownership errors when LiveViews continue to use database connections after the test terminates. Commit [7577d5e]
### Added
- Config option `sandbox_shutdown_delay`: Delay in milliseconds before shutting down the Ecto sandbox owner. Use when LiveViews or other processes need time to stop using the connections. Commit [7577d5e]

## [0.9.1] 2025-10-29
### Added
- Browser pooling (opt-in): Reduced memory, higher speed. See `PhoenixTest.Playwright.BrowserPool`. Commit [00e75c6]
Expand Down Expand Up @@ -126,6 +132,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
- `@tag trace: :open` to auto open recorded Playwright trace in viewer

[7577d5e]: https://github.com/ftes/phoenix_test_playwright/commit/7577d5e
[5ff530]: https://github.com/ftes/phoenix_test_playwright/commit/5ff530
[becf5e]: https://github.com/ftes/phoenix_test_playwright/commit/becf5e
[72edd9]: https://github.com/ftes/phoenix_test_playwright/commit/72edd9
Expand Down
52 changes: 48 additions & 4 deletions lib/phoenix_test/playwright.ex
Original file line number Diff line number Diff line change
Expand Up @@ -201,11 +201,55 @@ defmodule PhoenixTest.Playwright do
use PhoenixTest.Playwright.Case, async: true
```

`PhoenixTest.Playwright.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).
`PhoenixTest.Playwright.Case` automatically takes care of this. It starts the
sandbox under a separate process than your test and uses
`ExUnit.Callbacks.on_exit/1` to ensure the sandbox is shut down afterward. It
also sends a `User-Agent` header with the
`Phoenix.Ecto.SQL.Sandbox.html.metadata_for/3` your Ecto repos. This allows
the sandbox to be shared with the LiveView and other processes which need to
use the database inside the same transaction as the test. It also allows for
[concurrent browser
tests](https://hexdocs.pm/phoenix_ecto/main.html#concurrent-browser-tests).

### Ownership errors with LiveViews

Unlike `Phoenix.LiveViewTest`, which controls the lifecycle of LiveView
processes being tested, Playwright tests may end while such processes are
still using the sandbox.

In that case, you may encounter ownership errors like:
```
** (DBConnection.OwnershipError) cannot find owner for ...
```

To prevent this, the `sandbox_shutdown_delay` option allows you to delay the
sandbox owner's shutdown, giving LiveViews and other processes time to close
their database connections. The delay happens during
`ExUnit.Callbacks.on_exit/1`, which blocks the running of the next test, so
it affects test runtime as if it were a `Process.sleep/1` at the end of your
test.

So you probably want to use **as small a delay as you can**, and only for the
tests that need it, using `@tag` (or `@describetag` or `@moduletag`) like:

```
@tag sandbox_shutdown_delay: 100 # 100ms
test "does something" do
# ...
end
```

If you want to set a global default, you can:


```elixir
# config/test.exs
config :phoenix_test, playwright: [
sandbox_shutdown_delay: 50 # 50ms
]
```

Make sure to follow the advanced set up instructions if necessary:
For more details on LiveView and Ecto integration, see the advanced set up instructions:
- [with LiveViews](https://hexdocs.pm/phoenix_ecto/Phoenix.Ecto.SQL.Sandbox.html#module-acceptance-tests-with-liveviews)
- [with Channels](https://hexdocs.pm/phoenix_ecto/Phoenix.Ecto.SQL.Sandbox.html#module-acceptance-tests-with-channels)

Expand Down
42 changes: 31 additions & 11 deletions lib/phoenix_test/playwright/case.ex
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ defmodule PhoenixTest.Playwright.Case do
browser_context_opts =
Enum.into(config[:browser_context_opts], %{
locale: "en",
user_agent: checkout_ecto_repos(context.async) || "No user agent"
user_agent: checkout_ecto_repos(config, context) || "No user agent"
})

{:ok, browser_context_id} = Playwright.Browser.new_context(context.browser_id, browser_context_opts)
Expand Down Expand Up @@ -157,29 +157,49 @@ defmodule PhoenixTest.Playwright.Case do
Code.ensure_loaded?(Phoenix.Ecto.SQL.Sandbox)

if @includes_ecto do
defp checkout_ecto_repos(async?) do
defp checkout_ecto_repos(config, context) do
otp_app = Application.fetch_env!(:phoenix_test, :otp_app)
repos = Application.get_env(otp_app, :ecto_repos, [])

repos
|> Enum.map(&checkout_ecto_repo(&1, async?))
|> Enum.map(&maybe_start_sandbox_owner(&1, context, config))
|> Phoenix.Ecto.SQL.Sandbox.metadata_for(self())
|> Phoenix.Ecto.SQL.Sandbox.encode_metadata()
end

defp checkout_ecto_repo(repo, async?) do
case Sandbox.checkout(repo) do
:ok -> :ok
{:already, :allowed} -> :ok
{:already, :owner} -> :ok
end
defp maybe_start_sandbox_owner(repo, context, config) do
case start_sandbox_owner(repo, context) do
{:ok, pid} ->
on_exit(fn -> stop_sandbox_owner(pid, config[:sandbox_shutdown_delay], context.async) end)

if not async?, do: Sandbox.mode(repo, {:shared, self()})
_ ->
:ok
end

repo
end

defp start_sandbox_owner(repo, context) do
pid = Sandbox.start_owner!(repo, shared: !context.async)
{:ok, pid}
rescue
_ -> {:error, :probably_already_started}
Copy link
Owner

Choose a reason for hiding this comment

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

@nathanl Do you remember if this blanket rescue clause was just a guess, or handling actual errors you saw?

I've got a report of this rescue masking checkout errors.

Copy link
Owner

Choose a reason for hiding this comment

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

FYI removing the blanket rescue for now in 0a8538c

end

defp stop_sandbox_owner(checkout_pid, delay, async?) do
if async? do
spawn(fn -> do_stop_sandbox_owner(checkout_pid, delay) end)
else
do_stop_sandbox_owner(checkout_pid, delay)
end
end

defp do_stop_sandbox_owner(checkout_pid, delay) do
if delay > 0, do: Process.sleep(delay)
Sandbox.stop_owner(checkout_pid)
end
else
defp checkout_ecto_repos(_) do
defp checkout_ecto_repos(_, _) do
nil
end
end
Expand Down
12 changes: 11 additions & 1 deletion lib/phoenix_test/playwright/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,16 @@ schema_opts = [
Accepts either a binary executable exposed in PATH or the absolute path to it.
"""
],
sandbox_shutdown_delay: [
default: 0,
type: :non_neg_integer,
doc: """
Delay in milliseconds before shutting down the Ecto sandbox owner after a
test ends. Use this to allow LiveViews and other processes in your app
time to stop using database connections before the sandbox owner is
terminated. Default is 0 (immediate shutdown).
"""
],
screenshot: [
default: false,
type: {:or, [:boolean, non_empty_keyword_list: screenshot_opts_schema]},
Expand Down Expand Up @@ -135,7 +145,7 @@ schema_opts = [
schema = NimbleOptions.new!(schema_opts)

setup_all_keys = ~w(browser_pool browser browser_launch_timeout executable_path headless slow_mo)a
setup_keys = ~w(accept_dialogs screenshot trace browser_context_opts browser_page_opts)a
setup_keys = ~w(accept_dialogs sandbox_shutdown_delay screenshot trace browser_context_opts browser_page_opts)a

defmodule PhoenixTest.Playwright.Config do
@moduledoc """
Expand Down