Running a LiveView test with `async: true`

This definitely is a problem with my approach.

The only thing I can think to do to fix it is to make wrapper functions around the async live view functions and then write a credo rule to make sure nobody calls assign_async and start_async in the code. :person_shrugging:

I am going to supply a rough draft to workaround the async live view functions problem.

Hefty disclaimer: this is a hacky workaround and not an elegant solution. The need for this workaround should probably be interpreted as a sign to re-consider the entire approach.

With that said, I am doing this in one of my personal projects.

OK, here we go.

Make this new module:

defmodule MyAppWeb.Utils.LiveViewAsyncUtils do
  @moduledoc """
  Override the `assign_async` and `start_async`functions from `Phoenix.LiveView`

  We do this to allow checking out the `auto` mode Ecto Sandbox in tests.
  """

  alias Ecto.Adapters.SQL.Sandbox
  alias MyApp.Repo
  alias Phoenix.LiveView.Async

  def assign_async_aug(socket, key_or_keys, func, opts \\ []) do
    func_aug = build_augmented_func(func)
    # credo:disable-for-lines:1 MyApp.CustomCredo.Check.Design.NoLiveViewAsyncFunctions
    Async.assign_async(socket, key_or_keys, func_aug, opts)
  end

  def start_async_aug(socket, key_or_keys, func, opts \\ []) do
    func_aug = build_augmented_func(func)
    # credo:disable-for-lines:1 MyApp.CustomCredo.Check.Design.NoLiveViewAsyncFunctions
    Async.start_async(socket, key_or_keys, func_aug, opts)
  end

  defp build_augmented_func(func) do
    parent_pid = self()

    fn ->
      Sandbox.allow(Repo, parent_pid, self())
      func.()
    end
  end
end

then add this to html_helpers function in my_app_web.ex

  import MyAppWeb.Utils.LiveViewAsyncUtils

and then add this credo rule to .credo.exs

          {MyApp.CustomCredo.Check.Design.NoLiveViewAsyncFunctions},

and then add that new credo check somewhere in your project

defmodule MyApp.CustomCredo.Check.Design.NoLiveViewAsyncFunctions do
  @moduledoc false
  use Credo.Check,
    base_priority: :high,
    category: :design,
    explanations: [
      check: """
      This check ensures that `assign_async` and `start_async` from Phoenix.LiveView.Async
      are not used directly in the codebase.

      These functions are disallowed because they do not work correctly
      in async tests when Sandbox is in auto mode.
      """
    ]

  @doc false
  @impl true
  def run(%SourceFile{} = source_file, params) do
    issue_meta = IssueMeta.for(source_file, params)

    Credo.Code.prewalk(source_file, &traverse(&1, &2, issue_meta))
  end

  # Match full module path: Phoenix.LiveView.Async.assign_async/start_async
  defp traverse(
         {:., meta, [{:__aliases__, _, [Phoenix, LiveView, Async]}, func]} = ast,
         issues,
         issue_meta
       )
       when func in [:assign_async, :start_async] do
    {ast, [issue_for(issue_meta, func, meta[:line]) | issues]}
  end

  # Match aliased form: Async.assign_async/start_async
  defp traverse({:., meta, [{:__aliases__, _, [Async]}, func]} = ast, issues, issue_meta)
       when func in [:assign_async, :start_async] do
    {ast, [issue_for(issue_meta, func, meta[:line]) | issues]}
  end

  # Match direct function calls: assign_async/start_async (imported)
  defp traverse({func, meta, _args} = ast, issues, issue_meta)
       when func in [:assign_async, :start_async] do
    {ast, [issue_for(issue_meta, func, meta[:line]) | issues]}
  end

  defp traverse(ast, issues, _issue_meta) do
    {ast, issues}
  end

  defp issue_for(issue_meta, function_name, line_no) do
    format_issue(
      issue_meta,
      message:
        "Found disallowed call to Phoenix.LiveView.Async.#{function_name}/3. Use `MyAppWeb.Utils.LiveViewAsyncUtils.#{function_name}_aug/3` instead",
      trigger: "#{function_name}",
      line_no: line_no
    )
  end
end

and finally run mix credo to see what needs fixing.

I am not recommending that anybody do this. I only provide the information because I feel responsible for people who followed the OP.

Allowance can also be shared through caller tracking, which Tasks implement:

defmodule TrialTest do
  use Async.DataCase, async: true

  test "b" do
    IO.inspect("Start a")

    Task.async(fn ->
      {:ok, %{rows: [row]}} = Repo.query("SELECT txid_current()")
      IO.inspect(row, label: "a")
    end)

    Process.sleep(10000)
    IO.inspect("Stop a")
  end
end

defmodule Trial2Test do
  use Async.DataCase, async: true

  test "b" do
    IO.inspect("Start b")

    Task.async(fn ->
      {:ok, %{rows: [row]}} = Repo.query("SELECT txid_current()")
      IO.inspect(row, label: "b")
    end)

    Process.sleep(10000)
    IO.inspect("Stop b")
  end
end

Running that results in the following. No error and wrapped in distinct transactions.

"Start a"
a: [287939]
"Start b"
b: [287940]
"Stop a"
."Stop b".

The same mechanism should also apply for all the LV async apis.

1 Like

Ah I guess I see the issue now:

  def on_mount(:default, _params, session, socket) do
    if connected?(socket) do
      %{repo: repo, owner: owner} = get_in(session["sandbox"])

      Ecto.Adapters.SQL.Sandbox.allow(repo, owner, self())
    end

    {:cont, socket}
  end

This is only allowing the current process, but not setting :"$callers" on the process dict.

It would probably need to look like this:

def on_mount(:default, _params, session, socket) do
  if connected?(socket) do
    %{repo: repo, owner: owner} = get_in(session["sandbox"])
    Ecto.Adapters.SQL.Sandbox.allow(repo, owner, self())
    Process.put(:"$callers", [owner])
  end

  {:cont, socket}
end

Edit:
I’ve opened an issue about this:

6 Likes

Thank you for taking the time to help us. I hope others too will find the technique and the discussion helpful.