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-/)