defmodule Mix.Tasks.Screenshots do @shortdoc "Capture visual regression screenshots of all shop pages" @moduledoc """ Takes screenshots of shop pages at multiple breakpoints for visual regression testing during the CSS migration. Uses Playwright for reliable viewport sizing and page load handling. ## Prerequisites - Node.js in PATH - Playwright installed (`npx playwright install chromium`) ## Usage # Capture screenshots (saved to screenshots/) mix screenshots # Capture only specific pages mix screenshots /about /cart # Save to a named directory (e.g. "before" and "after") mix screenshots --name before mix screenshots --name after # Override port mix screenshots --port 4003 ## Output Screenshots are saved to `screenshots//` with filenames like `home-375.png`, `collection-768.png`, etc. Default name is a timestamp (YYYYMMDD-HHMMSS). """ use Mix.Task import Ecto.Query @default_port 4003 @breakpoints [ {"375", 375}, {"768", 768}, {"1024", 1024}, {"1440", 1440} ] @pages [ {"home", "/", nil}, {"collection", "/collections/all", nil}, {"about", "/about", nil}, {"contact", "/contact", nil}, {"delivery", "/delivery", nil}, {"privacy", "/privacy", nil}, {"terms", "/terms", nil}, {"cart-empty", "/cart", nil}, {"error-404", "/nonexistent-page-404", nil} ] @impl Mix.Task def run(args) do {opts, filter_paths, _} = OptionParser.parse(args, strict: [name: :string, port: :integer] ) port = opts[:port] || @default_port name = opts[:name] || timestamp() output_dir = Path.join("screenshots", name) pw_path = find_playwright!() build_prod_assets!() base_url = start_server!(port) wait_for_images() File.mkdir_p!(output_dir) pages = resolve_pages(base_url, filter_paths) total = length(pages) * length(@breakpoints) Mix.shell().info( "Capturing #{total} screenshots (#{length(pages)} pages x #{length(@breakpoints)} breakpoints)..." ) Mix.shell().info("Output: #{output_dir}/\n") script = build_playwright_script(pages, base_url, output_dir) script_path = Path.join(System.tmp_dir!(), "screenshots-#{System.unique_integer([:positive])}.cjs") try do File.write!(script_path, script) # Set NODE_PATH so require("playwright") resolves from the npx cache case System.cmd("node", [script_path], stderr_to_stdout: true, env: [{"NODE_PATH", pw_path}] ) do {output, 0} -> Mix.shell().info(output) count = count_screenshots(output_dir) Mix.shell().info( "\n#{IO.ANSI.green()}Done. #{count} screenshots saved to #{output_dir}/#{IO.ANSI.reset()}" ) {output, code} -> Mix.shell().error("Screenshot capture failed (exit #{code}):\n#{output}") exit({:shutdown, 1}) end after File.rm(script_path) end end # Locate the playwright package directory from the npx cache defp find_playwright! do {output, 0} = System.cmd("npx", ["playwright", "--version"], stderr_to_stdout: true) unless String.contains?(output, ".") do Mix.raise(""" Playwright not found. Install it with: npx playwright install chromium """) end # Find the actual node_modules path containing playwright {cache_output, _} = System.cmd("find", [ Path.expand("~/.npm/_npx"), "-name", "playwright", "-type", "d", "-path", "*/node_modules/playwright" ]) case cache_output |> String.trim() |> String.split("\n") |> List.first() do nil -> Mix.raise("Found playwright via npx but can't locate the package directory") "" -> Mix.raise("Found playwright via npx but can't locate the package directory") path -> Mix.shell().info(" Using playwright from #{path}") # Return the node_modules directory (parent of playwright/) Path.dirname(path) end end 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("esbuild", ["simpleshop_theme_shop_css", "--minify"]) Mix.Task.run("phx.digest") Mix.shell().info(" Assets built and digested.") end 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") 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 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(120) end end defp do_wait_for_images(0), do: Mix.shell().info(" Timed out — running screenshots anyway") 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 defp resolve_pages(base_url, []) do dynamic = dynamic_pages(base_url) @pages ++ dynamic end defp resolve_pages(_base_url, filter_paths) do Enum.filter(@pages, fn {_slug, path, _} -> path in filter_paths end) end defp dynamic_pages(_base_url) do case SimpleshopTheme.Products.list_visible_products(limit: 1) do [product] -> [{"pdp", "/products/#{product.id}", nil}] _ -> Mix.shell().info(" No products found — skipping PDP screenshot") [] end end defp build_playwright_script(pages, base_url, output_dir) do breakpoint_entries = Enum.map_join(@breakpoints, ",\n ", fn {label, width} -> ~s({ label: "#{label}", width: #{width} }) end) page_entries = Enum.map_join(pages, ",\n ", fn {slug, path, _setup} -> ~s({ slug: "#{slug}", path: "#{path}" }) end) ~s""" const { chromium } = require("playwright"); const path = require("path"); const baseUrl = "#{base_url}"; const outputDir = "#{output_dir}"; const breakpoints = [ #{breakpoint_entries} ]; const pages = [ #{page_entries} ]; (async () => { const browser = await chromium.launch(); for (const pg of pages) { for (const bp of breakpoints) { const context = await browser.newContext({ viewport: { width: bp.width, height: 900 }, deviceScaleFactor: 1 }); const tab = await context.newPage(); try { await tab.goto(baseUrl + pg.path, { waitUntil: "networkidle", timeout: 15000 }); await tab.waitForSelector("[data-phx-main]", { timeout: 5000 }).catch(() => {}); await tab.waitForTimeout(500); const filename = pg.slug + "-" + bp.label + ".png"; await tab.screenshot({ path: path.join(outputDir, filename), fullPage: true }); console.log(" " + filename); } catch (err) { console.error(" FAILED: " + pg.slug + "-" + bp.label + ": " + err.message); } await context.close(); } } await browser.close(); console.log("\\nScreenshots complete."); })(); """ end defp count_screenshots(dir) do dir |> File.ls!() |> Enum.count(&String.ends_with?(&1, ".png")) end defp timestamp do {{y, m, d}, {h, min, s}} = :calendar.local_time() :io_lib.format("~4..0B~2..0B~2..0B-~2..0B~2..0B~2..0B", [y, m, d, h, min, s]) |> IO.iodata_to_binary() end end