Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added
- The audit table of the extension opens on the snippets with errors or warnings, with the number of the others and a link to show them. Errors, Errors and warnings, and All change the rows it shows, each with its count; the panel remembers the choice, and Copy JSON and Download JSON keep every snippet.

---

## [2.1.0] — 2026-10-25
Expand Down
2 changes: 1 addition & 1 deletion content/extension.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ A Chrome DevTools panel that runs the snippets on the inspected page and shows t
- Lists the 56 snippets by category, with a search (`/`), and runs the chosen one on the inspected page (`Ctrl` `Enter` or `Cmd` `Enter`).
- Shows each result by the [return contract](https://github.com/nucliweb/webperf-snippets/blob/main/snippets/SCHEMA.md): the metric and its rating, the issues sorted by severity, the items as a table and the details. A selector in the result opens its element in the Elements panel.
- Runs snippets that record what happens, such as INP or long animation frames: run it, use the page, then press Read result.
- Runs a structural audit: the 22 snippets of the [CLI](/CLI) audit workflow, one after the other, on a page it reloads first, as the CLI loads it. Each audit says what changed since the previous audit of the same page: new, changed and resolved issues, paired by their code and the resource they name, and metrics whose rating moved. An issue whose severity moves is a change. The panel keeps the last ten audits.
- Runs a structural audit: the 22 snippets of the [CLI](/CLI) audit workflow, one after the other, on a page it reloads first, as the CLI loads it. The table opens on the snippets with errors or warnings, to keep them in focus; Errors and All change the rows it shows, and the panel remembers the choice. Each audit says what changed since the previous audit of the same page: new, changed and resolved issues, paired by their code and the resource they name, and metrics whose rating moved. An issue whose severity moves is a change. The panel keeps the last ten audits.
- Copies a result as JSON, downloads an audit as JSON in the shape of `--workflow audit --json`, and copies the prompt that runs a snippet with the CLI from an AI agent, with the inspected page in its command.
- Works on pages whose Content Security Policy blocks code pasted into the console, since it runs the snippets through the DevTools API.

Expand Down
10 changes: 10 additions & 0 deletions extension/lib/audit.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ export const bySeverity = (a, b) =>
Math.sign(b.summary.warnings) - Math.sign(a.summary.warnings) ||
Math.sign(b.summary.infos) - Math.sign(a.summary.infos)

// The filters of the audit table: the snippets with errors, with errors or warnings, or all of them.
// A filter the panel does not know shows every row
const FILTERS = {
errors: (r) => r.summary.errors > 0,
issues: (r) => r.summary.errors > 0 || r.summary.warnings > 0,
all: () => true,
}
export const filterRows = (rows, filter) => rows.filter(FILTERS[filter] ?? FILTERS.all)
export const filterCounts = (rows) => Object.fromEntries(Object.keys(FILTERS).map((f) => [f, filterRows(rows, f).length]))

function counts(rows) {
const withErrors = rows.filter((r) => r.summary.errors > 0).length
const withWarnings = rows.filter((r) => r.summary.errors === 0 && r.summary.warnings > 0).length
Expand Down
16 changes: 15 additions & 1 deletion extension/lib/audit.test.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { summarize, bySeverity, overview, report, reportFileName, fromReport, addToHistory, removeFromHistory, compare, previousOf } from './audit.js'
import { summarize, bySeverity, overview, report, reportFileName, fromReport, addToHistory, removeFromHistory, compare, previousOf, filterRows, filterCounts } from './audit.js'

test('summarize counts the issues of a result by severity and names the worst one', () => {
const s = summarize({
Expand Down Expand Up @@ -268,3 +268,17 @@ test('an audit saved before the codes pairs by message with one that has them',
const c = compare(after, before)
assert.deepEqual([c.added, c.resolved, c.changed, c.unchanged], [[], [], [], 1])
})

test('the filter of the audit keeps the snippets with errors, with errors or warnings, or all of them', () => {
const row = (id, errors, warnings, infos) => ({ snippet: { id }, summary: { errors, warnings, infos } })
const rows = [row('a', 1, 0, 0), row('b', 0, 2, 1), row('c', 0, 0, 3), row('d', 0, 0, 0), row('e', 2, 1, 0)]
const ids = (filter) => filterRows(rows, filter).map((r) => r.snippet.id)
assert.deepEqual(ids('errors'), ['a', 'e'])
assert.deepEqual(ids('issues'), ['a', 'b', 'e'])
assert.deepEqual(ids('all'), ['a', 'b', 'c', 'd', 'e'])
// A value the panel does not know, as a setting saved by another version, shows every row
assert.deepEqual(ids('unknown'), ids('all'))
assert.deepEqual(filterCounts(rows), { errors: 2, issues: 3, all: 5 })
// A snippet that failed counts as an error
assert.deepEqual(filterRows([{ snippet: { id: 'x' }, summary: summarize({ status: 'error', error: 'It failed' }) }], 'errors').length, 1)
})
7 changes: 4 additions & 3 deletions extension/lib/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
// fails, the panel uses the defaults and saves nothing.

// audits are the reports of the last audits (lib/audit.js), newest first, listed on the home page;
// reloadBeforeAudit makes the audit start from a fresh load of the page, as the CLI does
export const DEFAULTS = { theme: 'devtools', side: 'left', sidebar: 'expanded', snippet: null, audits: [], reloadBeforeAudit: true }
const SYNCED = new Set(['theme', 'side', 'reloadBeforeAudit'])
// reloadBeforeAudit makes the audit start from a fresh load of the page, as the CLI does; auditFilter
// is the rows the audit table shows (lib/audit.js): errors, issues (errors and warnings) or all
export const DEFAULTS = { theme: 'devtools', side: 'left', sidebar: 'expanded', snippet: null, audits: [], reloadBeforeAudit: true, auditFilter: 'issues' }
const SYNCED = new Set(['theme', 'side', 'reloadBeforeAudit', 'auditFilter'])

export function createSettings(storage) {
const areaOf = (key) => (SYNCED.has(key) ? storage?.sync : storage?.local)
Expand Down
4 changes: 3 additions & 1 deletion extension/lib/settings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ test('the preferences come from the synced area and the state of the panel from
snippet: 'LCP',
audits: [{ workflow: 'audit' }],
reloadBeforeAudit: true,
auditFilter: 'issues',
})
})

Expand All @@ -38,7 +39,8 @@ test('save writes the preferences to the synced area and the rest to the local o
await settings.save('side', 'right')
await settings.save('sidebar', 'collapsed')
await settings.save('snippet', 'TTFB')
assert.deepEqual(sync.data, { theme: 'light', side: 'right' })
await settings.save('auditFilter', 'all')
assert.deepEqual(sync.data, { theme: 'light', side: 'right', auditFilter: 'all' })
assert.deepEqual(local.data, { sidebar: 'collapsed', snippet: 'TTFB' })
})

Expand Down
6 changes: 6 additions & 0 deletions extension/panel.css
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,12 @@ fieldset label { display: flex; gap: 6px; align-items: center; padding: 2px 0; c
/* Audit */
#audit progress { width: 100%; margin: 0 0 12px; accent-color: var(--accent); }
.overview { margin: 0 0 12px; font-weight: 600; }
.filter { display: flex; flex-wrap: wrap; gap: 4px; margin: 0 0 8px; }
.filter button { padding: 2px 10px; font: inherit; color: var(--fg); background: none; border: 1px solid var(--border); border-radius: 4px; cursor: pointer; }
.filter button[aria-pressed="true"] { color: var(--accent); font-weight: 600; background: var(--accent-bg); border-color: var(--accent); }
.filter .count { font-weight: 400; color: var(--muted); }
.hidden-rows { margin: 8px 0 0; }
.empty-filter { margin: 0 0 8px; }
.row-link { padding: 0; font: inherit; color: var(--accent); text-align: left; background: none; border: 0; cursor: pointer; }
.row-link:hover, .row-link:focus-visible { text-decoration: underline; }
.tally { display: inline-block; min-width: 20px; padding: 0 6px; font-weight: 600; text-align: center; border-radius: 9px; }
Expand Down
38 changes: 32 additions & 6 deletions extension/panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { renderResult } from './lib/render.js'
import { readTrackingData, reloadPage, runSnippet } from './lib/run.js'
import { copyText, promptForPage } from './lib/clipboard.js'
import { createSettings } from './lib/settings.js'
import { addToHistory, bySeverity, compare, fromReport, overview, previousOf, removeFromHistory, report, reportFileName, summarize } from './lib/audit.js'
import { addToHistory, bySeverity, compare, filterCounts, filterRows, fromReport, overview, previousOf, removeFromHistory, report, reportFileName, summarize } from './lib/audit.js'

// The panel: the list of snippets, and the result of the one that runs on the inspected page

Expand Down Expand Up @@ -165,6 +165,11 @@ function showHome() {
let audits = []
let audit = null
let reloadBeforeAudit = saved.reloadBeforeAudit
// The rows the audit table shows, to keep the errors and warnings in focus; the panel remembers it
const FILTERS = { errors: 'Errors', issues: 'Errors and warnings', all: 'All' }
const HIDDEN_BY = { errors: 'without errors', issues: 'with info only or nothing to fix' }
const EMPTY_FOR = { errors: 'No snippet has errors.', issues: 'No snippet has errors or warnings.' }
let auditFilter = FILTERS[saved.auditFilter] ? saved.auditFilter : 'issues'

function auditList() {
if (audits.length === 0) return ''
Expand Down Expand Up @@ -242,6 +247,13 @@ function showAudit() {
downloadReport(report({ url: audit.url, date: audit.date, rows: audit.rows }))
)
document.getElementById('audit').addEventListener('click', (event) => {
const filter = event.target.closest('button[data-filter]')
if (filter) {
auditFilter = filter.dataset.filter
remember('auditFilter', auditFilter)
renderAudit()
return
}
const button = event.target.closest('button[data-row]')
if (button) {
const row = audit.rows.find((r) => r.snippet.id === button.dataset.row)
Expand Down Expand Up @@ -298,8 +310,20 @@ function renderAudit() {
: audit.done
? `<p class="overview">${escape(overview(audit.rows))}</p>`
: ''
// Once the audit is done the filter applies; while it runs, every row shows as it arrives
const shown = audit.done ? filterRows(rows, auditFilter) : rows
const hidden = rows.length - shown.length
const counts = filterCounts(rows)
const filters = audit.done
? `<div class="filter" role="group" aria-label="Show">${Object.entries(FILTERS)
.map(([key, label]) => `<button type="button" data-filter="${key}" aria-pressed="${key === auditFilter}">${label} <span class="count">${counts[key]}</span></button>`)
.join('')}</div>`
: ''
const more = hidden
? `<p class="note hidden-rows">${hidden} more snippet${hidden === 1 ? '' : 's'} ${HIDDEN_BY[auditFilter]} · <button type="button" class="row-link" data-filter="all">Show all</button></p>`
: ''
const cell = (n, kind) => `<td class="num">${n ? `<span class="tally tally-${kind}">${n}</span>` : ''}</td>`
const body = rows
const body = shown
.map(
({ snippet, summary }) => `<tr>
<td><button type="button" class="row-link" data-row="${escape(snippet.id)}">${escape(snippet.title)}</button></td>
Expand All @@ -309,11 +333,13 @@ function renderAudit() {
</tr>`
)
.join('')
view.innerHTML = `${status}${audit.done ? comparison() : ''}${
rows.length
view.innerHTML = `${status}${audit.done ? comparison() : ''}${filters}${
shown.length
? `<section><div class="table"><table><thead><tr><th>Snippet</th><th>Result</th><th>Errors</th><th>Warnings</th><th>Info</th><th>Main issue</th></tr></thead><tbody>${body}</tbody></table></div></section>`
: ''
}`
: rows.length
? `<p class="clean empty-filter">${EMPTY_FOR[auditFilter]}</p>`
: ''
}${more}`
}

function showSnippet(snippet, { result: given, fromAudit = false } = {}) {
Expand Down
28 changes: 25 additions & 3 deletions extension/tests/panel.e2e.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,25 @@ test('the audit runs the snippets of the audit of the CLI, sums them up and open
await panel.click('#go-home')
await panel.click('#run-audit')
await panel.waitForSelector('#audit[data-done="true"]', { timeout: 120000 })
assert.equal(await panel.locator('#audit tbody tr').count(), auditWorkflow.steps.length)
assert.match(await panel.textContent('#audit .overview'), new RegExp(`^${auditWorkflow.steps.length} snippets: `))
// The table opens on the snippets with errors or warnings, and says how many it leaves out
const shownRows = () => panel.locator('#audit tbody tr').count()
const pressed = () => panel.getAttribute('#audit .filter [aria-pressed="true"]', 'data-filter')
assert.equal(await pressed(), 'issues')
const withIssues = await shownRows()
assert.ok(withIssues > 0 && withIssues < auditWorkflow.steps.length, `${withIssues} rows with errors or warnings`)
assert.equal(await panel.locator('#audit tbody tr:not(:has(.tally-error)):not(:has(.tally-warning))').count(), 0)
assert.match(await panel.textContent('#audit .hidden-rows'), new RegExp(`^${auditWorkflow.steps.length - withIssues} more snippets? with info only or nothing to fix`))
assert.match(await panel.textContent('#audit .filter [data-filter="all"]'), new RegExp(`All ${auditWorkflow.steps.length}`))
// Errors keeps the snippets with errors only
await panel.click('#audit .filter [data-filter="errors"]')
assert.equal(await pressed(), 'errors')
assert.equal(await panel.locator('#audit tbody tr:not(:has(.tally-error))').count(), 0)
// Show all lists every snippet
await panel.click('#audit .hidden-rows button')
assert.equal(await pressed(), 'all')
assert.equal(await shownRows(), auditWorkflow.steps.length)
assert.equal(await panel.locator('#audit .hidden-rows').count(), 0)
// A snippet with nothing to fix says so, and a metric shows its value and rating
const rows = await panel.locator('#audit tbody tr').evaluateAll((trs) => trs.map((tr) => tr.textContent.replace(/\s+/g, ' ').trim()))
assert.ok(rows.some((r) => r.includes('No issues found')), 'no row says it has no issues')
Expand All @@ -324,13 +341,16 @@ test('the audit runs the snippets of the audit of the CLI, sums them up and open
await reopenPanel()
await panel.click('#go-home')
await panel.click('main .audits li >> nth=0 >> .open-audit')
// The panel remembers the filter after closing DevTools
assert.equal(await pressed(), 'all')
assert.equal(await panel.locator('#audit tbody tr').count(), auditWorkflow.steps.length)
await panel.click('#audit tbody tr >> nth=0 >> button')
await panel.waitForSelector('#result .raw pre', { state: 'attached' })
await panel.click('#back-to-audit')
assert.equal(await panel.locator('#audit tbody tr').count(), auditWorkflow.steps.length)

// Copy JSON and Download JSON give the audit in the shape of the CLI
// Copy JSON and Download JSON give the audit in the shape of the CLI, with every snippet whatever the filter
await panel.click('#audit .filter [data-filter="issues"]')
await panel.evaluate(() => (window.__copied.length = 0))
await panel.click('#copy-audit')
await panel.waitForFunction(() => window.__copied.length === 1)
Expand Down Expand Up @@ -361,9 +381,11 @@ test('the home page lists the audits, opens and deletes them, and keeps them aft
await reopenPanel()
await panel.click('#go-home')
assert.equal(await entries.count(), before + 1)
// The newest opens with its results
// The newest opens with its results, every one of them under All
await entries.nth(0).locator('.open-audit').click()
await panel.click('#audit .filter [data-filter="all"]')
assert.equal(await panel.locator('#audit tbody tr').count(), auditWorkflow.steps.length)
await panel.click('#audit .filter [data-filter="issues"]')
await panel.click('#go-home')
const [download] = await Promise.all([panel.waitForEvent('download'), entries.nth(0).locator('.download-audit').click()])
assert.match(download.suggestedFilename(), /^webperf-audit-127\.0\.0\.1-/)
Expand Down
Loading