Packages
Library for accessibility snapshot testing in Phoenix applications using axe-core and Playwright.
Current section
Files
Jump to
Current section
Files
lib/telemetry_capture.ex
defmodule Excessibility.TelemetryCapture do
@moduledoc """
Telemetry-based automatic snapshot capture for LiveView tests.
Attaches to Phoenix LiveView telemetry events to automatically capture
snapshots when LiveView events occur, with no test code changes required.
"""
alias Excessibility.TelemetryCapture.Formatter
alias Excessibility.TelemetryCapture.Registry
alias Excessibility.TelemetryCapture.Timeline
require Logger
@doc """
Attaches telemetry handlers for automatic snapshot capture.
Call this before running tests to enable auto-capture.
"""
def attach do
# Create ETS table for cross-process snapshot storage
unless :ets.whereis(:excessibility_snapshots) != :undefined do
:ets.new(:excessibility_snapshots, [:named_table, :public, :bag])
end
:telemetry.attach_many(
"excessibility-capture",
[
[:phoenix, :live_view, :mount, :stop],
[:phoenix, :live_view, :handle_event, :stop],
[:phoenix, :live_view, :handle_params, :stop],
[:phoenix, :live_view, :render, :stop]
],
&handle_event/4,
nil
)
end
@doc """
Detaches telemetry handlers.
"""
def detach do
:telemetry.detach("excessibility-capture")
end
@doc """
Handles telemetry events and captures snapshots.
"""
def handle_event([:phoenix, :live_view, :mount, :stop], measurements, metadata, _config) do
IO.puts("📸 Excessibility: Telemetry mount event fired!")
capture_snapshot("mount", measurements, metadata)
end
def handle_event([:phoenix, :live_view, :handle_event, :stop], measurements, metadata, _config) do
event_name =
try do
get_in(metadata, [:params, "event"]) || "event"
rescue
_ -> "event"
end
capture_snapshot("handle_event:#{event_name}", measurements, metadata)
end
def handle_event([:phoenix, :live_view, :handle_params, :stop], measurements, metadata, _config) do
capture_snapshot("handle_params", measurements, metadata)
end
def handle_event([:phoenix, :live_view, :render, :stop], measurements, metadata, _config) do
capture_snapshot("render", measurements, metadata)
end
defp capture_snapshot(event_type, measurements, metadata) do
socket = metadata[:socket]
if socket do
clean_assigns = extract_clean_assigns(socket)
view_module = extract_view_module(socket, metadata)
store_snapshot(event_type, clean_assigns, view_module, metadata, measurements)
else
Logger.debug("Excessibility: No socket in metadata for #{event_type}")
end
rescue
error ->
Logger.warning("Excessibility: Failed to capture snapshot for #{event_type}: #{inspect(error)}")
end
defp extract_clean_assigns(socket) do
assigns =
cond do
is_struct(socket.assigns) -> Map.from_struct(socket.assigns)
is_map(socket.assigns) -> socket.assigns
true -> %{}
end
assigns
|> Map.drop([:flash, :__changed__, :__temp__])
|> Enum.filter(fn {k, _v} -> !String.starts_with?(to_string(k), "_") end)
|> Map.new()
end
defp extract_view_module(socket, metadata) do
cond do
is_struct(socket) && Map.has_key?(socket, :view) -> socket.view
is_map(metadata) && Map.has_key?(metadata, :view) -> metadata[:view]
true -> :unknown
end
end
defp store_snapshot(event_type, clean_assigns, view_module, metadata, measurements) do
key = {DateTime.utc_now(), :erlang.unique_integer([:monotonic])}
snapshot = %{
event_type: event_type,
assigns: clean_assigns,
timestamp: DateTime.utc_now(),
view_module: view_module,
metadata_keys: Map.keys(metadata),
measurements: measurements
}
:ets.insert(:excessibility_snapshots, {key, snapshot})
IO.puts("✅ Captured telemetry snapshot for #{event_type}")
Logger.debug("Excessibility: Captured snapshot for #{event_type} with assigns: #{inspect(Map.keys(clean_assigns))}")
end
@doc """
Retrieves all captured snapshots and clears the table.
"""
def get_snapshots do
case :ets.whereis(:excessibility_snapshots) do
:undefined ->
[]
_table ->
snapshots =
:excessibility_snapshots
|> :ets.tab2list()
|> Enum.map(fn {_key, snapshot} -> snapshot end)
|> Enum.sort_by(& &1.timestamp, DateTime)
snapshots
end
end
@doc """
Clears all captured snapshots.
"""
def clear_snapshots(_test_name \\ nil) do
case :ets.whereis(:excessibility_snapshots) do
:undefined ->
:ok
_table ->
:ets.delete_all_objects(:excessibility_snapshots)
end
end
@doc """
Writes captured snapshots to timeline.json.
Note: HTML snapshot files are NOT generated from telemetry capture.
For real accessibility testing, use `html_snapshot(view)` in your tests
to capture actual rendered HTML.
"""
def write_snapshots(test_name) do
snapshots = get_snapshots()
if snapshots != [] do
output_path =
Application.get_env(
:excessibility,
:excessibility_output_path,
"test/excessibility"
)
File.mkdir_p!(output_path)
# Generate and write timeline.json with selective enrichment
enrichers = resolve_enrichers_from_env()
timeline = Timeline.build_timeline(snapshots, test_name, enrichers: enrichers)
timeline_json = Formatter.format_json(timeline)
timeline_path = Path.join(output_path, "timeline.json")
File.write!(timeline_path, timeline_json)
IO.puts("📊 Excessibility: Wrote timeline.json with #{length(snapshots)} events")
end
end
# Resolve which enrichers to run based on EXCESSIBILITY_ANALYZERS env var
defp resolve_enrichers_from_env do
case System.get_env("EXCESSIBILITY_ANALYZERS") do
nil ->
# No analyzer selection - use all enrichers
:all
"" ->
# Empty string means no analyzers - use no enrichers
[]
analyzers_str ->
# Parse analyzer names and resolve their enrichers
analyzer_names =
analyzers_str
|> String.split(",")
|> Enum.map(&String.to_atom/1)
Registry.resolve_enrichers(analyzer_names)
end
end
end