From 516d0d0070065b29afaf443f167b638dbbad9d52 Mon Sep 17 00:00:00 2001 From: jamey Date: Sun, 8 Feb 2026 18:18:01 +0000 Subject: [PATCH] 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 --- PROGRESS.md | 18 +- lib/mix/tasks/lighthouse.ex | 359 ++++++++++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+), 2 deletions(-) create mode 100644 lib/mix/tasks/lighthouse.ex diff --git a/PROGRESS.md b/PROGRESS.md index 0f220cd..f9ca988 100644 --- a/PROGRESS.md +++ b/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 | diff --git a/lib/mix/tasks/lighthouse.ex b/lib/mix/tasks/lighthouse.ex new file mode 100644 index 0000000..3507192 --- /dev/null +++ b/lib/mix/tasks/lighthouse.ex @@ -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