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