sourcemonkey 0.0.0-stage → 0.5.0

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 (367) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +87 -2
  3. package/dist/bytes.js +37 -0
  4. package/dist/channel-placeholder.js +5 -0
  5. package/dist/collection-scan.js +17 -0
  6. package/dist/collections.html +353 -0
  7. package/dist/compare.html +358 -0
  8. package/dist/composed-source.js +205 -0
  9. package/dist/connect-prompt.html +47 -0
  10. package/dist/debug/icons.html +211 -0
  11. package/dist/debug/storage.html +163 -0
  12. package/dist/debug-log.js +75 -0
  13. package/dist/diagnostic-location.js +64 -0
  14. package/dist/eval-hook.js +308 -0
  15. package/dist/extension/background.js +5263 -0
  16. package/dist/extension/channel.js +133 -0
  17. package/dist/extension/code-links.js +187 -0
  18. package/dist/extension/code-tabs.js +460 -0
  19. package/dist/extension/collection-dialog.js +619 -0
  20. package/dist/extension/collection-display.js +322 -0
  21. package/dist/extension/collections.js +2905 -0
  22. package/dist/extension/compare.js +867 -0
  23. package/dist/extension/concurrency.js +186 -0
  24. package/dist/extension/connect-grants.js +109 -0
  25. package/dist/extension/connect-prompt-page.js +70 -0
  26. package/dist/extension/connect-prompt.js +266 -0
  27. package/dist/extension/copy-button.js +63 -0
  28. package/dist/extension/copy-dialog.js +502 -0
  29. package/dist/extension/copy-preference.js +66 -0
  30. package/dist/extension/debug/icons.js +203 -0
  31. package/dist/extension/debug/storage.js +365 -0
  32. package/dist/extension/deep-link.js +77 -0
  33. package/dist/extension/delete-dialog.js +432 -0
  34. package/dist/extension/delete-icon.js +40 -0
  35. package/dist/extension/details-tooltips.js +66 -0
  36. package/dist/extension/directories.js +679 -0
  37. package/dist/extension/export-backup.js +407 -0
  38. package/dist/extension/export-dialog.js +142 -0
  39. package/dist/extension/file-access.js +74 -0
  40. package/dist/extension/file-changes.js +346 -0
  41. package/dist/extension/frame-info.js +85 -0
  42. package/dist/extension/github-provider.js +336 -0
  43. package/dist/extension/glyph.js +84 -0
  44. package/dist/extension/gm-bindings.js +601 -0
  45. package/dist/extension/gm-download.js +419 -0
  46. package/dist/extension/gm-menu.js +228 -0
  47. package/dist/extension/gm-notification.js +141 -0
  48. package/dist/extension/gm-originator-map.js +23 -0
  49. package/dist/extension/gm-tab-data.js +121 -0
  50. package/dist/extension/gm-tab.js +160 -0
  51. package/dist/extension/gm-url-change.js +111 -0
  52. package/dist/extension/gm-url-scheme.js +27 -0
  53. package/dist/extension/gm-values.js +176 -0
  54. package/dist/extension/gm-window-control.js +104 -0
  55. package/dist/extension/gm-xhr-headers.js +333 -0
  56. package/dist/extension/gm-xhr.js +584 -0
  57. package/dist/extension/injector.js +3145 -0
  58. package/dist/extension/install-capture.js +277 -0
  59. package/dist/extension/line-diff.js +233 -0
  60. package/dist/extension/local-copy.js +560 -0
  61. package/dist/extension/local-delete.js +276 -0
  62. package/dist/extension/location-kinds.js +108 -0
  63. package/dist/extension/log-format.js +158 -0
  64. package/dist/extension/managed-changes.js +361 -0
  65. package/dist/extension/managed-items.js +57 -0
  66. package/dist/extension/match-index.js +375 -0
  67. package/dist/extension/missing-scripts.js +66 -0
  68. package/dist/extension/more-menu.js +109 -0
  69. package/dist/extension/move-dialog.js +197 -0
  70. package/dist/extension/offscreen.js +37 -0
  71. package/dist/extension/options.js +504 -0
  72. package/dist/extension/page-heading.js +170 -0
  73. package/dist/extension/page-links.js +199 -0
  74. package/dist/extension/parse-check-client.js +70 -0
  75. package/dist/extension/parse-check.js +161 -0
  76. package/dist/extension/parse-sandbox-host.js +172 -0
  77. package/dist/extension/parse-sandbox.js +175 -0
  78. package/dist/extension/popup-commands-open.js +41 -0
  79. package/dist/extension/popup-view.js +387 -0
  80. package/dist/extension/popup.js +703 -0
  81. package/dist/extension/prism-userscript.js +53 -0
  82. package/dist/extension/refresh-error.js +136 -0
  83. package/dist/extension/refresh-targets.js +160 -0
  84. package/dist/extension/related-versions.js +1144 -0
  85. package/dist/extension/resize-observer-loop.js +19 -0
  86. package/dist/extension/row-cache.js +387 -0
  87. package/dist/extension/script-blobs.js +413 -0
  88. package/dist/extension/script-details.js +658 -0
  89. package/dist/extension/script-enablement.js +285 -0
  90. package/dist/extension/script-identity.js +310 -0
  91. package/dist/extension/script-keys.js +54 -0
  92. package/dist/extension/script-log-id.js +155 -0
  93. package/dist/extension/script-log.js +290 -0
  94. package/dist/extension/script-rejection.js +104 -0
  95. package/dist/extension/script-sites.js +158 -0
  96. package/dist/extension/script-store.js +3507 -0
  97. package/dist/extension/script-updater.js +991 -0
  98. package/dist/extension/script-url-hash.js +61 -0
  99. package/dist/extension/select-all.js +37 -0
  100. package/dist/extension/select-open-guard.js +90 -0
  101. package/dist/extension/site-icon.js +1261 -0
  102. package/dist/extension/sm-dev-link.js +302 -0
  103. package/dist/extension/solo-click.js +58 -0
  104. package/dist/extension/sticky-numbers.js +188 -0
  105. package/dist/extension/storage-breakdown.js +253 -0
  106. package/dist/extension/storage-scan.js +86 -0
  107. package/dist/extension/storage-usage.js +194 -0
  108. package/dist/extension/structural-keys.js +29 -0
  109. package/dist/extension/sw-teardown.js +36 -0
  110. package/dist/extension/tab-status.js +403 -0
  111. package/dist/extension/toolbar-summary.js +105 -0
  112. package/dist/extension/user-scripts-access.js +43 -0
  113. package/dist/extension/userscript.js +3672 -0
  114. package/dist/extension/value-store.js +204 -0
  115. package/dist/extension/version-probe-fetch.js +68 -0
  116. package/dist/extension/worker-client.js +109 -0
  117. package/dist/extension/worker-ops.js +171 -0
  118. package/dist/extension/zip-writer.js +24 -0
  119. package/dist/external-files.js +878 -0
  120. package/dist/file-loader.js +436 -0
  121. package/dist/file-locations.js +439 -0
  122. package/dist/generated-wrappers.js +5 -0
  123. package/dist/github-auth.js +148 -0
  124. package/dist/github-urls.js +46 -0
  125. package/dist/gm-api-catalog.js +217 -0
  126. package/dist/gm-connect.js +273 -0
  127. package/dist/gm-xhr-types.js +5 -0
  128. package/dist/http-read.js +142 -0
  129. package/dist/icons/icon-128.png +0 -0
  130. package/dist/icons/icon-16.png +0 -0
  131. package/dist/icons/icon-48.png +0 -0
  132. package/dist/local-file-overrides.js +558 -0
  133. package/dist/manifest.json +38 -0
  134. package/dist/markup-response.js +126 -0
  135. package/dist/match-targets.js +305 -0
  136. package/dist/menu-captions.js +22 -0
  137. package/dist/offscreen.html +39 -0
  138. package/dist/options.html +197 -0
  139. package/dist/parse-check-protocol.js +51 -0
  140. package/dist/parse-location.js +171 -0
  141. package/dist/parse-sandbox.html +56 -0
  142. package/dist/phase-timing.js +155 -0
  143. package/dist/popup.html +197 -0
  144. package/dist/public-suffixes.js +6957 -0
  145. package/dist/run-context.js +31 -0
  146. package/dist/runtime-messages.js +34 -0
  147. package/dist/sandboxed-page.js +35 -0
  148. package/dist/script-build.js +787 -0
  149. package/dist/script-display.js +383 -0
  150. package/dist/script-icon.js +43 -0
  151. package/dist/script-links.js +204 -0
  152. package/dist/script-source-url.js +214 -0
  153. package/dist/script-world.js +65 -0
  154. package/dist/sm-dev-protocol.js +84 -0
  155. package/dist/source-map.js +177 -0
  156. package/dist/stack-link.js +138 -0
  157. package/dist/styles.css +825 -0
  158. package/dist/unsafe-window-use.js +229 -0
  159. package/dist/url-match.js +734 -0
  160. package/dist/userscript-header.js +248 -0
  161. package/dist/userscript.html +1165 -0
  162. package/dist/vendor/fflate.LICENSE +21 -0
  163. package/dist/vendor/fflate.js +2692 -0
  164. package/dist/vendor/prism/prism-line-numbers.css +1 -0
  165. package/dist/vendor/prism/prism-line-numbers.js +1 -0
  166. package/dist/vendor/prism/prism.css +1 -0
  167. package/dist/vendor/prism/prism.js +1946 -0
  168. package/dist/version-compare.js +141 -0
  169. package/dist/version-probe.js +120 -0
  170. package/lib/build-info.json +1 -0
  171. package/lib/bytes.d.ts +3 -0
  172. package/lib/bytes.js +37 -0
  173. package/lib/channel-placeholder.d.ts +1 -0
  174. package/lib/channel-placeholder.js +5 -0
  175. package/lib/collection-scan.d.ts +8 -0
  176. package/lib/collection-scan.js +17 -0
  177. package/lib/composed-source.d.ts +28 -0
  178. package/lib/composed-source.js +205 -0
  179. package/lib/debug-log.d.ts +9 -0
  180. package/lib/debug-log.js +75 -0
  181. package/lib/diagnostic-location.d.ts +8 -0
  182. package/lib/diagnostic-location.js +64 -0
  183. package/lib/eval-hook.d.ts +34 -0
  184. package/lib/eval-hook.js +308 -0
  185. package/lib/external-files.d.ts +111 -0
  186. package/lib/external-files.js +878 -0
  187. package/lib/file-loader.d.ts +48 -0
  188. package/lib/file-loader.js +436 -0
  189. package/lib/file-locations.d.ts +44 -0
  190. package/lib/file-locations.js +439 -0
  191. package/lib/generated-wrappers.d.ts +11 -0
  192. package/lib/generated-wrappers.js +5 -0
  193. package/lib/github-auth.d.ts +12 -0
  194. package/lib/github-auth.js +148 -0
  195. package/lib/github-urls.d.ts +1 -0
  196. package/lib/github-urls.js +46 -0
  197. package/lib/gm-api-catalog.d.ts +24 -0
  198. package/lib/gm-api-catalog.js +217 -0
  199. package/lib/gm-connect.d.ts +34 -0
  200. package/lib/gm-connect.js +273 -0
  201. package/lib/gm-xhr-types.d.ts +68 -0
  202. package/lib/gm-xhr-types.js +5 -0
  203. package/lib/harness/browser-extensions.d.ts +73 -0
  204. package/lib/harness/browser-extensions.js +285 -0
  205. package/lib/harness/browser-lang.d.ts +20 -0
  206. package/lib/harness/browser-lang.js +86 -0
  207. package/lib/harness/browser-launch.d.ts +14 -0
  208. package/lib/harness/browser-launch.js +184 -0
  209. package/lib/harness/checks.d.ts +110 -0
  210. package/lib/harness/checks.js +321 -0
  211. package/lib/harness/cli.d.ts +2 -0
  212. package/lib/harness/cli.js +2259 -0
  213. package/lib/harness/command-args.d.ts +91 -0
  214. package/lib/harness/command-args.js +369 -0
  215. package/lib/harness/command-lines.d.ts +80 -0
  216. package/lib/harness/command-lines.js +180 -0
  217. package/lib/harness/command-list.d.ts +206 -0
  218. package/lib/harness/command-list.js +1475 -0
  219. package/lib/harness/console-commands.d.ts +463 -0
  220. package/lib/harness/console-commands.js +3216 -0
  221. package/lib/harness/download-watch.d.ts +21 -0
  222. package/lib/harness/download-watch.js +156 -0
  223. package/lib/harness/extension-backend.d.ts +41 -0
  224. package/lib/harness/extension-backend.js +100 -0
  225. package/lib/harness/extension-host.d.ts +112 -0
  226. package/lib/harness/extension-host.js +552 -0
  227. package/lib/harness/extension-launch.d.ts +67 -0
  228. package/lib/harness/extension-launch.js +285 -0
  229. package/lib/harness/extension-link.d.ts +315 -0
  230. package/lib/harness/extension-link.js +607 -0
  231. package/lib/harness/external-manager.d.ts +146 -0
  232. package/lib/harness/external-manager.js +414 -0
  233. package/lib/harness/fixture.d.ts +165 -0
  234. package/lib/harness/fixture.js +461 -0
  235. package/lib/harness/frame-sessions.d.ts +71 -0
  236. package/lib/harness/frame-sessions.js +248 -0
  237. package/lib/harness/frames.d.ts +68 -0
  238. package/lib/harness/frames.js +216 -0
  239. package/lib/harness/help-format.d.ts +17 -0
  240. package/lib/harness/help-format.js +127 -0
  241. package/lib/harness/host.d.ts +352 -0
  242. package/lib/harness/host.js +423 -0
  243. package/lib/harness/index.d.ts +8 -0
  244. package/lib/harness/index.js +10 -0
  245. package/lib/harness/input-file.d.ts +18 -0
  246. package/lib/harness/input-file.js +61 -0
  247. package/lib/harness/json-output.d.ts +565 -0
  248. package/lib/harness/json-output.js +71 -0
  249. package/lib/harness/manager-actions.d.ts +21 -0
  250. package/lib/harness/manager-actions.js +129 -0
  251. package/lib/harness/manager-commands.d.ts +142 -0
  252. package/lib/harness/manager-commands.js +1196 -0
  253. package/lib/harness/manager-fetch.d.ts +33 -0
  254. package/lib/harness/manager-fetch.js +239 -0
  255. package/lib/harness/managers.d.ts +24 -0
  256. package/lib/harness/managers.js +27 -0
  257. package/lib/harness/output-style.d.ts +125 -0
  258. package/lib/harness/output-style.js +322 -0
  259. package/lib/harness/page-commands.d.ts +134 -0
  260. package/lib/harness/page-commands.js +1141 -0
  261. package/lib/harness/page-output.d.ts +67 -0
  262. package/lib/harness/page-output.js +139 -0
  263. package/lib/harness/page-watch.d.ts +171 -0
  264. package/lib/harness/page-watch.js +607 -0
  265. package/lib/harness/parse-check-node.d.ts +3 -0
  266. package/lib/harness/parse-check-node.js +132 -0
  267. package/lib/harness/path-args.d.ts +22 -0
  268. package/lib/harness/path-args.js +148 -0
  269. package/lib/harness/playwright-chromium.d.ts +16 -0
  270. package/lib/harness/playwright-chromium.js +86 -0
  271. package/lib/harness/prepare.d.ts +168 -0
  272. package/lib/harness/prepare.js +364 -0
  273. package/lib/harness/prompt-editor.d.ts +17 -0
  274. package/lib/harness/prompt-editor.js +354 -0
  275. package/lib/harness/prompt-history.d.ts +13 -0
  276. package/lib/harness/prompt-history.js +89 -0
  277. package/lib/harness/rebuild-advice.d.ts +6 -0
  278. package/lib/harness/rebuild-advice.js +39 -0
  279. package/lib/harness/run-log.d.ts +47 -0
  280. package/lib/harness/run-log.js +140 -0
  281. package/lib/harness/run-logs.d.ts +132 -0
  282. package/lib/harness/run-logs.js +241 -0
  283. package/lib/harness/run.d.ts +194 -0
  284. package/lib/harness/run.js +1973 -0
  285. package/lib/harness/runs.d.ts +229 -0
  286. package/lib/harness/runs.js +726 -0
  287. package/lib/harness/save-html.d.ts +39 -0
  288. package/lib/harness/save-html.js +89 -0
  289. package/lib/harness/saved-pages.d.ts +120 -0
  290. package/lib/harness/saved-pages.js +275 -0
  291. package/lib/harness/script-mark.d.ts +17 -0
  292. package/lib/harness/script-mark.js +56 -0
  293. package/lib/harness/snapshot.d.ts +143 -0
  294. package/lib/harness/snapshot.js +474 -0
  295. package/lib/harness/stuck-tabs.d.ts +19 -0
  296. package/lib/harness/stuck-tabs.js +131 -0
  297. package/lib/harness/tab-refs.d.ts +20 -0
  298. package/lib/harness/tab-refs.js +80 -0
  299. package/lib/harness/tampermonkey.d.ts +71 -0
  300. package/lib/harness/tampermonkey.js +356 -0
  301. package/lib/harness/tm-ids.d.ts +18 -0
  302. package/lib/harness/tm-ids.js +88 -0
  303. package/lib/harness/violentmonkey.d.ts +34 -0
  304. package/lib/harness/violentmonkey.js +162 -0
  305. package/lib/harness/wait.d.ts +77 -0
  306. package/lib/harness/wait.js +458 -0
  307. package/lib/http-read.d.ts +30 -0
  308. package/lib/http-read.js +142 -0
  309. package/lib/local-file-overrides.d.ts +35 -0
  310. package/lib/local-file-overrides.js +558 -0
  311. package/lib/manifest.json +38 -0
  312. package/lib/markup-response.d.ts +11 -0
  313. package/lib/markup-response.js +126 -0
  314. package/lib/match-targets.d.ts +18 -0
  315. package/lib/match-targets.js +305 -0
  316. package/lib/menu-captions.d.ts +1 -0
  317. package/lib/menu-captions.js +22 -0
  318. package/lib/parse-check-protocol.d.ts +27 -0
  319. package/lib/parse-check-protocol.js +51 -0
  320. package/lib/parse-location.d.ts +12 -0
  321. package/lib/parse-location.js +171 -0
  322. package/lib/phase-timing.d.ts +12 -0
  323. package/lib/phase-timing.js +155 -0
  324. package/lib/public-suffixes.d.ts +1 -0
  325. package/lib/public-suffixes.js +6957 -0
  326. package/lib/run-context.d.ts +2 -0
  327. package/lib/run-context.js +31 -0
  328. package/lib/runtime-messages.d.ts +10 -0
  329. package/lib/runtime-messages.js +34 -0
  330. package/lib/sandboxed-page.d.ts +5 -0
  331. package/lib/sandboxed-page.js +35 -0
  332. package/lib/script-build.d.ts +111 -0
  333. package/lib/script-build.js +787 -0
  334. package/lib/script-display.d.ts +51 -0
  335. package/lib/script-display.js +383 -0
  336. package/lib/script-icon.d.ts +16 -0
  337. package/lib/script-icon.js +43 -0
  338. package/lib/script-links.d.ts +24 -0
  339. package/lib/script-links.js +204 -0
  340. package/lib/script-source-url.d.ts +8 -0
  341. package/lib/script-source-url.js +214 -0
  342. package/lib/script-world.d.ts +6 -0
  343. package/lib/script-world.js +65 -0
  344. package/lib/sm-dev-protocol.d.ts +251 -0
  345. package/lib/sm-dev-protocol.js +84 -0
  346. package/lib/source-map.d.ts +32 -0
  347. package/lib/source-map.js +177 -0
  348. package/lib/stack-link.d.ts +15 -0
  349. package/lib/stack-link.js +138 -0
  350. package/lib/unsafe-window-use.d.ts +1 -0
  351. package/lib/unsafe-window-use.js +229 -0
  352. package/lib/url-match.d.ts +40 -0
  353. package/lib/url-match.js +734 -0
  354. package/lib/userscript-header.d.ts +33 -0
  355. package/lib/userscript-header.js +248 -0
  356. package/lib/version-compare.d.ts +6 -0
  357. package/lib/version-compare.js +141 -0
  358. package/lib/version-probe.d.ts +17 -0
  359. package/lib/version-probe.js +120 -0
  360. package/package.json +55 -3
  361. package/skills/SourceMonkey-dev/SKILL.md +868 -0
  362. package/skills/SourceMonkey-dev/references/cdp.md +293 -0
  363. package/skills/SourceMonkey-dev/references/commands.md +297 -0
  364. package/skills/SourceMonkey-dev/references/other-managers.md +63 -0
  365. package/skills/SourceMonkey-dev/references/specs.md +46 -0
  366. package/skills/SourceMonkey-friction/SKILL.md +103 -0
  367. package/skills/SourceMonkey-friction/references/template.md +59 -0
@@ -0,0 +1,1141 @@
1
+ // The typed commands that act on a tab's page directly: drive it
2
+ // (click, hover, type, key, eval), look at it (snapshot, screenshot,
3
+ // savehtml, probe), and send it raw CDP calls (cdp). console-commands.ts
4
+ // picks the tab and hands the rest of the line here.
5
+ //
6
+ // Built on the run's own Playwright connection to its tabs: real input
7
+ // events (scrolled into view, the page sees them as a person's), key
8
+ // names as Playwright spells them (`Shift+ArrowRight`), and the
9
+ // accessibility tree as text. Actions wait for the page to settle (wait.ts)
10
+ // before returning, so the next command sees what the last one did.
11
+ import { writeFile } from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { PART_LIMIT, PROBE_MAX } from './command-list.js';
14
+ import { NoDocument, NoEvalHook, ScriptThrew } from './page-watch.js';
15
+ import { jsonResult } from './json-output.js';
16
+ import { readInputFile, writeProblem } from './input-file.js';
17
+ import { notSettledMessage, seconds, SETTLE_TIMEOUT_MS, settleTab, timeoutFlagMs } from './wait.js';
18
+ import { savePageHtml } from './save-html.js';
19
+ import { shownPath } from './prepare.js';
20
+ import { findFrame, frameNodes, parseFrameRef } from './frames.js';
21
+ import { asError, cmd, code, ERROR_PREFIX, escapeMarkup, heading, id as idRole, MARKUP, message, name as nameRole, quoted, ui, url } from './output-style.js';
22
+ import { documentOf, locate, named, noteRefs, onScreen, probeMatches, refOf, refProblem, refsOf, tidy, treeParts, withoutDocument } from './snapshot.js';
23
+ export const PAGE_COMMANDS = new Set(['click', 'hover', 'type', 'key', 'eval', 'snapshot', 'screenshot', 'savehtml', 'probe', 'cdp']);
24
+ // How long an action waits for its element. Settling after it has its
25
+ // own limit (wait.ts).
26
+ const ACTION_TIMEOUT_MS = 5000;
27
+ // How long a whole-page screenshot waits for the browser to capture it.
28
+ const SCREENSHOT_TIMEOUT_MS = 5000;
29
+ // How long a quick look into the page waits for an answer.
30
+ const CHECK_TIMEOUT_MS = 1000;
31
+ // How long each `cdp` call waits for its reply.
32
+ const CDP_TIMEOUT_MS = 10_000;
33
+ // A CDP method name, `Domain.method`, which starts each `cdp` call.
34
+ const CDP_METHOD = /^[A-Za-z]+\.[A-Za-z]+$/;
35
+ /**
36
+ * A quick yes/no from the page, false when it doesn't answer in time:
37
+ * a page stopped by a dialog or busy in a long script never does.
38
+ */
39
+ export function pageSays(page, check) {
40
+ const answer = page.evaluate(check).catch(() => false);
41
+ const timeout = new Promise((r) => setTimeout(() => r(false), CHECK_TIMEOUT_MS).unref());
42
+ return Promise.race([answer, timeout]);
43
+ }
44
+ // The keys `click --modifiers` can hold, as Playwright names them.
45
+ const MODIFIERS = ['Alt', 'Control', 'ControlOrMeta', 'Meta', 'Shift'];
46
+ // Image types `screenshot` can write, by the file's extension.
47
+ const IMAGE_FILE = /\.(png|jpe?g)$/i;
48
+ // Longer eval results are cut, so one huge value (a `data:` URL, a
49
+ // page's HTML) doesn't flood the output. `--out` writes it all.
50
+ const EVAL_SHOWN_CHARS = 10_000;
51
+ function shortEvalResult(text) {
52
+ if (text.length <= EVAL_SHOWN_CHARS)
53
+ return text;
54
+ // Not ending on the first half of a surrogate pair, so an emoji isn't
55
+ // cut in half. Sliced, not split into code points, since the text can
56
+ // be megabytes.
57
+ const end = /[\uD800-\uDBFF]/.test(text[EVAL_SHOWN_CHARS - 1]) ? EVAL_SHOWN_CHARS - 1 : EVAL_SHOWN_CHARS;
58
+ return ui `${text.slice(0, end)}... (${text.length} chars; ${cmd('eval --out FILE')} writes it all)`;
59
+ }
60
+ /**
61
+ * Runs one of `PAGE_COMMANDS` on `tab`. Reports through `out`, and
62
+ * resolves once the command and what it set off are done, or once a
63
+ * dialog it opened is waiting for an answer: the page stops until then,
64
+ * and `accept` or `dismiss` can't run while this does. False when it
65
+ * failed (no element, a timeout, an expression that threw), so a line
66
+ * of several commands stops there. A mistake in how it was typed is an
67
+ * `ERROR:` line, which stops the line too.
68
+ */
69
+ export async function runPageCommand(command, input, tab, out, cwd = process.cwd()) {
70
+ const say = (line) => out(`[sm-dev] ${line}`);
71
+ // A mistake in the command: `[sm-dev] ERROR: ...`, which ends it with exit code 2.
72
+ const sayMistake = (text) => say(`${ERROR_PREFIX}${text}`);
73
+ const { page } = tab;
74
+ // `--timeout-seconds`: each wait the command does, in place of that
75
+ // wait's default.
76
+ const given = timeoutFlagMs(input.flags, out);
77
+ if (given === null)
78
+ return false;
79
+ const within = (defaultMs) => given ?? defaultMs;
80
+ // `--frame F2`: the iframe the selector or expression is for.
81
+ const frame = await pickFrame(input.flags['--frame'], tab, say);
82
+ if (frame === null)
83
+ return false;
84
+ // After a selector in what a command says it did, marked up.
85
+ const frameShown = frame ? idRole(`F${tab.link.host.frameIds.number(frame)}`) : '';
86
+ const inFrame = frame ? ui ` in ${frameShown}` : '';
87
+ const actionMs = within(ACTION_TIMEOUT_MS);
88
+ // A file it names, from `cwd`, and as said back.
89
+ const file = (name) => path.resolve(cwd, name);
90
+ const shown = (name) => shownPath(file(name), cwd);
91
+ // `work`, or false as soon as a dialog opens during it. A dialog stops
92
+ // the page, so `work` would wait on it for good. The tracker says
93
+ // what opened; the command just stops waiting.
94
+ const done = async (work) => {
95
+ const finished = work.then(() => true);
96
+ // What `work` still does after a dialog is its own affair.
97
+ finished.catch(() => undefined);
98
+ if (!tab.dialogs)
99
+ return finished;
100
+ return !await tab.dialogs.untilDialog(finished.then(() => false));
101
+ };
102
+ // An action, then the page settling (see wait.ts), each stopping at
103
+ // a dialog. `what` is markup. A page that doesn't settle in time says why, and the
104
+ // action still counts as done.
105
+ const act = async (work, what) => {
106
+ if (!await done(work)) {
107
+ say(ui `${what}. It opened a dialog, which is waiting for an answer.`);
108
+ return;
109
+ }
110
+ if (input.flags['--nowait']) {
111
+ say(what);
112
+ return;
113
+ }
114
+ const settled = await settleTab(tab, tab.link.host, within(SETTLE_TIMEOUT_MS));
115
+ if (!settled.settled && 'dialog' in settled) {
116
+ say(ui `${what}. The page opened a dialog, which is waiting for an answer.`);
117
+ return;
118
+ }
119
+ say(what);
120
+ if (!settled.settled && 'busy' in settled)
121
+ say(notSettledMessage(tab, within(SETTLE_TIMEOUT_MS), settled.busy));
122
+ };
123
+ try {
124
+ switch (command) {
125
+ case 'eval': {
126
+ // Its JavaScript: the rest of the line as typed, its quotes its
127
+ // own, or a block's lines, or a file's.
128
+ const evalIn = input.evalIn ?? 'page';
129
+ const from = input.flags['--file'];
130
+ let expression = [input.text, input.block].filter((part) => part).join('\n');
131
+ if (typeof from === 'string') {
132
+ if (expression) {
133
+ sayMistake('The `eval` command takes JavaScript or `--file`, not both');
134
+ return;
135
+ }
136
+ try {
137
+ expression = readInputFile(file(from), shown(from));
138
+ }
139
+ catch (err) {
140
+ sayMistake(`${err.message}`);
141
+ return;
142
+ }
143
+ }
144
+ if (!expression) {
145
+ sayMistake('The `eval` command needs an expression, e.g. `eval document.title`');
146
+ return;
147
+ }
148
+ let result = '';
149
+ // Why a script has no scope to reach here: not in the extension
150
+ // at all (it failed to install), or not run in this document.
151
+ // Under another manager (`manager`, its label), which reports
152
+ // nothing, sm-dev can't tell which.
153
+ // Marked up.
154
+ const noScope = typeof evalIn !== 'object' ? ''
155
+ : evalIn.manager && evalIn.scope ? ui `sm-dev couldn't reach the scope of ${nameRole(evalIn.name)} under ${evalIn.manager}`
156
+ : ui `${nameRole(evalIn.name)} ${evalIn.scope ? ui `hasn't run ${frame ? ui `in ${frameShown}` : 'on this page'}` : 'isn\'t installed in the extension'}`;
157
+ // A bare `eval` whose script has no scope runs its code in the
158
+ // page, as said. With `--script` the script was asked for, so
159
+ // that's a failure.
160
+ const evaluate = () => {
161
+ // --timeout-seconds (`given`): how long to wait for a document,
162
+ // on a page between them.
163
+ if (typeof evalIn === 'string')
164
+ return tab.link.evaluate(expression, evalIn, given, frame);
165
+ const inScript = evalIn.scope ? tab.link.evaluate(expression, evalIn.scope, given, frame) : Promise.reject(new NoEvalHook());
166
+ return inScript.catch((err) => {
167
+ if (!(err instanceof NoEvalHook))
168
+ throw err;
169
+ if (!evalIn.inPageIfMissing)
170
+ throw err;
171
+ say(ui `${noScope}, so this ran in the page`);
172
+ return tab.link.evaluate(expression, 'page', given, frame);
173
+ });
174
+ };
175
+ const evaluated = evaluate().then((r) => { result = r; });
176
+ try {
177
+ if (!await done(evaluated)) {
178
+ say('The expression opened a dialog, which is waiting for an answer.');
179
+ return;
180
+ }
181
+ }
182
+ catch (err) {
183
+ // Navigating or closing may be what the expression was for, so
184
+ // those aren't failures.
185
+ if (page.isClosed())
186
+ say('The tab closed before the expression finished');
187
+ else if (CUT_OFF_BY_NAVIGATION.test(err.message))
188
+ say('The page navigated away before the expression finished');
189
+ else {
190
+ if (err instanceof NoEvalHook && typeof evalIn === 'object') {
191
+ // Under another manager, the cause isn't known, so no fix is offered.
192
+ say(evalIn.manager && evalIn.scope ? noScope
193
+ : evalIn.scope ? ui `${noScope}, so its scope can't be reached. Load a page it matches, or reload this one.`
194
+ : ui `${noScope}, so its scope can't be reached`);
195
+ }
196
+ else if (err instanceof ScriptThrew)
197
+ say(asError(`The expression threw: ${err.message}`));
198
+ else if (err instanceof NoDocument)
199
+ say(err.message);
200
+ else
201
+ say(`Internal error: ${err.message}`);
202
+ return false;
203
+ }
204
+ return;
205
+ }
206
+ // Its console lines come through another session than its result.
207
+ await done(tab.link.drain());
208
+ const outFile = input.flags['--out'];
209
+ if (typeof outFile === 'string') {
210
+ await write(outFile, () => writeFile(file(outFile), result + '\n'));
211
+ say(ui `Wrote ${url(shown(outFile))} (${result.length} chars)`);
212
+ return;
213
+ }
214
+ out(shortEvalResult(result));
215
+ return;
216
+ }
217
+ case 'click':
218
+ case 'hover': {
219
+ const options = clickOptions(input.flags, input.text ?? '');
220
+ if (typeof options === 'string') {
221
+ sayMistake(`${options}`);
222
+ return;
223
+ }
224
+ const { selector } = options;
225
+ if (!selector) {
226
+ sayMistake(`The ${quoted(command)} command needs a selector, e.g. ${quoted(`${command} '#submit'`)}`);
227
+ return;
228
+ }
229
+ const count = await matches(page, selector, say, frame, tab.link.host.frameIds);
230
+ if (count === null)
231
+ return false;
232
+ const target = locate(page, selector, frame).first();
233
+ const action = (command === 'click'
234
+ ? target.click({ ...options.click, timeout: actionMs })
235
+ : target.hover({ modifiers: options.click.modifiers, position: options.click.position, timeout: actionMs }))
236
+ // A point it can't reach says so, rather than that the element
237
+ // was covered (by whatever is at that point) or not ready.
238
+ .catch(async (err) => {
239
+ if (!/Timeout \d+ms exceeded/.test(err.message))
240
+ throw err;
241
+ const why = plain(err);
242
+ // The call log's own reasons (hidden, disabled, moving) stand.
243
+ if (why !== notReadyGeneral(actionMs / 1000) && !why.startsWith(COVERED))
244
+ throw err;
245
+ throw new Error(await placeProblem(page, target, options.click.position) ?? err.message);
246
+ });
247
+ const how = options.how.length > 0 ? ` with ${options.how.join(', ')}` : '';
248
+ await act(action, ui `${command === 'click' ? 'Clicked' : 'Hovered over'} ${namedMarked(page, selector)}${inFrame}${count > 1 ? ` (the first of ${count} matches)` : ''}${how}`);
249
+ return;
250
+ }
251
+ case 'type': {
252
+ // `type <selector> <text>`, or the text as a `{{` block's lines
253
+ // (exactly, each line break an Enter), or `--file <path>` (the
254
+ // flag first works too). Typed, the selector and flag are on
255
+ // the command's own line.
256
+ const [selector] = input.words;
257
+ const from = input.flags['--file'];
258
+ let text;
259
+ if (typeof from === 'string') {
260
+ if (input.text || input.block !== undefined) {
261
+ sayMistake('The `type` command takes text or `--file`, not both');
262
+ return;
263
+ }
264
+ if (!selector) {
265
+ sayMistake('The `type` command takes a selector and one file, e.g. `type \'#notes\' --file notes.txt`');
266
+ return;
267
+ }
268
+ try {
269
+ // The file's text as it is, a last line break included.
270
+ // Windows line breaks as one Enter each, not two.
271
+ text = readInputFile(file(from), shown(from), { blankOk: true }).replace(/\r\n?/g, '\n');
272
+ }
273
+ catch (err) {
274
+ sayMistake(`${err.message}`);
275
+ return;
276
+ }
277
+ }
278
+ else if (input.block !== undefined) {
279
+ if (input.text || !selector) {
280
+ sayMistake('The `type` command takes one selector before `{{`. Quote a selector that has spaces.');
281
+ return;
282
+ }
283
+ text = input.block;
284
+ }
285
+ else {
286
+ text = input.text ?? '';
287
+ }
288
+ // `--clear`: emptied first, so the text is optional.
289
+ const clear = input.flags['--clear'] === true;
290
+ if (!selector || (!text && !clear)) {
291
+ sayMistake('The `type` command needs a selector and the text (or `--clear`), e.g. `type \'input[name=q]\' \'hello world\'`');
292
+ return;
293
+ }
294
+ const count = await matches(page, selector, say, frame, tab.link.host.frameIds);
295
+ if (count === null)
296
+ return false;
297
+ const element = locate(page, selector, frame).first();
298
+ const typing = (async () => {
299
+ if (clear)
300
+ await element.clear({ timeout: actionMs });
301
+ if (text)
302
+ await element.pressSequentially(text, { timeout: actionMs });
303
+ })();
304
+ const done = !text ? 'Cleared' : clear ? 'Cleared and typed into' : 'Typed into';
305
+ await act(typing, ui `${done} ${namedMarked(page, selector)}${inFrame}${count > 1 ? ` (the first of ${count} matches)` : ''}`);
306
+ return;
307
+ }
308
+ case 'key': {
309
+ const keys = input.words;
310
+ if (keys.length === 0) {
311
+ sayMistake('The `key` command needs a key, e.g. `key j` or `key Shift+ArrowRight`');
312
+ return;
313
+ }
314
+ const unjoined = keys.find((k) => keyParts(k).includes(''));
315
+ if (unjoined) {
316
+ sayMistake(`${quoted(unjoined)} has a ${quoted('+')} with no key on one side. Join keys with ${quoted('+')}, as in ${quoted('Shift+Tab')}.`);
317
+ return;
318
+ }
319
+ const named = keys.map(keyName);
320
+ // Escape leaves fullscreen as Chrome's own shortcut, which a key
321
+ // sent over CDP never reaches. The page may still leave on its
322
+ // own. Fullscreen before and after, so one the keys entered isn't
323
+ // taken for one Escape failed to leave.
324
+ const escape = named.some((k) => keyParts(k).at(-1) === 'Escape');
325
+ const isFullscreen = () => document.fullscreenElement !== null;
326
+ const wasFullscreen = escape && await pageSays(page, isFullscreen);
327
+ const pressing = (async () => {
328
+ for (const key of named)
329
+ await page.keyboard.press(key);
330
+ })();
331
+ await act(pressing, ui `Pressed ${named.map((k) => code(k)).join(', ')}`);
332
+ if (wasFullscreen && !tab.dialogs?.current && await pageSays(page, isFullscreen)) {
333
+ say('The page is still fullscreen. Escape sent by sm-dev goes to the page rather than to Chrome. To exit, use `eval document.exitFullscreen()`.');
334
+ }
335
+ return;
336
+ }
337
+ case 'snapshot': {
338
+ const selector = input.text;
339
+ const visible = input.flags['--visible'] === true;
340
+ const parts = partOptions(input.flags, !!selector);
341
+ if (typeof parts === 'string') {
342
+ sayMistake(`${parts}`);
343
+ return;
344
+ }
345
+ const count = selector ? await matches(page, selector, say, frame, tab.link.host.frameIds) : 0;
346
+ if (count === null)
347
+ return false;
348
+ let tree = '';
349
+ const taking = (async () => {
350
+ const doc = await documentOf(page);
351
+ // The whole page is `:root`, not `body`: scripts often add their
352
+ // panels to <html>, after <body>.
353
+ const whole = (frame ?? page).locator(':root');
354
+ tree = await whole.ariaSnapshot({ mode: 'ai', boxes: visible, timeout: actionMs });
355
+ noteRefs(page, tree, doc, frame);
356
+ if (visible) {
357
+ // A frame's boxes are from its own corner, so by its window.
358
+ const [width, height] = await (frame ?? page).evaluate(() => [window.innerWidth, window.innerHeight]);
359
+ tree = onScreen(tree, width, height);
360
+ }
361
+ if (!parts) {
362
+ tree = tidy(withoutDocument(tree));
363
+ return;
364
+ }
365
+ // Each match is found in the whole tree by its refs (an element
366
+ // keeps its ref across snapshots). Only enough for the limit:
367
+ // each costs a snapshot of the match.
368
+ const found = selector ? locate(page, selector, frame) : null;
369
+ const tops = [];
370
+ let rest = {};
371
+ for (let i = 0; found && i < count; i++) {
372
+ tops.push(await refsOf(found.nth(i), actionMs));
373
+ if (parts.limit > 0 && i + 1 < count && treeParts(tree, { ...parts, matches: tops }).length >= parts.limit) {
374
+ // The selector's count is the total, but --find's hits in
375
+ // the matches not looked at are unknown.
376
+ rest = parts.text === undefined ? { total: count } : { unsearched: count - i - 1 };
377
+ break;
378
+ }
379
+ }
380
+ // Refs resolve against the latest snapshot only, so the whole
381
+ // is taken again, after the matches'. Failing, it costs only the
382
+ // refs outside the last match.
383
+ if (found)
384
+ await whole.ariaSnapshot({ mode: 'ai', timeout: actionMs }).catch(() => null);
385
+ // A part can be the whole, however named (`:root`, `html`).
386
+ const shown = treeParts(tree, { ...parts, matches: found ? tops : undefined }).map((part) => tidy(withoutDocument(part)));
387
+ tree = shown.length === 0 ? '' : partsText(shown, parts.limit, rest);
388
+ })();
389
+ if (!await done(taking)) {
390
+ say('The page opened a dialog during the snapshot, which is waiting for an answer.');
391
+ return;
392
+ }
393
+ if (parts && tree === '') {
394
+ // Not the selector itself, which can be long.
395
+ const where = selector ? ' in the selector\'s matches' : '';
396
+ if (parts.text !== undefined)
397
+ say(`No element${where} contains "${parts.text}"${visible ? ' on screen' : ''}`);
398
+ else
399
+ say(`No selector match is ${visible ? 'on screen' : 'in the accessibility tree, which leaves out hidden elements'}`);
400
+ return;
401
+ }
402
+ const outFile = input.flags['--out'];
403
+ if (typeof outFile === 'string') {
404
+ await write(outFile, () => writeFile(file(outFile), tree + '\n'));
405
+ say(ui `Wrote ${url(shown(outFile))} (${tree.split('\n').length} lines) from ${url(page.url())}`);
406
+ return;
407
+ }
408
+ for (const line of tree.split('\n'))
409
+ out(snapshotLine(line));
410
+ return;
411
+ }
412
+ case 'screenshot': {
413
+ const rest = [...input.words];
414
+ const image = rest.pop();
415
+ if (!image) {
416
+ sayMistake('The `screenshot` command needs a file, and takes one selector before it, e.g. `screenshot \'#comments\' c.png`');
417
+ return;
418
+ }
419
+ if (!IMAGE_FILE.test(image)) {
420
+ sayMistake(`The ${quoted('screenshot')} command writes a .png or .jpg file, and ${quoted(image)} is neither. Name the file last.`);
421
+ return;
422
+ }
423
+ const selector = rest[0];
424
+ if (selector) {
425
+ if (input.flags['--full']) {
426
+ sayMistake('The `--full` flag is for the whole page, not an element');
427
+ return;
428
+ }
429
+ if (await matches(page, selector, say, frame, tab.link.host.frameIds) === null)
430
+ return false;
431
+ await write(image, () => locate(page, selector, frame).first().screenshot({ path: file(image), timeout: actionMs }));
432
+ }
433
+ else if (frame) {
434
+ // The frame as it shows on the page: its <iframe> element.
435
+ if (input.flags['--full']) {
436
+ sayMistake('The `--full` flag is for the whole page, not a frame');
437
+ return;
438
+ }
439
+ await write(image, async () => (await frame.frameElement()).screenshot({ path: file(image), timeout: actionMs }));
440
+ }
441
+ else {
442
+ try {
443
+ await write(image, () => page.screenshot({ path: file(image), fullPage: input.flags['--full'] === true, timeout: within(SCREENSHOT_TIMEOUT_MS) }));
444
+ }
445
+ catch (err) {
446
+ if (err.name !== 'TimeoutError' || page.isClosed())
447
+ throw err;
448
+ say(`Screenshot failed: Browser did not capture the page within ${seconds(within(SCREENSHOT_TIMEOUT_MS))}`);
449
+ return false;
450
+ }
451
+ }
452
+ say(ui `Wrote ${url(shown(image))}${selector ? ui ` (${namedMarked(page, selector)}${inFrame})` : frame ? ui ` (in ${frameShown})` : ''} from ${url(page.url())}`);
453
+ return;
454
+ }
455
+ case 'savehtml': {
456
+ const words = input.words;
457
+ if (words.length !== 1) {
458
+ sayMistake('The `savehtml` command needs a file, e.g. `savehtml page.html`');
459
+ return;
460
+ }
461
+ let html = '';
462
+ await write(words[0], async () => { html = await savePageHtml(page, file(words[0]), input.userscripts); });
463
+ say(ui `Wrote ${url(shown(words[0]))} (${html.length} chars) from ${url(page.url())}`);
464
+ if (input.userscripts.length > 0)
465
+ say('It has the scripts\' changes in it, and its header lists them. For a copy without them, save it from a tab without scripts (`newtab --noscript`).');
466
+ return;
467
+ }
468
+ case 'probe': {
469
+ if (input.words.length === 0) {
470
+ sayMistake('The `probe` command needs a selector or more, e.g. `probe \'.titleline\' \'#hnmain\'`');
471
+ return;
472
+ }
473
+ const css = input.flags['--css'] === true;
474
+ const html = input.flags['--html'] === true;
475
+ if (input.json) {
476
+ const selectors = [];
477
+ for (const selector of input.words) {
478
+ const entry = { selector };
479
+ const probing = (async () => {
480
+ const count = await refProblem(page, selector) ?? await refFrameProblem(page, selector, frame, tab.link.host.frameIds) ?? await countOf(page, selector, frame);
481
+ if (typeof count === 'string') {
482
+ entry.error = count;
483
+ return;
484
+ }
485
+ entry.count = count;
486
+ if (count === 0 || (!css && !html))
487
+ return;
488
+ const found = await probeMatches(page, selector, PROBE_MAX, actionMs, frame).catch((err) => plain(err));
489
+ if (typeof found === 'string') {
490
+ say(`The matches of ${quoted(selector)} could not be read: ${found}`);
491
+ return;
492
+ }
493
+ const inFrame = await matchesFrame(page, selector, found.framed, frame);
494
+ if (inFrame)
495
+ entry.frame = `F${tab.link.host.frameIds.number(inFrame)}`;
496
+ entry.matches = found.matches.map((match) => ({
497
+ // Not for a frame that can't be named, where it wouldn't work.
498
+ ...(css && inFrame !== null && !match.shadow && match.css ? { css: match.css } : {}),
499
+ ...(html ? { html: match.html } : {}),
500
+ }));
501
+ })();
502
+ if (!await done(probing)) {
503
+ say('The page opened a dialog during the probe, which is waiting for an answer.');
504
+ return false;
505
+ }
506
+ selectors.push(entry);
507
+ }
508
+ out(jsonResult('probe', { tab: `T${tab.n}`, url: page.url(), selectors }));
509
+ return;
510
+ }
511
+ for (const selector of input.words) {
512
+ const lines = [];
513
+ const probing = (async () => {
514
+ const count = await refProblem(page, selector) ?? await refFrameProblem(page, selector, frame, tab.link.host.frameIds) ?? await countOf(page, selector, frame);
515
+ // Marked up, for output.
516
+ if (typeof count === 'string')
517
+ lines.push(ui `invalid ${code(selector)} (${message(count)})`);
518
+ else if (count === 0)
519
+ lines.push(ui `missing ${code(selector)}`);
520
+ else {
521
+ lines.push(ui `found ${code(selector)} (${count})`);
522
+ if (!css && !html)
523
+ return;
524
+ const found = await probeMatches(page, selector, PROBE_MAX, actionMs, frame).catch((err) => plain(err));
525
+ if (typeof found === 'string') {
526
+ lines.push(` The matches could not be read: ${found}`);
527
+ return;
528
+ }
529
+ // A selector for one in a frame only works within it
530
+ // (`--frame`), so the frame comes first: the selector is
531
+ // free-form, and the end of the line is its own. One in a
532
+ // frame that can't be named gets none.
533
+ const inFrame = await matchesFrame(page, selector, found.framed, frame);
534
+ const where = inFrame ? ui ` (in ${idRole(`F${tab.link.host.frameIds.number(inFrame)}`)})` : '';
535
+ for (const match of found.matches) {
536
+ const none = inFrame === null ? 'in a frame' : match.shadow ? 'in a shadow root' : null;
537
+ if (css)
538
+ lines.push(ui ` selector${where}: ${none ? `(none: ${none})` : match.css != null ? code(match.css) : '(none found)'}`);
539
+ if (html)
540
+ lines.push(ui ` html${where}: ${code(match.html)}`);
541
+ }
542
+ if (found.count > found.matches.length)
543
+ lines.push(` (${found.count - found.matches.length} more)`);
544
+ }
545
+ })();
546
+ const finished = await done(probing);
547
+ for (const line of lines)
548
+ out(line);
549
+ if (!finished) {
550
+ say('The page opened a dialog during the probe, which is waiting for an answer.');
551
+ return;
552
+ }
553
+ }
554
+ return;
555
+ }
556
+ case 'cdp': {
557
+ // `--file <path>`: the calls from a file, one per line, as a block
558
+ // has them.
559
+ let calls;
560
+ const name = input.flags['--file'];
561
+ if (typeof name === 'string') {
562
+ if (input.text || input.block !== undefined) {
563
+ sayMistake('The `cdp` command takes calls or `--file <path>`, not both');
564
+ return;
565
+ }
566
+ try {
567
+ calls = cdpCallsTyped(readInputFile(file(name), shown(name)), { first: 1, name: (n) => `${shown(name)}, line ${n}` });
568
+ }
569
+ catch (err) {
570
+ sayMistake(`${err.message}`);
571
+ return;
572
+ }
573
+ }
574
+ else if (input.block !== undefined) {
575
+ // The command's own line is line 0, the block's first line 1.
576
+ calls = cdpCallsTyped(`${input.text ?? ''}\n${input.block}`, { first: 0, name: (n) => `Line ${n} of the block` });
577
+ }
578
+ else {
579
+ calls = cdpCallsTyped(input.text ?? '');
580
+ }
581
+ if (typeof calls === 'string') {
582
+ sayMistake(`${calls}`);
583
+ return;
584
+ }
585
+ // A session of its own, detached at the end, so what a call
586
+ // sets for its session (Emulation.* overrides, enabled domains)
587
+ // ends with the command, as it does for any CDP client. With
588
+ // `--frame`, on the target the frame is in: its own, or for one
589
+ // in its parent's process (which Playwright refuses a session
590
+ // for) the nearest ancestor's that has one, the tab's at the top.
591
+ // That target reaches all its frames, so the calls name the
592
+ // frame themselves where they need to; said, with its id.
593
+ let session;
594
+ for (let at = frame ?? null; at && at !== page.mainFrame() && !session; at = at.parentFrame()) {
595
+ session = await page.context().newCDPSession(at).catch(() => undefined);
596
+ if (session && at !== frame) {
597
+ const frameId = await tab.link.frameCdpId(frame);
598
+ say(ui `${frameShown} is part of ${idRole(`F${tab.link.host.frameIds.number(at)}`)}'s target, so the calls go there.${frameId ? ui ` Name the frame in them by its frameId, ${code(frameId)}.` : ''}`);
599
+ }
600
+ }
601
+ if (!session) {
602
+ session = await page.context().newCDPSession(page);
603
+ if (frame) {
604
+ const frameId = await tab.link.frameCdpId(frame);
605
+ say(ui `${frameShown} is part of ${idRole(`T${tab.n}`)}'s target, so the calls go there.${frameId ? ui ` Name the frame in them by its frameId, ${code(frameId)}.` : ''}`);
606
+ }
607
+ }
608
+ // A call that navigates the page is cut off, or never answers,
609
+ // which is no failure when navigating was its point.
610
+ // - Cut off: its error says so.
611
+ // - No answer: a new document loaded during the call (with a
612
+ // navigation request), not a hash or pushState change.
613
+ // An error of another kind is the call's own, whatever the page
614
+ // did meanwhile (a Page.reload before it lands during the next).
615
+ let navigated = false;
616
+ const onRequest = (request) => {
617
+ if (request.isNavigationRequest() && request.frame() === page.mainFrame())
618
+ navigated = true;
619
+ };
620
+ page.on('request', onRequest);
621
+ try {
622
+ for (const { method, params } of calls) {
623
+ navigated = false;
624
+ let result;
625
+ const sent = session.send(method, params).then((r) => { result = r; });
626
+ // A reply may never come: a Runtime.evaluate that navigates
627
+ // the page, a page paused in the debugger.
628
+ const timeout = new Promise((r) => setTimeout(() => r('timeout'), within(CDP_TIMEOUT_MS)).unref());
629
+ let outcome;
630
+ try {
631
+ outcome = await Promise.race([done(sent), timeout]);
632
+ }
633
+ catch (err) {
634
+ // Playwright's wrapping, `Protocol error (Page.nope): `, repeats the method.
635
+ const message = plain(err).replace(/^Protocol error \([^)]*\): /, '');
636
+ if (page.isClosed() || CUT_OFF_BY_NAVIGATION.test(message)) {
637
+ say(page.isClosed() ? `The tab closed before ${method} finished` : `${method} got no answer, since the page navigated`);
638
+ return;
639
+ }
640
+ say(`${method} failed: ${message.charAt(0).toUpperCase()}${message.slice(1)}`);
641
+ return false;
642
+ }
643
+ if (outcome === 'timeout') {
644
+ if (navigated) {
645
+ say(`${method} got no answer, since the page navigated`);
646
+ return;
647
+ }
648
+ say(`${method} got no answer within ${seconds(within(CDP_TIMEOUT_MS))}. A paused page never answers.`);
649
+ return false;
650
+ }
651
+ if (!outcome) {
652
+ say(`${method} opened a dialog, which is waiting for an answer.`);
653
+ return;
654
+ }
655
+ out(JSON.stringify(result, null, 2));
656
+ }
657
+ }
658
+ finally {
659
+ // Not waited for: with a dialog open, detaching doesn't finish
660
+ // until it's answered, and the command must return so that
661
+ // accept or dismiss can run.
662
+ session.detach().catch(() => undefined);
663
+ page.off('request', onRequest);
664
+ }
665
+ return;
666
+ }
667
+ }
668
+ }
669
+ catch (err) {
670
+ say(page.isClosed() ? `The tab closed before the ${command} command finished` : `The ${command} command failed: ${plain(err)}`);
671
+ return false;
672
+ }
673
+ }
674
+ // How CDP and Playwright say a call was cut off by the page leaving.
675
+ const CUT_OFF_BY_NAVIGATION = /context was destroyed|Cannot find context|navigated or closed/i;
676
+ const CDP_USAGE = 'e.g. `cdp Input.dispatchMouseEvent \'{"type": "mouseWheel", "x": 400, "y": 300, "deltaX": 0, "deltaY": 600}\'`';
677
+ const ONE_CALL = 'A `cdp` command makes one call. For multiple in one session, put one per line in a `cdp {{` block.';
678
+ const notAMethod = (word) => `${quoted(word)} is not a CDP method. Methods are named Domain.method, as in ${quoted('Page.reload')}.`;
679
+ /**
680
+ * `cdp`'s calls (on its line, in a `{{` block, or in a `--file`): one
681
+ * per line, all in one session. Not split into words first, which would
682
+ * take the quotes out of JSON strings. Blank lines and `#` comments are
683
+ * skipped.
684
+ * - `lines`: for a block or file, how to name a line in a message, from
685
+ * its number. A block's first line is the command's own, line 0.
686
+ */
687
+ export function cdpCallsTyped(raw, lines) {
688
+ const calls = [];
689
+ for (const [i, line] of raw.split('\n').entries()) {
690
+ const text = line.trim();
691
+ if (!text || text.startsWith('#'))
692
+ continue;
693
+ const call = cdpCallLine(text);
694
+ if (typeof call === 'string')
695
+ return lines ? `${lines.name(lines.first + i)}: ${call}` : call;
696
+ calls.push(call);
697
+ }
698
+ if (calls.length === 0)
699
+ return `The ${quoted('cdp')} command needs a method, ${CDP_USAGE}`;
700
+ return calls;
701
+ }
702
+ /**
703
+ * One call: a `Domain.method`, then its params as a JSON object (`{}`
704
+ * when none), bare or in single quotes. Quotes of either kind may be in
705
+ * the JSON's strings.
706
+ */
707
+ function cdpCallLine(text) {
708
+ const [method] = text.split(/\s/, 1);
709
+ if (!CDP_METHOD.test(method))
710
+ return notAMethod(method);
711
+ let params = text.slice(method.length).trim();
712
+ if (CDP_METHOD.test(params.split(/\s/, 1)[0]))
713
+ return ONE_CALL;
714
+ const singleQuoted = params.startsWith('\'{');
715
+ let after = '';
716
+ if (singleQuoted || params.startsWith('{')) {
717
+ const stop = jsonObjectEnd(params, singleQuoted ? 1 : 0);
718
+ if (stop < 0)
719
+ return `The params for ${quoted(method)} have no closing ${quoted('}')}. A call and its params go on one line.`;
720
+ if (singleQuoted && params[stop] !== '\'')
721
+ return `The params for ${quoted(method)} open with a ${quoted("'")} that doesn't close after them`;
722
+ after = params.slice(singleQuoted ? stop + 1 : stop).trim();
723
+ params = params.slice(singleQuoted ? 1 : 0, stop);
724
+ }
725
+ if (after)
726
+ return CDP_METHOD.test(after.split(/\s/, 1)[0]) ? ONE_CALL : `The params for ${quoted(method)} have more after them: ${quoted(after)}`;
727
+ if (!params)
728
+ return { method, params: {} };
729
+ let parsed;
730
+ try {
731
+ parsed = JSON.parse(params);
732
+ }
733
+ catch (err) {
734
+ return `The params for ${quoted(method)} are not valid JSON (${err.message}): ${quoted(params)}`;
735
+ }
736
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
737
+ return `The params for ${quoted(method)} must be a JSON object: ${quoted(params)}`;
738
+ return { method, params: parsed };
739
+ }
740
+ // Where the JSON object starting at `from` ends: past its closing brace,
741
+ // braces in strings not counted. -1 if it never closes.
742
+ function jsonObjectEnd(text, from) {
743
+ let depth = 0;
744
+ for (let i = from; i < text.length; i++) {
745
+ const c = text[i];
746
+ if (c === '"') {
747
+ for (i++; i < text.length && text[i] !== '"'; i++)
748
+ if (text[i] === '\\')
749
+ i++;
750
+ }
751
+ else if (c === '{' || c === '[')
752
+ depth++;
753
+ else if ((c === '}' || c === ']') && --depth === 0)
754
+ return i + 1;
755
+ }
756
+ return -1;
757
+ }
758
+ // Writes a file, saying which and why when it can't be.
759
+ async function write(name, work) {
760
+ try {
761
+ await work();
762
+ }
763
+ catch (err) {
764
+ const problem = writeProblem(err);
765
+ if (problem)
766
+ throw new Error(`Could not write ${quoted(name)}: ${problem}`);
767
+ throw err;
768
+ }
769
+ }
770
+ // The key names Playwright's `keyboard.press` takes, matched without
771
+ // regard to case: its US layout's `code` names (KeyA, Digit1, Home),
772
+ // which mostly match the standard `key` names, and the modifiers. It
773
+ // mirrors playwright-core's USKeyboardLayout, so a Playwright upgrade
774
+ // may add names.
775
+ const KEY_NAMES = [
776
+ 'Shift', 'Control', 'Alt', 'Meta', 'ControlOrMeta', 'AltGraph',
777
+ 'ShiftLeft', 'ShiftRight', 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'MetaLeft', 'MetaRight',
778
+ 'Enter', 'Tab', 'Escape', 'Backspace', 'Delete', 'Insert', 'Space',
779
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown',
780
+ 'CapsLock', 'NumLock', 'ScrollLock', 'PrintScreen', 'Pause', 'ContextMenu',
781
+ 'AudioVolumeUp', 'AudioVolumeDown', 'AudioVolumeMute', 'MediaPlayPause', 'MediaTrackNext', 'MediaTrackPrevious',
782
+ ...Array.from({ length: 12 }, (_, i) => `F${i + 1}`),
783
+ ...Array.from({ length: 26 }, (_, i) => `Key${String.fromCharCode(65 + i)}`),
784
+ ...Array.from({ length: 10 }, (_, i) => `Digit${i}`),
785
+ ...Array.from({ length: 10 }, (_, i) => `Numpad${i}`),
786
+ 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter',
787
+ 'Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight', 'Backslash', 'Semicolon', 'Quote',
788
+ 'Comma', 'Period', 'Slash',
789
+ ];
790
+ // Other names people reach for, lowercase. None may be a name above
791
+ // too (`minus` would shadow the Minus key, which Shift makes `_`).
792
+ const KEY_ALIASES = {
793
+ ctrl: 'Control', ctl: 'Control', cmd: 'Meta', command: 'Meta', super: 'Meta', win: 'Meta', windows: 'Meta',
794
+ opt: 'Alt', option: 'Alt', mod: 'ControlOrMeta', cmdorctrl: 'ControlOrMeta', ctrlorcmd: 'ControlOrMeta',
795
+ esc: 'Escape', return: 'Enter', del: 'Delete', ins: 'Insert', bksp: 'Backspace', bs: 'Backspace',
796
+ spacebar: 'Space', up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight',
797
+ pgup: 'PageUp', pgdn: 'PageDown', pgdown: 'PageDown', menu: 'ContextMenu', apps: 'ContextMenu',
798
+ prtsc: 'PrintScreen', prtscn: 'PrintScreen', break: 'Pause', caps: 'CapsLock', plus: '+',
799
+ };
800
+ const KEYS_BY_LOWER = new Map([
801
+ ...KEY_NAMES.map((name) => [name.toLowerCase(), name]),
802
+ ...Object.entries(KEY_ALIASES),
803
+ ]);
804
+ /**
805
+ * A typed key, or combination (`ctrl+home`), as Playwright names it.
806
+ * A single character stays as typed, since its case is the key (`A` is
807
+ * Shift+a). A name it doesn't know is passed on, for Playwright's error.
808
+ */
809
+ export function keyName(typed) {
810
+ return keyParts(typed).map((part) => (part.length <= 1 ? part : KEYS_BY_LOWER.get(part.toLowerCase()) ?? part)).join('+');
811
+ }
812
+ // `+` joins the parts, and is a key itself last (`Control++`). An
813
+ // empty part is a `+` with no key on one side (`Shift+`).
814
+ function keyParts(typed) {
815
+ return typed === '+' ? ['+'] : typed.endsWith('++') ? [...typed.slice(0, -2).split('+'), '+'] : typed.split('+');
816
+ }
817
+ /**
818
+ * Which parts of the page `snapshot` shows, from its flags: the
819
+ * `--find` text, and how much around each match (see `treeParts`).
820
+ * Null for all of it (no selector or --find); what's wrong, if anything
821
+ * is.
822
+ */
823
+ export function partOptions(flags, selector) {
824
+ const count = (flag, fallback) => {
825
+ const value = flags[flag];
826
+ if (value === undefined)
827
+ return fallback;
828
+ const n = Number(value);
829
+ return typeof value === 'string' && Number.isInteger(n) && n >= 0 ? n : `The ${quoted(flag)} flag takes a whole number, not ${quoted(value)}`;
830
+ };
831
+ const find = flags['--find'];
832
+ if (typeof find === 'string' && find.trim() === '')
833
+ return 'The `--find` flag needs some text';
834
+ const text = typeof find === 'string' ? find : undefined;
835
+ if (text === undefined && !selector) {
836
+ const other = ['--up', '--context', '--limit'].find((f) => flags[f] !== undefined);
837
+ return other ? `The ${quoted(other)} flag goes with a selector or ${quoted('--find')}` : null;
838
+ }
839
+ const up = count('--up', 0);
840
+ if (typeof up === 'string')
841
+ return up;
842
+ const limit = count('--limit', PART_LIMIT);
843
+ if (typeof limit === 'string')
844
+ return limit;
845
+ return { text, up, context: flags['--context'] === true, limit };
846
+ }
847
+ /**
848
+ * `snapshot`'s parts as shown: one bare; more numbered, a blank line
849
+ * apart, the first `limit` of them (0: all), and what wasn't shown.
850
+ * When only some of a selector's matches were looked at: `total`, all
851
+ * the matches; or for --find, `unsearched`, how many weren't searched.
852
+ */
853
+ export function partsText(parts, limit, { total = parts.length, unsearched = 0 } = {}) {
854
+ if (total === 1 && unsearched === 0)
855
+ return parts[0];
856
+ const shown = limit === 0 ? parts : parts.slice(0, limit);
857
+ let text = shown.map((part, i) => `# Match ${i + 1}\n${part}`).join('\n\n');
858
+ if (unsearched > 0) {
859
+ const more = unsearched === 1 ? '1 more selector match was' : `${unsearched} more selector matches were`;
860
+ text += `\n\n# Showed ${shown.length} matches. ${more} not searched for find matches. --limit 0 shows all.`;
861
+ }
862
+ else if (shown.length < total) {
863
+ text += `\n\n# Showed ${shown.length} of ${total} matches. --limit 0 shows all.`;
864
+ }
865
+ return text;
866
+ }
867
+ /**
868
+ * `click`'s or `hover`'s flags as Playwright's options for them, with
869
+ * its selector, and how the action differs from a plain one, as said
870
+ * after it (`the right button`). Or what's wrong with them. Which flags
871
+ * each takes is its syntax's (command-list.ts).
872
+ */
873
+ export function clickOptions(given, selector) {
874
+ const flags = Object.fromEntries(Object.entries(given).filter((entry) => typeof entry[1] === 'string'));
875
+ const click = {};
876
+ const how = [];
877
+ const button = flags['--button']?.toLowerCase();
878
+ if (button !== undefined) {
879
+ if (button !== 'left' && button !== 'right' && button !== 'middle')
880
+ return `${quoted(flags['--button'])} is not a mouse button. Use ${quoted('left')}, ${quoted('right')} or ${quoted('middle')}.`;
881
+ click.button = button;
882
+ if (button !== 'left')
883
+ how.push(`the ${button} button`);
884
+ }
885
+ if (flags['--modifiers'] !== undefined) {
886
+ const keys = flags['--modifiers'].split(/[+,]/).filter(Boolean).map(keyName);
887
+ const bad = keys.find((key) => !MODIFIERS.includes(key));
888
+ if (bad !== undefined || keys.length === 0)
889
+ return `${quoted(bad ?? flags['--modifiers'])} is not a modifier key. Use ${MODIFIERS.map((m) => quoted(m)).join(', ')}, joined by ${quoted('+')}.`;
890
+ click.modifiers = keys;
891
+ how.push(`${keys.join('+')} held`);
892
+ }
893
+ if (flags['--count'] !== undefined) {
894
+ const count = Number(flags['--count']);
895
+ if (!Number.isInteger(count) || count < 1 || count > 10)
896
+ return `The ${quoted('--count')} flag takes a number of clicks from 1 to 10, not ${quoted(flags['--count'])}`;
897
+ click.clickCount = count;
898
+ if (count > 1)
899
+ how.push(`${count} clicks`);
900
+ }
901
+ if (flags['--at'] !== undefined) {
902
+ const at = /^(\d+(?:\.\d+)?),(\d+(?:\.\d+)?)$/.exec(flags['--at'].replace(/\s/g, ''));
903
+ if (!at)
904
+ return `The ${quoted('--at')} flag takes x,y in pixels from the element's top left corner, e.g. ${quoted('--at 10,5')}, not ${quoted(flags['--at'])}`;
905
+ click.position = { x: Number(at[1]), y: Number(at[2]) };
906
+ how.push(`the mouse at ${at[1]},${at[2]} in it`);
907
+ }
908
+ return { selector, click, how };
909
+ }
910
+ // Playwright's message as a person should see it: its first line,
911
+ // without the method it came from (`locator.click: `), capitalized, and
912
+ // with plain words for the common cases.
913
+ export function plain(err) {
914
+ const first = err.message.split('\n')[0].replace(/^(\w+(\.\w+)+: )+/, '').replace(/\.$/, '');
915
+ const key = /Unknown key: "(.+)"/.exec(first);
916
+ if (key)
917
+ return `${quoted(key[1])} is not a key name. Keys are named as in ${quoted('Enter')}, ${quoted('ArrowLeft')}, ${quoted('Shift+Tab')} (case-insensitive), or a single character.`;
918
+ const timeout = /Timeout (\d+)ms exceeded/.exec(first);
919
+ if (timeout)
920
+ return notReady(err.message, Number(timeout[1]) / 1000);
921
+ return first.charAt(0).toUpperCase() + first.slice(1);
922
+ }
923
+ // Longest a covering element's HTML is shown, in characters.
924
+ const COVER_HTML_MAX = 150;
925
+ /**
926
+ * Why an action timed out waiting on its element, from the last check
927
+ * Playwright's call log says failed (it retries them until the timeout).
928
+ * A log that names none keeps the general wording.
929
+ */
930
+ function notReady(message, seconds) {
931
+ // The call log's lines are dimmed with terminal color codes.
932
+ const lines = message.replace(/\x1b\[\d+m/g, '').split('\n').map((line) => line.trim().replace(/^- /, ''));
933
+ const within = `for ${seconds} seconds`;
934
+ for (const line of lines.reverse()) {
935
+ const covered = /^(.+) intercepts pointer events$/.exec(line);
936
+ if (covered) {
937
+ const html = covered[1].length > COVER_HTML_MAX ? `${covered[1].slice(0, COVER_HTML_MAX)}…` : covered[1];
938
+ return `${COVERED}${within}, by ${html}`;
939
+ }
940
+ if (line === 'element is not visible')
941
+ return `The element stayed hidden (or zero-size) ${within}`;
942
+ if (line === 'element is not enabled')
943
+ return `The element stayed disabled ${within}`;
944
+ if (line === 'element is not stable')
945
+ return `The element kept moving ${within}`;
946
+ if (line === 'element is outside of the viewport')
947
+ return `The element stayed outside the viewport ${within}, where it can't be scrolled to`;
948
+ }
949
+ return notReadyGeneral(seconds);
950
+ }
951
+ // How `notReady` starts when the element was covered.
952
+ const COVERED = 'The element stayed covered ';
953
+ // `notReady` when the call log names no reason.
954
+ function notReadyGeneral(seconds) {
955
+ return `The element wasn't ready (visible, stable, and not covered) within ${seconds} seconds`;
956
+ }
957
+ /**
958
+ * Why the mouse can't reach the element, for a timeout Playwright gave
959
+ * no specific reason for, or called covered: the `--at` point is
960
+ * outside the element, or outside the window (where Playwright reports
961
+ * whatever is under the point as covering it), or with no `--at`, the
962
+ * whole element is off the window. (Playwright aims at the middle of
963
+ * the element's visible part itself.) Null when none of those, or the
964
+ * element can't be measured.
965
+ */
966
+ async function placeProblem(page, target, position) {
967
+ const box = await target.boundingBox({ timeout: 1000 }).catch(() => null);
968
+ if (!box)
969
+ return null;
970
+ const view = page.viewportSize() ?? await withTimeout(page.evaluate(() => ({ width: innerWidth, height: innerHeight })), 1000).catch(() => null);
971
+ if (!view)
972
+ return null;
973
+ const r = Math.round;
974
+ const size = `${r(box.width)}x${r(box.height)} pixels`;
975
+ if (position && (position.x > box.width || position.y > box.height)) {
976
+ return `The point ${position.x},${position.y} is outside the element, which is ${size} (${quoted('--at')} counts from its top left corner)`;
977
+ }
978
+ // One sentence each: plain() drops a final period.
979
+ const win = `the ${view.width}x${view.height} window`;
980
+ if (position) {
981
+ const x = box.x + position.x;
982
+ const y = box.y + position.y;
983
+ if (x >= 0 && y >= 0 && x < view.width && y < view.height)
984
+ return null;
985
+ return `The point ${position.x},${position.y} in the element is outside ${win}, at ${r(x)},${r(y)}, so pick a point in the element's visible part`;
986
+ }
987
+ const shows = box.x + box.width > 0 && box.y + box.height > 0 && box.x < view.width && box.y < view.height;
988
+ return shows ? null : `The element (${size} at ${r(box.x)},${r(box.y)}) is entirely outside ${win}, where it can't be scrolled to`;
989
+ }
990
+ // `promise`, or a rejection after `ms`: a page held by a dialog never answers.
991
+ function withTimeout(promise, ms) {
992
+ let timer;
993
+ const late = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timed out')), ms); });
994
+ return Promise.race([promise, late]).finally(() => clearTimeout(timer));
995
+ }
996
+ // How many elements a selector matches, or why it can't be counted.
997
+ export async function countOf(page, selector, within) {
998
+ try {
999
+ return await locate(page, selector, within).count();
1000
+ }
1001
+ catch (err) {
1002
+ if (page.isClosed())
1003
+ return 'The tab closed';
1004
+ const message = err.message;
1005
+ if (/SyntaxError|not a valid selector|Unexpected token|Unknown engine|Unexpected end/i.test(message))
1006
+ return 'It isn\'t a valid selector';
1007
+ return plain(err);
1008
+ }
1009
+ }
1010
+ // The iframe a probe's matches are in: the `--frame` one, or else a
1011
+ // ref's own (a selector without `--frame` looks in the top frame only).
1012
+ // Undefined in the top frame; null when in one that can't be found.
1013
+ async function matchesFrame(page, selector, framed, within) {
1014
+ if (!framed)
1015
+ return undefined;
1016
+ return within ?? await refOwner(page, selector);
1017
+ }
1018
+ // The frame a ref's element is in, or null when it can't be found.
1019
+ function refOwner(page, selector) {
1020
+ return locate(page, selector).first().elementHandle({ timeout: 1000 })
1021
+ .then((el) => el.ownerFrame()).catch(() => null);
1022
+ }
1023
+ /**
1024
+ * With `--frame` (`within`), why a ref doesn't do there: it names an
1025
+ * element in another frame. Null when it's in that frame, or isn't a
1026
+ * ref, or can't be found (which the count says).
1027
+ */
1028
+ export async function refFrameProblem(page, selector, within, ids) {
1029
+ const ref = refOf(selector);
1030
+ if (!within || !ref)
1031
+ return null;
1032
+ const owner = await refOwner(page, selector);
1033
+ if (!owner || owner === within)
1034
+ return null;
1035
+ const where = owner === page.mainFrame() ? 'the top frame' : `F${ids.number(owner)}`;
1036
+ return `The ref ${quoted(ref)} is in ${where}, not F${ids.number(within)}. A ref names its own frame, so ${quoted('--frame')} can be left out.`;
1037
+ }
1038
+ /**
1039
+ * The iframe a `--frame` flag names in `tab`: undefined for none given,
1040
+ * or the tab's own (its top frame); null, having said why, for one that
1041
+ * isn't there. It takes `F2`, `T1.F2`, the tab's own `T1`, or a CDP id
1042
+ * as the output shows it (`frameId=...` or `targetId=...`, or the start
1043
+ * of one).
1044
+ */
1045
+ export async function pickFrame(word, tab, say) {
1046
+ if (word === undefined)
1047
+ return undefined;
1048
+ const sayMistake = (line) => say(`${ERROR_PREFIX}${line}`);
1049
+ const text = word === true ? '' : word;
1050
+ // A tab's own ID is its top frame: the default, for this tab.
1051
+ const tabWord = /^[Tt](\d+)$/.exec(text);
1052
+ if (tabWord && Number(tabWord[1]) === tab.n)
1053
+ return undefined;
1054
+ if (tabWord) {
1055
+ sayMistake(`The ${quoted('--frame')} flag names ${quoted(`T${tabWord[1]}`)}, but the command acts on T${tab.n}`);
1056
+ return null;
1057
+ }
1058
+ const cdpId = CDP_FRAME_ARG.exec(text)?.[1];
1059
+ if (cdpId)
1060
+ return frameByCdpId(cdpId, tab, say);
1061
+ const ref = parseFrameRef(text);
1062
+ if (!ref) {
1063
+ sayMistake(`The ${quoted('--frame')} flag takes a frame ID, such as ${quoted('F2')} or ${quoted(`T${tab.n}.F2`)}, or a ${quoted('frameId=...')}, not ${quoted(text)}. Use ${quoted('frames')} to list them.`);
1064
+ return null;
1065
+ }
1066
+ if (ref.tab !== undefined && ref.tab !== tab.n) {
1067
+ sayMistake(`The ${quoted('--frame')} flag names a frame in ${quoted(`T${ref.tab}`)}, but the command acts on T${tab.n}`);
1068
+ return null;
1069
+ }
1070
+ const frame = findFrame(tab.page, tab.link.host.frameIds, ref.frame);
1071
+ if (!frame)
1072
+ say(`T${tab.n} has no frame ${quoted(`F${ref.frame}`)} now. Use ${quoted('frames')} to list its frames.`);
1073
+ return frame ?? null;
1074
+ }
1075
+ /**
1076
+ * A frame's CDP id as given, copied from the output or typed: `frameId=`
1077
+ * or `targetId=`, with its brackets or not, or the start of one long
1078
+ * enough not to be a slip.
1079
+ */
1080
+ export const CDP_FRAME_ARG = /^\[?(?:frameId|targetId)=([0-9a-f]{6,})\]?$/i;
1081
+ // The frame of `tab` whose CDP frame id starts with `id`: the top frame
1082
+ // (undefined) for the tab's own target id, which is its frame id too.
1083
+ async function frameByCdpId(id, tab, say) {
1084
+ const upper = id.toUpperCase();
1085
+ if (tab.targetId?.toUpperCase().startsWith(upper))
1086
+ return undefined;
1087
+ const hits = [];
1088
+ for (const node of frameNodes(tab.page, tab.link.host.frameIds)) {
1089
+ if ((await tab.link.frameCdpId(node.frame))?.toUpperCase().startsWith(upper))
1090
+ hits.push(node.frame);
1091
+ }
1092
+ if (hits.length === 1)
1093
+ return hits[0];
1094
+ const ids = tab.link.host.frameIds;
1095
+ say(hits.length > 1
1096
+ ? `Multiple frames in T${tab.n} have ids starting with ${quoted(id)} (${hits.map((f) => `F${ids.number(f)}`).join(', ')}). Give more of it.`
1097
+ : `No frame of T${tab.n} has an id starting with ${quoted(id)}. Use ${quoted('frames')} to list them.`);
1098
+ return null;
1099
+ }
1100
+ // A selector as `named` gives it, marked up: a ref as an ID, any other
1101
+ // selector as code, and the ref's label after it plain.
1102
+ function namedMarked(page, selector) {
1103
+ const label = named(page, selector).slice(selector.length);
1104
+ const ref = refOf(selector);
1105
+ if (!ref)
1106
+ return ui `${code(selector)}${label}`;
1107
+ // Only the ref itself in `[ref=e4]` or `ref=e4`.
1108
+ const at = selector.indexOf(ref);
1109
+ return ui `${selector.slice(0, at)}${idRole(ref)}${selector.slice(at + ref.length)}${label}`;
1110
+ }
1111
+ // A line of a snapshot as printed: each `[ref=e4]`'s ref marked as an
1112
+ // ID, and a part's `# Match 1` heading. The rest is the page's text, so
1113
+ // escaped; a snapshot written to a file stays plain.
1114
+ function snapshotLine(line) {
1115
+ if (/^# Match \d+$/.test(line))
1116
+ return heading(line);
1117
+ if (!line.includes('[ref='))
1118
+ return line;
1119
+ return MARKUP + line.split(/\[ref=([^\]\s]+)\]/).map((part, i) => (i % 2 === 1 ? `[ref=${idRole(part)}]` : escapeMarkup(part))).join('');
1120
+ }
1121
+ // How many elements a selector matches, or null (said why) when none
1122
+ // does or it can't be counted.
1123
+ async function matches(page, selector, say, within, ids) {
1124
+ const problem = await refProblem(page, selector) ?? (ids ? await refFrameProblem(page, selector, within, ids) : null);
1125
+ if (problem) {
1126
+ say(problem);
1127
+ return null;
1128
+ }
1129
+ const count = await countOf(page, selector, within);
1130
+ if (typeof count === 'string')
1131
+ say(`The selector ${quoted(selector)} can't be used: ${count}`);
1132
+ else if (count === 0) {
1133
+ const ref = refOf(selector);
1134
+ const inFrame = within && ids ? `F${ids.number(within)}` : undefined;
1135
+ // Quoted as a typed word, for the probe hint.
1136
+ const word = !/[\s'"]/.test(selector) ? selector : selector.includes("'") ? `"${selector}"` : `'${selector}'`;
1137
+ say(ref ? `No element has the ref ${quoted(ref)}. Take a new snapshot for the page's refs.`
1138
+ : `No element matches ${quoted(selector)}${inFrame ? ` in ${inFrame}` : ''}. Use ${quoted(`probe${inFrame ? ` --frame ${inFrame}` : ''} ${word}`)} to look for it.`);
1139
+ }
1140
+ return typeof count === 'number' && count > 0 ? count : null;
1141
+ }