# 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