Skip to main content

lib/mix/tasks/scoria.ui.e2e.ex

defmodule Mix.Tasks.Scoria.Ui.E2e do
  @shortdoc "Runs the dashboard e2e assertion lane (Playwright) against a running dev server"

  @moduledoc """
  Runs the real-browser e2e assertion lane for the Scoria dashboard.

  This is the Tier 2 complement to the server-rendered LiveView tests: it asserts
  the truths a `Phoenix.LiveViewTest` (Floki, no JS engine) cannot reach —
  client-side JS execution (`JS.hide` auto-dismiss), CSS layout, and async
  re-render in a live browser. Specs live in `priv/dev/e2e/*.spec.mjs` and are
  picked up automatically (the lane is `testDir`-driven), so a future phase adds
  a spec file with no new mix task.

  Like `mix scoria.ui.shots`, this task does **not** boot a web server — the
  Node/Playwright process drives an already-running dev server. It starts only
  Repo/PubSub locally to top up destructive e2e fixtures before Playwright runs,
  unless `--no-seed-approvals` is passed.

  ## Usage

      mix scoria.ui.e2e [--base-url http://localhost:4799/scoria] [--no-seed-approvals]

  ## Prerequisites

    * `Node.js >= 18` on `PATH`.
    * The e2e deps + Chromium installed:

          npm --prefix priv/dev ci
          npx --prefix priv/dev playwright install --with-deps chromium

    * The dev database created and migrated, and the dev server running:

          make dev

  Then, in a second shell:

      mix scoria.ui.e2e --base-url http://localhost:4799/scoria

  ## Notes

    * Spec files are dev-only and excluded from the Hex package (`priv/dev` is not
      in `mix.exs` `files:`).
    * The toast specs make destructive approval decisions. This task tops up
      pending approval fixtures for tenant `acme-corp` before Playwright runs so
      local and CI reruns are repeatable.
  """

  use Mix.Task

  import Ecto.Query, only: [from: 2]

  @pending_approval_floor 5
  @switches [base_url: :string, url: :string, seed_approvals: :boolean]

  @impl Mix.Task
  def run(args) do
    {opts, _, invalid} = OptionParser.parse(args, strict: @switches)

    if invalid != [] do
      Mix.raise("invalid options: #{inspect(invalid)}")
    end

    if is_nil(System.find_executable("node")) do
      Mix.raise("""
      Cannot find the `node` executable. Install Node.js >= 18 and ensure it
      is on your PATH, then re-run:

          mix scoria.ui.e2e
      """)
    end

    if is_nil(System.find_executable("npx")) do
      Mix.raise("Cannot find `npx` (ships with Node.js >= 18). Install Node.js and re-run.")
    end

    base_url = opts[:base_url] || opts[:url] || "http://localhost:4799/scoria"
    priv_dev = Path.join([File.cwd!(), "priv", "dev"])

    if Keyword.get(opts, :seed_approvals, true) do
      ensure_pending_approval_fixtures!()
    end

    # Resolve a few seeded demo ids (dev_seed.exs) so specs that must land on a
    # specific object page stay deterministic without hardcoding UUIDs. Best-effort:
    # if the DB is unavailable the spec skips those checks rather than failing.
    demo_env = resolve_demo_env()

    Mix.shell().info("[scoria.ui.e2e] Running Playwright e2e against #{base_url} ...")

    # Discrete args list — never a shell command string (T-11-04 injection mitigation).
    # Run from priv/dev so @playwright/test resolves from priv/dev/node_modules; the
    # config's testDir is relative to the config file (priv/dev/e2e).
    cmd_args = ["playwright", "test", "--config", "e2e/playwright.config.mjs"]

    case System.cmd("npx", cmd_args,
           cd: priv_dev,
           env: [{"PLAYWRIGHT_BASE_URL", base_url} | demo_env],
           stderr_to_stdout: true,
           into: IO.stream()
         ) do
      {_, 0} ->
        Mix.shell().info(
          "[scoria.ui.e2e] Done. Report: priv/dev/e2e/playwright-report/index.html"
        )

        :ok

      {_, code} ->
        Mix.raise("playwright e2e exited with code #{code}")
    end
  end

  # Best-effort resolution of seeded demo ids exported to Playwright as env vars.
  # Specs read these (e.g. SCORIA_E2E_REPLAY_RUN_ID) to deep-link a specific object
  # page and skip gracefully when absent. Never fails the run — returns [] on error.
  defp resolve_demo_env do
    start_fixture_services!()
    tenant_id = Scoria.SupportJourney.tenant_id()

    replay_run_id =
      Scoria.Repo.one(
        from(r in Scoria.Workflows.Run,
          where: r.tenant_id == ^tenant_id and r.execution_mode == "replay",
          order_by: [desc: r.inserted_at],
          limit: 1,
          select: r.id
        )
      )

    case replay_run_id do
      nil ->
        []

      id ->
        Mix.shell().info("[scoria.ui.e2e] Seeded replay run id: #{id}")
        [{"SCORIA_E2E_REPLAY_RUN_ID", to_string(id)}]
    end
  rescue
    _ -> []
  end

  defp ensure_pending_approval_fixtures! do
    start_fixture_services!()

    tenant_id = Scoria.SupportJourney.tenant_id()

    existing =
      %{tenant_id: tenant_id}
      |> Scoria.Workflows.list_pending_remote_approvals()
      |> length()

    missing = max(@pending_approval_floor - existing, 0)

    if missing > 0 do
      for _ <- 1..missing do
        create_pending_approval_fixture!()
      end
    end

    available = existing + missing

    Mix.shell().info(
      "[scoria.ui.e2e] Pending approval fixtures ready: #{available} for tenant #{tenant_id}"
    )
  rescue
    error ->
      Mix.raise("""
      Could not prepare e2e approval fixtures: #{Exception.message(error)}

      Ensure the dev database is created and migrated, then re-run:

          make dev
          mix scoria.ui.e2e
      """)
  end

  defp start_fixture_services! do
    {:ok, _} = Application.ensure_all_started(:ecto_sql)
    {:ok, _} = Application.ensure_all_started(:postgrex)
    {:ok, _} = Application.ensure_all_started(:phoenix_pubsub)

    start_repo!()
    start_pubsub!()
  end

  defp start_repo! do
    case Process.whereis(Scoria.Repo) do
      nil ->
        case Scoria.Repo.start_link() do
          {:ok, _pid} -> :ok
          {:error, {:already_started, _pid}} -> :ok
          {:error, reason} -> raise "could not start Scoria.Repo: #{inspect(reason)}"
        end

      _pid ->
        :ok
    end
  end

  defp start_pubsub! do
    case Process.whereis(Scoria.PubSub) do
      nil ->
        case Phoenix.PubSub.Supervisor.start_link(name: Scoria.PubSub) do
          {:ok, _pid} -> :ok
          {:error, {:already_started, _pid}} -> :ok
          {:error, reason} -> raise "could not start Scoria.PubSub: #{inspect(reason)}"
        end

      _pid ->
        :ok
    end
  end

  defp create_pending_approval_fixture! do
    support = Scoria.SupportJourney
    workflows = Scoria.Workflows

    {:ok, approval_run} =
      workflows.create_run(%{
        root_role_id: "support_agent",
        tenant_id: support.tenant_id(),
        session_id: support.session_id()
      })

    {:ok, approval_step} =
      workflows.create_step(approval_run.id, %{
        sequence: 1,
        kind: "approval",
        role_id: "support_agent",
        status: "running"
      })

    {:ok, _approval} =
      workflows.mark_waiting_for_approval(approval_run.id, approval_step.id, %{
        tool_name: support.refund_approval_tool(),
        arguments: %{"ticket_id" => support.ticket_fixture()["id"]},
        reason: "Refund requires operator approval"
      })
  end
end