@monoes/monobrowse 1.0.12 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/__tests__/profiler-heap.test.d.ts +2 -0
- package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
- package/dist/src/__tests__/profiler-heap.test.js +154 -0
- package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
- package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
- package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-a11y.test.js +147 -0
- package/dist/src/__tests__/report-a11y.test.js.map +1 -0
- package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
- package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-analyze.test.js +157 -0
- package/dist/src/__tests__/report-analyze.test.js.map +1 -0
- package/dist/src/__tests__/report-budget.test.d.ts +2 -0
- package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-budget.test.js +80 -0
- package/dist/src/__tests__/report-budget.test.js.map +1 -0
- package/dist/src/__tests__/report-diff.test.d.ts +2 -0
- package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-diff.test.js +231 -0
- package/dist/src/__tests__/report-diff.test.js.map +1 -0
- package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
- package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-evidence.test.js +214 -0
- package/dist/src/__tests__/report-evidence.test.js.map +1 -0
- package/dist/src/__tests__/report-flake.test.d.ts +2 -0
- package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-flake.test.js +182 -0
- package/dist/src/__tests__/report-flake.test.js.map +1 -0
- package/dist/src/__tests__/report-history.test.d.ts +2 -0
- package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-history.test.js +229 -0
- package/dist/src/__tests__/report-history.test.js.map +1 -0
- package/dist/src/__tests__/report-output.test.d.ts +2 -0
- package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-output.test.js +169 -0
- package/dist/src/__tests__/report-output.test.js.map +1 -0
- package/dist/src/__tests__/report-png.test.d.ts +2 -0
- package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-png.test.js +84 -0
- package/dist/src/__tests__/report-png.test.js.map +1 -0
- package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
- package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-render-extras.test.js +285 -0
- package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
- package/dist/src/__tests__/report-render.test.d.ts +2 -0
- package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-render.test.js +143 -0
- package/dist/src/__tests__/report-render.test.js.map +1 -0
- package/dist/src/__tests__/report-run.test.d.ts +2 -0
- package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
- package/dist/src/__tests__/report-run.test.js +234 -0
- package/dist/src/__tests__/report-run.test.js.map +1 -0
- package/dist/src/browser/index.d.ts +1 -0
- package/dist/src/browser/index.d.ts.map +1 -1
- package/dist/src/browser/index.js +1 -0
- package/dist/src/browser/index.js.map +1 -1
- package/dist/src/browser/profiler.d.ts.map +1 -1
- package/dist/src/browser/profiler.js +19 -22
- package/dist/src/browser/profiler.js.map +1 -1
- package/dist/src/cli/commands.d.ts.map +1 -1
- package/dist/src/cli/commands.js +254 -0
- package/dist/src/cli/commands.js.map +1 -1
- package/dist/src/report/a11y.d.ts +52 -0
- package/dist/src/report/a11y.d.ts.map +1 -0
- package/dist/src/report/a11y.js +231 -0
- package/dist/src/report/a11y.js.map +1 -0
- package/dist/src/report/analyze.d.ts +20 -0
- package/dist/src/report/analyze.d.ts.map +1 -0
- package/dist/src/report/analyze.js +136 -0
- package/dist/src/report/analyze.js.map +1 -0
- package/dist/src/report/budget.d.ts +27 -0
- package/dist/src/report/budget.d.ts.map +1 -0
- package/dist/src/report/budget.js +143 -0
- package/dist/src/report/budget.js.map +1 -0
- package/dist/src/report/collect-a11y.d.ts +25 -0
- package/dist/src/report/collect-a11y.d.ts.map +1 -0
- package/dist/src/report/collect-a11y.js +154 -0
- package/dist/src/report/collect-a11y.js.map +1 -0
- package/dist/src/report/collect.d.ts +45 -0
- package/dist/src/report/collect.d.ts.map +1 -0
- package/dist/src/report/collect.js +275 -0
- package/dist/src/report/collect.js.map +1 -0
- package/dist/src/report/evidence.d.ts +80 -0
- package/dist/src/report/evidence.d.ts.map +1 -0
- package/dist/src/report/evidence.js +279 -0
- package/dist/src/report/evidence.js.map +1 -0
- package/dist/src/report/flake.d.ts +88 -0
- package/dist/src/report/flake.d.ts.map +1 -0
- package/dist/src/report/flake.js +232 -0
- package/dist/src/report/flake.js.map +1 -0
- package/dist/src/report/history.d.ts +85 -0
- package/dist/src/report/history.d.ts.map +1 -0
- package/dist/src/report/history.js +176 -0
- package/dist/src/report/history.js.map +1 -0
- package/dist/src/report/index.d.ts +102 -0
- package/dist/src/report/index.d.ts.map +1 -0
- package/dist/src/report/index.js +216 -0
- package/dist/src/report/index.js.map +1 -0
- package/dist/src/report/pixel-diff.d.ts +30 -0
- package/dist/src/report/pixel-diff.d.ts.map +1 -0
- package/dist/src/report/pixel-diff.js +175 -0
- package/dist/src/report/pixel-diff.js.map +1 -0
- package/dist/src/report/png.d.ts +28 -0
- package/dist/src/report/png.d.ts.map +1 -0
- package/dist/src/report/png.js +259 -0
- package/dist/src/report/png.js.map +1 -0
- package/dist/src/report/render-evidence.d.ts +16 -0
- package/dist/src/report/render-evidence.d.ts.map +1 -0
- package/dist/src/report/render-evidence.js +174 -0
- package/dist/src/report/render-evidence.js.map +1 -0
- package/dist/src/report/render-trend.d.ts +16 -0
- package/dist/src/report/render-trend.d.ts.map +1 -0
- package/dist/src/report/render-trend.js +194 -0
- package/dist/src/report/render-trend.js.map +1 -0
- package/dist/src/report/render.d.ts +21 -0
- package/dist/src/report/render.d.ts.map +1 -0
- package/dist/src/report/render.js +286 -0
- package/dist/src/report/render.js.map +1 -0
- package/dist/src/report/run-diff.d.ts +18 -0
- package/dist/src/report/run-diff.d.ts.map +1 -0
- package/dist/src/report/run-diff.js +51 -0
- package/dist/src/report/run-diff.js.map +1 -0
- package/dist/src/report/structure.d.ts +41 -0
- package/dist/src/report/structure.d.ts.map +1 -0
- package/dist/src/report/structure.js +210 -0
- package/dist/src/report/structure.js.map +1 -0
- package/dist/src/report/trend.d.ts +26 -0
- package/dist/src/report/trend.d.ts.map +1 -0
- package/dist/src/report/trend.js +164 -0
- package/dist/src/report/trend.js.map +1 -0
- package/dist/src/report/types.d.ts +320 -0
- package/dist/src/report/types.d.ts.map +1 -0
- package/dist/src/report/types.js +11 -0
- package/dist/src/report/types.js.map +1 -0
- package/dist/src/report/util.d.ts +17 -0
- package/dist/src/report/util.d.ts.map +1 -0
- package/dist/src/report/util.js +45 -0
- package/dist/src/report/util.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/__tests__/profiler-heap.test.ts +191 -0
- package/src/__tests__/report-a11y.test.ts +180 -0
- package/src/__tests__/report-analyze.test.ts +202 -0
- package/src/__tests__/report-budget.test.ts +92 -0
- package/src/__tests__/report-diff.test.ts +272 -0
- package/src/__tests__/report-evidence.test.ts +276 -0
- package/src/__tests__/report-flake.test.ts +214 -0
- package/src/__tests__/report-history.test.ts +293 -0
- package/src/__tests__/report-output.test.ts +210 -0
- package/src/__tests__/report-png.test.ts +95 -0
- package/src/__tests__/report-render-extras.test.ts +315 -0
- package/src/__tests__/report-render.test.ts +175 -0
- package/src/__tests__/report-run.test.ts +264 -0
- package/src/browser/index.ts +1 -0
- package/src/browser/profiler.ts +28 -21
- package/src/cli/commands.ts +272 -0
- package/src/report/a11y.ts +242 -0
- package/src/report/analyze.ts +181 -0
- package/src/report/budget.ts +155 -0
- package/src/report/collect-a11y.ts +200 -0
- package/src/report/collect.ts +408 -0
- package/src/report/evidence.ts +379 -0
- package/src/report/flake.ts +303 -0
- package/src/report/history.ts +219 -0
- package/src/report/index.ts +307 -0
- package/src/report/pixel-diff.ts +197 -0
- package/src/report/png.ts +290 -0
- package/src/report/render-evidence.ts +194 -0
- package/src/report/render-trend.ts +225 -0
- package/src/report/render.ts +314 -0
- package/src/report/run-diff.ts +63 -0
- package/src/report/structure.ts +212 -0
- package/src/report/trend.ts +189 -0
- package/src/report/types.ts +356 -0
- package/src/report/util.ts +48 -0
package/src/cli/commands.ts
CHANGED
|
@@ -1698,6 +1698,272 @@ const evalCommand: Command = {
|
|
|
1698
1698
|
},
|
|
1699
1699
|
};
|
|
1700
1700
|
|
|
1701
|
+
const reportCommand: Command = {
|
|
1702
|
+
name: 'report',
|
|
1703
|
+
description:
|
|
1704
|
+
'Test a page and write one self-contained HTML report + sibling JSON. Usage: monomind browse report <url>',
|
|
1705
|
+
options: [
|
|
1706
|
+
{ name: 'out', short: 'o', type: 'string', description: 'Output .html path, or a directory' },
|
|
1707
|
+
{
|
|
1708
|
+
name: 'budget',
|
|
1709
|
+
short: 'b',
|
|
1710
|
+
type: 'string',
|
|
1711
|
+
description: 'Budget JSON file path, or inline JSON',
|
|
1712
|
+
},
|
|
1713
|
+
{
|
|
1714
|
+
name: 'devices',
|
|
1715
|
+
short: 'd',
|
|
1716
|
+
type: 'string',
|
|
1717
|
+
description: 'Comma-separated device names for the screenshot matrix',
|
|
1718
|
+
},
|
|
1719
|
+
{
|
|
1720
|
+
name: 'wait',
|
|
1721
|
+
short: 'w',
|
|
1722
|
+
type: 'string',
|
|
1723
|
+
description: 'Extra settle step: a CSS selector to wait for, or milliseconds',
|
|
1724
|
+
},
|
|
1725
|
+
{ name: 'json', type: 'boolean', description: 'Output as JSON', default: false },
|
|
1726
|
+
{
|
|
1727
|
+
name: 'full-page',
|
|
1728
|
+
type: 'boolean',
|
|
1729
|
+
description: 'Full-page screenshot (--no-full-page for viewport only)',
|
|
1730
|
+
default: true,
|
|
1731
|
+
},
|
|
1732
|
+
{
|
|
1733
|
+
name: 'vitals-wait',
|
|
1734
|
+
type: 'number',
|
|
1735
|
+
description: 'Milliseconds to let the web-vitals observers run',
|
|
1736
|
+
default: 2500,
|
|
1737
|
+
},
|
|
1738
|
+
{
|
|
1739
|
+
name: 'timeout',
|
|
1740
|
+
type: 'number',
|
|
1741
|
+
description: 'Milliseconds to wait for the page to go network-idle',
|
|
1742
|
+
default: 20000,
|
|
1743
|
+
},
|
|
1744
|
+
{
|
|
1745
|
+
name: 'keep-open',
|
|
1746
|
+
type: 'boolean',
|
|
1747
|
+
description: 'Leave the browser running after the report',
|
|
1748
|
+
default: false,
|
|
1749
|
+
},
|
|
1750
|
+
{
|
|
1751
|
+
name: 'repeat',
|
|
1752
|
+
type: 'number',
|
|
1753
|
+
description: 'Run the URL N times and report per-check flake rates (RIG-14)',
|
|
1754
|
+
},
|
|
1755
|
+
{
|
|
1756
|
+
name: 'record',
|
|
1757
|
+
type: 'boolean',
|
|
1758
|
+
description: 'Record frames for the evidence timeline even when the run passes',
|
|
1759
|
+
default: false,
|
|
1760
|
+
},
|
|
1761
|
+
{
|
|
1762
|
+
name: 'history',
|
|
1763
|
+
type: 'boolean',
|
|
1764
|
+
description: 'Print the stored run history for this URL instead of running it',
|
|
1765
|
+
default: false,
|
|
1766
|
+
},
|
|
1767
|
+
{
|
|
1768
|
+
name: 'save',
|
|
1769
|
+
type: 'boolean',
|
|
1770
|
+
description: 'Save this run to the history store (--no-save to skip)',
|
|
1771
|
+
default: true,
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
name: 'history-max',
|
|
1775
|
+
type: 'number',
|
|
1776
|
+
description: 'Runs kept per URL before the oldest are pruned (default 20)',
|
|
1777
|
+
},
|
|
1778
|
+
{
|
|
1779
|
+
name: 'trend-window',
|
|
1780
|
+
type: 'number',
|
|
1781
|
+
description: 'Prior runs charted in the trend section (default 10)',
|
|
1782
|
+
},
|
|
1783
|
+
],
|
|
1784
|
+
examples: [
|
|
1785
|
+
{ command: 'monomind browse report https://example.com', description: 'Report with defaults' },
|
|
1786
|
+
{
|
|
1787
|
+
command: 'monomind browse report https://example.com --repeat 5',
|
|
1788
|
+
description: 'Run five times and report flake rates',
|
|
1789
|
+
},
|
|
1790
|
+
{
|
|
1791
|
+
command: 'monomind browse report https://example.com --history',
|
|
1792
|
+
description: 'Show the stored run history for a URL',
|
|
1793
|
+
},
|
|
1794
|
+
{
|
|
1795
|
+
command: 'monomind browse report https://example.com --record',
|
|
1796
|
+
description: 'Attach a frame-by-frame evidence timeline',
|
|
1797
|
+
},
|
|
1798
|
+
{
|
|
1799
|
+
command: 'monomind browse report https://example.com --out ./reports/home.html',
|
|
1800
|
+
description: 'Choose the output path',
|
|
1801
|
+
},
|
|
1802
|
+
{
|
|
1803
|
+
command: 'monomind browse report https://example.com --budget \'{"lcp":4000}\'',
|
|
1804
|
+
description: 'Relax a budget inline',
|
|
1805
|
+
},
|
|
1806
|
+
{
|
|
1807
|
+
command: 'monomind browse report https://example.com --devices "iPhone 14,iPad"',
|
|
1808
|
+
description: 'Add a device screenshot matrix',
|
|
1809
|
+
},
|
|
1810
|
+
],
|
|
1811
|
+
action: async (ctx: CommandContext): Promise<CommandResult> => {
|
|
1812
|
+
const url = ctx.args[0] as string;
|
|
1813
|
+
if (!url) throw new Error('URL required. Usage: monomind browse report <url>');
|
|
1814
|
+
|
|
1815
|
+
// Imported lazily for the same reason getBrowser() is: this module loads
|
|
1816
|
+
// on every CLI invocation, and nothing but `report` needs the renderer.
|
|
1817
|
+
const { runReport, runReportRepeated, readHistory } = await import('../report/index.js');
|
|
1818
|
+
|
|
1819
|
+
// --history is a read of the store, so it deliberately never launches a
|
|
1820
|
+
// browser — `report --history` must work offline and on a dead site.
|
|
1821
|
+
if (ctx.flags.history) {
|
|
1822
|
+
const { dir, runs } = await readHistory(url);
|
|
1823
|
+
if (ctx.flags.json) {
|
|
1824
|
+
print(JSON.stringify({ data: { dir, runs } }, null, 2));
|
|
1825
|
+
} else if (!runs.length) {
|
|
1826
|
+
output.printWarning(`No stored runs for ${url}`);
|
|
1827
|
+
print(`Looked in: ${dir}`);
|
|
1828
|
+
} else {
|
|
1829
|
+
print(`${runs.length} run(s) for ${url}`);
|
|
1830
|
+
print(`Stored in: ${dir}`);
|
|
1831
|
+
for (const run of runs) {
|
|
1832
|
+
const lcp = run.vitals.lcp === undefined ? '—' : `${Math.round(run.vitals.lcp)}ms`;
|
|
1833
|
+
print(
|
|
1834
|
+
` ${run.capturedAt} ${run.verdict.toUpperCase().padEnd(4)} ` +
|
|
1835
|
+
`LCP ${lcp.padStart(7)} ${run.counts.consoleErrors} console errors ` +
|
|
1836
|
+
`${run.counts.failedRequests} failed requests ${run.counts.a11yErrors} a11y errors`,
|
|
1837
|
+
);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
return { success: true, data: { dir, runs } };
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
const repeatRaw = ctx.flags.repeat as number | undefined;
|
|
1844
|
+
const repeat =
|
|
1845
|
+
typeof repeatRaw === 'number' && Number.isFinite(repeatRaw) && repeatRaw > 1
|
|
1846
|
+
? Math.floor(repeatRaw)
|
|
1847
|
+
: undefined;
|
|
1848
|
+
|
|
1849
|
+
const { client, sessionId } = await ensureConnected(_port);
|
|
1850
|
+
const browser = await getBrowser();
|
|
1851
|
+
|
|
1852
|
+
const rawDevices = ctx.flags.devices;
|
|
1853
|
+
const devices =
|
|
1854
|
+
typeof rawDevices === 'string'
|
|
1855
|
+
? rawDevices
|
|
1856
|
+
.split(',')
|
|
1857
|
+
.map((d) => d.trim())
|
|
1858
|
+
.filter(Boolean)
|
|
1859
|
+
: undefined;
|
|
1860
|
+
|
|
1861
|
+
const runOptions = {
|
|
1862
|
+
url,
|
|
1863
|
+
out: ctx.flags.out as string | undefined,
|
|
1864
|
+
budget: ctx.flags.budget as string | undefined,
|
|
1865
|
+
devices,
|
|
1866
|
+
wait: ctx.flags.wait as string | undefined,
|
|
1867
|
+
vitalsWaitMs: ctx.flags['vitals-wait'] as number | undefined,
|
|
1868
|
+
loadTimeoutMs: ctx.flags.timeout as number | undefined,
|
|
1869
|
+
fullPage: ctx.flags['full-page'] !== false,
|
|
1870
|
+
cwd: ctx.cwd,
|
|
1871
|
+
// A failing run records its own evidence without being asked; --record
|
|
1872
|
+
// is for when you want the timeline from a run that passes.
|
|
1873
|
+
record: ctx.flags.record === true,
|
|
1874
|
+
history: ctx.flags.save !== false,
|
|
1875
|
+
historyMax: ctx.flags['history-max'] as number | undefined,
|
|
1876
|
+
trendWindow: ctx.flags['trend-window'] as number | undefined,
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
let result:
|
|
1880
|
+
| Awaited<ReturnType<typeof runReport>>
|
|
1881
|
+
| Awaited<ReturnType<typeof runReportRepeated>>;
|
|
1882
|
+
try {
|
|
1883
|
+
result = repeat
|
|
1884
|
+
? await runReportRepeated(client, sessionId, { ...runOptions, repeat })
|
|
1885
|
+
: await runReport(client, sessionId, runOptions);
|
|
1886
|
+
} finally {
|
|
1887
|
+
// A report is a one-shot command — CI should not be left with an
|
|
1888
|
+
// orphan Chrome. Only close a browser THIS process launched: an
|
|
1889
|
+
// attached one belongs to the user's own `open`/`connect` session.
|
|
1890
|
+
if (!ctx.flags['keep-open'] && browser.getLaunchedPid(_port) !== undefined) {
|
|
1891
|
+
browser.stopRequestCapture(sessionId);
|
|
1892
|
+
browser.teardownConsoleCapture(sessionId);
|
|
1893
|
+
try {
|
|
1894
|
+
await browser.closeBrowser(client, _port);
|
|
1895
|
+
} catch {
|
|
1896
|
+
/* best-effort */
|
|
1897
|
+
}
|
|
1898
|
+
_client = null;
|
|
1899
|
+
_sessionId = '';
|
|
1900
|
+
_targetId = '';
|
|
1901
|
+
_refs = new Map();
|
|
1902
|
+
await browser.clearActivePort();
|
|
1903
|
+
await browser.clearRefCache();
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
const { report, htmlPath, jsonPath, summary, historyDir } = result;
|
|
1908
|
+
const { flake } = result;
|
|
1909
|
+
// With --repeat the flake verdict governs: a check that failed 2 of 5
|
|
1910
|
+
// runs must not exit 0 just because the last run happened to be green.
|
|
1911
|
+
const passed = flake ? flake.verdict === 'pass' : report.verdict === 'pass';
|
|
1912
|
+
|
|
1913
|
+
if (ctx.flags.json) {
|
|
1914
|
+
const { toJsonReport } = await import('../report/index.js');
|
|
1915
|
+
print(
|
|
1916
|
+
JSON.stringify(
|
|
1917
|
+
{ data: { ...toJsonReport(report), flake, htmlPath, jsonPath, historyDir } },
|
|
1918
|
+
null,
|
|
1919
|
+
2,
|
|
1920
|
+
),
|
|
1921
|
+
);
|
|
1922
|
+
} else {
|
|
1923
|
+
if (passed) output.printSuccess(summary);
|
|
1924
|
+
else output.printError(summary);
|
|
1925
|
+
if (flake) {
|
|
1926
|
+
print(` ${flake.confidenceNote}`);
|
|
1927
|
+
for (const check of flake.checks.filter((c) => !c.stable)) {
|
|
1928
|
+
print(` ${check.label}: failed ${check.failed} of ${check.runs} runs`);
|
|
1929
|
+
}
|
|
1930
|
+
for (const signal of flake.signals.slice(0, 5)) {
|
|
1931
|
+
print(
|
|
1932
|
+
` ${signal.kind} "${signal.signature}" in ${signal.runs} of ${signal.total} runs`,
|
|
1933
|
+
);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
for (const failure of report.failures) {
|
|
1937
|
+
print(` ${failure.budget}: expected ${failure.expected}, got ${failure.actual}`);
|
|
1938
|
+
if (failure.detail) print(` ${failure.detail}`);
|
|
1939
|
+
}
|
|
1940
|
+
for (const headline of report.trend?.headlines ?? []) output.printWarning(headline);
|
|
1941
|
+
if (report.diff?.structure) {
|
|
1942
|
+
const d = report.diff.structure;
|
|
1943
|
+
print(
|
|
1944
|
+
` vs previous run: ${d.gained.length} gained, ${d.lost.length} lost, ${d.renamed.length} renamed`,
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
if (report.diff?.pixels?.comparable) {
|
|
1948
|
+
print(
|
|
1949
|
+
` vs previous run: ${report.diff.pixels.changedPercent.toFixed(2)}% of pixels changed`,
|
|
1950
|
+
);
|
|
1951
|
+
}
|
|
1952
|
+
for (const note of report.notes) output.printWarning(note);
|
|
1953
|
+
print(`Report: ${htmlPath}`);
|
|
1954
|
+
print(`JSON: ${jsonPath}`);
|
|
1955
|
+
if (historyDir) print(`History: ${historyDir}`);
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
// Non-zero exit is the point of RIG-06 — CI and agents gate on it.
|
|
1959
|
+
return {
|
|
1960
|
+
success: passed,
|
|
1961
|
+
exitCode: passed ? 0 : 1,
|
|
1962
|
+
data: { htmlPath, jsonPath, report, flake },
|
|
1963
|
+
};
|
|
1964
|
+
},
|
|
1965
|
+
};
|
|
1966
|
+
|
|
1701
1967
|
const closeCommand: Command = {
|
|
1702
1968
|
name: 'close',
|
|
1703
1969
|
description: 'Close the active browser session',
|
|
@@ -3859,6 +4125,7 @@ const browseCommand: Command = {
|
|
|
3859
4125
|
traceCommand,
|
|
3860
4126
|
profilerCommand,
|
|
3861
4127
|
vitalsCommand,
|
|
4128
|
+
reportCommand,
|
|
3862
4129
|
harCommand,
|
|
3863
4130
|
resizeCommand,
|
|
3864
4131
|
closeCommand,
|
|
@@ -3892,6 +4159,10 @@ const browseCommand: Command = {
|
|
|
3892
4159
|
command: 'monomind browse network route --pattern "https://api.*" --abort',
|
|
3893
4160
|
description: 'Abort API calls',
|
|
3894
4161
|
},
|
|
4162
|
+
{
|
|
4163
|
+
command: 'monomind browse report https://example.com',
|
|
4164
|
+
description: 'One-command page report with a pass/fail verdict',
|
|
4165
|
+
},
|
|
3895
4166
|
{ command: 'monomind browse close', description: 'Close browser session' },
|
|
3896
4167
|
],
|
|
3897
4168
|
action: async (_ctx: CommandContext): Promise<CommandResult> => {
|
|
@@ -3948,6 +4219,7 @@ const browseCommand: Command = {
|
|
|
3948
4219
|
output.printInfo(' batch Execute multiple commands');
|
|
3949
4220
|
output.printInfo(' addinitscript Add script to run before page navigation');
|
|
3950
4221
|
output.printInfo(' removeinitscript Remove a previously added init script');
|
|
4222
|
+
output.printInfo(' report Test a page and write an HTML report');
|
|
3951
4223
|
output.printInfo(' close Close the browser session');
|
|
3952
4224
|
return { success: true };
|
|
3953
4225
|
},
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accessibility rules computed from the CDP accessibility tree.
|
|
3
|
+
*
|
|
4
|
+
* Deliberate scope limit: colour contrast is NOT computed here. Contrast
|
|
5
|
+
* needs resolved foreground/background pixels or computed styles, and the AX
|
|
6
|
+
* tree carries neither — guessing it would produce confident nonsense. The
|
|
7
|
+
* report says so explicitly instead.
|
|
8
|
+
*
|
|
9
|
+
* Every rule is a pure function of a node array, so the fixtures in the tests
|
|
10
|
+
* are just hand-written AX trees.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { A11yFinding, AxNode, FocusCandidate } from './types.js';
|
|
14
|
+
|
|
15
|
+
/** Roles whose whole purpose is to be activated — a nameless one is unusable. */
|
|
16
|
+
const COMMAND_ROLES = new Set([
|
|
17
|
+
'button',
|
|
18
|
+
'link',
|
|
19
|
+
'menuitem',
|
|
20
|
+
'menuitemcheckbox',
|
|
21
|
+
'menuitemradio',
|
|
22
|
+
'tab',
|
|
23
|
+
'switch',
|
|
24
|
+
'treeitem',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** Roles that take user input and therefore need a programmatic label. */
|
|
28
|
+
const FORM_FIELD_ROLES = new Set([
|
|
29
|
+
'textbox',
|
|
30
|
+
'searchbox',
|
|
31
|
+
'combobox',
|
|
32
|
+
'listbox',
|
|
33
|
+
'checkbox',
|
|
34
|
+
'radio',
|
|
35
|
+
'slider',
|
|
36
|
+
'spinbutton',
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const IMAGE_ROLES = new Set(['image', 'img']);
|
|
40
|
+
|
|
41
|
+
/** Natively focusable tags — a negative tabindex on these removes them from the tab order. */
|
|
42
|
+
const NATIVELY_FOCUSABLE = new Set(['a', 'button', 'input', 'select', 'textarea', 'summary']);
|
|
43
|
+
|
|
44
|
+
function strValue(v: unknown): string {
|
|
45
|
+
return typeof v === 'string' ? v : v == null ? '' : String(v);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function roleOf(node: AxNode): string {
|
|
49
|
+
return strValue(node.role?.value).toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function nameOf(node: AxNode): string {
|
|
53
|
+
return strValue(node.name?.value).trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function propOf(node: AxNode, name: string): unknown {
|
|
57
|
+
return node.properties?.find((p) => p.name === name)?.value?.value;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function locatorFor(node: AxNode, locators?: Map<number, string>): string {
|
|
61
|
+
if (node.backendDOMNodeId !== undefined) {
|
|
62
|
+
const found = locators?.get(node.backendDOMNodeId);
|
|
63
|
+
if (found) return found;
|
|
64
|
+
}
|
|
65
|
+
return `ax-node:${node.nodeId}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Depth-first document order. `Accessibility.getFullAXTree` usually returns
|
|
70
|
+
* nodes pre-ordered already, but heading-order is the one rule where a
|
|
71
|
+
* reordered array would silently produce wrong findings, so we walk childIds
|
|
72
|
+
* ourselves. Nodes unreachable from a root are appended in array order rather
|
|
73
|
+
* than dropped.
|
|
74
|
+
*/
|
|
75
|
+
export function orderNodes(nodes: AxNode[]): AxNode[] {
|
|
76
|
+
const byId = new Map<string, AxNode>();
|
|
77
|
+
for (const n of nodes) byId.set(String(n.nodeId), n);
|
|
78
|
+
|
|
79
|
+
const ids = new Set(byId.keys());
|
|
80
|
+
const roots = nodes.filter((n) => n.parentId === undefined || !ids.has(String(n.parentId)));
|
|
81
|
+
|
|
82
|
+
const seen = new Set<string>();
|
|
83
|
+
const ordered: AxNode[] = [];
|
|
84
|
+
const visit = (node: AxNode): void => {
|
|
85
|
+
const id = String(node.nodeId);
|
|
86
|
+
if (seen.has(id)) return;
|
|
87
|
+
seen.add(id);
|
|
88
|
+
ordered.push(node);
|
|
89
|
+
for (const childId of node.childIds ?? []) {
|
|
90
|
+
const child = byId.get(String(childId));
|
|
91
|
+
if (child) visit(child);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
for (const root of roots) visit(root);
|
|
95
|
+
for (const node of nodes) if (!seen.has(String(node.nodeId))) ordered.push(node);
|
|
96
|
+
return ordered;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Nameless controls, images without alt text, and unlabelled form fields.
|
|
101
|
+
*
|
|
102
|
+
* `ignored` nodes are skipped: that is how `<img alt="">` correctly escapes
|
|
103
|
+
* the image rule — a deliberately decorative image is dropped from the AX
|
|
104
|
+
* tree, while `<img>` with no alt attribute at all stays in it with an empty
|
|
105
|
+
* name, which is the bug we want to report.
|
|
106
|
+
*/
|
|
107
|
+
export function findNamingIssues(nodes: AxNode[], locators?: Map<number, string>): A11yFinding[] {
|
|
108
|
+
const findings: A11yFinding[] = [];
|
|
109
|
+
for (const node of nodes) {
|
|
110
|
+
if (node.ignored) continue;
|
|
111
|
+
const role = roleOf(node);
|
|
112
|
+
if (!role) continue;
|
|
113
|
+
const name = nameOf(node);
|
|
114
|
+
if (name) continue;
|
|
115
|
+
|
|
116
|
+
if (FORM_FIELD_ROLES.has(role)) {
|
|
117
|
+
findings.push({
|
|
118
|
+
rule: 'form-field-no-label',
|
|
119
|
+
impact: 'error',
|
|
120
|
+
role,
|
|
121
|
+
name: null,
|
|
122
|
+
locator: locatorFor(node, locators),
|
|
123
|
+
detail: `${role} has no accessible name — no <label>, aria-label or aria-labelledby resolves to one`,
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (COMMAND_ROLES.has(role)) {
|
|
129
|
+
findings.push({
|
|
130
|
+
rule: 'unlabelled-control',
|
|
131
|
+
impact: 'error',
|
|
132
|
+
role,
|
|
133
|
+
name: null,
|
|
134
|
+
locator: locatorFor(node, locators),
|
|
135
|
+
detail: `${role} has no accessible name — a screen reader announces it as just "${role}"`,
|
|
136
|
+
});
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (IMAGE_ROLES.has(role)) {
|
|
141
|
+
findings.push({
|
|
142
|
+
rule: 'image-missing-alt',
|
|
143
|
+
impact: 'error',
|
|
144
|
+
role,
|
|
145
|
+
name: null,
|
|
146
|
+
locator: locatorFor(node, locators),
|
|
147
|
+
detail: 'image has no alt text (use alt="" if it is purely decorative)',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return findings;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Headings that skip a level (h1 -> h3), plus a document whose first heading
|
|
156
|
+
* is not h1. Both break the outline screen-reader users navigate by.
|
|
157
|
+
*/
|
|
158
|
+
export function findHeadingIssues(nodes: AxNode[], locators?: Map<number, string>): A11yFinding[] {
|
|
159
|
+
const findings: A11yFinding[] = [];
|
|
160
|
+
let previous: number | null = null;
|
|
161
|
+
|
|
162
|
+
for (const node of orderNodes(nodes)) {
|
|
163
|
+
if (node.ignored) continue;
|
|
164
|
+
if (roleOf(node) !== 'heading') continue;
|
|
165
|
+
const raw = propOf(node, 'level');
|
|
166
|
+
const level = typeof raw === 'number' ? raw : Number.parseInt(strValue(raw), 10);
|
|
167
|
+
if (!Number.isFinite(level) || level < 1) continue;
|
|
168
|
+
|
|
169
|
+
const name = nameOf(node);
|
|
170
|
+
if (previous === null) {
|
|
171
|
+
if (level > 1) {
|
|
172
|
+
findings.push({
|
|
173
|
+
rule: 'heading-order-jump',
|
|
174
|
+
impact: 'warning',
|
|
175
|
+
role: 'heading',
|
|
176
|
+
name: name || null,
|
|
177
|
+
locator: locatorFor(node, locators),
|
|
178
|
+
detail: `document outline starts at h${level} — the first heading should be h1`,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
} else if (level > previous + 1) {
|
|
182
|
+
findings.push({
|
|
183
|
+
rule: 'heading-order-jump',
|
|
184
|
+
impact: 'warning',
|
|
185
|
+
role: 'heading',
|
|
186
|
+
name: name || null,
|
|
187
|
+
locator: locatorFor(node, locators),
|
|
188
|
+
detail: `heading level jumps h${previous} -> h${level}`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
previous = level;
|
|
192
|
+
}
|
|
193
|
+
return findings;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Interactive elements pulled out of the keyboard tab order by a negative
|
|
198
|
+
* tabindex. They stay clickable with a mouse and stay in the AX tree, so
|
|
199
|
+
* nothing else in this file can see them — the candidates come from a DOM
|
|
200
|
+
* sweep in collect.ts.
|
|
201
|
+
*/
|
|
202
|
+
export function findFocusOrderIssues(candidates: FocusCandidate[]): A11yFinding[] {
|
|
203
|
+
const findings: A11yFinding[] = [];
|
|
204
|
+
for (const c of candidates) {
|
|
205
|
+
if (c.tabindex >= 0) continue;
|
|
206
|
+
const interactive = NATIVELY_FOCUSABLE.has(c.tag.toLowerCase()) || c.role !== '';
|
|
207
|
+
if (!interactive) continue;
|
|
208
|
+
findings.push({
|
|
209
|
+
rule: 'negative-tabindex',
|
|
210
|
+
impact: 'warning',
|
|
211
|
+
role: c.role || c.tag.toLowerCase(),
|
|
212
|
+
name: c.name,
|
|
213
|
+
locator: c.locator,
|
|
214
|
+
detail: `<${c.tag.toLowerCase()}> has tabindex="${c.tabindex}" — mouse users can reach it, keyboard users cannot`,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return findings;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface A11yInput {
|
|
221
|
+
nodes: AxNode[];
|
|
222
|
+
focusCandidates?: FocusCandidate[];
|
|
223
|
+
locators?: Map<number, string>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function runA11yRules({ nodes, focusCandidates = [], locators }: A11yInput): A11yFinding[] {
|
|
227
|
+
return [
|
|
228
|
+
...findNamingIssues(nodes, locators),
|
|
229
|
+
...findHeadingIssues(nodes, locators),
|
|
230
|
+
...findFocusOrderIssues(focusCandidates),
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function countA11y(findings: A11yFinding[]): { errors: number; warnings: number } {
|
|
235
|
+
let errors = 0;
|
|
236
|
+
let warnings = 0;
|
|
237
|
+
for (const f of findings) {
|
|
238
|
+
if (f.impact === 'error') errors++;
|
|
239
|
+
else warnings++;
|
|
240
|
+
}
|
|
241
|
+
return { errors, warnings };
|
|
242
|
+
}
|