add mix lighthouse task for PageSpeed auditing
Single-file Mix task that runs Google Lighthouse against the shop and checks scores against configurable thresholds. Builds production assets (minified + digested) before auditing for realistic scores. Waits for image variant cache to finish processing. Supports mobile/ desktop modes, custom thresholds, multiple pages. All 4 key pages score 95+ on mobile, 97+ on desktop. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
88291f276b
commit
516d0d0070
18
PROGRESS.md
18
PROGRESS.md
@ -20,7 +20,7 @@
|
||||
- Transactional emails (order confirmation, shipping notification)
|
||||
- Demo content polished and ready for production
|
||||
|
||||
**Tier 1 MVP complete.** CI pipeline done. Next up: Tier 2 — hosting & deployment.
|
||||
**Tier 1 MVP complete.** CI pipeline done. Hosting & deployment done (including observability). Next up: remaining Tier 2 items (Litestream backup, PageSpeed CI, e2e tests).
|
||||
|
||||
## Roadmap
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
|
||||
### Tier 2 — Production readiness (can deploy and run reliably)
|
||||
|
||||
5. **Hosting & deployment** — In progress. Alpine Docker image (131 MB), Fly.io config, release overlays, health check endpoint, hardcoded path fixes for releases. Still to do: observability (structured logging, error tracking, basic metrics).
|
||||
5. ~~**Hosting & deployment**~~ — ✅ Complete. Alpine Docker image (131 MB), Fly.io config, release overlays, health check endpoint, hardcoded path fixes for releases. Observability: LiveDashboard in prod behind admin auth, ErrorTracker for exception capture, JSON structured logging, Oban/LiveView telemetry metrics, os_mon for CPU/disk/memory.
|
||||
6. **Litestream / SQLite replication** — Litestream for continuous SQLite backup to S3-compatible storage. Point-in-time recovery. Simple sidecar process, no code changes needed, works with vanilla SQLite. For the hosted platform (Tier 5), evaluate [Turso](https://turso.tech/) (libSQL fork of SQLite) with embedded read replicas via [ecto_libsql](https://github.com/ocean/ecto_libsql) adapter — gives multi-node reads without a separate replication daemon, but adds a dependency on the libSQL fork.
|
||||
7. ~~**CI pipeline**~~ — ✅ Complete. `mix ci` alias: compile --warning-as-errors, deps.unlock --unused, format --check-formatted, credo, dialyzer, test. Credo configured with sensible defaults. Dialyzer with ignore file for false positives (Stripe types, Mix tasks, ExUnit internals). 612 tests, 0 failures.
|
||||
8. **PageSpeed in CI** — Lighthouse CI to catch regressions. Fail the build if score drops below threshold. Protects the current 100% score.
|
||||
@ -229,6 +229,18 @@ All shop pages now have LiveView integration tests (612 total):
|
||||
- All credo issues resolved (map_join, filter consolidation, nesting extraction)
|
||||
- 612 tests, 0 failures
|
||||
|
||||
### Hosting & Deployment
|
||||
**Status:** Complete
|
||||
|
||||
- Alpine Docker image (131 MB), Fly.io config, release overlays
|
||||
- Health check endpoint at `/health`
|
||||
- Hardcoded path fixes for releases (`Application.app_dir/2`)
|
||||
- LiveDashboard at `/admin/dashboard` behind admin auth (all envs)
|
||||
- ErrorTracker at `/admin/errors` — auto-captures Phoenix/LiveView/Oban exceptions, stored in SQLite, pruner for resolved errors
|
||||
- JSON structured logging in prod via `logger_json` (machine-parseable)
|
||||
- Oban job duration/count and LiveView mount/event telemetry metrics
|
||||
- os_mon for CPU, disk, and OS memory in LiveDashboard
|
||||
|
||||
### Page Editor
|
||||
**Status:** Future (Tier 4)
|
||||
|
||||
@ -242,6 +254,8 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
|
||||
|
||||
| Feature | Commit | Notes |
|
||||
|---------|--------|-------|
|
||||
| Observability | eaa4bbb | LiveDashboard in prod, ErrorTracker, JSON logging, Oban/LV metrics, os_mon |
|
||||
| Hosting & deployment | — | Alpine Docker, Fly.io, health check, release path fixes |
|
||||
| CI pipeline | — | mix ci/precommit aliases, credo, dialyzer, 612 tests |
|
||||
| Default content pages | 5a43cfc | Generic Content LiveView, delivery/privacy/terms pages, 10 tests |
|
||||
| Transactional emails | — | Plain text order confirmation + shipping notification, 10 tests |
|
||||
|
||||
359
lib/mix/tasks/lighthouse.ex
Normal file
359
lib/mix/tasks/lighthouse.ex
Normal file
@ -0,0 +1,359 @@
|
||||
defmodule Mix.Tasks.Lighthouse do
|
||||
@shortdoc "Run Lighthouse PageSpeed audit against the shop"
|
||||
@moduledoc """
|
||||
Runs Google Lighthouse against one or more pages and checks scores
|
||||
against configurable thresholds.
|
||||
|
||||
Starts the Phoenix server on port 4002 (so it won't clash with a
|
||||
running dev server), runs each audit, then prints a colour-coded
|
||||
pass/fail report. Exits with code 1 if any score falls below its
|
||||
threshold.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `lighthouse` CLI in PATH (`npm install -g lighthouse`)
|
||||
- Chrome or Chromium installed
|
||||
|
||||
## Usage
|
||||
|
||||
# Audit the home page with default thresholds (90 for all categories)
|
||||
mix lighthouse
|
||||
|
||||
# Audit specific pages
|
||||
mix lighthouse / /about /products/1
|
||||
|
||||
# Override thresholds
|
||||
mix lighthouse --performance 95 --accessibility 100
|
||||
|
||||
# Desktop mode (default is mobile)
|
||||
mix lighthouse --strategy desktop
|
||||
|
||||
## Options
|
||||
|
||||
* `--performance N` - Minimum performance score (default: 90)
|
||||
* `--accessibility N` - Minimum accessibility score (default: 90)
|
||||
* `--best-practices N` - Minimum best practices score (default: 90)
|
||||
* `--seo N` - Minimum SEO score (default: 90)
|
||||
* `--strategy MODE` - "mobile" or "desktop" (default: "mobile")
|
||||
* `--port PORT` - Port to start the server on (default: 4002)
|
||||
"""
|
||||
|
||||
use Mix.Task
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
@default_port 4002
|
||||
@default_strategy "mobile"
|
||||
@categories ["performance", "accessibility", "best-practices", "seo"]
|
||||
|
||||
@default_thresholds %{
|
||||
"performance" => 90,
|
||||
"accessibility" => 90,
|
||||
"best-practices" => 90,
|
||||
"seo" => 90
|
||||
}
|
||||
|
||||
@category_labels %{
|
||||
"performance" => "Performance",
|
||||
"accessibility" => "Accessibility",
|
||||
"best-practices" => "Best practices",
|
||||
"seo" => "SEO"
|
||||
}
|
||||
|
||||
@impl Mix.Task
|
||||
def run(args) do
|
||||
{opts, pages, _} =
|
||||
OptionParser.parse(args,
|
||||
strict: [
|
||||
performance: :integer,
|
||||
accessibility: :integer,
|
||||
best_practices: :integer,
|
||||
seo: :integer,
|
||||
strategy: :string,
|
||||
port: :integer
|
||||
]
|
||||
)
|
||||
|
||||
pages = if pages == [], do: ["/"], else: pages
|
||||
port = opts[:port] || @default_port
|
||||
strategy = opts[:strategy] || @default_strategy
|
||||
|
||||
unless strategy in ["mobile", "desktop"] do
|
||||
Mix.raise(~s(--strategy must be "mobile" or "desktop", got: #{inspect(strategy)}))
|
||||
end
|
||||
|
||||
thresholds =
|
||||
@default_thresholds
|
||||
|> maybe_put("performance", opts[:performance])
|
||||
|> maybe_put("accessibility", opts[:accessibility])
|
||||
|> maybe_put("best-practices", opts[:best_practices])
|
||||
|> maybe_put("seo", opts[:seo])
|
||||
|
||||
lighthouse_path = check_lighthouse!()
|
||||
build_prod_assets!()
|
||||
base_url = start_server!(port)
|
||||
wait_for_images()
|
||||
|
||||
Mix.shell().info("Running Lighthouse (#{strategy}) against #{length(pages)} page(s)...\n")
|
||||
|
||||
results =
|
||||
Enum.map(pages, fn path ->
|
||||
Mix.shell().info(" Auditing #{path}...")
|
||||
run_audit(lighthouse_path, base_url, path, strategy)
|
||||
end)
|
||||
|
||||
all_passed = print_results(results, thresholds)
|
||||
|
||||
unless all_passed do
|
||||
exit({:shutdown, 1})
|
||||
end
|
||||
end
|
||||
|
||||
# -- Prerequisites --
|
||||
|
||||
defp check_lighthouse! do
|
||||
case System.find_executable("lighthouse") do
|
||||
nil ->
|
||||
Mix.raise("""
|
||||
lighthouse CLI not found in PATH.
|
||||
|
||||
Install it with:
|
||||
npm install -g lighthouse
|
||||
|
||||
You'll also need Chrome or Chromium installed.
|
||||
""")
|
||||
|
||||
path ->
|
||||
path
|
||||
end
|
||||
end
|
||||
|
||||
# -- Asset build --
|
||||
|
||||
defp build_prod_assets! do
|
||||
Mix.shell().info("Building production assets...")
|
||||
|
||||
Mix.Task.run("tailwind", ["simpleshop_theme", "--minify"])
|
||||
Mix.Task.run("tailwind", ["simpleshop_theme_shop", "--minify"])
|
||||
Mix.Task.run("esbuild", ["simpleshop_theme", "--minify"])
|
||||
Mix.Task.run("phx.digest")
|
||||
|
||||
Mix.shell().info(" Assets built and digested.")
|
||||
end
|
||||
|
||||
# -- Server lifecycle --
|
||||
|
||||
defp start_server!(port) do
|
||||
config = Application.get_env(:simpleshop_theme, SimpleshopThemeWeb.Endpoint, [])
|
||||
|
||||
config =
|
||||
Keyword.merge(config,
|
||||
http: [ip: {127, 0, 0, 1}, port: port],
|
||||
server: true,
|
||||
watchers: [],
|
||||
cache_static_manifest: "priv/static/cache_manifest.json"
|
||||
)
|
||||
|
||||
Application.put_env(:simpleshop_theme, SimpleshopThemeWeb.Endpoint, config)
|
||||
|
||||
Mix.Task.run("app.start")
|
||||
|
||||
base_url = "http://localhost:#{port}"
|
||||
wait_for_server(base_url, 20)
|
||||
base_url
|
||||
end
|
||||
|
||||
defp wait_for_server(_url, 0) do
|
||||
Mix.raise("Server failed to start — timed out waiting for it to accept connections")
|
||||
end
|
||||
|
||||
defp wait_for_server(url, attempts) do
|
||||
Application.ensure_all_started(:inets)
|
||||
Application.ensure_all_started(:ssl)
|
||||
|
||||
case :httpc.request(:get, {~c"#{url}/health", []}, [timeout: 2_000], []) do
|
||||
{:ok, {{_, status, _}, _, _}} when status in 200..399 ->
|
||||
:ok
|
||||
|
||||
_ ->
|
||||
Process.sleep(250)
|
||||
wait_for_server(url, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
# Wait for the image variant cache to finish processing so background
|
||||
# IO doesn't skew lighthouse results
|
||||
defp wait_for_images do
|
||||
pending = count_pending_images()
|
||||
|
||||
if pending > 0 do
|
||||
Mix.shell().info("Waiting for #{pending} image variant(s) to finish processing...")
|
||||
do_wait_for_images(180)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_wait_for_images(0) do
|
||||
Mix.shell().info(
|
||||
" #{IO.ANSI.yellow()}Timed out waiting for image variants — running audits anyway#{IO.ANSI.reset()}"
|
||||
)
|
||||
end
|
||||
|
||||
defp do_wait_for_images(seconds_left) do
|
||||
case count_pending_images() do
|
||||
0 ->
|
||||
Mix.shell().info(" Image variants ready.")
|
||||
|
||||
n ->
|
||||
if rem(seconds_left, 10) == 0, do: Mix.shell().info(" #{n} image(s) still processing...")
|
||||
Process.sleep(1_000)
|
||||
do_wait_for_images(seconds_left - 1)
|
||||
end
|
||||
end
|
||||
|
||||
defp count_pending_images do
|
||||
SimpleshopTheme.Media.Image
|
||||
|> where([i], i.is_svg == false)
|
||||
|> where([i], i.variants_status != "complete" or is_nil(i.variants_status))
|
||||
|> SimpleshopTheme.Repo.aggregate(:count)
|
||||
end
|
||||
|
||||
# -- Lighthouse execution --
|
||||
|
||||
defp run_audit(lighthouse_path, base_url, path, strategy) do
|
||||
url = base_url <> path
|
||||
|
||||
report_path =
|
||||
Path.join(System.tmp_dir!(), "lh-#{System.unique_integer([:positive])}.json")
|
||||
|
||||
try do
|
||||
args =
|
||||
[
|
||||
url,
|
||||
"--output=json",
|
||||
"--output-path=#{report_path}",
|
||||
"--chrome-flags=--headless --no-sandbox",
|
||||
"--only-categories=#{Enum.join(@categories, ",")}",
|
||||
"--quiet"
|
||||
]
|
||||
|> maybe_append(strategy == "desktop", "--preset=desktop")
|
||||
|
||||
case System.cmd(lighthouse_path, args, stderr_to_stdout: true) do
|
||||
{_output, 0} ->
|
||||
parse_report(report_path, path)
|
||||
|
||||
{output, _} ->
|
||||
{:error, path, interpret_error(output)}
|
||||
end
|
||||
after
|
||||
File.rm(report_path)
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_report(report_path, path) do
|
||||
with {:ok, json} <- File.read(report_path),
|
||||
{:ok, %{"categories" => categories}} <- Jason.decode(json) do
|
||||
scores =
|
||||
Map.new(categories, fn {key, %{"score" => score}} ->
|
||||
{key, if(is_number(score), do: round(score * 100), else: nil)}
|
||||
end)
|
||||
|
||||
{:ok, path, scores}
|
||||
else
|
||||
{:ok, _} -> {:error, path, "unexpected JSON structure in Lighthouse report"}
|
||||
{:error, reason} -> {:error, path, "failed to read report: #{inspect(reason)}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp interpret_error(output) do
|
||||
cond do
|
||||
output =~ "Chrome" or output =~ "chromium" ->
|
||||
"""
|
||||
Chrome/Chromium not found. Lighthouse needs a browser to run.
|
||||
|
||||
Install Chrome or Chromium, or set CHROME_PATH:
|
||||
export CHROME_PATH=/usr/bin/chromium-browser
|
||||
"""
|
||||
|
||||
output =~ "ECONNREFUSED" ->
|
||||
"couldn't connect to the server — it may have failed to start"
|
||||
|
||||
true ->
|
||||
"lighthouse error:\n#{String.slice(output, 0, 500)}"
|
||||
end
|
||||
end
|
||||
|
||||
# -- Output --
|
||||
|
||||
defp print_results(results, thresholds) do
|
||||
Mix.shell().info("")
|
||||
Mix.shell().info("#{IO.ANSI.bright()}Lighthouse results#{IO.ANSI.reset()}")
|
||||
Mix.shell().info(String.duplicate("─", 56))
|
||||
|
||||
all_passed =
|
||||
Enum.reduce(results, true, fn result, all_ok ->
|
||||
case result do
|
||||
{:ok, path, scores} ->
|
||||
Mix.shell().info("\n #{IO.ANSI.bright()}#{path}#{IO.ANSI.reset()}")
|
||||
page_ok = print_scores(scores, thresholds)
|
||||
all_ok and page_ok
|
||||
|
||||
{:error, path, message} ->
|
||||
Mix.shell().info("\n #{IO.ANSI.bright()}#{path}#{IO.ANSI.reset()}")
|
||||
Mix.shell().info(" #{IO.ANSI.red()}ERROR#{IO.ANSI.reset()} #{message}")
|
||||
false
|
||||
end
|
||||
end)
|
||||
|
||||
Mix.shell().info("")
|
||||
Mix.shell().info(String.duplicate("─", 56))
|
||||
|
||||
if all_passed do
|
||||
Mix.shell().info("#{IO.ANSI.green()}All audits passed.#{IO.ANSI.reset()}\n")
|
||||
else
|
||||
Mix.shell().info("#{IO.ANSI.red()}Some audits failed.#{IO.ANSI.reset()}\n")
|
||||
end
|
||||
|
||||
all_passed
|
||||
end
|
||||
|
||||
defp print_scores(scores, thresholds) do
|
||||
Enum.reduce(@categories, true, fn cat, all_ok ->
|
||||
score = Map.get(scores, cat)
|
||||
threshold = Map.get(thresholds, cat, 0)
|
||||
label = Map.get(@category_labels, cat, cat)
|
||||
|
||||
if is_nil(score) do
|
||||
Mix.shell().info(
|
||||
" #{IO.ANSI.red()}FAIL#{IO.ANSI.reset()} #{String.pad_trailing(label, 16)} #{IO.ANSI.red()}N/A#{IO.ANSI.reset()} / #{threshold}"
|
||||
)
|
||||
|
||||
false
|
||||
else
|
||||
passed = score >= threshold
|
||||
|
||||
icon =
|
||||
if passed,
|
||||
do: "#{IO.ANSI.green()}pass#{IO.ANSI.reset()}",
|
||||
else: "#{IO.ANSI.red()}FAIL#{IO.ANSI.reset()}"
|
||||
|
||||
Mix.shell().info(
|
||||
" #{icon} #{String.pad_trailing(label, 16)} #{score_colour(score)}#{score}#{IO.ANSI.reset()} / #{threshold}"
|
||||
)
|
||||
|
||||
all_ok and passed
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp score_colour(score) when score >= 90, do: IO.ANSI.green()
|
||||
defp score_colour(score) when score >= 50, do: IO.ANSI.yellow()
|
||||
defp score_colour(_score), do: IO.ANSI.red()
|
||||
|
||||
# -- Helpers --
|
||||
|
||||
defp maybe_put(map, _key, nil), do: map
|
||||
defp maybe_put(map, key, val), do: Map.put(map, key, val)
|
||||
|
||||
defp maybe_append(list, true, item), do: list ++ [item]
|
||||
defp maybe_append(list, false, _item), do: list
|
||||
end
|
||||
Loading…
Reference in New Issue
Block a user