@monoes/monobrowse 1.0.12 → 1.0.14

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 (271) hide show
  1. package/dist/src/__tests__/bridge-instruments.test.d.ts +2 -0
  2. package/dist/src/__tests__/bridge-instruments.test.d.ts.map +1 -0
  3. package/dist/src/__tests__/bridge-instruments.test.js +132 -0
  4. package/dist/src/__tests__/bridge-instruments.test.js.map +1 -0
  5. package/dist/src/__tests__/bridge-transport.test.d.ts +2 -0
  6. package/dist/src/__tests__/bridge-transport.test.d.ts.map +1 -0
  7. package/dist/src/__tests__/bridge-transport.test.js +287 -0
  8. package/dist/src/__tests__/bridge-transport.test.js.map +1 -0
  9. package/dist/src/__tests__/cdp-transport.test.d.ts +2 -0
  10. package/dist/src/__tests__/cdp-transport.test.d.ts.map +1 -0
  11. package/dist/src/__tests__/cdp-transport.test.js +167 -0
  12. package/dist/src/__tests__/cdp-transport.test.js.map +1 -0
  13. package/dist/src/__tests__/profiler-heap.test.d.ts +2 -0
  14. package/dist/src/__tests__/profiler-heap.test.d.ts.map +1 -0
  15. package/dist/src/__tests__/profiler-heap.test.js +154 -0
  16. package/dist/src/__tests__/profiler-heap.test.js.map +1 -0
  17. package/dist/src/__tests__/report-a11y.test.d.ts +2 -0
  18. package/dist/src/__tests__/report-a11y.test.d.ts.map +1 -0
  19. package/dist/src/__tests__/report-a11y.test.js +147 -0
  20. package/dist/src/__tests__/report-a11y.test.js.map +1 -0
  21. package/dist/src/__tests__/report-analyze.test.d.ts +2 -0
  22. package/dist/src/__tests__/report-analyze.test.d.ts.map +1 -0
  23. package/dist/src/__tests__/report-analyze.test.js +157 -0
  24. package/dist/src/__tests__/report-analyze.test.js.map +1 -0
  25. package/dist/src/__tests__/report-budget.test.d.ts +2 -0
  26. package/dist/src/__tests__/report-budget.test.d.ts.map +1 -0
  27. package/dist/src/__tests__/report-budget.test.js +80 -0
  28. package/dist/src/__tests__/report-budget.test.js.map +1 -0
  29. package/dist/src/__tests__/report-diff.test.d.ts +2 -0
  30. package/dist/src/__tests__/report-diff.test.d.ts.map +1 -0
  31. package/dist/src/__tests__/report-diff.test.js +231 -0
  32. package/dist/src/__tests__/report-diff.test.js.map +1 -0
  33. package/dist/src/__tests__/report-evidence.test.d.ts +2 -0
  34. package/dist/src/__tests__/report-evidence.test.d.ts.map +1 -0
  35. package/dist/src/__tests__/report-evidence.test.js +214 -0
  36. package/dist/src/__tests__/report-evidence.test.js.map +1 -0
  37. package/dist/src/__tests__/report-flake.test.d.ts +2 -0
  38. package/dist/src/__tests__/report-flake.test.d.ts.map +1 -0
  39. package/dist/src/__tests__/report-flake.test.js +182 -0
  40. package/dist/src/__tests__/report-flake.test.js.map +1 -0
  41. package/dist/src/__tests__/report-history.test.d.ts +2 -0
  42. package/dist/src/__tests__/report-history.test.d.ts.map +1 -0
  43. package/dist/src/__tests__/report-history.test.js +229 -0
  44. package/dist/src/__tests__/report-history.test.js.map +1 -0
  45. package/dist/src/__tests__/report-output.test.d.ts +2 -0
  46. package/dist/src/__tests__/report-output.test.d.ts.map +1 -0
  47. package/dist/src/__tests__/report-output.test.js +169 -0
  48. package/dist/src/__tests__/report-output.test.js.map +1 -0
  49. package/dist/src/__tests__/report-png.test.d.ts +2 -0
  50. package/dist/src/__tests__/report-png.test.d.ts.map +1 -0
  51. package/dist/src/__tests__/report-png.test.js +84 -0
  52. package/dist/src/__tests__/report-png.test.js.map +1 -0
  53. package/dist/src/__tests__/report-render-extras.test.d.ts +2 -0
  54. package/dist/src/__tests__/report-render-extras.test.d.ts.map +1 -0
  55. package/dist/src/__tests__/report-render-extras.test.js +285 -0
  56. package/dist/src/__tests__/report-render-extras.test.js.map +1 -0
  57. package/dist/src/__tests__/report-render.test.d.ts +2 -0
  58. package/dist/src/__tests__/report-render.test.d.ts.map +1 -0
  59. package/dist/src/__tests__/report-render.test.js +143 -0
  60. package/dist/src/__tests__/report-render.test.js.map +1 -0
  61. package/dist/src/__tests__/report-run.test.d.ts +2 -0
  62. package/dist/src/__tests__/report-run.test.d.ts.map +1 -0
  63. package/dist/src/__tests__/report-run.test.js +234 -0
  64. package/dist/src/__tests__/report-run.test.js.map +1 -0
  65. package/dist/src/browser/bridge.d.ts +119 -0
  66. package/dist/src/browser/bridge.d.ts.map +1 -0
  67. package/dist/src/browser/bridge.js +264 -0
  68. package/dist/src/browser/bridge.js.map +1 -0
  69. package/dist/src/browser/cdp.d.ts +11 -1
  70. package/dist/src/browser/cdp.d.ts.map +1 -1
  71. package/dist/src/browser/cdp.js +60 -56
  72. package/dist/src/browser/cdp.js.map +1 -1
  73. package/dist/src/browser/index.d.ts +3 -0
  74. package/dist/src/browser/index.d.ts.map +1 -1
  75. package/dist/src/browser/index.js +3 -0
  76. package/dist/src/browser/index.js.map +1 -1
  77. package/dist/src/browser/profiler.d.ts.map +1 -1
  78. package/dist/src/browser/profiler.js +19 -22
  79. package/dist/src/browser/profiler.js.map +1 -1
  80. package/dist/src/browser/transport.d.ts +62 -0
  81. package/dist/src/browser/transport.d.ts.map +1 -0
  82. package/dist/src/browser/transport.js +65 -0
  83. package/dist/src/browser/transport.js.map +1 -0
  84. package/dist/src/cli/commander-adapter.d.ts +16 -0
  85. package/dist/src/cli/commander-adapter.d.ts.map +1 -0
  86. package/dist/src/cli/commander-adapter.js +68 -0
  87. package/dist/src/cli/commander-adapter.js.map +1 -0
  88. package/dist/src/cli/commands-context.d.ts +14 -0
  89. package/dist/src/cli/commands-context.d.ts.map +1 -0
  90. package/dist/src/cli/commands-context.js +351 -0
  91. package/dist/src/cli/commands-context.js.map +1 -0
  92. package/dist/src/cli/commands-data.d.ts +12 -0
  93. package/dist/src/cli/commands-data.d.ts.map +1 -0
  94. package/dist/src/cli/commands-data.js +246 -0
  95. package/dist/src/cli/commands-data.js.map +1 -0
  96. package/dist/src/cli/commands-element.d.ts +15 -0
  97. package/dist/src/cli/commands-element.d.ts.map +1 -0
  98. package/dist/src/cli/commands-element.js +262 -0
  99. package/dist/src/cli/commands-element.js.map +1 -0
  100. package/dist/src/cli/commands-files.d.ts +9 -0
  101. package/dist/src/cli/commands-files.d.ts.map +1 -0
  102. package/dist/src/cli/commands-files.js +207 -0
  103. package/dist/src/cli/commands-files.js.map +1 -0
  104. package/dist/src/cli/commands-input.d.ts +26 -0
  105. package/dist/src/cli/commands-input.d.ts.map +1 -0
  106. package/dist/src/cli/commands-input.js +399 -0
  107. package/dist/src/cli/commands-input.js.map +1 -0
  108. package/dist/src/cli/commands-navigate.d.ts +12 -0
  109. package/dist/src/cli/commands-navigate.d.ts.map +1 -0
  110. package/dist/src/cli/commands-navigate.js +389 -0
  111. package/dist/src/cli/commands-navigate.js.map +1 -0
  112. package/dist/src/cli/commands-page.d.ts +25 -0
  113. package/dist/src/cli/commands-page.d.ts.map +1 -0
  114. package/dist/src/cli/commands-page.js +377 -0
  115. package/dist/src/cli/commands-page.js.map +1 -0
  116. package/dist/src/cli/commands-report.d.ts +8 -0
  117. package/dist/src/cli/commands-report.d.ts.map +1 -0
  118. package/dist/src/cli/commands-report.js +256 -0
  119. package/dist/src/cli/commands-report.js.map +1 -0
  120. package/dist/src/cli/commands-script.d.ts +43 -0
  121. package/dist/src/cli/commands-script.d.ts.map +1 -0
  122. package/dist/src/cli/commands-script.js +168 -0
  123. package/dist/src/cli/commands-script.js.map +1 -0
  124. package/dist/src/cli/commands-session.d.ts +15 -0
  125. package/dist/src/cli/commands-session.d.ts.map +1 -0
  126. package/dist/src/cli/commands-session.js +357 -0
  127. package/dist/src/cli/commands-session.js.map +1 -0
  128. package/dist/src/cli/commands-trace.d.ts +12 -0
  129. package/dist/src/cli/commands-trace.d.ts.map +1 -0
  130. package/dist/src/cli/commands-trace.js +347 -0
  131. package/dist/src/cli/commands-trace.js.map +1 -0
  132. package/dist/src/cli/commands.d.ts +22 -42
  133. package/dist/src/cli/commands.d.ts.map +1 -1
  134. package/dist/src/cli/commands.js +48 -3400
  135. package/dist/src/cli/commands.js.map +1 -1
  136. package/dist/src/cli/session.d.ts +47 -0
  137. package/dist/src/cli/session.d.ts.map +1 -0
  138. package/dist/src/cli/session.js +343 -0
  139. package/dist/src/cli/session.js.map +1 -0
  140. package/dist/src/report/a11y.d.ts +52 -0
  141. package/dist/src/report/a11y.d.ts.map +1 -0
  142. package/dist/src/report/a11y.js +231 -0
  143. package/dist/src/report/a11y.js.map +1 -0
  144. package/dist/src/report/analyze.d.ts +20 -0
  145. package/dist/src/report/analyze.d.ts.map +1 -0
  146. package/dist/src/report/analyze.js +136 -0
  147. package/dist/src/report/analyze.js.map +1 -0
  148. package/dist/src/report/budget.d.ts +27 -0
  149. package/dist/src/report/budget.d.ts.map +1 -0
  150. package/dist/src/report/budget.js +143 -0
  151. package/dist/src/report/budget.js.map +1 -0
  152. package/dist/src/report/collect-a11y.d.ts +25 -0
  153. package/dist/src/report/collect-a11y.d.ts.map +1 -0
  154. package/dist/src/report/collect-a11y.js +154 -0
  155. package/dist/src/report/collect-a11y.js.map +1 -0
  156. package/dist/src/report/collect.d.ts +45 -0
  157. package/dist/src/report/collect.d.ts.map +1 -0
  158. package/dist/src/report/collect.js +275 -0
  159. package/dist/src/report/collect.js.map +1 -0
  160. package/dist/src/report/evidence.d.ts +80 -0
  161. package/dist/src/report/evidence.d.ts.map +1 -0
  162. package/dist/src/report/evidence.js +279 -0
  163. package/dist/src/report/evidence.js.map +1 -0
  164. package/dist/src/report/flake.d.ts +88 -0
  165. package/dist/src/report/flake.d.ts.map +1 -0
  166. package/dist/src/report/flake.js +232 -0
  167. package/dist/src/report/flake.js.map +1 -0
  168. package/dist/src/report/history.d.ts +85 -0
  169. package/dist/src/report/history.d.ts.map +1 -0
  170. package/dist/src/report/history.js +176 -0
  171. package/dist/src/report/history.js.map +1 -0
  172. package/dist/src/report/index.d.ts +102 -0
  173. package/dist/src/report/index.d.ts.map +1 -0
  174. package/dist/src/report/index.js +216 -0
  175. package/dist/src/report/index.js.map +1 -0
  176. package/dist/src/report/pixel-diff.d.ts +30 -0
  177. package/dist/src/report/pixel-diff.d.ts.map +1 -0
  178. package/dist/src/report/pixel-diff.js +175 -0
  179. package/dist/src/report/pixel-diff.js.map +1 -0
  180. package/dist/src/report/png.d.ts +28 -0
  181. package/dist/src/report/png.d.ts.map +1 -0
  182. package/dist/src/report/png.js +259 -0
  183. package/dist/src/report/png.js.map +1 -0
  184. package/dist/src/report/render-evidence.d.ts +16 -0
  185. package/dist/src/report/render-evidence.d.ts.map +1 -0
  186. package/dist/src/report/render-evidence.js +174 -0
  187. package/dist/src/report/render-evidence.js.map +1 -0
  188. package/dist/src/report/render-trend.d.ts +16 -0
  189. package/dist/src/report/render-trend.d.ts.map +1 -0
  190. package/dist/src/report/render-trend.js +194 -0
  191. package/dist/src/report/render-trend.js.map +1 -0
  192. package/dist/src/report/render.d.ts +21 -0
  193. package/dist/src/report/render.d.ts.map +1 -0
  194. package/dist/src/report/render.js +286 -0
  195. package/dist/src/report/render.js.map +1 -0
  196. package/dist/src/report/run-diff.d.ts +18 -0
  197. package/dist/src/report/run-diff.d.ts.map +1 -0
  198. package/dist/src/report/run-diff.js +51 -0
  199. package/dist/src/report/run-diff.js.map +1 -0
  200. package/dist/src/report/structure.d.ts +41 -0
  201. package/dist/src/report/structure.d.ts.map +1 -0
  202. package/dist/src/report/structure.js +210 -0
  203. package/dist/src/report/structure.js.map +1 -0
  204. package/dist/src/report/trend.d.ts +26 -0
  205. package/dist/src/report/trend.d.ts.map +1 -0
  206. package/dist/src/report/trend.js +164 -0
  207. package/dist/src/report/trend.js.map +1 -0
  208. package/dist/src/report/types.d.ts +320 -0
  209. package/dist/src/report/types.d.ts.map +1 -0
  210. package/dist/src/report/types.js +11 -0
  211. package/dist/src/report/types.js.map +1 -0
  212. package/dist/src/report/util.d.ts +17 -0
  213. package/dist/src/report/util.d.ts.map +1 -0
  214. package/dist/src/report/util.js +45 -0
  215. package/dist/src/report/util.js.map +1 -0
  216. package/dist/tsconfig.tsbuildinfo +1 -1
  217. package/package.json +1 -1
  218. package/src/__tests__/bridge-instruments.test.ts +180 -0
  219. package/src/__tests__/bridge-transport.test.ts +365 -0
  220. package/src/__tests__/cdp-transport.test.ts +198 -0
  221. package/src/__tests__/profiler-heap.test.ts +191 -0
  222. package/src/__tests__/report-a11y.test.ts +180 -0
  223. package/src/__tests__/report-analyze.test.ts +202 -0
  224. package/src/__tests__/report-budget.test.ts +92 -0
  225. package/src/__tests__/report-diff.test.ts +272 -0
  226. package/src/__tests__/report-evidence.test.ts +276 -0
  227. package/src/__tests__/report-flake.test.ts +214 -0
  228. package/src/__tests__/report-history.test.ts +293 -0
  229. package/src/__tests__/report-output.test.ts +210 -0
  230. package/src/__tests__/report-png.test.ts +95 -0
  231. package/src/__tests__/report-render-extras.test.ts +315 -0
  232. package/src/__tests__/report-render.test.ts +175 -0
  233. package/src/__tests__/report-run.test.ts +264 -0
  234. package/src/browser/bridge.ts +354 -0
  235. package/src/browser/cdp.ts +57 -52
  236. package/src/browser/index.ts +3 -0
  237. package/src/browser/profiler.ts +28 -21
  238. package/src/browser/transport.ts +111 -0
  239. package/src/cli/commander-adapter.ts +65 -0
  240. package/src/cli/commands-context.ts +367 -0
  241. package/src/cli/commands-data.ts +248 -0
  242. package/src/cli/commands-element.ts +305 -0
  243. package/src/cli/commands-files.ts +248 -0
  244. package/src/cli/commands-input.ts +434 -0
  245. package/src/cli/commands-navigate.ts +442 -0
  246. package/src/cli/commands-page.ts +420 -0
  247. package/src/cli/commands-report.ts +275 -0
  248. package/src/cli/commands-script.ts +183 -0
  249. package/src/cli/commands-session.ts +376 -0
  250. package/src/cli/commands-trace.ts +349 -0
  251. package/src/cli/commands.ts +101 -3677
  252. package/src/cli/session.ts +412 -0
  253. package/src/report/a11y.ts +242 -0
  254. package/src/report/analyze.ts +181 -0
  255. package/src/report/budget.ts +155 -0
  256. package/src/report/collect-a11y.ts +200 -0
  257. package/src/report/collect.ts +408 -0
  258. package/src/report/evidence.ts +379 -0
  259. package/src/report/flake.ts +303 -0
  260. package/src/report/history.ts +219 -0
  261. package/src/report/index.ts +307 -0
  262. package/src/report/pixel-diff.ts +197 -0
  263. package/src/report/png.ts +290 -0
  264. package/src/report/render-evidence.ts +194 -0
  265. package/src/report/render-trend.ts +225 -0
  266. package/src/report/render.ts +314 -0
  267. package/src/report/run-diff.ts +63 -0
  268. package/src/report/structure.ts +212 -0
  269. package/src/report/trend.ts +189 -0
  270. package/src/report/types.ts +356 -0
  271. package/src/report/util.ts +48 -0
@@ -0,0 +1,412 @@
1
+ /**
2
+ * Browse session runtime — the one live CDP session every browse subcommand
3
+ * acts on, the helpers that establish and tear it down, and the small
4
+ * flag/output helpers they all share.
5
+ *
6
+ * A CLI invocation is a single process with a single session, so the state is
7
+ * module-level. It is a mutable record rather than separate bindings because
8
+ * the subcommand modules that update it import it (see `session`).
9
+ *
10
+ * Split out of commands.ts, which is now the command catalogue.
11
+ */
12
+
13
+ import type { CdpClient, ElementRef } from '../index.js';
14
+ import { output } from './output.js';
15
+
16
+ const DEFAULT_PORT = 9222;
17
+
18
+ // Runtime state (single session per CLI process).
19
+ //
20
+ // One mutable record rather than separate module-level bindings: every
21
+ // subcommand module reads and writes the SAME session, and ESM import
22
+ // bindings are read-only for the importer, so the state has to live behind
23
+ // an object for a command in another file to be able to update it.
24
+ export const session: {
25
+ client: CdpClient | null;
26
+ sessionId: string;
27
+ targetId: string;
28
+ port: number;
29
+ refs: Map<string, ElementRef>;
30
+ /** Saved parent sessionId when inside an iframe — restored by `frame main`. */
31
+ parentSessionId: string;
32
+ } = {
33
+ client: null,
34
+ sessionId: '',
35
+ targetId: '',
36
+ port: DEFAULT_PORT,
37
+ refs: new Map(),
38
+ parentSessionId: '',
39
+ };
40
+
41
+ export async function getBrowser() {
42
+ return import('../index.js');
43
+ }
44
+
45
+ // Best-effort cleanup on Ctrl-C / kill so a launched Chrome doesn't linger as
46
+ // an orphan process when a command is interrupted mid-flight (e.g. during a
47
+ // long-running wait/eval that CdpClient.send()'s own timeout hasn't tripped
48
+ // yet).
49
+ //
50
+ // This module is imported for EVERY `monomind` CLI invocation (commands/
51
+ // index.ts statically imports browse.ts, which imports this file) — not just
52
+ // browse commands — so the handler is only *registered* once a browser
53
+ // session is actually launched here (see ensureSignalCleanupHandlers(),
54
+ // called from the `open` action below), not at module load. Registering
55
+ // unconditionally at import time would call process.exit() on Ctrl-C for
56
+ // every unrelated monomind command (e.g. a long-running `org run` daemon),
57
+ // pre-empting whatever other SIGINT/SIGTERM handling that process needs.
58
+ const SIGNAL_EXIT_CODES: Record<'SIGINT' | 'SIGTERM', number> = { SIGINT: 130, SIGTERM: 143 };
59
+ let _signalHandlersRegistered = false;
60
+ export function ensureSignalCleanupHandlers(): void {
61
+ if (_signalHandlersRegistered) return;
62
+ _signalHandlersRegistered = true;
63
+ for (const sig of ['SIGINT', 'SIGTERM'] as const) {
64
+ process.on(sig, () => {
65
+ void (async () => {
66
+ try {
67
+ const browser = await getBrowser();
68
+ if (session.client) {
69
+ await browser.closeBrowser(session.client, session.port);
70
+ } else {
71
+ const pid = browser.getLaunchedPid(session.port);
72
+ if (pid !== undefined) {
73
+ try {
74
+ process.kill(pid, 'SIGKILL');
75
+ } catch {
76
+ /* already exited */
77
+ }
78
+ }
79
+ }
80
+ } catch {
81
+ // best-effort — never block process exit on cleanup failure
82
+ }
83
+ })().finally(() => process.exit(SIGNAL_EXIT_CODES[sig]));
84
+ });
85
+ }
86
+ }
87
+
88
+ // Each CLI invocation is a fresh process, so this module's `session.port` variable
89
+ // is always freshly initialized to DEFAULT_PORT — a `--port 9333` passed to
90
+ // an earlier `open` command has no effect on this process's own default.
91
+ // Resolve the persisted "active port" (written by `open --port N`, read via
92
+ // ref-cache.ts's saveActivePort/loadActivePort) so subsequent commands
93
+ // attach to the browser the user actually opened instead of silently
94
+ // launching/attaching to a second, unrelated Chrome instance on 9222.
95
+ export async function resolveDefaultPort(
96
+ browser: Awaited<ReturnType<typeof getBrowser>>,
97
+ ): Promise<number> {
98
+ const persisted = await browser.loadActivePortInfo();
99
+ if (!persisted) return DEFAULT_PORT;
100
+ if (!persisted.launched) {
101
+ // connect-origin port: the browser belongs to someone else. If it died,
102
+ // relaunching our own headless Chrome on that port would squat the
103
+ // user's debug port and silently swap which browser commands act on —
104
+ // fail loudly instead.
105
+ try {
106
+ await fetch(`http://127.0.0.1:${persisted.port}/json/version`, {
107
+ signal: AbortSignal.timeout(1500),
108
+ });
109
+ } catch {
110
+ await browser.clearActivePort();
111
+ throw new Error(
112
+ `Connected browser on port ${persisted.port} is gone — re-run \`connect\` (or \`open\` to launch a fresh one).`,
113
+ );
114
+ }
115
+ }
116
+ return persisted.port;
117
+ }
118
+
119
+ export async function ensureConnected(port: number, targetId?: string) {
120
+ const browser = await getBrowser();
121
+ if (!session.client?.isConnected()) {
122
+ if (session.client && session.sessionId) {
123
+ browser.teardownRouteInterception(session.sessionId);
124
+ browser.stopRequestCapture(session.sessionId);
125
+ browser.teardownDialogHandling(session.sessionId);
126
+ browser.teardownConsoleCapture(session.sessionId);
127
+ session.client.close();
128
+ }
129
+ const effectivePort = port === DEFAULT_PORT ? await resolveDefaultPort(browser) : port;
130
+ session.port = await browser.launchBrowser({ port: effectivePort, headless: true });
131
+ const conn = await browser.connectToTarget(session.port, targetId);
132
+ session.client = conn.client;
133
+ session.sessionId = conn.sessionId;
134
+ session.targetId = conn.target.id;
135
+ session.parentSessionId = '';
136
+ session.refs = new Map();
137
+ await hydrateRefsFromCache(browser, session.targetId, conn.target.url);
138
+ }
139
+ return { client: session.client!, sessionId: session.sessionId, targetId: session.targetId };
140
+ }
141
+
142
+ // Each CLI invocation is a fresh process, so the in-memory session.refs Map built by
143
+ // a prior `snapshot` command is gone by the time a later `find`/`click`/etc.
144
+ // command runs. Rehydrate it from the on-disk ref cache (written by
145
+ // captureSnapshot call sites below) so refs resolved by a previous process
146
+ // remain usable. Falls back silently to an empty Map if no cache exists.
147
+ //
148
+ // The cached `url` field (captured at snapshot time) is compared against the
149
+ // browser's CURRENT url (`currentUrl`, from the just-fetched target info).
150
+ // A stale `backendDOMNodeId` can still successfully resolve via
151
+ // DOM.getBoxModel even after a same-tab SPA navigation or DOM mutation
152
+ // changed what's actually at those coordinates — so a mismatch here means
153
+ // EVERY ref in the cache is potentially pointing at the wrong element. We
154
+ // hard-invalidate (skip hydration entirely, so any @eN lookup fails loudly
155
+ // via resolveRef's "not found" error) rather than the weaker 30s time-based
156
+ // staleness check below, which only warns.
157
+ export async function hydrateRefsFromCache(
158
+ browser: Awaited<ReturnType<typeof getBrowser>>,
159
+ targetId: string,
160
+ currentUrl: string,
161
+ ): Promise<void> {
162
+ const cached = await browser.loadRefCache(targetId);
163
+ if (!cached) return;
164
+ if (currentUrl && cached.url && currentUrl !== cached.url) {
165
+ output.printError(
166
+ `Stale references — page has navigated (cache: ${cached.url} → current: ${currentUrl}). Re-run snapshot before using @eN refs.`,
167
+ );
168
+ return; // leave session.refs empty — do not attempt to resolve refs against a different page
169
+ }
170
+ session.refs = cached.refs;
171
+ if (cached.stale) {
172
+ output.printWarning(
173
+ `AX ref cache is ${Math.round(cached.ageMs / 1000)}s old — page may have changed since the last snapshot; re-run snapshot if refs don't resolve as expected`,
174
+ );
175
+ }
176
+ }
177
+
178
+ // URL patterns that signal an unambiguous login/auth wall.
179
+ // Excludes 'auth', 'oauth', 'sso', 'saml' — their callback/ACS/token paths
180
+ // (/auth/callback, /sso/callback, /saml/acs) are completion endpoints, not walls.
181
+ // DOM detection (password field, CAPTCHA widgets) handles SSO/SAML login pages.
182
+ const ATTENTION_URL_RE =
183
+ /\/(login|log-in|signin|sign-in|captcha|mfa|2fa|account\/login|accounts\/login|session\/new|users\/sign_in)(?:[/?#]|$)/i;
184
+
185
+ export async function detectAttentionNeeded(
186
+ client: CdpClient,
187
+ sessionId: string,
188
+ url: string,
189
+ ): Promise<'login' | 'captcha' | null> {
190
+ if (ATTENTION_URL_RE.test(url)) return 'login';
191
+ try {
192
+ const hasPassword = await client
193
+ .send<{ result: { value: boolean } }>(
194
+ 'Runtime.evaluate',
195
+ {
196
+ expression: '!!document.querySelector("input[type=password]")',
197
+ returnByValue: true,
198
+ },
199
+ sessionId,
200
+ )
201
+ .then((r) => r.result?.value === true)
202
+ .catch(() => false);
203
+ if (hasPassword) return 'login';
204
+
205
+ const hasCaptcha = await client
206
+ .send<{ result: { value: boolean } }>(
207
+ 'Runtime.evaluate',
208
+ {
209
+ expression:
210
+ '!!(document.querySelector("iframe[src*=recaptcha]") || document.querySelector("iframe[src*=hcaptcha]") || document.querySelector(".g-recaptcha") || document.querySelector(".h-captcha") || document.querySelector("[data-sitekey]"))',
211
+ returnByValue: true,
212
+ },
213
+ sessionId,
214
+ )
215
+ .then((r) => r.result?.value === true)
216
+ .catch(() => false);
217
+ if (hasCaptcha) return 'captcha';
218
+ } catch {
219
+ /* ignore CDP errors */
220
+ }
221
+ return null;
222
+ }
223
+
224
+ export function waitForEnter(): Promise<void> {
225
+ if (!process.stdin.isTTY) {
226
+ // Non-interactive context (MCP server, piped input, CI) — cannot safely read stdin.
227
+ // Auto-continue after a short grace period so automation isn't blocked.
228
+ output.printWarning(
229
+ 'Non-interactive mode: auto-continuing in 30s. Switch to headed manually if needed.',
230
+ );
231
+ return new Promise((resolve) => setTimeout(resolve, 30_000));
232
+ }
233
+ return new Promise((resolve) => {
234
+ process.stdin.resume();
235
+ process.stdin.setEncoding('utf8');
236
+ const onData = () => {
237
+ process.stdin.pause();
238
+ process.stdin.off('data', onData);
239
+ resolve();
240
+ };
241
+ process.stdin.once('data', onData);
242
+ });
243
+ }
244
+
245
+ export async function switchToHeaded(url: string, port: number): Promise<void> {
246
+ const browser = await getBrowser();
247
+
248
+ // Snapshot cookies from current headless session before closing
249
+ let savedCookies: unknown[] = [];
250
+ if (session.client && session.sessionId) {
251
+ try {
252
+ savedCookies = await browser.getCookies(session.client, session.sessionId);
253
+ } catch {
254
+ /* ignore */
255
+ }
256
+ browser.teardownRouteInterception(session.sessionId);
257
+ browser.stopRequestCapture(session.sessionId);
258
+ browser.teardownDialogHandling(session.sessionId);
259
+ browser.teardownConsoleCapture(session.sessionId);
260
+ session.client.close();
261
+ session.client = null;
262
+ session.sessionId = '';
263
+ session.parentSessionId = '';
264
+ session.targetId = '';
265
+ }
266
+
267
+ // Launch headed on a neighbouring port to avoid collision
268
+ const headedPort = port + 10;
269
+ try {
270
+ await browser.launchBrowser({ port: headedPort, headless: false });
271
+ } catch (err) {
272
+ // Headed launch failed (no display, locked down env). Restore headless and rethrow.
273
+ session.port = await browser.launchBrowser({ port, headless: true });
274
+ const fallback = await browser.connectToTarget(session.port);
275
+ session.client = fallback.client;
276
+ session.sessionId = fallback.sessionId;
277
+ session.targetId = fallback.target.id;
278
+ session.refs = new Map();
279
+ throw new Error(
280
+ `Cannot open headed browser: ${err instanceof Error ? err.message : String(err)}`,
281
+ );
282
+ }
283
+ const conn = await browser.connectToTarget(headedPort);
284
+ session.client = conn.client;
285
+ session.sessionId = conn.sessionId;
286
+ session.targetId = conn.target.id;
287
+ session.refs = new Map();
288
+
289
+ if (savedCookies.length) {
290
+ try {
291
+ await browser.setCookies(
292
+ session.client,
293
+ session.sessionId,
294
+ savedCookies as Parameters<typeof browser.setCookies>[2],
295
+ );
296
+ } catch {
297
+ /* ignore */
298
+ }
299
+ }
300
+ await browser.openUrl(session.client, session.sessionId, url);
301
+
302
+ output.printInfo(
303
+ 'Browser window opened. Complete the required action (login / CAPTCHA), then press Enter to continue in headless mode...',
304
+ );
305
+ await waitForEnter();
306
+
307
+ // Capture post-auth cookies
308
+ const authCookies = await browser
309
+ .getCookies(session.client, session.sessionId)
310
+ .catch(() => [] as unknown[]);
311
+ const authLocalStorage = await browser
312
+ .getLocalStorage(session.client, session.sessionId)
313
+ .catch(() => ({}) as Record<string, string>);
314
+
315
+ // Close headed session — actually terminate the underlying Chrome process
316
+ // (Browser.close CDP command, PID-kill fallback), not just our CDP client
317
+ // connection, so the visible authenticated window doesn't linger forever.
318
+ browser.teardownRouteInterception(session.sessionId);
319
+ browser.stopRequestCapture(session.sessionId);
320
+ browser.teardownDialogHandling(session.sessionId);
321
+ browser.teardownConsoleCapture(session.sessionId);
322
+ await browser.closeBrowser(session.client, headedPort);
323
+ session.client.close();
324
+ session.client = null;
325
+ session.sessionId = '';
326
+ session.parentSessionId = '';
327
+ session.targetId = '';
328
+
329
+ // Relaunch headless and restore session
330
+ session.port = await browser.launchBrowser({ port, headless: true });
331
+ const headlessConn = await browser.connectToTarget(session.port);
332
+ session.client = headlessConn.client;
333
+ session.sessionId = headlessConn.sessionId;
334
+ session.targetId = headlessConn.target.id;
335
+ session.refs = new Map();
336
+
337
+ if (authCookies.length) {
338
+ try {
339
+ await browser.setCookies(
340
+ session.client,
341
+ session.sessionId,
342
+ authCookies as Parameters<typeof browser.setCookies>[2],
343
+ );
344
+ } catch {
345
+ /* ignore */
346
+ }
347
+ }
348
+ if (authLocalStorage && Object.keys(authLocalStorage).length) {
349
+ try {
350
+ await browser.setLocalStorage(session.client, session.sessionId, authLocalStorage);
351
+ } catch {
352
+ /* ignore */
353
+ }
354
+ }
355
+ }
356
+
357
+ // Cap on printed output so a command cannot flood a terminal or an agent's
358
+ // context with a whole page. 0 disables truncation.
359
+ export const DEFAULT_EVAL_MAX_OUTPUT = 50_000;
360
+
361
+ export function truncateForOutput(text: string, maxOutput: number): string {
362
+ if (!(maxOutput > 0) || text.length <= maxOutput) return text;
363
+ return `${text.slice(0, maxOutput)}\n[... truncated at ${maxOutput} chars]`;
364
+ }
365
+
366
+ /**
367
+ * Sanitize an image-format flag. The host CLI (monomind) defines a GLOBAL
368
+ * --format flag for output shaping (text|json|table, default 'text') that
369
+ * leaks into subcommand flags — so ctx.flags.format arrives as 'text' even
370
+ * when the user never passed it, and Chrome rejects it with
371
+ * "CDP error -32602: Invalid image format". Only trust values that are real
372
+ * image formats; otherwise fall back to the subcommand's own default.
373
+ */
374
+ export function imageFormat<T extends string>(
375
+ value: unknown,
376
+ allowed: readonly T[],
377
+ fallback: T,
378
+ ): T {
379
+ return allowed.includes(value as T) ? (value as T) : fallback;
380
+ }
381
+
382
+ export function print(msg: string) {
383
+ process.stdout.write(`${msg}\n`);
384
+ }
385
+
386
+ export async function resolveElementObjectId(
387
+ client: import('../index.js').CdpClient,
388
+ sessionId: string,
389
+ refs: Map<string, import('../index.js').ElementRef>,
390
+ refOrSelector: string,
391
+ ): Promise<string> {
392
+ const browser = await getBrowser();
393
+ if (refOrSelector.startsWith('@') || /^e\d+$/.test(refOrSelector)) {
394
+ const key = refOrSelector.startsWith('@') ? refOrSelector.slice(1) : refOrSelector;
395
+ const ref = await browser.resolveRef(client, sessionId, refs, key);
396
+ const objectId = await browser.getObjectIdForRef(client, sessionId, ref);
397
+ if (!objectId) throw new Error(`Element @${key} not found in DOM`);
398
+ return objectId;
399
+ }
400
+ // CSS selector path
401
+ const res = await client.send<{ result: { objectId?: string; subtype?: string } }>(
402
+ 'Runtime.evaluate',
403
+ {
404
+ expression: `document.querySelector(${JSON.stringify(refOrSelector)})`,
405
+ returnByValue: false,
406
+ },
407
+ sessionId,
408
+ );
409
+ if (!res.result?.objectId || res.result?.subtype === 'null')
410
+ throw new Error(`Selector not found: ${refOrSelector}`);
411
+ return res.result.objectId;
412
+ }
@@ -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
+ }