@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.
Files changed (175) hide show
  1. package/dist/src/__tests__/profiler-heap.test.d.ts +2 -0
  2. package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/profiler-heap.test.js +154 -0
  4. package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
  5. package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
  6. package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/report-a11y.test.js +147 -0
  8. package/dist/src/__tests__/report-a11y.test.js.map +1 -0
  9. package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
  10. package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/report-analyze.test.js +157 -0
  12. package/dist/src/__tests__/report-analyze.test.js.map +1 -0
  13. package/dist/src/__tests__/report-budget.test.d.ts +2 -0
  14. package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
  15. package/dist/src/__tests__/report-budget.test.js +80 -0
  16. package/dist/src/__tests__/report-budget.test.js.map +1 -0
  17. package/dist/src/__tests__/report-diff.test.d.ts +2 -0
  18. package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
  19. package/dist/src/__tests__/report-diff.test.js +231 -0
  20. package/dist/src/__tests__/report-diff.test.js.map +1 -0
  21. package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
  22. package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
  23. package/dist/src/__tests__/report-evidence.test.js +214 -0
  24. package/dist/src/__tests__/report-evidence.test.js.map +1 -0
  25. package/dist/src/__tests__/report-flake.test.d.ts +2 -0
  26. package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
  27. package/dist/src/__tests__/report-flake.test.js +182 -0
  28. package/dist/src/__tests__/report-flake.test.js.map +1 -0
  29. package/dist/src/__tests__/report-history.test.d.ts +2 -0
  30. package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
  31. package/dist/src/__tests__/report-history.test.js +229 -0
  32. package/dist/src/__tests__/report-history.test.js.map +1 -0
  33. package/dist/src/__tests__/report-output.test.d.ts +2 -0
  34. package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
  35. package/dist/src/__tests__/report-output.test.js +169 -0
  36. package/dist/src/__tests__/report-output.test.js.map +1 -0
  37. package/dist/src/__tests__/report-png.test.d.ts +2 -0
  38. package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
  39. package/dist/src/__tests__/report-png.test.js +84 -0
  40. package/dist/src/__tests__/report-png.test.js.map +1 -0
  41. package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
  42. package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
  43. package/dist/src/__tests__/report-render-extras.test.js +285 -0
  44. package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
  45. package/dist/src/__tests__/report-render.test.d.ts +2 -0
  46. package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
  47. package/dist/src/__tests__/report-render.test.js +143 -0
  48. package/dist/src/__tests__/report-render.test.js.map +1 -0
  49. package/dist/src/__tests__/report-run.test.d.ts +2 -0
  50. package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
  51. package/dist/src/__tests__/report-run.test.js +234 -0
  52. package/dist/src/__tests__/report-run.test.js.map +1 -0
  53. package/dist/src/browser/index.d.ts +1 -0
  54. package/dist/src/browser/index.d.ts.map +1 -1
  55. package/dist/src/browser/index.js +1 -0
  56. package/dist/src/browser/index.js.map +1 -1
  57. package/dist/src/browser/profiler.d.ts.map +1 -1
  58. package/dist/src/browser/profiler.js +19 -22
  59. package/dist/src/browser/profiler.js.map +1 -1
  60. package/dist/src/cli/commands.d.ts.map +1 -1
  61. package/dist/src/cli/commands.js +254 -0
  62. package/dist/src/cli/commands.js.map +1 -1
  63. package/dist/src/report/a11y.d.ts +52 -0
  64. package/dist/src/report/a11y.d.ts.map +1 -0
  65. package/dist/src/report/a11y.js +231 -0
  66. package/dist/src/report/a11y.js.map +1 -0
  67. package/dist/src/report/analyze.d.ts +20 -0
  68. package/dist/src/report/analyze.d.ts.map +1 -0
  69. package/dist/src/report/analyze.js +136 -0
  70. package/dist/src/report/analyze.js.map +1 -0
  71. package/dist/src/report/budget.d.ts +27 -0
  72. package/dist/src/report/budget.d.ts.map +1 -0
  73. package/dist/src/report/budget.js +143 -0
  74. package/dist/src/report/budget.js.map +1 -0
  75. package/dist/src/report/collect-a11y.d.ts +25 -0
  76. package/dist/src/report/collect-a11y.d.ts.map +1 -0
  77. package/dist/src/report/collect-a11y.js +154 -0
  78. package/dist/src/report/collect-a11y.js.map +1 -0
  79. package/dist/src/report/collect.d.ts +45 -0
  80. package/dist/src/report/collect.d.ts.map +1 -0
  81. package/dist/src/report/collect.js +275 -0
  82. package/dist/src/report/collect.js.map +1 -0
  83. package/dist/src/report/evidence.d.ts +80 -0
  84. package/dist/src/report/evidence.d.ts.map +1 -0
  85. package/dist/src/report/evidence.js +279 -0
  86. package/dist/src/report/evidence.js.map +1 -0
  87. package/dist/src/report/flake.d.ts +88 -0
  88. package/dist/src/report/flake.d.ts.map +1 -0
  89. package/dist/src/report/flake.js +232 -0
  90. package/dist/src/report/flake.js.map +1 -0
  91. package/dist/src/report/history.d.ts +85 -0
  92. package/dist/src/report/history.d.ts.map +1 -0
  93. package/dist/src/report/history.js +176 -0
  94. package/dist/src/report/history.js.map +1 -0
  95. package/dist/src/report/index.d.ts +102 -0
  96. package/dist/src/report/index.d.ts.map +1 -0
  97. package/dist/src/report/index.js +216 -0
  98. package/dist/src/report/index.js.map +1 -0
  99. package/dist/src/report/pixel-diff.d.ts +30 -0
  100. package/dist/src/report/pixel-diff.d.ts.map +1 -0
  101. package/dist/src/report/pixel-diff.js +175 -0
  102. package/dist/src/report/pixel-diff.js.map +1 -0
  103. package/dist/src/report/png.d.ts +28 -0
  104. package/dist/src/report/png.d.ts.map +1 -0
  105. package/dist/src/report/png.js +259 -0
  106. package/dist/src/report/png.js.map +1 -0
  107. package/dist/src/report/render-evidence.d.ts +16 -0
  108. package/dist/src/report/render-evidence.d.ts.map +1 -0
  109. package/dist/src/report/render-evidence.js +174 -0
  110. package/dist/src/report/render-evidence.js.map +1 -0
  111. package/dist/src/report/render-trend.d.ts +16 -0
  112. package/dist/src/report/render-trend.d.ts.map +1 -0
  113. package/dist/src/report/render-trend.js +194 -0
  114. package/dist/src/report/render-trend.js.map +1 -0
  115. package/dist/src/report/render.d.ts +21 -0
  116. package/dist/src/report/render.d.ts.map +1 -0
  117. package/dist/src/report/render.js +286 -0
  118. package/dist/src/report/render.js.map +1 -0
  119. package/dist/src/report/run-diff.d.ts +18 -0
  120. package/dist/src/report/run-diff.d.ts.map +1 -0
  121. package/dist/src/report/run-diff.js +51 -0
  122. package/dist/src/report/run-diff.js.map +1 -0
  123. package/dist/src/report/structure.d.ts +41 -0
  124. package/dist/src/report/structure.d.ts.map +1 -0
  125. package/dist/src/report/structure.js +210 -0
  126. package/dist/src/report/structure.js.map +1 -0
  127. package/dist/src/report/trend.d.ts +26 -0
  128. package/dist/src/report/trend.d.ts.map +1 -0
  129. package/dist/src/report/trend.js +164 -0
  130. package/dist/src/report/trend.js.map +1 -0
  131. package/dist/src/report/types.d.ts +320 -0
  132. package/dist/src/report/types.d.ts.map +1 -0
  133. package/dist/src/report/types.js +11 -0
  134. package/dist/src/report/types.js.map +1 -0
  135. package/dist/src/report/util.d.ts +17 -0
  136. package/dist/src/report/util.d.ts.map +1 -0
  137. package/dist/src/report/util.js +45 -0
  138. package/dist/src/report/util.js.map +1 -0
  139. package/dist/tsconfig.tsbuildinfo +1 -1
  140. package/package.json +1 -1
  141. package/src/__tests__/profiler-heap.test.ts +191 -0
  142. package/src/__tests__/report-a11y.test.ts +180 -0
  143. package/src/__tests__/report-analyze.test.ts +202 -0
  144. package/src/__tests__/report-budget.test.ts +92 -0
  145. package/src/__tests__/report-diff.test.ts +272 -0
  146. package/src/__tests__/report-evidence.test.ts +276 -0
  147. package/src/__tests__/report-flake.test.ts +214 -0
  148. package/src/__tests__/report-history.test.ts +293 -0
  149. package/src/__tests__/report-output.test.ts +210 -0
  150. package/src/__tests__/report-png.test.ts +95 -0
  151. package/src/__tests__/report-render-extras.test.ts +315 -0
  152. package/src/__tests__/report-render.test.ts +175 -0
  153. package/src/__tests__/report-run.test.ts +264 -0
  154. package/src/browser/index.ts +1 -0
  155. package/src/browser/profiler.ts +28 -21
  156. package/src/cli/commands.ts +272 -0
  157. package/src/report/a11y.ts +242 -0
  158. package/src/report/analyze.ts +181 -0
  159. package/src/report/budget.ts +155 -0
  160. package/src/report/collect-a11y.ts +200 -0
  161. package/src/report/collect.ts +408 -0
  162. package/src/report/evidence.ts +379 -0
  163. package/src/report/flake.ts +303 -0
  164. package/src/report/history.ts +219 -0
  165. package/src/report/index.ts +307 -0
  166. package/src/report/pixel-diff.ts +197 -0
  167. package/src/report/png.ts +290 -0
  168. package/src/report/render-evidence.ts +194 -0
  169. package/src/report/render-trend.ts +225 -0
  170. package/src/report/render.ts +314 -0
  171. package/src/report/run-diff.ts +63 -0
  172. package/src/report/structure.ts +212 -0
  173. package/src/report/trend.ts +189 -0
  174. package/src/report/types.ts +356 -0
  175. package/src/report/util.ts +48 -0
@@ -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
+ }