From 09c5e64d3886472e596ab1b89d0b022bc8c560fe Mon Sep 17 00:00:00 2001 From: Joan Leon Date: Sun, 25 Oct 2026 18:58:43 +0100 Subject: [PATCH] feat(extension): filter the audit table to keep errors and warnings in focus The audit table opens on the snippets with errors or warnings. Three buttons above it, Errors, Errors and warnings, and All, change the rows it shows, each with the number of rows it has, and a line below the table says how many snippets the filter leaves out, with a link to show them all. The panel remembers the choice in the synced settings, as it does the reload before the audit, and Copy JSON and Download JSON keep every snippet. While the audit runs, every row shows as it arrives. --- CHANGELOG.md | 3 +++ content/extension.mdx | 2 +- extension/lib/audit.js | 10 ++++++++ extension/lib/audit.test.js | 16 ++++++++++++- extension/lib/settings.js | 7 +++--- extension/lib/settings.test.js | 4 +++- extension/panel.css | 6 +++++ extension/panel.js | 38 ++++++++++++++++++++++++++----- extension/tests/panel.e2e.test.js | 28 ++++++++++++++++++++--- 9 files changed, 99 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d65fbb2..da31dd6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/content/extension.mdx b/content/extension.mdx index 3296d8cf..cc78989c 100644 --- a/content/extension.mdx +++ b/content/extension.mdx @@ -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. diff --git a/extension/lib/audit.js b/extension/lib/audit.js index 5b1188a5..0709a266 100644 --- a/extension/lib/audit.js +++ b/extension/lib/audit.js @@ -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 diff --git a/extension/lib/audit.test.js b/extension/lib/audit.test.js index 8ec33b27..df65f629 100644 --- a/extension/lib/audit.test.js +++ b/extension/lib/audit.test.js @@ -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({ @@ -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) +}) diff --git a/extension/lib/settings.js b/extension/lib/settings.js index f71cf57c..bd05ab34 100644 --- a/extension/lib/settings.js +++ b/extension/lib/settings.js @@ -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) diff --git a/extension/lib/settings.test.js b/extension/lib/settings.test.js index 754bc95f..0f245f43 100644 --- a/extension/lib/settings.test.js +++ b/extension/lib/settings.test.js @@ -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', }) }) @@ -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' }) }) diff --git a/extension/panel.css b/extension/panel.css index 027bc1b1..d64bdf48 100644 --- a/extension/panel.css +++ b/extension/panel.css @@ -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; } diff --git a/extension/panel.js b/extension/panel.js index 5ccf23d6..c84bfa2e 100644 --- a/extension/panel.js +++ b/extension/panel.js @@ -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 @@ -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 '' @@ -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) @@ -298,8 +310,20 @@ function renderAudit() { : audit.done ? `

${escape(overview(audit.rows))}

` : '' + // 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 + ? `
${Object.entries(FILTERS) + .map(([key, label]) => ``) + .join('')}
` + : '' + const more = hidden + ? `

${hidden} more snippet${hidden === 1 ? '' : 's'} ${HIDDEN_BY[auditFilter]} ·

` + : '' const cell = (n, kind) => `${n ? `${n}` : ''}` - const body = rows + const body = shown .map( ({ snippet, summary }) => ` @@ -309,11 +333,13 @@ function renderAudit() { ` ) .join('') - view.innerHTML = `${status}${audit.done ? comparison() : ''}${ - rows.length + view.innerHTML = `${status}${audit.done ? comparison() : ''}${filters}${ + shown.length ? `
${body}
SnippetResultErrorsWarningsInfoMain issue
` - : '' - }` + : rows.length + ? `

${EMPTY_FOR[auditFilter]}

` + : '' + }${more}` } function showSnippet(snippet, { result: given, fromAudit = false } = {}) { diff --git a/extension/tests/panel.e2e.test.js b/extension/tests/panel.e2e.test.js index 9f658c93..0391e6a7 100644 --- a/extension/tests/panel.e2e.test.js +++ b/extension/tests/panel.e2e.test.js @@ -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') @@ -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) @@ -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-/)