simpleshop_theme/lib/mix/tasks/lighthouse.ex
jamey 516d0d0070 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>
2026-02-08 18:18:01 +00:00

360 lines
10 KiB
Elixir

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