360 lines
10 KiB
Elixir
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
|