Skip to main content

lib/mix/tasks/attached_dashboard.screenshots.ex

# Supporting modules (Repo/Router/Endpoint/…) only compile in :screenshots
# env — they pull in SQLite/plug_cowboy/wallaby which aren't available
# in :dev/:test/:prod.
if Mix.env() == :screenshots do
  defmodule AttachedDashboard.Screenshots.ErrorHTML do
    @moduledoc false
    def render("500.html", _), do: "Internal Server Error"
    def render("404.html", _), do: "Not Found"
  end

  defmodule AttachedDashboard.Screenshots.Repo do
    @moduledoc false
    use Ecto.Repo, otp_app: :attached_dashboard, adapter: Ecto.Adapters.SQLite3
  end

  defmodule AttachedDashboard.Screenshots.Router do
    @moduledoc false
    use Phoenix.Router
    import AttachedDashboard.Web.Router

    pipeline :browser do
      plug :accepts, ["html"]
      plug :fetch_session
      plug :put_root_layout, {AttachedDashboard.Web.Components.Layouts, :root}
    end

    scope "/" do
      pipe_through :browser
      attached_dashboard("/files")
    end
  end

  defmodule AttachedDashboard.Screenshots.Endpoint do
    @moduledoc false
    use Phoenix.Endpoint, otp_app: :attached_dashboard

    socket "/live", Phoenix.LiveView.Socket

    plug Plug.Session,
      store: :cookie,
      key: "_attached_dashboard_screenshots_key",
      signing_salt: "attached_dashboard_shots!!"

    plug AttachedDashboard.Screenshots.Router
  end

  defmodule AttachedDashboard.Screenshots.AttachedMigration do
    @moduledoc false
    use Ecto.Migration
    def up, do: Attached.Ecto.Migration.up()
    def down, do: Attached.Ecto.Migration.down()
  end

  defmodule AttachedDashboard.Screenshots.ObanMigration do
    @moduledoc false
    use Ecto.Migration
    def change, do: Oban.Migration.up(version: 12)
  end
end

defmodule Mix.Tasks.AttachedDashboard.Screenshots do
  @shortdoc "Generates dashboard documentation screenshots via headless Chromium"

  @moduledoc """
  Generates PNG screenshots of every dashboard page into `docs/screenshots/`.

  Boots a self-contained Phoenix endpoint against an in-memory SQLite repo,
  seeds deterministic fixtures, then drives a Chromium-based browser via
  Wallaby.

  Manual task — not wired into CI. Run locally whenever the UI changes and
  commit the updated PNGs.

      mix attached_dashboard.screenshots

  (The task forces `MIX_ENV=screenshots` via preferred_envs, so you do not
  need to set it manually.)

  Requires:

    * `chromedriver` on PATH (provided by the Nix flake)
    * A Chromium-based browser on the host — Helium, Chrome, Chromium,
      Brave, or Arc. Auto-detected, or set `CHROME_BINARY=/path/to/binary`.

  Everything screenshot-specific (Endpoint, Router, Repo, seed data, browser
  config) lives in this file — the test suite is untouched.
  """

  use Mix.Task

  # Supporting modules + Wallaby only exist in :screenshots env; silence
  # compile warnings when this task is compiled elsewhere. The task
  # raises at runtime if invoked outside :screenshots.
  @compile {:no_warn_undefined,
            [
              AttachedDashboard.Screenshots.Endpoint,
              AttachedDashboard.Screenshots.Repo,
              AttachedDashboard.Screenshots.AttachedMigration,
              AttachedDashboard.Screenshots.ObanMigration,
              AttachedDashboard.Screenshots.ErrorHTML,
              Wallaby,
              Wallaby.Browser,
              Wallaby.Chrome
            ]}

  alias AttachedDashboard.Screenshots.{AttachedMigration, Endpoint, Repo, ObanMigration}
  alias Attached.Originals.Original
  alias Attached.Variants.Variant
  import Ecto.Query

  @port 4737
  @base_url "http://localhost:#{@port}"
  @output_dir "docs/screenshots"
  @window_size [width: 1440, height: 900]

  @impl Mix.Task
  def run(_args) do
    unless Mix.env() == :screenshots do
      Mix.raise("Run with: mix attached_dashboard.screenshots (forces MIX_ENV=screenshots)")
    end

    Mix.Task.run("app.config")
    Application.ensure_all_started(:phoenix)
    Application.ensure_all_started(:phoenix_live_view)

    File.mkdir_p!(@output_dir)

    configure_wallaby!()
    {:ok, _} = Application.ensure_all_started(:wallaby)

    boot_app!()
    seed!()

    {:ok, session} = Wallaby.start_session(window_size: @window_size)

    shots = [
      {"overview", "/files"},
      {"originals_index", "/files/originals"},
      {"variants_index", "/files/variants"},
      {"owners_index", "/files/owners"},
      {"processors_index", "/files/processors"},
      {"orphans_index", "/files/orphans"}
    ]

    Enum.each(shots, fn {name, path} ->
      Mix.shell().info("  → #{name} (#{path})")

      session
      |> Wallaby.Browser.visit(@base_url <> path)
      |> take_shot(name)
    end)

    hero = Repo.one(from b in Original, where: b.filename == "hero-banner.jpg")

    variant =
      hero &&
        Repo.one(
          from v in Variant,
            where: v.original_id == ^hero.id and v.name == "medium"
        )

    if hero do
      Mix.shell().info("  → originals_show")

      session
      |> Wallaby.Browser.visit(@base_url <> "/files/originals/#{hero.id}")
      |> take_shot("originals_show")
    end

    if variant do
      Mix.shell().info("  → variants_show")

      session
      |> Wallaby.Browser.visit(@base_url <> "/files/originals/#{variant.original_id}/variants/#{variant.id}")
      |> take_shot("variants_show")
    end

    Wallaby.end_session(session)
    Mix.shell().info("\nDone. Screenshots in #{@output_dir}/")
  end

  # --------------------------------------------------------------------------
  # App boot
  # --------------------------------------------------------------------------

  defp boot_app! do
    Application.put_env(:attached_dashboard, Endpoint,
      check_origin: false,
      live_view: [signing_salt: "attached_dashboard_shot_salt!!"],
      render_errors: [formats: [html: AttachedDashboard.Screenshots.ErrorHTML], layout: false],
      secret_key_base: String.duplicate("a", 64),
      server: true,
      http: [port: @port],
      url: [host: "localhost", port: @port]
    )

    {:ok, _} = Repo.start_link()
    migrate!()

    {:ok, _} = Application.ensure_all_started(:oban)

    {:ok, _} =
      Oban.start_link(
        name: Oban,
        repo: Repo,
        engine: Oban.Engines.Lite,
        testing: :manual,
        queues: false,
        plugins: false
      )

    {:ok, _} = Endpoint.start_link()
    :ok
  end

  defp migrate! do
    # Canonical schema for `attached_originals` — same migration real apps run.
    Ecto.Migrator.up(Repo, 0, AttachedMigration, log: false)
    Ecto.Migrator.up(Repo, 1, ObanMigration, log: false)

    # Fake owner tables the `seed!/0` fixture references. Non-ghost tables
    # get populated with a row per original so those originals look "alive" to the
    # orphan detector; `ghost_owners` stays empty so its two originals show up
    # as orphans on the dashboard.
    sql = fn q -> Ecto.Adapters.SQL.query!(Repo, q) end

    for {table, fields} <- owner_tables() do
      columns = Enum.map_join(fields, ",\n          ", &"#{&1} TEXT")

      sql.("""
        CREATE TABLE IF NOT EXISTS #{table} (
          id TEXT PRIMARY KEY,
          #{columns}
        )
      """)
    end

    :ok
  end

  defp owner_tables do
    [
      {"posts", ["cover_attached_original_id"]},
      {"users", ["avatar_attached_original_id"]},
      {"documents", ["file_attached_original_id"]},
      {"lessons", ["intro_attached_original_id"]},
      {"ghost_owners", ["photo_attached_original_id", "avatar_attached_original_id"]}
    ]
  end

  # --------------------------------------------------------------------------
  # Seed data — deterministic realistic fixture
  # --------------------------------------------------------------------------

  defp seed! do
    now = DateTime.utc_now() |> DateTime.truncate(:second)

    hero =
      insert_original_with_owner!(
        filename: "hero-banner.jpg",
        content_type: "image/jpeg",
        byte_size: 482_117,
        storage_backend: "Attached.StorageBackends.Disk",
        metadata: %{"width" => "1920", "height" => "1080", "color_space" => "sRGB"},
        owner_table: "posts",
        owner_field: "cover_attached_original_id",
        inserted_at: shift(now, -3600 * 2)
      )

    insert_original_with_owner!(
      filename: "profile-photo.png",
      content_type: "image/png",
      byte_size: 94_321,
      storage_backend: "Attached.StorageBackends.Disk",
      metadata: %{"width" => "512", "height" => "512"},
      owner_table: "users",
      owner_field: "avatar_attached_original_id",
      inserted_at: shift(now, -3600 * 4)
    )

    insert_original_with_owner!(
      filename: "terms-2026.pdf",
      content_type: "application/pdf",
      byte_size: 218_440,
      storage_backend: "Attached.StorageBackends.S3",
      metadata: %{"page_count" => "14"},
      owner_table: "documents",
      owner_field: "file_attached_original_id",
      inserted_at: shift(now, -3600 * 24)
    )

    insert_original_with_owner!(
      filename: "intro-video.mp4",
      content_type: "video/mp4",
      byte_size: 8_421_330,
      storage_backend: "Attached.StorageBackends.S3",
      metadata: %{"duration" => "42.1", "width" => "1280", "height" => "720"},
      owner_table: "lessons",
      owner_field: "intro_attached_original_id",
      inserted_at: shift(now, -3600 * 48)
    )

    insert_variant!(hero,
      name: "thumbnail",
      content_type: "image/webp",
      byte_size: 18_210,
      metadata: %{"width" => "240", "height" => "135"}
    )

    insert_variant!(hero,
      name: "medium",
      content_type: "image/webp",
      byte_size: 74_904,
      metadata: %{"width" => "800", "height" => "450"}
    )

    # Orphans — originals whose owner row is gone
    insert_original!(
      filename: "abandoned-upload.png",
      content_type: "image/png",
      byte_size: 12_004,
      owner_table: "ghost_owners",
      owner_field: "photo_attached_original_id",
      inserted_at: shift(now, -3600 * 72)
    )

    insert_original!(
      filename: "abandoned-avatar.jpg",
      content_type: "image/jpeg",
      byte_size: 33_110,
      owner_table: "ghost_owners",
      owner_field: "avatar_attached_original_id",
      inserted_at: shift(now, -3600 * 96)
    )

    :ok
  end

  # Inserts an original AND a corresponding row in its owner table, so the
  # orphan detector treats the original as "alive". Use `insert_original!` directly
  # to leave the owner side missing (→ orphan).
  defp insert_original_with_owner!(attrs) do
    original = insert_original!(attrs)
    attrs = Map.new(attrs)

    Ecto.Adapters.SQL.query!(
      Repo,
      "INSERT INTO #{Map.fetch!(attrs, :owner_table)} (id, #{Map.fetch!(attrs, :owner_field)}) VALUES (?, ?)",
      [Ecto.UUID.generate(), original.id]
    )

    original
  end

  defp insert_original!(attrs) do
    attrs = Map.new(attrs)
    now = DateTime.utc_now() |> DateTime.truncate(:second)

    %Original{}
    |> Ecto.Changeset.change(
      Map.merge(
        %{
          id: Ecto.UUID.generate(),
          key: "key-#{System.unique_integer([:positive])}",
          checksum: "abc123==",
          metadata: %{},
          storage_backend: "Attached.StorageBackends.Disk",
          inserted_at: now,
          updated_at: now
        },
        attrs
      )
    )
    |> Repo.insert!()
  end

  defp insert_variant!(%Original{} = parent, attrs) do
    attrs = Map.new(attrs)
    now = DateTime.utc_now() |> DateTime.truncate(:second)

    %Variant{}
    |> Ecto.Changeset.change(
      Map.merge(
        %{
          id: Ecto.UUID.generate(),
          original_id: parent.id,
          transform_digest: "digest#{System.unique_integer([:positive])}",
          checksum: "seed==",
          inserted_at: now,
          updated_at: now
        },
        attrs
      )
    )
    |> Repo.insert!()
  end

  defp shift(dt, seconds), do: DateTime.add(dt, seconds, :second)

  # --------------------------------------------------------------------------
  # Wallaby / Chromium config
  # --------------------------------------------------------------------------

  defp take_shot(session, name) do
    # Give LiveView a moment to settle (initial render + connect).
    Process.sleep(400)
    Wallaby.Browser.take_screenshot(session, name: name)
    canonical = Path.join(@output_dir, "#{name}.png")

    unless File.exists?(canonical), do: rename_latest_screenshot(name)

    session
  end

  defp rename_latest_screenshot(name) do
    case File.ls(@output_dir) do
      {:ok, files} ->
        latest =
          files
          |> Enum.filter(&String.ends_with?(&1, ".png"))
          |> Enum.map(&Path.join(@output_dir, &1))
          |> Enum.sort_by(&File.stat!(&1).mtime, :desc)
          |> List.first()

        if latest, do: File.rename!(latest, Path.join(@output_dir, "#{name}.png"))

      _ ->
        :ok
    end
  end

  defp configure_wallaby! do
    Application.put_env(:wallaby, :base_url, @base_url)
    Application.put_env(:wallaby, :screenshot_dir, @output_dir)
    Application.put_env(:wallaby, :screenshot_on_failure, false)
    Application.put_env(:wallaby, :driver, Wallaby.Chrome)

    chrome_options = %{
      binary: chrome_binary(),
      args: [
        "--headless=new",
        "--no-sandbox",
        "--disable-gpu",
        "--disable-dev-shm-usage",
        "--window-size=#{@window_size[:width]},#{@window_size[:height]}"
      ]
    }

    Application.put_env(:wallaby, :chromedriver,
      headless: true,
      binary: chrome_binary(),
      capabilities: %{chromeOptions: chrome_options}
    )
  end

  # Any Chromium-based browser works — auto-detect common installs, or
  # override with CHROME_BINARY=/path/to/binary.
  defp chrome_binary do
    System.get_env("CHROME_BINARY") ||
      Enum.find(
        [
          "/Applications/Helium.app/Contents/MacOS/Helium",
          "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
          "/Applications/Chromium.app/Contents/MacOS/Chromium",
          "/Applications/Arc.app/Contents/MacOS/Arc",
          "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
          "/usr/bin/chromium",
          "/usr/bin/google-chrome"
        ],
        &File.exists?/1
      ) ||
      Mix.raise("""
      No Chromium-based browser found. Install one of:
        brew install --cask helium          (or Chrome, Chromium, Arc, Brave)
      Or set CHROME_BINARY=/path/to/binary.
      """)
  end
end