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,3216 @@
1
+ // The commands a developer types while `sm-dev start` is streaming.
2
+ //
3
+ // The point is that the interesting things a script does are not all
4
+ // available at startup: a menu command may need firing twice, or after
5
+ // some state exists, and what the script stored is worth reading
6
+ // between steps rather than only at the end. So the same actions the
7
+ // Playwright fixture gives a test (`gm.fireMenu`, `gm.get`, `gm.set`,
8
+ // `gm.list`) are available here as lines on stdin.
9
+ //
10
+ // Kept apart from run.ts so the parsing and the wording of each reply
11
+ // sit in one place, and so a non-interactive run (a pipe, an agent
12
+ // reading the stream) can skip the whole thing.
13
+ import { AsyncLocalStorage } from 'node:async_hooks';
14
+ import { existsSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import readline from 'node:readline';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { CommandAssembler, endsOutsideBlock, listed } from './command-lines.js';
19
+ import { isJsonLine, jsonLayoutMistake, jsonResult, JSON_COMMANDS, markedLine, STDERR_LINE, withLayout } from './json-output.js';
20
+ import { managerState, managersText, otherManagersWarning, stateLabel } from './browser-extensions.js';
21
+ import { DEFAULT_MANAGER, MANAGERS } from './managers.js';
22
+ import { firstLine } from './extension-link.js';
23
+ import { collectionList, count, managerVerb, noSuchScript, noSuchVerb, scriptList, } from './manager-commands.js';
24
+ import { helpRequest, helpSubcommand, parseCommandArgs, splitWords, unquoteWhole } from './command-args.js';
25
+ import { ALIASES, COMMAND_NAMES, RUN_COMMAND_NAMES, SAME_COMMANDS, commandHelp, commandSyntax, unsupportedCommand } from './command-list.js';
26
+ import { errorSentence, readJsonFile, writeProblem } from './input-file.js';
27
+ import { CDP_FRAME_ARG, PAGE_COMMANDS, runPageCommand } from './page-commands.js';
28
+ import { editMultiLine } from './prompt-editor.js';
29
+ import { appendHistory, HISTORY_SIZE, historyFile, loadHistory } from './prompt-history.js';
30
+ import { frameNodes, parseFrameRef } from './frames.js';
31
+ import { defaultPageUrl, explainMatch, NO_DEFAULT_PAGE, preparedNoframes, preparedScriptMatches, preparedScriptName, preparedScriptPath, shownPath, withDefaultScheme } from './prepare.js';
32
+ import { representativeMatch } from '../match-targets.js';
33
+ import { RUN_SCRIPT_LABEL } from '../userscript-header.js';
34
+ import { currentTab, dialogKind, dialogWaitingMessage, NO_TABS, pickTab, TAB_ARG, tabName } from './tab-refs.js';
35
+ import { runLogLabel } from './run-log.js';
36
+ import { DEFAULT_LOG_SOURCES, isoTime, linesLost, logsLines, logsUpTo, inSources, noLogLines, nowMicros, parseLogSources, parsePosition, parseTail, streamText, textMatcher, } from './run-logs.js';
37
+ import { err as errorLine, message, MISTAKE_PREFIX, paintAs, paintOutput, quoted, streamHasColor, ui } from './output-style.js';
38
+ // The roles for marked-up output, as S.id(...): their names are common locals here.
39
+ import * as S from './output-style.js';
40
+ import { loadPage, PageNotLoaded, seconds, settleTabs, timeoutFlagMs, waitFor, within } from './wait.js';
41
+ import { userscriptLabel } from './save-html.js';
42
+ import { visibleCommands } from '../menu-captions.js';
43
+ import { comparableUrl, openingSavedLine, pagesToOpen, routeSavedPage, savedPageServe, } from './saved-pages.js';
44
+ import { SCRIPT_ARG, UsageError, expandPathArg, expandPathArgs, matchJson, matchLines, prepareSource, scriptSources, validateAll, validateSources, } from './checks.js';
45
+ /** ` [targetId=...]`, the tab's CDP id, for the end of a line. */
46
+ export function targetIdSuffix(targetId) {
47
+ return targetId ? ` [targetId=${targetId}]` : '';
48
+ }
49
+ // One wording for having none, whether as a reply or in a list.
50
+ const NO_SCRIPTS = 'No scripts are loaded';
51
+ // The commands that act on the current tab, which there may not be.
52
+ const NEEDS_A_TAB = new Set(['menu', 'open', 'reload', 'focus', 'closetab', 'accept', 'dismiss', ...PAGE_COMMANDS]);
53
+ // `script` and `tab` pick one, as `scripts` and `tabs` do given one,
54
+ // so each is run as its plural.
55
+ const RUN_ALIASES = { ...ALIASES, ...SAME_COMMANDS };
56
+ /**
57
+ * What's wrong with a run command's words, before it runs, or null (for
58
+ * a name that is no run command too: that is said elsewhere). `input`:
59
+ * the command as typed, its name first (a block's lines after a line
60
+ * break), or as a sender's words. `flag`: the flag it's about, so one
61
+ * that belongs before the command can be said to.
62
+ */
63
+ export function runCommandMistake(input) {
64
+ const own = ownWords(input);
65
+ if (!own)
66
+ return null;
67
+ const parsed = parseCommandArgs(own.command, own.rest);
68
+ return 'error' in parsed ? parsed : null;
69
+ }
70
+ // The run's permission prompts still waiting for an answer, oldest
71
+ // first.
72
+ function waitingPrompts(ctx) {
73
+ return ctx.host.reports.connectPrompts.filter((r) => !r.answer && !r.closed);
74
+ }
75
+ // A request URL without its query string or fragment: those can be
76
+ // long (signed CDN links) and hold keys, and the host and path are what
77
+ // the question is about.
78
+ export function withoutQuery(url) {
79
+ try {
80
+ const u = new URL(url);
81
+ return `${u.origin}${u.pathname}`;
82
+ }
83
+ catch {
84
+ return url;
85
+ }
86
+ }
87
+ // What is still waiting for an answer as a command ends: permission
88
+ // prompts, and dialogs open on the run's tabs. Each is a stuck state
89
+ // (a request, or the whole page, can't go on), and the line announcing
90
+ // it may have come between commands, where only the run's log saw it.
91
+ // So each command repeats them, after its own output, until answered.
92
+ //
93
+ // Only what was already waiting when the command began: one that opens
94
+ // during the command has just been announced in its output. Nor a
95
+ // dialog the command's output already named (a command refused for it).
96
+ export function waitingReminders(ctx, before, said) {
97
+ const lines = [];
98
+ const selected = selectedScript(ctx);
99
+ for (const prompt of waitingPrompts(ctx)) {
100
+ if (!before.prompts.has(prompt.promptId))
101
+ continue;
102
+ const script = ctx.scripts.find((s) => s.id === prompt.scriptId);
103
+ const select = script && script !== selected && ctx.scripts.length > 1 ? ` (select it first with ${quoted(`script ${ctx.scripts.indexOf(script) + 1}`)})` : '';
104
+ lines.push(`[sm-dev] *PERMISSION PROMPT* still waiting: ${prompt.name} requests permission to connect to ${prompt.host}. Answer using ${quoted('connect once|cancel|allow|deny|allow-all')}${select}.`);
105
+ }
106
+ const current = currentTab(ctx);
107
+ for (const tab of ctx.tabs()) {
108
+ const dialog = tab.dialogs?.current;
109
+ if (!dialog || before.dialogs.get(tab) !== dialog)
110
+ continue;
111
+ const refusal = dialogWaitingMessage(tab, current);
112
+ if (said.some((l) => l.includes(refusal)))
113
+ continue;
114
+ const named = tab === current ? '' : ` ${tabName(tab)}`;
115
+ lines.push(`[sm-dev] *DIALOG* still waiting: ${tab === current ? 'The page' : tabName(tab)} is showing ${dialogKind(dialog)} dialog. Answer using ${quoted(`accept${named}`)} or ${quoted(`dismiss${named}`)}.`);
116
+ }
117
+ return lines;
118
+ }
119
+ export function waitingState(ctx) {
120
+ return {
121
+ prompts: new Set(waitingPrompts(ctx).map((r) => r.promptId)),
122
+ dialogs: new Map(ctx.tabs().flatMap((t) => (t.dialogs?.current ? [[t, t.dialogs.current]] : []))),
123
+ };
124
+ }
125
+ // `connect once|cancel|allow|deny|allow-all [host]`: answers a connection prompt,
126
+ // as the person would in its window.
127
+ async function answerConnectCommand(ctx, args) {
128
+ const [choice, host] = args;
129
+ if (choice !== 'once' && choice !== 'cancel' && choice !== 'allow' && choice !== 'deny' && choice !== 'allow-all') {
130
+ ctx.out(MISTAKE_PREFIX + 'The `connect` command takes `once`, `cancel`, `allow`, `deny` or `allow-all`, then optionally the host.');
131
+ return false;
132
+ }
133
+ // The selected script's, as for `get` and `set`. A prompt is per
134
+ // script and site, so with `host` there is at most one.
135
+ const script = selectedScript(ctx);
136
+ const waiting = waitingPrompts(ctx).filter((r) => host === undefined || r.host === host.toLowerCase());
137
+ const prompt = waiting.find((r) => r.scriptId === script?.id);
138
+ if (!prompt) {
139
+ const forHost = host === undefined ? '' : ` for ${host}`;
140
+ const name = script ? preparedScriptName(script) : 'the selected script';
141
+ const other = waiting.find((r) => r.scriptId !== script?.id);
142
+ const otherScript = other && ctx.scripts.find((s) => s.id === other.scriptId);
143
+ ctx.out(otherScript
144
+ ? `[sm-dev] No permission prompt${forHost} is waiting for ${name}. ${preparedScriptName(otherScript)} has one: select it with ${quoted(`script ${ctx.scripts.indexOf(otherScript) + 1}`)}, then use ${quoted('connect')} again.`
145
+ : `[sm-dev] No permission prompt${forHost} is waiting for ${name}`);
146
+ return;
147
+ }
148
+ let answered;
149
+ try {
150
+ answered = await ctx.host.answerConnectPrompt(prompt.promptId, choice);
151
+ }
152
+ catch (err) {
153
+ ctx.out(`${MISTAKE_PREFIX}${err.message}`);
154
+ return false;
155
+ }
156
+ if (!answered) {
157
+ ctx.out(`[sm-dev] The permission prompt for ${prompt.host} is no longer waiting`);
158
+ return;
159
+ }
160
+ const said = choice === 'allow' ? `Allowed ${prompt.host}` : choice === 'allow-all' ? 'Allowed all sites'
161
+ : choice === 'deny' ? `Denied ${prompt.host}` : choice === 'once' ? `Allowed ${prompt.host} once`
162
+ : `Cancelled the permission prompt about ${prompt.host}`;
163
+ ctx.out(`[sm-dev] ${said} for ${prompt.name}`);
164
+ }
165
+ /**
166
+ * The run command, and its subcommand, whose help `--help` asks for
167
+ * (`click --help`, `script save --help`), or null when it isn't asked;
168
+ * an error for words that name no subcommand.
169
+ */
170
+ export function runCommandHelp(input) {
171
+ const own = ownWords(input);
172
+ if (!own)
173
+ return null;
174
+ const parsed = parseCommandArgs(own.command, own.rest);
175
+ return 'help' in parsed && parsed.help ? helpRequest(own.command, own.rest) : null;
176
+ }
177
+ // The error for a word that names no command a run takes.
178
+ function notACommand(word) {
179
+ return `${quoted(word)} is not a command. Use ${quoted('help commands')} to list them.`;
180
+ }
181
+ // A run command's name, aliases resolved, and its words after the name;
182
+ // null for a name that is no run command.
183
+ function ownWords(input) {
184
+ let name;
185
+ let rest;
186
+ if ('line' in input) {
187
+ // A block's line breaks are kept, so its lines stay its own.
188
+ const trimmed = input.line.replace(/^\s+/, '');
189
+ name = trimmed.split(/\s/)[0];
190
+ rest = { line: trimmed.slice(name.length).replace(/^[^\S\n]+/, '') };
191
+ }
192
+ else {
193
+ name = input.words[0] ?? '';
194
+ rest = { words: input.words.slice(1) };
195
+ }
196
+ if (!RUN_COMMAND_NAMES.includes(ALIASES[name] ?? name))
197
+ return null;
198
+ // `scripts E4 enable` is the script command; `scripts E4` picks one.
199
+ const isScriptVerb = (name === 'script' || name === 'scripts') && managedVerb(name, rest) !== null;
200
+ return { command: isScriptVerb ? 'script' : RUN_ALIASES[name] ?? name, rest };
201
+ }
202
+ /**
203
+ * Which of the commands for what's installed a command is (`collection
204
+ * C3 show`, `script list`), in any spelling, with its verb, or null for
205
+ * another command. These need SourceMonkey but no run.
206
+ */
207
+ export function managerCommandOf(words) {
208
+ const [name = '', ...rest] = words;
209
+ if ((ALIASES[name] ?? name) === 'collection') {
210
+ // Alone, or with flags only, it lists.
211
+ const verb = rest.every((w) => w.startsWith('--')) ? 'list' : managedVerb('collection', { words: rest }) ?? undefined;
212
+ return { command: 'collection', verb };
213
+ }
214
+ const verb = managedVerb(name, { words: rest });
215
+ return verb !== null ? { command: 'script', verb } : null;
216
+ }
217
+ // The verb of `script list`, `script E4 show`, `collection C3 refresh`
218
+ // and the rest, as command-args.ts reads it, or null: for `script`,
219
+ // picking a script instead (`script E4`). A line that doesn't parse is
220
+ // still the verb's when a word of it is one, so the verb's own message
221
+ // says what's wrong (`script enable E4`).
222
+ function managedVerb(name, rest) {
223
+ const command = name === 'script' || name === 'scripts' ? 'script' : name === 'collection' ? 'collection' : null;
224
+ if (command === null)
225
+ return null;
226
+ const parsed = parseCommandArgs(command, 'line' in rest ? { line: rest.line.split('\n')[0] } : rest);
227
+ if (!('error' in parsed))
228
+ return parsed.form ?? null;
229
+ const all = 'line' in rest ? splitWords(rest.line.split('\n')[0]).map((w) => w.text) : rest.words;
230
+ const verbs = commandSyntax(command).forms;
231
+ return all.find((w) => !w.startsWith('--') && Object.hasOwn(verbs, w)) ?? null;
232
+ }
233
+ // The verbs that only look.
234
+ const LOOKING_VERBS = new Set(['list', 'show', 'print', 'save']);
235
+ /**
236
+ * A command line's words, as the prompt splits them (quotes kept
237
+ * together), for telling which command it is and where it runs.
238
+ */
239
+ export function commandWords(line) {
240
+ const trimmed = line.trim();
241
+ const name = trimmed.split(/\s+/)[0];
242
+ return [name, ...splitWords(trimmed.slice(name.length)).map((w) => w.text)];
243
+ }
244
+ // Commands that only look: what they print answers whoever asked, and
245
+ // is no part of the run's story.
246
+ const INSPECTING = new Set([
247
+ 'help', 'status', 'values', 'get', 'snapshot', 'screenshot', 'savehtml', 'probe', 'logs', 'match', 'validate', 'frames',
248
+ ]);
249
+ /**
250
+ * Whether a command line only inspects, so its output goes back to the
251
+ * client that sent it alone, rather than into the run's stream too. `tabs`, `scripts` and `menu` inspect when they list, and
252
+ * change things when they pick or fire.
253
+ */
254
+ export function onlyInspects(line) {
255
+ const [typed = '', ...words] = line.trim().split(/\s+/);
256
+ // `tabs --json` and `tabs --frames` still list.
257
+ const rest = words.filter((word) => !/^--(no)?json(=|$)/.test(word) && word !== '--frames');
258
+ // A script or collection named in quotes is one word.
259
+ const managing = managerCommandOf(commandWords(line));
260
+ if (managing)
261
+ return managing.verb !== undefined && LOOKING_VERBS.has(managing.verb);
262
+ const command = RUN_ALIASES[typed] ?? typed;
263
+ if (INSPECTING.has(command))
264
+ return true;
265
+ if (command === 'tabs' || command === 'scripts')
266
+ return rest.length === 0;
267
+ if (command === 'menu')
268
+ return rest.length === 0 || (rest.length === 1 && (rest[0] === 'all' || /^[Tt]\d+$/.test(rest[0])));
269
+ return false;
270
+ }
271
+ /**
272
+ * Whether a command line has a JSON form (`--json`): one of
273
+ * `JSON_COMMANDS`, in a form that only looks (`tabs`, not `tabs T2`).
274
+ */
275
+ export function hasJsonForm(line) {
276
+ const [typed = '', ...words] = commandWords(line);
277
+ // Each collection and script verb has one, the changing ones too.
278
+ if (managerCommandOf([typed, ...words]))
279
+ return true;
280
+ return JSON_COMMANDS.includes(RUN_ALIASES[typed] ?? typed) && onlyInspects(line);
281
+ }
282
+ /**
283
+ * Where a running command's output goes instead of the run's stream,
284
+ * while it runs: an inspecting command answers the client that sent it
285
+ * alone. Output the page or the scripts produce meanwhile
286
+ * isn't the command's, and still goes to the stream. `live` goes false
287
+ * when the command ends, so anything it started that prints later goes
288
+ * to the stream rather than a closed connection.
289
+ */
290
+ export const commandOutput = new AsyncLocalStorage();
291
+ /** How a line saying a command was invalid starts. */
292
+ export { MISTAKE_PREFIX };
293
+ /**
294
+ * Set while a command runs, in its own async work: what it prints is
295
+ * its reply, not something the page or a script printed on its own,
296
+ * which `wait log` looks for. Lines from the page and scripts arrive
297
+ * through events outside it. `inspects`: the command only looks, so
298
+ * its reply is no part of the run's log either.
299
+ */
300
+ export const inCommand = new AsyncLocalStorage();
301
+ // The commands that say which scripts a tab runs, or act on the ones
302
+ // it does (`save` lists them in the saved page, `values T1` reads them).
303
+ const SHOWS_ACTIVITY = new Set(['scripts', 'script', 'status', 'tabs', 'logs', 'save', 'values']);
304
+ // The commands that show a tab's frames, or the scripts in them.
305
+ const SHOWS_FRAMES = new Set(['scripts', 'script', 'status', 'tabs', 'frames', 'menu']);
306
+ /**
307
+ * Run one typed line. Returns once its effect has been applied, so an
308
+ * agent driving this over a pipe can send the next line knowing the
309
+ * previous one is done. For a line a client sent: `cwd` is
310
+ * where the files it names are, and `words` are the sender's own words
311
+ * (the line is them joined by spaces), which page commands take as
312
+ * they are rather than splitting the line.
313
+ */
314
+ export async function runConsoleCommand(line, ctx, { cwd = process.cwd(), words } = {}) {
315
+ // A block's lines (after the first line break) are kept as they are,
316
+ // spaces at the end included, since `type` types them.
317
+ const block = line.includes('\n');
318
+ const trimmed = block ? line.trimStart() : line.trim();
319
+ if (!trimmed)
320
+ return;
321
+ const typedCommand = trimmed.split(/\s+/)[0];
322
+ // The sender's words when they start with the command; a whole
323
+ // command sent as one word is read like a typed one.
324
+ const own = ownWords(words?.[0] === typedCommand ? { words } : { line: trimmed });
325
+ if (!own) {
326
+ ctx.out(`${MISTAKE_PREFIX}${notACommand(typedCommand)}`);
327
+ return false;
328
+ }
329
+ const { command } = own;
330
+ const parsed = parseCommandArgs(command, own.rest);
331
+ if ('error' in parsed) {
332
+ ctx.out(`${MISTAKE_PREFIX}${parsed.error}`);
333
+ return false;
334
+ }
335
+ // What the manager has turned off, for the commands that show what
336
+ // the tabs are expected to run.
337
+ if (ctx.predictedOff && SHOWS_ACTIVITY.has(command))
338
+ await ctx.predictedOff.refresh();
339
+ // The trees and JSON show each frame's details too, which cost a call
340
+ // per frame.
341
+ if (SHOWS_FRAMES.has(command)) {
342
+ await loadFrames(ctx, command === 'frames' || parsed.flags['--frames'] === true || ctx.json === true || parsed.flags['--json'] !== undefined);
343
+ }
344
+ // The command's own --json or --nojson over the sender's global one,
345
+ // and its own layout over the client's. Its help is text either way.
346
+ const ownJson = parsed.flags['--json'];
347
+ if (typeof ownJson === 'string') {
348
+ const mistake = jsonLayoutMistake(ownJson);
349
+ if (mistake) {
350
+ ctx.out(`${MISTAKE_PREFIX}${mistake}`);
351
+ return false;
352
+ }
353
+ if (ctx.json !== undefined)
354
+ ctx.jsonLayout = ownJson;
355
+ }
356
+ if (ctx.json !== undefined)
357
+ ctx.json = !parsed.help && (parsed.flags['--nojson'] ? false : ownJson ? true : ctx.json);
358
+ // `click --help`: its help, as `help click` gives it, rather than a
359
+ // click; `script save --help`, the subcommand's.
360
+ if (parsed.help) {
361
+ const subcommand = helpSubcommand(command, parsed);
362
+ if (typeof subcommand === 'object') {
363
+ ctx.out(`${MISTAKE_PREFIX}${subcommand.error}`);
364
+ return false;
365
+ }
366
+ for (const l of commandHelp(command, subcommand).trimEnd().split('\n'))
367
+ ctx.out(l);
368
+ return;
369
+ }
370
+ const unsupported = unsupportedCommand(command, ctx.managerName ?? DEFAULT_MANAGER, 'this run uses');
371
+ if (unsupported) {
372
+ ctx.out(`${MISTAKE_PREFIX}${unsupported}`);
373
+ return false;
374
+ }
375
+ // Its arguments, its text (the rest of its line, for the commands
376
+ // that take one), and its flags.
377
+ const args = parsed.words;
378
+ const text = parsed.text ?? '';
379
+ const { flags } = parsed;
380
+ const current = ctx.current ?? ctx.scripts[0];
381
+ const scriptId = current?.id;
382
+ const other = ctx.currentOther;
383
+ // Named in the output only when there is a choice to be clear about.
384
+ const whose = other ? ` (${other.label}: ${other.name})`
385
+ : ctx.scripts.length > 1 && current ? ` (${preparedScriptName(current)})` : '';
386
+ const picked = currentTab(ctx);
387
+ // Every controlled tab can be closed, T1 included.
388
+ if (!picked && NEEDS_A_TAB.has(command)) {
389
+ ctx.out(NO_TABS);
390
+ return;
391
+ }
392
+ // Only undefined for the commands that don't act on it.
393
+ const tab = picked;
394
+ // An open dialog blocks the page's JavaScript, so a command that
395
+ // reaches into the page would wait on it, and hold up the accept or
396
+ // dismiss typed after it. `set` reaches every tab, so any tab's
397
+ // dialog blocks it.
398
+ const blockedByDialog = (tabs = [tab]) => {
399
+ const waiting = tabs.find((t) => t.dialogs?.current);
400
+ if (!waiting)
401
+ return false;
402
+ ctx.out(`[sm-dev] ${dialogWaitingMessage(waiting, tab)}`);
403
+ return true;
404
+ };
405
+ switch (command) {
406
+ case 'collection':
407
+ case 'script':
408
+ return managerCommand(ctx, command, parsed, cwd);
409
+ case 'match':
410
+ return matchCommand(ctx, args, cwd, flags['--frames'] === true);
411
+ case 'validate':
412
+ return validateCommand(ctx, args, cwd);
413
+ case 'menu': {
414
+ const timeoutMs = timeoutFlagMs(flags, ctx.out);
415
+ if (timeoutMs === null)
416
+ return false;
417
+ // A label quoted as a whole is just that, even `all` or `T2.x`.
418
+ const label = unquoteWhole(text);
419
+ if (label.quoted) {
420
+ if (blockedByDialog([tab]))
421
+ return false;
422
+ return fireMenu(ctx, tab, label.text, timeoutMs);
423
+ }
424
+ if (ctx.json && (text === 'all' || !text || /^[Tt]\d+$/.test(text))) {
425
+ const which = text === 'all' ? ctx.tabs() : [text ? pickTab(ctx, text) : tab];
426
+ if (which.includes(null))
427
+ return false;
428
+ ctx.out(jsonResult('menu', { tabs: which.map((t) => menuJson(ctx, t)) }));
429
+ return;
430
+ }
431
+ if (text === 'all') {
432
+ ctx.out(ui `${S.heading('Menu commands')}:`);
433
+ printPerTab(ctx, tab, (t) => menuLines(ctx, t), 'None registered');
434
+ return;
435
+ }
436
+ const ref = tabRef(ctx, text, tab, true);
437
+ if (!ref)
438
+ return false;
439
+ if (!ref.rest) {
440
+ const lines = menuLines(ctx, ref.tab);
441
+ if (lines.length === 0) {
442
+ ctx.out(noMenusMessage(ctx, ref.tab));
443
+ return;
444
+ }
445
+ ctx.out(ui `${S.heading('Menu commands')}${onTabUi(ctx, ref.tab)}:`);
446
+ for (const l of lines)
447
+ ctx.out(l);
448
+ return;
449
+ }
450
+ if (blockedByDialog([ref.tab]))
451
+ return false;
452
+ // The label after `T2.` may be quoted too.
453
+ return fireMenu(ctx, ref.tab, unquoteWhole(ref.rest).text, timeoutMs);
454
+ }
455
+ case 'frames':
456
+ return framesCommand(ctx, args, tab);
457
+ case 'newtab':
458
+ return openNewTab(ctx, args, flags, cwd);
459
+ case 'closetab': {
460
+ const timeoutMs = timeoutFlagMs(flags, ctx.out);
461
+ if (timeoutMs === null)
462
+ return false;
463
+ return closeTabs(ctx, args, flags['--force'] === true, tab, timeoutMs);
464
+ }
465
+ case 'tabs': {
466
+ const named = namedTab(ctx, 'tabs', parsed);
467
+ if (named === null)
468
+ return false;
469
+ if (named === undefined) {
470
+ if (ctx.json)
471
+ ctx.out(jsonResult('tabs', { tabs: ctx.tabs().map((t) => tabJson(ctx, t)) }));
472
+ else
473
+ printTabs(ctx, flags['--frames'] === true);
474
+ return;
475
+ }
476
+ // `tabs new` was newtab's old name.
477
+ if (named === 'new') {
478
+ ctx.out(MISTAKE_PREFIX + 'The `tabs` command picks a tab. To open one, use `newtab`.');
479
+ return false;
480
+ }
481
+ const picked = pickTab(ctx, named);
482
+ if (!picked)
483
+ return false;
484
+ ctx.tab = picked;
485
+ ctx.out(`[sm-dev] ${ui `The page commands now act on ${S.id(tabName(picked))} (${S.url(picked.page.url())})`}`);
486
+ return;
487
+ }
488
+ case 'scripts': {
489
+ if (!text && ctx.json) {
490
+ const others = await otherScriptsJson(ctx, picked);
491
+ if (!others)
492
+ return false;
493
+ ctx.out(jsonResult('scripts', {
494
+ ...(picked ? { tab: tabName(picked) } : {}),
495
+ scripts: [...ctx.scripts.map((script) => scriptJson(ctx, script)), ...others],
496
+ }));
497
+ return;
498
+ }
499
+ if (!text && ctx.predicted && picked && ctx.scripts.length > 0) {
500
+ for (const l of predictedLines(ctx, picked, 'Loaded scripts'))
501
+ ctx.out(l);
502
+ for (const l of await otherLines(ctx, picked))
503
+ ctx.out(l);
504
+ return;
505
+ }
506
+ if (!text) {
507
+ ctx.out(ui `${S.heading('Loaded scripts')}${activityHeading(picked)}:`);
508
+ if (ctx.scripts.length === 0)
509
+ ctx.out(` ${NO_SCRIPTS}`);
510
+ for (const script of ctx.scripts) {
511
+ ctx.out(scriptRow(ctx, script));
512
+ if (picked)
513
+ ctx.out(activityLine(ctx, picked, script.id));
514
+ }
515
+ for (const l of await otherLines(ctx, picked))
516
+ ctx.out(l);
517
+ return;
518
+ }
519
+ // Under another manager, one of the run's own by its E-id.
520
+ const own = OTHER_LABEL.test(text) ? ownByLabel(ctx, text) : undefined;
521
+ if (own) {
522
+ ctx.current = own;
523
+ ctx.currentOther = undefined;
524
+ ctx.out(`[sm-dev] ${ui `Selected ${scriptLabelUi(own)}`}`);
525
+ return;
526
+ }
527
+ if (OTHER_LABEL.test(text)) {
528
+ const found = await pickOther(ctx, text, picked);
529
+ if (!found)
530
+ return false;
531
+ ctx.currentOther = found;
532
+ const away = await otherOnTab(ctx, found, picked) ? '' : picked ? ui `. It isn't on ${S.id(tabName(picked))}'s page.` : '. No tab is open.';
533
+ ctx.out(`[sm-dev] ${ui `Selected ${S.id(found.label)}: ${S.name(found.name)}${away}`}`);
534
+ return;
535
+ }
536
+ const ref = tabRef(ctx, text, tab);
537
+ if (!ref)
538
+ return false;
539
+ if (!ref.rest) {
540
+ ctx.out(MISTAKE_PREFIX + 'The `scripts` command needs a script after the tab, e.g. `scripts T2.2`');
541
+ return false;
542
+ }
543
+ const found = pickScript(ctx, ref.rest);
544
+ if (!found)
545
+ return false;
546
+ ctx.current = found;
547
+ ctx.currentOther = undefined;
548
+ if (!ref.named) {
549
+ ctx.out(`[sm-dev] ${ui `Selected ${scriptLabelUi(found)}`}`);
550
+ return;
551
+ }
552
+ ctx.tab = ref.tab;
553
+ ctx.out(`[sm-dev] ${ui `Selected ${scriptLabelUi(found)}, and ${S.id(tabName(ref.tab))} for the page commands`}`);
554
+ return;
555
+ }
556
+ case 'status': {
557
+ if (ctx.json) {
558
+ ctx.out(jsonResult('status', await statusJson(ctx, picked)));
559
+ return;
560
+ }
561
+ if (ctx.runId) {
562
+ // Asked each time, since the person may install one mid-run.
563
+ const chosen = ctx.managerName ?? DEFAULT_MANAGER;
564
+ const managers = ctx.browser ? await ctx.managers?.() ?? null : null;
565
+ const extension = managers ? stateLabel(managerState(managers, chosen), chosen) : '';
566
+ ctx.out(ui `${S.heading('Run')}: ${S.id(ctx.runId)} (pid ${process.pid})${ctx.browser ? `, ${ctx.browser}${extension ? ` (${extension})` : ''}` : ''}`);
567
+ if (managers) {
568
+ ctx.out(ui `${S.heading('Managers')}: ${managersText(managers, chosen)}`);
569
+ for (const line of otherManagersWarning(managers, chosen, ctx.browserArg))
570
+ ctx.out(line);
571
+ }
572
+ }
573
+ if (ctx.backend)
574
+ ctx.out(ui `${S.heading('Backend')}: ${ctx.backend()}`);
575
+ if (ctx.log)
576
+ ctx.out(ui `${S.heading('Log')}: ${runLogLabelUi(ctx.log, ctx.runId)}`);
577
+ // Chrome makes the folder with the first download, so it's only
578
+ // worth a line once there is one.
579
+ if (ctx.downloadsDir && existsSync(ctx.downloadsDir))
580
+ ctx.out(ui `${S.heading('Downloads')}: ${S.url(ctx.downloadsDir)}`);
581
+ // The tabs first, each with its URL, ids and any dialog; then
582
+ // the scripts on the current one, which is marked there. With no
583
+ // tab, still the scripts and what they have stored.
584
+ printTabs(ctx, flags['--frames'] === true);
585
+ for (const l of await statusLines(ctx, tab))
586
+ ctx.out(l);
587
+ for (const l of await otherLines(ctx, tab))
588
+ ctx.out(l);
589
+ return;
590
+ }
591
+ case 'values': {
592
+ if (OTHER_LABEL.test(text)) {
593
+ const found = await pickOther(ctx, text, picked);
594
+ if (!found)
595
+ return false;
596
+ if (ctx.json) {
597
+ ctx.out(jsonResult('values', { scripts: [{ script: found.label, name: found.name, values: await found.values() }] }));
598
+ return;
599
+ }
600
+ ctx.out(ui `${S.heading('Stored values')}:`);
601
+ ctx.out(ui ` ${otherLabelUi(found)}`);
602
+ for (const l of valueEntries(await found.values()))
603
+ ctx.out(l);
604
+ return;
605
+ }
606
+ if (ctx.scripts.length === 0) {
607
+ if (ctx.json)
608
+ ctx.out(jsonResult('values', { scripts: [] }));
609
+ else
610
+ ctx.out(`[sm-dev] ${NO_SCRIPTS}`);
611
+ return;
612
+ }
613
+ // The values are each script's, the same on every tab, so all is
614
+ // every script's, as with nothing named, not a list per tab.
615
+ const ref = tabRef(ctx, text === 'all' ? '' : text, tab);
616
+ if (!ref)
617
+ return false;
618
+ let scripts = ctx.scripts;
619
+ if (ref.rest) {
620
+ const found = pickScript(ctx, ref.rest);
621
+ if (!found)
622
+ return false;
623
+ scripts = [found];
624
+ }
625
+ else if (ref.named) {
626
+ scripts = runningOn(ctx, ref.tab);
627
+ if (scripts.length === 0 && !ctx.json) {
628
+ ctx.out(`[sm-dev] ${ui `No script is running on ${S.id(tabName(ref.tab))}`}`);
629
+ return;
630
+ }
631
+ }
632
+ await settleWrites();
633
+ if (ctx.json) {
634
+ const entries = [];
635
+ for (const script of scripts) {
636
+ entries.push({ script: scriptNumber(ctx, script), name: preparedScriptName(script), values: script.id ? await ctx.host.valuesOf(script.id) : {} });
637
+ }
638
+ // Named a tab: only the scripts running there, which `tab` says.
639
+ const onTab = ref.named && !ref.rest ? { tab: tabName(ref.tab) } : {};
640
+ ctx.out(jsonResult('values', { ...onTab, scripts: entries }));
641
+ return;
642
+ }
643
+ ctx.out(ui `${S.heading('Stored values')}:`);
644
+ for (const l of await valueLines(ctx, scripts))
645
+ ctx.out(l);
646
+ return;
647
+ }
648
+ case 'get': {
649
+ if (!scriptId && !other) {
650
+ ctx.out(`[sm-dev] ${NO_SCRIPTS}`);
651
+ return;
652
+ }
653
+ if (!text) {
654
+ ctx.out(MISTAKE_PREFIX + 'The `get` command needs a key');
655
+ return false;
656
+ }
657
+ await settleWrites();
658
+ const values = other ? await other.values() : await ctx.host.valuesOf(scriptId);
659
+ if (ctx.json) {
660
+ const found = Object.hasOwn(values, text);
661
+ ctx.out(jsonResult('get', {
662
+ script: other ? other.label : scriptNumber(ctx, current), key: text, found, ...(found ? { value: values[text] } : {}),
663
+ }));
664
+ return;
665
+ }
666
+ ctx.out(ui ` ${S.code(text)} = ${S.code(String(JSON.stringify(values[text])))}`);
667
+ return;
668
+ }
669
+ case 'set': {
670
+ if (!scriptId && !other) {
671
+ ctx.out(`[sm-dev] ${NO_SCRIPTS}`);
672
+ return;
673
+ }
674
+ // `set <key> <json>`, the JSON maybe a `{{` block's lines, or
675
+ // `set <key> --file <path>`. The key is on the command's own
676
+ // line, never the block's first.
677
+ const [key] = args;
678
+ const from = flags['--file'];
679
+ const raw = [text, parsed.block].filter((part) => part).join('\n');
680
+ let value;
681
+ if (typeof from === 'string') {
682
+ if (!key || raw) {
683
+ ctx.out(MISTAKE_PREFIX + 'The `set` command takes a key and one file, e.g. `set units --file units.json`');
684
+ return false;
685
+ }
686
+ const file = path.resolve(cwd, from);
687
+ try {
688
+ value = readJsonFile(file, shownPath(file, cwd));
689
+ }
690
+ catch (err) {
691
+ ctx.out(`${MISTAKE_PREFIX}${err.message}`);
692
+ return;
693
+ }
694
+ }
695
+ else {
696
+ if (!key || !raw) {
697
+ ctx.out(MISTAKE_PREFIX + 'The `set` command needs a key and a JSON value, e.g. `set units "metric"`');
698
+ return false;
699
+ }
700
+ try {
701
+ value = JSON.parse(raw);
702
+ }
703
+ catch (err) {
704
+ // A bare word typed inline is most likely a string without
705
+ // its quotes; a block's JSON is just broken.
706
+ ctx.out(parsed.block !== undefined
707
+ ? `${MISTAKE_PREFIX}The value of ${quoted(key)} is not JSON: ${err.message}`
708
+ : `${MISTAKE_PREFIX}${quoted(raw)} is not JSON. A string needs quotes.`);
709
+ return;
710
+ }
711
+ }
712
+ if (flags['--force'] === true && !other) {
713
+ ctx.out(MISTAKE_PREFIX + 'The --force flag is for SourceMonkey-managed scripts only (E1, ...)');
714
+ return false;
715
+ }
716
+ // A SourceMonkey-managed script never runs in the run's tabs, so a
717
+ // dialog there doesn't hold up its write.
718
+ if (!other && blockedByDialog(ctx.tabs()))
719
+ return;
720
+ // Through the host, so a running script sees it on its next read
721
+ // and its value-change listeners fire, as another tab's write
722
+ // would.
723
+ if (other) {
724
+ if (ctx.others?.guarded && flags['--force'] !== true) {
725
+ ctx.out(`${MISTAKE_PREFIX}${other.label} is a SourceMonkey-managed script, so this would change its real stored data. Use ${quoted('--force')} to continue.`);
726
+ return false;
727
+ }
728
+ await other.setValues({ [key]: value });
729
+ }
730
+ else {
731
+ await ctx.host.setValues(scriptId, { [key]: value });
732
+ }
733
+ ctx.out(`[sm-dev] Set ${key}${whose}`);
734
+ return;
735
+ }
736
+ case 'reload': {
737
+ const noWait = flags['--nowait'] === true;
738
+ const timeoutMs = timeoutFlagMs(flags, ctx.out);
739
+ if (timeoutMs === null)
740
+ return false;
741
+ const mode = scriptsMode(ctx, 'reload', flags);
742
+ if (mode === null)
743
+ return false;
744
+ const named = namedTab(ctx, 'reload', parsed);
745
+ if (named === null)
746
+ return false;
747
+ if (named === 'all') {
748
+ // All at once, so one slow page holds up no other.
749
+ const all = reloadable(ctx.tabs(), ctx.out);
750
+ for (const t of all)
751
+ if (!await switchScripts(ctx, t, mode))
752
+ return false;
753
+ const loaded = await Promise.all(all.map((t) => reloadTab(t, ctx.host, ctx.out, timeoutMs)));
754
+ if (!noWait)
755
+ await settleTabs(ctx.host, all.filter((_, i) => loaded[i]), ctx.out, timeoutMs);
756
+ return;
757
+ }
758
+ const target = named !== undefined ? pickTab(ctx, named) : tab;
759
+ if (!target || blockedByDialog([target]))
760
+ return false;
761
+ if (!await switchScripts(ctx, target, mode))
762
+ return false;
763
+ if (await reloadTab(target, ctx.host, ctx.out, timeoutMs) && !noWait)
764
+ await settleTabs(ctx.host, [target], ctx.out, timeoutMs);
765
+ return;
766
+ }
767
+ case 'load': {
768
+ if (args.length === 0) {
769
+ ctx.out(MISTAKE_PREFIX + 'The `load` command needs a script file');
770
+ return false;
771
+ }
772
+ if (!ctx.loadScript) {
773
+ ctx.out('[sm-dev] This run cannot load scripts');
774
+ return;
775
+ }
776
+ const timeoutMs = timeoutFlagMs(flags, ctx.out);
777
+ if (timeoutMs === null)
778
+ return false;
779
+ // It reloads every tab it affects, so any tab's dialog blocks it.
780
+ if (blockedByDialog(ctx.tabs()))
781
+ return;
782
+ // All expanded first, so a glob matching nothing loads none.
783
+ for (const file of expandPathArgs(args, cwd, { scripts: true })) {
784
+ // A relative path sent from another directory is the sender's.
785
+ await ctx.loadScript(cwd !== process.cwd() && !path.isAbsolute(file) ? path.resolve(cwd, file) : file, timeoutMs);
786
+ }
787
+ return;
788
+ }
789
+ case 'update': {
790
+ if (!ctx.updateScripts) {
791
+ ctx.out('[sm-dev] This run cannot update scripts');
792
+ return;
793
+ }
794
+ if (ctx.scripts.length === 0) {
795
+ ctx.out(`[sm-dev] ${NO_SCRIPTS}`);
796
+ return;
797
+ }
798
+ const timeoutMs = timeoutFlagMs(flags, ctx.out);
799
+ if (timeoutMs === null)
800
+ return false;
801
+ if (blockedByDialog(ctx.tabs()))
802
+ return;
803
+ if (!text || text === 'all') {
804
+ await ctx.updateScripts(undefined, timeoutMs);
805
+ return;
806
+ }
807
+ const script = pickScript(ctx, text);
808
+ if (!script)
809
+ return false;
810
+ await ctx.updateScripts([script], timeoutMs);
811
+ return;
812
+ }
813
+ case 'open': {
814
+ // `--noscript` / `--script` switch the tab's mode, which lasts,
815
+ // as does `--from-saved`.
816
+ const saved = savedFlags(ctx, flags);
817
+ if (!saved)
818
+ return false;
819
+ const noWait = flags['--nowait'] === true;
820
+ const timeoutMs = timeoutFlagMs(flags, ctx.out);
821
+ if (timeoutMs === null)
822
+ return false;
823
+ const mode = scriptsMode(ctx, 'open', flags);
824
+ if (mode === null)
825
+ return false;
826
+ // A glob stands for the files it matches, of which it takes one.
827
+ const page = args.flatMap((w) => expandPathArg(w, cwd));
828
+ // `open T2 <page>`: a tab first, as page commands take one.
829
+ let inTab;
830
+ if (page.length === 2 && TAB_ARG.test(page[0])) {
831
+ const picked = pickTab(ctx, page[0]);
832
+ if (!picked)
833
+ return false;
834
+ inTab = picked;
835
+ page.shift();
836
+ }
837
+ // Rather than a page at http://all.
838
+ if (page[0] === 'all' && page.length === 1) {
839
+ ctx.out(MISTAKE_PREFIX + 'The `open` command navigates one tab. To open a tab for each script, use `newtab all`.');
840
+ return false;
841
+ }
842
+ if (page.length > 1) {
843
+ ctx.out(MISTAKE_PREFIX + 'The `open` command opens one page, so it takes one URL, file or script. To open multiple, use `newtab`.');
844
+ return false;
845
+ }
846
+ const where = page[0] ?? '';
847
+ // `T2`: the selected script's page, in T2; `T2.3`: script 3's. Only
848
+ // those shapes, so a host like `t2.example.com` stays a URL.
849
+ const target = inTab ? { tab: inTab, rest: where } : TAB_REF.test(where) ? tabRef(ctx, where, tab) : { tab, rest: where };
850
+ if (!target)
851
+ return false;
852
+ const named = target.rest ? await pageFor(ctx, target.rest, cwd) : undefined;
853
+ if (named === null)
854
+ return false;
855
+ const [opening] = await pagesToOpen(named ? [{ word: target.rest, url: named }] : [], saved, cwd);
856
+ // With the mode switched and no page named, the page it's on.
857
+ const url = opening?.url ?? (mode !== undefined ? target.tab.page.url()
858
+ : current ? scriptPage(ctx, current) : null);
859
+ if (!url) {
860
+ if (!current)
861
+ ctx.out(MISTAKE_PREFIX + 'The `open` command needs a URL, since no script is loaded');
862
+ return false;
863
+ }
864
+ if (blockedByDialog([target.tab]))
865
+ return false;
866
+ if (!await switchScripts(ctx, target.tab, mode))
867
+ return false;
868
+ if (opening?.saved) {
869
+ await serveSavedPage(target.tab, opening.saved, ctx.out, target.tab.noScript === true || ctx.scripts.length === 0);
870
+ ctx.out(openingSavedLine(opening.saved.given, url, tabName(target.tab)));
871
+ }
872
+ if (!await gotoInTab(target.tab, ctx.host, url, ctx.out, timeoutMs))
873
+ return false;
874
+ if (!noWait)
875
+ await settleTabs(ctx.host, [target.tab], ctx.out, timeoutMs);
876
+ return;
877
+ }
878
+ case 'wait':
879
+ return waitFor(ctx, parsed);
880
+ case 'logs':
881
+ return logsCommand(ctx, parsed, picked);
882
+ case 'focus': {
883
+ const named = namedTab(ctx, 'focus', parsed);
884
+ if (named === null)
885
+ return false;
886
+ const target = named !== undefined ? pickTab(ctx, named) : tab;
887
+ if (!target)
888
+ return false;
889
+ const which = ctx.tabs().length > 1 ? tabName(target) : 'the tab';
890
+ await focusTab(target.page)
891
+ .then(() => ctx.out(`[sm-dev] Brought ${which} to the front`))
892
+ .catch((err) => ctx.out(`[sm-dev] Could not bring ${which} to the front: ${err.message}`));
893
+ return;
894
+ }
895
+ case 'connect':
896
+ return answerConnectCommand(ctx, args);
897
+ case 'accept':
898
+ case 'dismiss': {
899
+ const target = parsed.tab !== undefined ? pickTab(ctx, parsed.tab) : tab;
900
+ if (!target)
901
+ return false;
902
+ const dialog = target.dialogs?.take();
903
+ if (!dialog) {
904
+ const elsewhere = ctx.tabs().find((t) => t.dialogs?.current);
905
+ ctx.out(elsewhere
906
+ ? `[sm-dev] No dialog is open on ${tabName(target)}. ${tabName(elsewhere)} has one; name its tab: ${quoted(`${command} ${tabName(elsewhere)}${text ? ' ...' : ''}`)}.`
907
+ : '[sm-dev] No dialog is open');
908
+ return;
909
+ }
910
+ if (text && dialog.type() !== 'prompt') {
911
+ ctx.out(`${MISTAKE_PREFIX}Only a prompt dialog takes text. This one is ${dialogKind(dialog)}.`);
912
+ target.dialogs.putBack(dialog);
913
+ return;
914
+ }
915
+ // A bare accept keeps the prompt's own default, as pressing OK
916
+ // on it would.
917
+ const answered = command === 'accept'
918
+ ? dialog.accept(text || dialog.defaultValue())
919
+ : dialog.dismiss();
920
+ await answered.then(() => ctx.out(`[sm-dev] ${command === 'accept' ? 'Accepted' : 'Dismissed'} the dialog`), () => ctx.out('[sm-dev] The dialog was already closed'));
921
+ return;
922
+ }
923
+ default: {
924
+ if (!PAGE_COMMANDS.has(command)) {
925
+ ctx.out(`${MISTAKE_PREFIX}${notACommand(command)}`);
926
+ return false;
927
+ }
928
+ // The tab named first, or the current one.
929
+ let target = tab;
930
+ if (parsed.tab !== undefined) {
931
+ const named = pickTab(ctx, parsed.tab);
932
+ if (!named)
933
+ return false;
934
+ target = named;
935
+ }
936
+ // They reach into a page, which a dialog has stopped.
937
+ if (blockedByDialog([target]))
938
+ return false;
939
+ let evalIn;
940
+ if (command === 'eval') {
941
+ // A tab that runs no scripts has no script to be in, so a bare
942
+ // `eval` there runs in the page without a word about it.
943
+ const page = parsed.flags['--page'] === true || (target.noScript === true && parsed.flags['--script'] === undefined);
944
+ const resolved = evalScope(ctx, parsed.flags['--script'], page);
945
+ if (!resolved)
946
+ return false;
947
+ evalIn = resolved;
948
+ }
949
+ return runPageCommand(command, { ...parsed, userscripts: runningOn(ctx, target).map(userscriptLabel), evalIn, json: ctx.json === true }, target, ctx.out, cwd);
950
+ }
951
+ }
952
+ }
953
+ /**
954
+ * The tab a command whose one argument is a tab names: the one the
955
+ * parser took, or else the word given, which `pickTab` checks (a
956
+ * target id's start, which isn't tab-shaped enough to lead a page
957
+ * command). Undefined for none; null (said why) for more than one.
958
+ */
959
+ function namedTab(ctx, command, parsed) {
960
+ if (parsed.tab !== undefined && parsed.words.length > 0) {
961
+ ctx.out(`${MISTAKE_PREFIX}The ${quoted(command)} command takes one tab`);
962
+ return null;
963
+ }
964
+ return parsed.tab ?? parsed.words[0];
965
+ }
966
+ // How a script is shown in every list: its @name, and the file when
967
+ // that says something the name doesn't.
968
+ function scriptLabel(script) {
969
+ const name = preparedScriptName(script);
970
+ const file = preparedScriptPath(script);
971
+ // No `@name`: the name is its file already, so the path alone.
972
+ return name === script.script.relativePath || name === file ? file : `${name} (${file})`;
973
+ }
974
+ /** `runLogLabel` marked up, for output: the log's path as one. */
975
+ export function runLogLabelUi(file, id) {
976
+ return ui `${S.url(file)}${runLogLabel(file, id).slice(file.length)}`;
977
+ }
978
+ // `scriptLabel` marked up, for output.
979
+ function scriptLabelUi(script) {
980
+ const name = preparedScriptName(script);
981
+ const file = preparedScriptPath(script);
982
+ return name === script.script.relativePath || name === file ? S.url(file) : ui `${S.name(name)} (${S.url(file)})`;
983
+ }
984
+ // `* ` on the selected item in a list, or its spaces, marked up.
985
+ function selectMark(isSelected) {
986
+ return isSelected ? ui `${S.selected('*')} ` : ' ';
987
+ }
988
+ // One script's row in any list of them: its number in the `scripts` list,
989
+ // with `*` on the one `get` and `set` act on. Under another manager,
990
+ // with its E-id there. Marked up, so only for output.
991
+ function scriptRow(ctx, script) {
992
+ const id = ctx.predicted ? ctx.extensionIdOf?.(script) : undefined;
993
+ const there = id !== undefined ? ui `, ${S.id(`E${id}`)} in ${MANAGERS[ctx.managerName ?? DEFAULT_MANAGER].label}` : '';
994
+ return ui `${selectMark(script === selectedScript(ctx))}${S.id(ctx.scripts.indexOf(script) + 1)}: ${scriptLabelUi(script)}${there}`;
995
+ }
996
+ // Under another manager, the run's own script with E-id `label`.
997
+ function ownByLabel(ctx, label) {
998
+ if (!ctx.predicted)
999
+ return undefined;
1000
+ return ctx.scripts.find((script) => `E${ctx.extensionIdOf?.(script)}` === label.toUpperCase());
1001
+ }
1002
+ // The run's script `get` and `set` act on, marked `*` in the lists:
1003
+ // none while a SourceMonkey-managed script (`E1`) is picked instead.
1004
+ function selectedScript(ctx) {
1005
+ return ctx.currentOther ? undefined : ctx.current ?? ctx.scripts[0];
1006
+ }
1007
+ /**
1008
+ * Where `eval` runs: inside the selected script, or the one
1009
+ * `--script=<script>` names; in the page with `--page`.
1010
+ * - With no run script selected (none loaded, or an `E` script picked,
1011
+ * which has no eval hook), a bare `eval` runs in the page, and
1012
+ * `--script` is an error.
1013
+ * - A selected script that hasn't run on the page: a bare `eval` runs
1014
+ * in the page, so its globals aren't silently out of reach. With
1015
+ * `--script` that's a failure, as the script was asked for.
1016
+ * Null, said why, when the name picks none, `--script` has no script,
1017
+ * or the flags clash.
1018
+ */
1019
+ function evalScope(ctx, flag, page) {
1020
+ if (page) {
1021
+ if (flag === undefined)
1022
+ return 'page';
1023
+ ctx.out(`${MISTAKE_PREFIX}The ${quoted('eval')} command takes ${quoted('--page')} or ${quoted('--script')}, not both`);
1024
+ return null;
1025
+ }
1026
+ const script = flag === undefined || flag === true ? selectedScript(ctx) : pickScript(ctx, flag);
1027
+ if (!script) {
1028
+ if (flag === undefined)
1029
+ return 'page';
1030
+ if (flag === true)
1031
+ ctx.out(`${MISTAKE_PREFIX}No script of this run is selected, so ${quoted('eval --script')} has none to run in. Name one with ${quoted('--script=<script>')}, or use ${quoted('--page')}.`);
1032
+ return null;
1033
+ }
1034
+ // No hook: not pushed into the extension, so it hasn't run anywhere.
1035
+ const hook = script.id ? ctx.host.evalHook(script.id) : null;
1036
+ // Another manager reports nothing, so a missed scope there says
1037
+ // nothing about whether the script ran.
1038
+ const manager = ctx.managerName && ctx.managerName !== DEFAULT_MANAGER ? MANAGERS[ctx.managerName].label : undefined;
1039
+ return { scope: hook && { world: script.world, ...hook }, name: preparedScriptName(script), inPageIfMissing: flag === undefined, manager };
1040
+ }
1041
+ /**
1042
+ * The script a typed argument names: its number in the `scripts` list, an
1043
+ * exact path, file name or `@name`, or else a piece of its name or path
1044
+ * that no other loaded script shares. Reports why and returns null when
1045
+ * there is no single answer.
1046
+ */
1047
+ function pickScript(ctx, arg) {
1048
+ const own = ownByLabel(ctx, arg);
1049
+ if (own)
1050
+ return own;
1051
+ if (/^\d+$/.test(arg)) {
1052
+ const script = ctx.scripts[Number(arg) - 1];
1053
+ if (!script)
1054
+ ctx.out(`[sm-dev] There is no script ${quoted(arg)}. Use ${quoted('scripts')} to list them.`);
1055
+ return script ?? null;
1056
+ }
1057
+ const exact = ctx.scripts.filter((script) => preparedScriptMatches(script, arg));
1058
+ if (exact.length === 1)
1059
+ return exact[0];
1060
+ const needle = arg.toLowerCase();
1061
+ const hits = ctx.scripts.filter((script) => preparedScriptName(script).toLowerCase().includes(needle)
1062
+ || script.script.relativePath.toLowerCase().includes(needle));
1063
+ if (hits.length === 1)
1064
+ return hits[0];
1065
+ if (hits.length === 0) {
1066
+ ctx.out(`[sm-dev] No loaded script matches ${quoted(arg)}. Use ${quoted('scripts')} to list them.`);
1067
+ }
1068
+ else {
1069
+ const numbers = hits.map((script) => ctx.scripts.indexOf(script) + 1).join(', ');
1070
+ ctx.out(`[sm-dev] Multiple loaded scripts match ${quoted(arg)} (${numbers}). Name one by its number.`);
1071
+ }
1072
+ return null;
1073
+ }
1074
+ // ---------- Checking scripts ----------
1075
+ // Where the loaded scripts' local @require copies are, for re-reading
1076
+ // one: the run's roots, or the sender's directory.
1077
+ function rootsOf(ctx, cwd) {
1078
+ return ctx.roots ?? { collectionRoot: cwd, localRoots: [] };
1079
+ }
1080
+ // A loaded script's number and label, as the scripts list shows it.
1081
+ function numbered(ctx, script) {
1082
+ return `${ctx.scripts.indexOf(script) + 1}: ${scriptLabel(script)}`;
1083
+ }
1084
+ /**
1085
+ * The `collection` and `script` commands for what's installed (`collection`
1086
+ * alone lists). `parsed`: its words as command-args.ts reads them, the
1087
+ * verb as `form` (`list`), what it acts on as `subject` (`C3` in
1088
+ * `collection C3 show`). `cwd`: where a path `script install` is given
1089
+ * is from.
1090
+ */
1091
+ export async function managerCommand(ctx, command, parsed, cwd = process.cwd()) {
1092
+ const { form, subject, words: args, flags } = parsed;
1093
+ if (form === undefined && args.length > 0) {
1094
+ ctx.out(`${MISTAKE_PREFIX}${noSuchVerb(args)}`);
1095
+ return false;
1096
+ }
1097
+ if (!ctx.manager) {
1098
+ ctx.out('[sm-dev] This run has no SourceMonkey to manage');
1099
+ return false;
1100
+ }
1101
+ const kind = command === 'script' ? 'script' : 'collection';
1102
+ if (form !== undefined && form !== 'list') {
1103
+ return managerVerb({
1104
+ manager: ctx.manager,
1105
+ out: ctx.out,
1106
+ json: ctx.json === true,
1107
+ ...(ctx.scripts ? { run: managerRun(ctx) } : {}),
1108
+ // A throwaway browser's data is the run's own, as for `set`; with
1109
+ // no run, the browser is always the person's.
1110
+ guarded: ctx.others?.guarded ?? true,
1111
+ cwd,
1112
+ }, kind, form, subject, args, flags);
1113
+ }
1114
+ try {
1115
+ if (kind === 'script')
1116
+ await scriptList(ctx.manager, args[0], ctx.out, ctx.json === true, runView(ctx));
1117
+ else
1118
+ await collectionList(ctx.manager, ctx.out, ctx.json === true);
1119
+ }
1120
+ catch (err) {
1121
+ // What it named isn't there, or SourceMonkey failed: a failure, not
1122
+ // a mistake in the command, as `pickScript` reports one.
1123
+ ctx.out(`[sm-dev] ${firstLine(err)}`);
1124
+ return false;
1125
+ }
1126
+ }
1127
+ // In a run, what the commands that show and change things need of it.
1128
+ function managerRun(ctx) {
1129
+ const ownScript = (internalId) => ctx.scripts.find((script) => ctx.extensionIdOf?.(script) === internalId);
1130
+ return {
1131
+ ...runView(ctx),
1132
+ updateOwn: async (internalId) => {
1133
+ const script = ownScript(internalId);
1134
+ if (!script || !ctx.updateScripts)
1135
+ throw new Error('This run cannot update scripts');
1136
+ // It reloads the tabs the script runs in, as `update` does, which
1137
+ // an open dialog would hold up.
1138
+ const waiting = ctx.tabs().find((t) => t.dialogs?.current);
1139
+ if (waiting)
1140
+ throw new Error(dialogWaitingMessage(waiting, currentTab(ctx)));
1141
+ await ctx.updateScripts([script]);
1142
+ },
1143
+ tabsOf: async (managed, label) => {
1144
+ const script = ownScript(managed.id);
1145
+ const out = [];
1146
+ for (const tab of ctx.tabs()) {
1147
+ const name = tabName(tab);
1148
+ if (script) {
1149
+ const running = activeOn(ctx, tab, script.id);
1150
+ const waiting = waitingOn(ctx, tab, script.id, running);
1151
+ out.push({ activity: { tab: name, state: activityState(ctx, running, waiting) }, text: activityWord(ctx, running, waiting) });
1152
+ continue;
1153
+ }
1154
+ const other = label === undefined ? undefined : (await ctx.others?.onTab(tab).catch(() => []))?.find((o) => o.label === label);
1155
+ out.push(other
1156
+ ? { activity: { tab: name, state: OTHER_STATE[other.status] ?? 'running' }, text: OTHER_STATUS[other.status] ?? other.status }
1157
+ : { activity: { tab: name, state: activityState(ctx, false) }, text: ctx.predicted ? 'Not expected using match rules' : 'Not on this page' });
1158
+ }
1159
+ return out;
1160
+ },
1161
+ };
1162
+ }
1163
+ // In a run, how `script list` shows the run's own scripts: by their
1164
+ // numbers here (`2`), with the selected script (one of them, or an `E`
1165
+ // script picked) marked.
1166
+ function runView(ctx) {
1167
+ if (!ctx.scripts)
1168
+ return undefined;
1169
+ const own = new Map();
1170
+ ctx.scripts.forEach((script, i) => {
1171
+ const id = ctx.extensionIdOf?.(script);
1172
+ if (id)
1173
+ own.set(id, String(i + 1));
1174
+ });
1175
+ const selected = ctx.currentOther ? { label: ctx.currentOther.label } : undefined;
1176
+ const mine = ctx.currentOther ? undefined : ctx.current ?? ctx.scripts[0];
1177
+ const mineId = mine && ctx.extensionIdOf?.(mine);
1178
+ return { own, selected: selected ?? (mineId ? { internalId: mineId } : undefined) };
1179
+ }
1180
+ /**
1181
+ * `match` in a run: the selected script on the current tab, unless the
1182
+ * words name others. Scripts: a number, `T2.3`, a loaded script's
1183
+ * name or file name, a `.user.js` file (or its URL), `all-scripts`.
1184
+ * Pages: a tab, `T2.3`'s tab, a URL, `all-tabs`. `all` is both. 1 when
1185
+ * any page isn't matched.
1186
+ */
1187
+ async function matchCommand(ctx, words, cwd, withFrames = false) {
1188
+ const loaded = [];
1189
+ const files = [];
1190
+ const pages = [];
1191
+ let allScripts = false;
1192
+ let allTabs = false;
1193
+ if (withFrames)
1194
+ await loadFrames(ctx);
1195
+ // With --frames, a tab's iframes are checked too.
1196
+ const framesOf = (tab) => (withFrames ? {
1197
+ frames: (ctx.frames?.get(tab) ?? []).map((f) => ({
1198
+ id: `F${f.n}`, parent: f.parent === null ? tabName(tab) : `F${f.parent}`, url: f.url, depth: f.depth,
1199
+ })),
1200
+ } : {});
1201
+ const tabPage = (tab) => ({ url: tab.page.url(), name: tabName(tab), ...framesOf(tab) });
1202
+ for (const word of words) {
1203
+ const lower = word.toLowerCase();
1204
+ if (lower === 'all' || lower === 'all-scripts')
1205
+ allScripts = true;
1206
+ if (lower === 'all' || lower === 'all-tabs')
1207
+ allTabs = true;
1208
+ if (lower === 'all' || lower === 'all-scripts' || lower === 'all-tabs')
1209
+ continue;
1210
+ const ref = /^[Tt](\d+)(?:\.(\d+))?$/.exec(word);
1211
+ if (ref) {
1212
+ const tab = pickTab(ctx, `T${ref[1]}`);
1213
+ if (!tab)
1214
+ return 2;
1215
+ pages.push(tabPage(tab));
1216
+ if (ref[2] !== undefined) {
1217
+ const script = pickScript(ctx, ref[2]);
1218
+ if (!script)
1219
+ return 2;
1220
+ loaded.push(script);
1221
+ }
1222
+ }
1223
+ else if (/^\d+$/.test(word)) {
1224
+ const script = pickScript(ctx, word);
1225
+ if (!script)
1226
+ return 2;
1227
+ loaded.push(script);
1228
+ }
1229
+ else if (ctx.scripts.some((script) => preparedScriptMatches(script, word))) {
1230
+ // A loaded script by its exact name or path, before a file or URL.
1231
+ loaded.push(...ctx.scripts.filter((script) => preparedScriptMatches(script, word)));
1232
+ }
1233
+ else {
1234
+ // A glob expanded, a directory taken for its scripts.
1235
+ for (const found of expandPathArg(word, cwd, { scripts: true })) {
1236
+ if (SCRIPT_ARG.test(found))
1237
+ files.push(found);
1238
+ else
1239
+ pages.push({ url: withDefaultScheme(found, cwd) });
1240
+ }
1241
+ }
1242
+ }
1243
+ if (allScripts)
1244
+ loaded.splice(0, loaded.length, ...ctx.scripts);
1245
+ const current = ctx.current ?? ctx.scripts[0];
1246
+ if (loaded.length === 0 && files.length === 0) {
1247
+ if (!current) {
1248
+ ctx.out(`[sm-dev] ${NO_SCRIPTS}. Name a .user.js file to match.`);
1249
+ return 1;
1250
+ }
1251
+ loaded.push(current);
1252
+ }
1253
+ // Every tab, then any URLs named.
1254
+ if (allTabs)
1255
+ pages.splice(0, pages.length, ...ctx.tabs().map(tabPage), ...pages.filter((p) => p.name === undefined));
1256
+ if (pages.length === 0) {
1257
+ const tab = currentTab(ctx);
1258
+ if (!tab) {
1259
+ ctx.out(NO_TABS);
1260
+ return 1;
1261
+ }
1262
+ // Named only when there is another tab it could have been, or
1263
+ // frames under it to tell it from.
1264
+ pages.push(ctx.tabs().length > 1 || withFrames ? tabPage(tab) : { url: tab.page.url() });
1265
+ }
1266
+ // Each once, as on the command line.
1267
+ const unique = pages.filter((p, i) => pages.findIndex((q) => q.url === p.url && q.name === p.name) === i);
1268
+ pages.splice(0, pages.length, ...unique);
1269
+ const scripts = [...new Set(loaded)].map((script) => ({ label: numbered(ctx, script), prepared: script }));
1270
+ const loadedCount = scripts.length;
1271
+ for (const source of await scriptSources(files, cwd))
1272
+ scripts.push({ label: source.label, prepared: await prepareSource(source, rootsOf(ctx, cwd)) });
1273
+ if (ctx.json) {
1274
+ const results = scripts.map(({ label, prepared }, i) => ({
1275
+ script: i < loadedCount ? scriptNumber(ctx, prepared) : label,
1276
+ name: preparedScriptName(prepared),
1277
+ ...matchJson(prepared, pages),
1278
+ }));
1279
+ ctx.out(jsonResult('match', { results }));
1280
+ return results.every((r) => r.valid && r.pages.every((p) => p.matches)) ? 0 : 1;
1281
+ }
1282
+ let misses = 0;
1283
+ for (const [i, { label, prepared }] of scripts.entries()) {
1284
+ // Headed only when there are several, as on the command line.
1285
+ if (scripts.length > 1) {
1286
+ if (i > 0)
1287
+ ctx.out('');
1288
+ ctx.out(label);
1289
+ }
1290
+ const report = matchLines(prepared, pages, scripts.length > 1 ? undefined : label);
1291
+ misses += report.misses;
1292
+ for (const line of report.lines)
1293
+ ctx.out(line);
1294
+ }
1295
+ return misses === 0 ? 0 : 1;
1296
+ }
1297
+ /**
1298
+ * `validate` in a run: the selected script re-read from disk, unless
1299
+ * the words name others: a number, `T2.3`, `all` (every loaded
1300
+ * script), or files and directories. 1 when any is invalid.
1301
+ */
1302
+ async function validateCommand(ctx, words, cwd) {
1303
+ const loaded = [];
1304
+ const sources = [];
1305
+ const paths = [];
1306
+ for (const word of words) {
1307
+ if (word.toLowerCase() === 'all') {
1308
+ loaded.push(...ctx.scripts);
1309
+ continue;
1310
+ }
1311
+ const ref = /^(?:[Tt](\d+)\.)?(\d+)$/.exec(word);
1312
+ if (!ref) {
1313
+ paths.push(word);
1314
+ continue;
1315
+ }
1316
+ if (ref[1] !== undefined && !pickTab(ctx, `T${ref[1]}`))
1317
+ return 2;
1318
+ const script = pickScript(ctx, ref[2]);
1319
+ if (!script)
1320
+ return 2;
1321
+ loaded.push(script);
1322
+ }
1323
+ if (paths.length > 0)
1324
+ sources.push(...await validateSources(paths, cwd));
1325
+ if (words.length === 0) {
1326
+ const current = ctx.current ?? ctx.scripts[0];
1327
+ if (!current) {
1328
+ ctx.out(`[sm-dev] ${NO_SCRIPTS}. Name a file or directory to validate.`);
1329
+ return 1;
1330
+ }
1331
+ loaded.push(current);
1332
+ }
1333
+ const reread = [];
1334
+ for (const script of new Set(loaded)) {
1335
+ const url = script.script.fileUrl;
1336
+ if (!url.startsWith('file:')) {
1337
+ ctx.out(`[sm-dev] Script ${numbered(ctx, script)} has no file to re-read`);
1338
+ return 1;
1339
+ }
1340
+ const file = fileURLToPath(url);
1341
+ reread.push({ label: shownPath(file, cwd), file });
1342
+ }
1343
+ // The loaded scripts first, in their order, then the paths.
1344
+ sources.unshift(...reread);
1345
+ return validateAll(sources, rootsOf(ctx, cwd), (line) => ctx.out(line), ctx.json === true);
1346
+ }
1347
+ // ---------- Tabs ----------
1348
+ // `--noscript` / `--script` among a command's flags: true for no
1349
+ // scripts, false for scripts, undefined for neither, and null (said
1350
+ // why) for both.
1351
+ function scriptsMode(ctx, command, flags) {
1352
+ if (flags['--noscript'] && flags['--script']) {
1353
+ ctx.out(`${MISTAKE_PREFIX}The ${quoted(command)} command takes ${quoted('--noscript')} or ${quoted('--script')}, not both`);
1354
+ return null;
1355
+ }
1356
+ return flags['--noscript'] ? true : flags['--script'] ? false : undefined;
1357
+ }
1358
+ // `--from-saved`, `--url URL` and `--offline` among a command's flags;
1359
+ // null (said why) when `--url` is empty. What they combine with is
1360
+ // `pagesToOpen`'s to check.
1361
+ function savedFlags(ctx, flags) {
1362
+ const url = flags['--url'];
1363
+ if (url === '') {
1364
+ ctx.out(MISTAKE_PREFIX + 'The --url flag needs a URL, e.g. --url https://example.com/page');
1365
+ return null;
1366
+ }
1367
+ return { fromSaved: flags['--from-saved'] === true, url: typeof url === 'string' ? url : undefined, offline: flags['--offline'] === true };
1368
+ }
1369
+ /**
1370
+ * Warns when a saved page was saved with userscripts running: their
1371
+ * changes are in it, so running them again may apply them twice.
1372
+ * Nothing to say for a tab that runs none.
1373
+ */
1374
+ export function warnSavedUserscripts(out, given, header, noScript) {
1375
+ if (noScript || header.userscripts.length === 0)
1376
+ return;
1377
+ out(`[sm-dev] ${given} was saved with userscripts running, so their changes are already in it: ${header.userscripts.join(', ')}`);
1378
+ }
1379
+ // The pages already routed for saved pages. The route reads the tab's
1380
+ // `saved` on each request, so it is added once per page.
1381
+ const routedPages = new WeakSet();
1382
+ /**
1383
+ * Makes `tab` serve `saved` from now on: its URL gets the file, read
1384
+ * fresh on each load. It lasts until another saved page replaces it;
1385
+ * loading another URL in the tab goes to the live site meanwhile.
1386
+ */
1387
+ /**
1388
+ * Starts serving a saved page in `tab` (`serveSaved`), first warning of
1389
+ * the userscripts whose changes its file has, unless `runsNone`: the
1390
+ * tab runs no scripts, or none are loaded.
1391
+ */
1392
+ export async function serveSavedPage(tab, saved, out, runsNone) {
1393
+ const { userscripts, ...file } = saved;
1394
+ warnSavedUserscripts(out, file.given, { url: file.url, userscripts }, runsNone);
1395
+ await serveSaved(tab, file, out);
1396
+ }
1397
+ export async function serveSaved(tab, saved, out) {
1398
+ tab.saved = saved;
1399
+ if (routedPages.has(tab.page))
1400
+ return;
1401
+ routedPages.add(tab.page);
1402
+ await routeSavedPage(tab.page, () => (tab.saved ? savedPageServe(tab.page, tab.saved, out) : undefined));
1403
+ }
1404
+ // Whether the tab shows its saved page now.
1405
+ function showingSaved(tab) {
1406
+ return tab.saved !== undefined && comparableUrl(tab.page.url()) === comparableUrl(tab.saved.url);
1407
+ }
1408
+ // ` (from saved: page.html)` after a tab's URL, while it shows its saved page.
1409
+ function savedSuffix(tab) {
1410
+ if (!showingSaved(tab))
1411
+ return '';
1412
+ return ` (from saved: ${tab.saved.given}${tab.saved.offline ? ', offline' : ''})`;
1413
+ }
1414
+ // Switches a tab to run no scripts (`noScript` true) or the scripts
1415
+ // again, when that's a change, and says so. The next load in it goes
1416
+ // the new way, and every one after. False (said why) if it can't.
1417
+ async function switchScripts(ctx, tab, noScript) {
1418
+ if (noScript === undefined)
1419
+ return true;
1420
+ if ((tab.noScript === true) === noScript) {
1421
+ ctx.out(`[sm-dev] ${tabName(tab)} already runs ${noScript ? 'no scripts' : 'the scripts'}`);
1422
+ return true;
1423
+ }
1424
+ if (!ctx.setTabScripts) {
1425
+ ctx.out('[sm-dev] This run cannot switch a tab\'s scripts');
1426
+ return false;
1427
+ }
1428
+ try {
1429
+ await ctx.setTabScripts(tab, noScript);
1430
+ }
1431
+ catch (err) {
1432
+ ctx.out(`[sm-dev] ${tabName(tab)} could not be switched to run ${noScript ? 'no scripts' : 'the scripts'}: ${err.message}`);
1433
+ return false;
1434
+ }
1435
+ ctx.out(noScript
1436
+ ? `[sm-dev] ${tabName(tab)} runs no scripts from now on (--script switches back)`
1437
+ : `[sm-dev] ${tabName(tab)} runs the scripts again`);
1438
+ return true;
1439
+ }
1440
+ // ` on T2` in a heading, only when there is more than one tab to
1441
+ // tell apart.
1442
+ function onTab(ctx, tab) {
1443
+ return ctx.tabs().length > 1 ? ` on ${tabName(tab)}` : '';
1444
+ }
1445
+ // `onTab` marked up, for output.
1446
+ function onTabUi(ctx, tab) {
1447
+ return ctx.tabs().length > 1 ? ui ` on ${S.id(tabName(tab))}` : '';
1448
+ }
1449
+ function fromTab(ctx, tab, records) {
1450
+ return records.filter((r) => ctx.host.pageOf(r) === tab.link);
1451
+ }
1452
+ // Reads each tab's frames and the scripts in them, for the command
1453
+ // about to show them. Says once why frames' scripts can't be shown
1454
+ // (an extension too old), listing the frames anyway.
1455
+ //
1456
+ // With `details`, also whether each is hidden and its CDP target.
1457
+ async function loadFrames(ctx, details = false) {
1458
+ const views = new Map();
1459
+ let problem;
1460
+ for (const tab of ctx.tabs()) {
1461
+ views.set(tab, await tabFrames(ctx, tab, (why) => { problem ??= why; }, details).catch(() => []));
1462
+ }
1463
+ ctx.frames = views;
1464
+ if (problem)
1465
+ ctx.out(`[sm-dev] ${problem}`);
1466
+ }
1467
+ // A tab's iframes, each after its parent. Under SourceMonkey, a frame's
1468
+ // scripts are those that started in its current document. Under another
1469
+ // manager, which reports nothing, those whose rules cover its URL, as
1470
+ // `activeOn` predicts for the top frame.
1471
+ async function tabFrames(ctx, tab, unknown, details) {
1472
+ const nodes = frameNodes(tab.page, ctx.host.frameIds);
1473
+ if (nodes.length === 0)
1474
+ return [];
1475
+ let scriptsIn = (_url, _frame) => [];
1476
+ const documentsOf = new Map();
1477
+ if (tab.noScript) {
1478
+ // No scripts run in any of its frames.
1479
+ }
1480
+ else if (ctx.predicted) {
1481
+ scriptsIn = (url) => ctx.scripts.filter((s) => !preparedNoframes(s) && !ctx.predictedOff?.has(ctx.extensionIdOf?.(s)) && explainMatch(s.matching, url).matches);
1482
+ }
1483
+ else {
1484
+ const documents = await ctx.host.liveFrameDocuments(tab.link).catch((err) => {
1485
+ unknown(err.message);
1486
+ return null;
1487
+ }) ?? new Map();
1488
+ const runs = ctx.host.frameRuns(tab.link);
1489
+ const byFrame = new Map();
1490
+ for (const [documentId, frame] of documents) {
1491
+ documentsOf.set(frame, [...documentsOf.get(frame) ?? [], documentId]);
1492
+ for (const id of runs.get(documentId) ?? []) {
1493
+ let ids = byFrame.get(frame);
1494
+ if (!ids)
1495
+ byFrame.set(frame, ids = new Set());
1496
+ ids.add(id);
1497
+ }
1498
+ }
1499
+ scriptsIn = (_url, frame) => ctx.scripts.filter((s) => s.id !== undefined && byFrame.get(frame)?.has(s.id) === true);
1500
+ }
1501
+ // All at once, each bounded: a frame's page that's busy only costs
1502
+ // its own details.
1503
+ const found = (id) => (id === 'timeout' ? undefined : id);
1504
+ const detailOf = (frame) => (details ? Promise.all([
1505
+ within(frame.frameElement().then((el) => el.isVisible()), DETAIL_MS).catch(() => true),
1506
+ within(tab.link.frameCdpId(frame), DETAIL_MS).catch(() => undefined),
1507
+ within(tab.link.frameTargetId(frame), DETAIL_MS).catch(() => undefined),
1508
+ ]).then(([visible, frameId, targetId]) => ({
1509
+ // Unknown (too slow) counts as visible: only a frame known to be hidden is marked.
1510
+ ...(visible === false ? { hidden: true } : {}),
1511
+ ...(found(frameId) ? { frameId: found(frameId) } : {}),
1512
+ ...(found(targetId) ? { targetId: found(targetId) } : {}),
1513
+ })) : Promise.resolve({}));
1514
+ const detailed = await Promise.all(nodes.map((node) => detailOf(node.frame)));
1515
+ return nodes.map((node, i) => ({
1516
+ n: node.n, parent: node.parent, depth: node.depth, url: node.url, scripts: scriptsIn(node.url, node.frame), documents: documentsOf.get(node.frame) ?? [],
1517
+ ...detailed[i],
1518
+ }));
1519
+ }
1520
+ // How long a frame's details (`loadFrames`) may take to read.
1521
+ const DETAIL_MS = 1000;
1522
+ // The numbers of a tab's frames a script runs in.
1523
+ function framesRunning(ctx, tab, scriptId) {
1524
+ if (scriptId === undefined)
1525
+ return [];
1526
+ return (ctx.frames?.get(tab) ?? []).filter((f) => f.scripts.some((s) => s.id === scriptId)).map((f) => f.n);
1527
+ }
1528
+ // Whether a script runs anywhere in a tab: its top frame or an iframe.
1529
+ function activeInTab(ctx, tab, scriptId) {
1530
+ return activeOn(ctx, tab, scriptId) || framesRunning(ctx, tab, scriptId).length > 0;
1531
+ }
1532
+ // Where in a tab a script runs, by frame ids: the tab's own id for its
1533
+ // top frame. Undefined when that's the top frame only, or nowhere.
1534
+ function framesWhere(ctx, tab, scriptId) {
1535
+ const frames = framesRunning(ctx, tab, scriptId);
1536
+ if (frames.length === 0)
1537
+ return undefined;
1538
+ return [...(activeOn(ctx, tab, scriptId) ? [tabName(tab)] : []), ...frames.map((n) => `F${n}`)];
1539
+ }
1540
+ // `framesWhere` after a tab's name or a script's: ` (T1, F1, F3)`.
1541
+ function framesSuffix(ctx, tab, scriptId) {
1542
+ const where = framesWhere(ctx, tab, scriptId);
1543
+ return where ? ui ` (${idList(where)})` : '';
1544
+ }
1545
+ // IDs joined by commas, each marked up.
1546
+ function idList(ids) {
1547
+ return ui `${ids.map((i) => S.id(i)).join(', ')}`;
1548
+ }
1549
+ // After a tab's URL: how many iframes it has.
1550
+ function frameCountSuffix(ctx, tab) {
1551
+ const n = ctx.frames?.get(tab)?.length ?? 0;
1552
+ return n === 0 ? '' : ` (+${count(n, 'frame')})`;
1553
+ }
1554
+ // A tab's frame tree: the top frame (the tab's id), then each iframe
1555
+ // under its parent, each with the scripts in it. `indent` goes before
1556
+ // each line; `from` limits it to that frame and the frames in it.
1557
+ function frameTreeLines(ctx, tab, indent, from) {
1558
+ const label = ctx.predicted ? 'Expected' : 'Scripts';
1559
+ const scriptsLine = (scripts, pad) => ui `${pad} ${S.heading(label)}: ${scripts.length === 0 ? 'None' : idList(scripts.map((s) => scriptNumber(ctx, s)))}`;
1560
+ const frames = ctx.frames?.get(tab) ?? [];
1561
+ const lines = [];
1562
+ let base = 0;
1563
+ let shown = frames;
1564
+ if (from === undefined) {
1565
+ // The top frame's frame id is its tab's target id.
1566
+ const ids = tab.targetId ? ` [frameId=${tab.targetId}]${targetIdSuffix(tab.targetId)}` : '';
1567
+ lines.push(ui `${indent}${S.id(tabName(tab))}: ${S.url(tab.page.url())}${ids}`, scriptsLine(runningOn(ctx, tab), indent));
1568
+ }
1569
+ else {
1570
+ // The frame and those under it: those after it, until one no deeper.
1571
+ const at = frames.findIndex((f) => f.n === from);
1572
+ const end = frames.findIndex((f, i) => i > at && f.depth <= frames[at].depth);
1573
+ shown = frames.slice(at, end < 0 ? undefined : end);
1574
+ base = frames[at].depth;
1575
+ }
1576
+ for (const frame of shown) {
1577
+ const pad = `${indent}${' '.repeat(frame.depth - base)}`;
1578
+ // A frame's own target, when it has one, is the same id as its frame
1579
+ // id; both are shown, so each is there to copy.
1580
+ const ids = `${frame.frameId ? ` [frameId=${frame.frameId}]` : ''}${targetIdSuffix(frame.targetId)}`;
1581
+ lines.push(ui `${pad}${S.id(`F${frame.n}`)}: ${S.url(frame.url)}${frame.hidden ? ' (hidden)' : ''}${ids}`, scriptsLine(frame.scripts, pad));
1582
+ }
1583
+ return lines;
1584
+ }
1585
+ // A tab's frames as JSON: absent for none.
1586
+ function framesJson(ctx, tab) {
1587
+ const frames = ctx.frames?.get(tab) ?? [];
1588
+ if (frames.length === 0)
1589
+ return {};
1590
+ return {
1591
+ frames: frames.map((f) => ({
1592
+ id: `F${f.n}`, parent: f.parent === null ? tabName(tab) : `F${f.parent}`, url: f.url, scripts: f.scripts.map((s) => scriptNumber(ctx, s)),
1593
+ ...(f.hidden ? { hidden: true } : {}), ...(f.frameId ? { frameId: f.frameId } : {}), ...(f.targetId ? { targetId: f.targetId } : {}),
1594
+ })),
1595
+ };
1596
+ }
1597
+ // `frames [tab|frame|all]`.
1598
+ function framesCommand(ctx, words, current) {
1599
+ const word = words[0];
1600
+ const tabs = [];
1601
+ if (word?.toLowerCase() === 'all') {
1602
+ if (ctx.tabs().length === 0) {
1603
+ ctx.out(NO_TABS);
1604
+ return false;
1605
+ }
1606
+ tabs.push(...ctx.tabs().map((tab) => ({ tab })));
1607
+ }
1608
+ else if (word && CDP_FRAME_ARG.test(word)) {
1609
+ // An iframe by its CDP id, or a tab by its target id: its top frame's.
1610
+ const id = CDP_FRAME_ARG.exec(word)[1];
1611
+ const hits = [
1612
+ ...ctx.tabs().filter((t) => t.targetId?.toUpperCase().startsWith(id.toUpperCase())).map((tab) => ({ tab, name: tabName(tab), from: undefined })),
1613
+ ...framesWithId(ctx, id).map((h) => ({ tab: h.tab, name: `F${h.frame.n}`, from: h.frame.n })),
1614
+ ];
1615
+ if (hits.length !== 1) {
1616
+ ctx.out(hits.length > 1
1617
+ ? `[sm-dev] Multiple frames have ids starting with ${quoted(id)} (${hits.map((h) => h.name).join(', ')}). Give more of it.`
1618
+ : `[sm-dev] No frame's id starts with ${quoted(id)}. Use ${quoted('frames all')} to list them.`);
1619
+ return false;
1620
+ }
1621
+ tabs.push({ tab: hits[0].tab, from: hits[0].from });
1622
+ }
1623
+ else if (word && parseFrameRef(word)) {
1624
+ const ref = parseFrameRef(word);
1625
+ const found = ctx.tabs().find((t) => (ctx.frames?.get(t) ?? []).some((f) => f.n === ref.frame));
1626
+ if (!found || (ref.tab !== undefined && found.n !== ref.tab)) {
1627
+ ctx.out(`${MISTAKE_PREFIX}${ref.tab !== undefined ? `T${ref.tab} has no frame F${ref.frame}` : `No tab has a frame F${ref.frame}`}. Use ${quoted('frames all')} to list them.`);
1628
+ return false;
1629
+ }
1630
+ tabs.push({ tab: found, from: ref.frame });
1631
+ }
1632
+ else if (word) {
1633
+ const tab = pickTab(ctx, word);
1634
+ if (!tab)
1635
+ return false;
1636
+ tabs.push({ tab });
1637
+ }
1638
+ else {
1639
+ if (!current) {
1640
+ ctx.out(NO_TABS);
1641
+ return false;
1642
+ }
1643
+ tabs.push({ tab: current });
1644
+ }
1645
+ if (ctx.json) {
1646
+ ctx.out(jsonResult('frames', {
1647
+ tabs: tabs.map(({ tab, from }) => {
1648
+ const all = framesJson(ctx, tab).frames ?? [];
1649
+ const under = from === undefined ? all : subtree(all, `F${from}`);
1650
+ return { tab: tabName(tab), url: tab.page.url(), scripts: runningOn(ctx, tab).map((s) => scriptNumber(ctx, s)), frames: under };
1651
+ }),
1652
+ }));
1653
+ return;
1654
+ }
1655
+ for (const { tab, from } of tabs) {
1656
+ for (const line of frameTreeLines(ctx, tab, '', from))
1657
+ ctx.out(line);
1658
+ if (from === undefined && (ctx.frames?.get(tab)?.length ?? 0) === 0)
1659
+ ctx.out(' No iframes');
1660
+ }
1661
+ }
1662
+ // The iframes, of any tab, whose CDP frame id starts with `id`.
1663
+ function framesWithId(ctx, id) {
1664
+ const upper = id.toUpperCase();
1665
+ return ctx.tabs().flatMap((tab) => (ctx.frames?.get(tab) ?? [])
1666
+ .filter((frame) => frame.frameId?.toUpperCase().startsWith(upper)).map((frame) => ({ tab, frame })));
1667
+ }
1668
+ // A frame and those under it, from a tab's frames as JSON.
1669
+ function subtree(frames, id) {
1670
+ const ids = new Set([id]);
1671
+ return frames.filter((f) => {
1672
+ if (f.id === id)
1673
+ return true;
1674
+ if (!ids.has(f.parent))
1675
+ return false;
1676
+ ids.add(f.id);
1677
+ return true;
1678
+ });
1679
+ }
1680
+ // Whether a script is running on a tab's current top-frame document.
1681
+ // Under another manager (`predicted`), whether its rules cover the
1682
+ // tab's page, since nothing reports what ran.
1683
+ function activeOn(ctx, tab, scriptId) {
1684
+ // Switched to run no scripts: the host's last word from it can still
1685
+ // be a start from before, since a load with none says nothing.
1686
+ if (tab.noScript)
1687
+ return false;
1688
+ if (ctx.predicted) {
1689
+ const script = ctx.scripts.find((s) => s.id === scriptId);
1690
+ if (script === undefined || ctx.predictedOff?.has(ctx.extensionIdOf?.(script)))
1691
+ return false;
1692
+ return explainMatch(script.matching, tab.page.url()).matches;
1693
+ }
1694
+ return scriptId !== undefined && ctx.host.isRunning(tab.link, scriptId);
1695
+ }
1696
+ // What a manual script that hasn't run on a tab is doing there.
1697
+ const WAITING_FOR_MENU = 'Waiting for menu to run it (@run-at context-menu)';
1698
+ // Whether a manual script (`@run-at context-menu`) not running on a
1699
+ // tab could be run there.
1700
+ function waitingOn(ctx, tab, scriptId, running) {
1701
+ return !running && !ctx.predicted && scriptId !== undefined && ctx.host.manualScripts(tab.link).includes(scriptId);
1702
+ }
1703
+ // `activeOn`'s answer, as JSON.
1704
+ function activityState(ctx, active, waiting = false) {
1705
+ if (ctx.predicted)
1706
+ return active ? 'expected' : 'not-expected';
1707
+ if (waiting)
1708
+ return 'manual';
1709
+ return active ? 'running' : 'not-running';
1710
+ }
1711
+ // `activeOn`'s answer, in words.
1712
+ function activityWord(ctx, active, waiting = false) {
1713
+ if (ctx.predicted)
1714
+ return active ? 'Expected using match rules' : 'Not expected using match rules';
1715
+ if (waiting)
1716
+ return WAITING_FOR_MENU;
1717
+ return active ? 'Running' : 'Not running';
1718
+ }
1719
+ /**
1720
+ * The tabs of `tabs` that can be reloaded now. One waiting on a dialog
1721
+ * would hang its reload, so it is left for the person, and named.
1722
+ */
1723
+ export function reloadable(tabs, out) {
1724
+ const waiting = tabs.filter((t) => t.dialogs?.current);
1725
+ for (const t of waiting)
1726
+ out(`[sm-dev] ${tabName(t)} is waiting on a dialog, so it was not reloaded`);
1727
+ return tabs.filter((t) => !waiting.includes(t));
1728
+ }
1729
+ /**
1730
+ * Reload one tab, saying so if it fails. False when it failed, or its
1731
+ * scripts never reported (said already), so nothing waits on it more.
1732
+ */
1733
+ export function reloadTab(tab, host, out, timeoutMs) {
1734
+ const saved = showingSaved(tab);
1735
+ const load = () => loadPage(tab.page, (o) => tab.page.reload(o), timeoutMs);
1736
+ return loadInTab(tab, host, () => withOfflineHint(tab, saved, load), `[sm-dev] ${tabName(tab)} could not be reloaded`, out);
1737
+ }
1738
+ /** Navigate one tab to `url`, as `reloadTab` reloads it. */
1739
+ export function gotoInTab(tab, host, url, out, timeoutMs) {
1740
+ const saved = tab.saved !== undefined && comparableUrl(url) === comparableUrl(tab.saved.url);
1741
+ const load = () => loadPage(tab.page, (o) => tab.page.goto(url, o), timeoutMs);
1742
+ return loadInTab(tab, host, () => withOfflineHint(tab, saved, load), '[sm-dev] Navigation failed', out);
1743
+ }
1744
+ // A saved page without --offline still loads its images, scripts and
1745
+ // styles from the live site, and one that never answers holds up its load
1746
+ // (as a site that needs a login does). So a load of one that timed
1747
+ // out says how to stop that.
1748
+ async function withOfflineHint(tab, saved, load) {
1749
+ try {
1750
+ await load();
1751
+ }
1752
+ catch (err) {
1753
+ if (err instanceof PageNotLoaded && saved && !tab.saved?.offline) {
1754
+ throw new PageNotLoaded(`${err.message}. The saved page still loads its images, scripts and styles from the live site. Open it with --from-saved --offline to block those.`);
1755
+ }
1756
+ throw err;
1757
+ }
1758
+ }
1759
+ // A backstop for a script that never reports, not a guess at when the
1760
+ // output is done: every injected script reports once per document.
1761
+ // Counted from the load's end, so a slow page isn't blamed on them.
1762
+ const SCRIPTS_REPORT_MAX_MS = 10_000;
1763
+ /**
1764
+ * Load a page in `tab`, done once each script injected there has
1765
+ * started, been skipped or thrown in the new document, and what their
1766
+ * bodies printed has arrived. So a command that loads a page ends with
1767
+ * its scripts' output rather than before it. Also done when a dialog
1768
+ * opens, since the page waits on it, or the tab closes. `failed` is
1769
+ * what to say, before the error, if the load fails.
1770
+ */
1771
+ async function loadInTab(tab, host, load, failed, out) {
1772
+ const scripts = tab.link.injectedScripts().filter((s) => s.id);
1773
+ const ids = scripts.map((s) => s.id);
1774
+ const before = tab.link.documentStart();
1775
+ let since = before;
1776
+ // Ends the wait on the scripts however it ends, so it stops checking.
1777
+ const stopped = new AbortController();
1778
+ let timer;
1779
+ let onClose;
1780
+ let loadFailed = false;
1781
+ // Marked on the tab too, so the waits after it (settleTabs) skip it.
1782
+ const result = (ok) => {
1783
+ tab.stalled = !ok;
1784
+ return ok;
1785
+ };
1786
+ const outcome = new Promise((resolve) => {
1787
+ onClose = () => resolve(true);
1788
+ tab.page.once('close', onClose);
1789
+ load().then(async () => {
1790
+ await tab.link.caughtUp();
1791
+ since = tab.link.documentStart();
1792
+ // Only the hash changed: no new document, so nothing starts again.
1793
+ if (since === before || ids.length === 0) {
1794
+ resolve(true);
1795
+ return;
1796
+ }
1797
+ timer = setTimeout(() => resolve(false), SCRIPTS_REPORT_MAX_MS);
1798
+ // Reports from the new document only, not late ones from the old.
1799
+ await host.untilReported(tab.link, ids, since, stopped.signal);
1800
+ // A script's start report comes before its body runs.
1801
+ await tab.link.drain();
1802
+ resolve(true);
1803
+ }, (err) => {
1804
+ out(`${failed}: ${err.message}`);
1805
+ loadFailed = true;
1806
+ resolve(true);
1807
+ });
1808
+ });
1809
+ try {
1810
+ if (await (tab.dialogs ? tab.dialogs.untilDialog(outcome) : outcome))
1811
+ return result(!loadFailed);
1812
+ const waiting = host.unreported(tab.link, ids, since);
1813
+ const names = scripts.filter((s) => waiting.includes(s.id)).map(preparedScriptName);
1814
+ if (names.length > 0) {
1815
+ out(`[sm-dev] ${names.join(', ')} had not started on ${tabName(tab)} after ${SCRIPTS_REPORT_MAX_MS / 1000} seconds`);
1816
+ }
1817
+ return result(false);
1818
+ }
1819
+ finally {
1820
+ stopped.abort();
1821
+ clearTimeout(timer);
1822
+ tab.page.off('close', onClose);
1823
+ }
1824
+ }
1825
+ // `tabs`, and the end of `status` when there are several: each tab
1826
+ // with `*` on the current one, what opened it, an open dialog, and
1827
+ // which scripts run there.
1828
+ //
1829
+ // With `frames`, each tab's frame tree in place of its scripts.
1830
+ function printTabs(ctx, frames = false) {
1831
+ const current = currentTab(ctx);
1832
+ ctx.out(ui `${S.heading('Tabs')}:`);
1833
+ if (ctx.tabs().length === 0)
1834
+ ctx.out(' No active tabs');
1835
+ for (const tab of ctx.tabs()) {
1836
+ const opener = tab.openedBy ? ui ` (opened by ${S.id(`T${tab.openedBy}`)})` : '';
1837
+ const none = tab.noScript ? ' (no scripts)' : '';
1838
+ ctx.out(ui `${selectMark(tab === current)}${S.id(tabName(tab))}: ${S.url(tab.page.url())}${frameCountSuffix(ctx, tab)}${savedSuffix(tab)}${none}${opener}${targetIdSuffix(tab.targetId)}`);
1839
+ const dialog = tab.dialogs?.current;
1840
+ if (dialog)
1841
+ ctx.out(ui ` Waiting on ${dialogKind(dialog)} dialog: ${S.code(JSON.stringify(dialog.message()))}`);
1842
+ if (frames) {
1843
+ // The tab's own line is above; the tree's first line repeats it.
1844
+ // Its lines are indented as the tab's frames, two deeper than its
1845
+ // scripts' lines would be.
1846
+ for (const line of frameTreeLines(ctx, tab, ' ').slice(1))
1847
+ ctx.out(line);
1848
+ continue;
1849
+ }
1850
+ // In its top frame or any of its iframes, with where when not only
1851
+ // the top frame.
1852
+ const running = ctx.scripts.filter((script) => activeInTab(ctx, tab, script.id));
1853
+ // By @name alone, the file being in `scripts`. The selected script is one
1854
+ // for the whole run, so its `*` goes only under the current tab.
1855
+ const selected = tab === current ? selectedScript(ctx) : undefined;
1856
+ for (const script of running) {
1857
+ ctx.out(ui ` ${selectMark(script === selected)}${S.id(ctx.scripts.indexOf(script) + 1)}: ${S.name(preparedScriptName(script))}${framesSuffix(ctx, tab, script.id)}`);
1858
+ }
1859
+ }
1860
+ }
1861
+ // ---------- JSON forms (see json-output.ts) ----------
1862
+ // A loaded script's id: its number in the `scripts` list.
1863
+ function scriptNumber(ctx, script) {
1864
+ return String(ctx.scripts.indexOf(script) + 1);
1865
+ }
1866
+ function tabJson(ctx, tab) {
1867
+ const dialog = tab.dialogs?.current;
1868
+ const saved = showingSaved(tab) ? tab.saved : undefined;
1869
+ return {
1870
+ id: tabName(tab),
1871
+ url: tab.page.url(),
1872
+ ...(tab.targetId ? { targetId: tab.targetId } : {}),
1873
+ selected: tab === currentTab(ctx),
1874
+ ...(tab.noScript ? { noScript: true } : {}),
1875
+ ...(saved ? { savedFrom: saved.given, ...(saved.offline ? { offline: true } : {}) } : {}),
1876
+ ...(tab.openedBy ? { openedBy: `T${tab.openedBy}` } : {}),
1877
+ ...(dialog ? { dialog: { kind: dialogKind(dialog), message: dialog.message() } } : {}),
1878
+ scripts: runningOn(ctx, tab).map((script) => scriptNumber(ctx, script)),
1879
+ ...framesJson(ctx, tab),
1880
+ };
1881
+ }
1882
+ function scriptJson(ctx, script) {
1883
+ const installed = ctx.predicted ? ctx.extensionIdOf?.(script) : undefined;
1884
+ return {
1885
+ id: scriptNumber(ctx, script),
1886
+ ...(installed !== undefined ? { installedAs: `E${installed}` } : {}),
1887
+ kind: 'run',
1888
+ name: preparedScriptName(script),
1889
+ file: preparedScriptPath(script),
1890
+ status: script.status,
1891
+ ...(script.diagnostic ? { error: script.diagnostic } : {}),
1892
+ ...(script === selectedScript(ctx) ? { selected: true } : {}),
1893
+ tabs: ctx.tabs().map((tab) => {
1894
+ const running = activeInTab(ctx, tab, script.id);
1895
+ const where = framesWhere(ctx, tab, script.id);
1896
+ return { tab: tabName(tab), state: activityState(ctx, running, waitingOn(ctx, tab, script.id, running)), ...(where ? { frames: where } : {}) };
1897
+ }),
1898
+ };
1899
+ }
1900
+ // The badge's word for an extension script's state, as JSON.
1901
+ const OTHER_STATE = {
1902
+ OK: 'running',
1903
+ ERROR: 'error',
1904
+ REJECTED: 'refused',
1905
+ MISSING: 'not-started',
1906
+ UNEXPECTED: 'unexpected',
1907
+ RELOAD: 'changed',
1908
+ NOT_LOADED: 'kept-out',
1909
+ UNTRACKED: 'unknown',
1910
+ EXPECTED: 'expected',
1911
+ };
1912
+ // The SourceMonkey-managed scripts on `tab`, as JSON; null (said why)
1913
+ // when they couldn't be listed.
1914
+ async function otherScriptsJson(ctx, tab) {
1915
+ if (!ctx.others)
1916
+ return [];
1917
+ // With no tab, only the picked one, which is on no page.
1918
+ if (!tab)
1919
+ return ctx.currentOther ? [{ ...otherJson(ctx, ctx.currentOther), tabs: [] }] : [];
1920
+ let others;
1921
+ try {
1922
+ others = await ctx.others.onTab(tab);
1923
+ }
1924
+ catch (err) {
1925
+ ctx.out(`[sm-dev] ${othersKind(ctx)} on ${tabName(tab)} could not be listed: ${firstLine(err)}`);
1926
+ return null;
1927
+ }
1928
+ const away = ctx.currentOther && !others.includes(ctx.currentOther) ? [ctx.currentOther] : [];
1929
+ return [...others, ...away].map((o) => ({
1930
+ ...otherJson(ctx, o),
1931
+ tabs: [{ tab: tabName(tab), state: away.includes(o) ? 'not-running' : OTHER_STATE[o.status] ?? 'running' }],
1932
+ }));
1933
+ }
1934
+ function otherJson(ctx, o) {
1935
+ return {
1936
+ id: o.label,
1937
+ kind: 'installed',
1938
+ name: o.name,
1939
+ ...(o.collection ? { collection: { name: o.collection } } : {}),
1940
+ ...(o.label === ctx.currentOther?.label ? { selected: true } : {}),
1941
+ };
1942
+ }
1943
+ async function statusJson(ctx, tab) {
1944
+ const { reports } = ctx.host;
1945
+ const scripts = [];
1946
+ for (const script of ctx.scripts) {
1947
+ const id = script.id;
1948
+ const errors = (tab ? fromTab(ctx, tab, reports.errors) : reports.errors).filter((r) => r.scriptId === id);
1949
+ scripts.push({
1950
+ ...scriptJson(ctx, script),
1951
+ ...(tab ? { menuCommands: tabMenuCommands(ctx, tab).filter((r) => r.scriptId === id).length } : {}),
1952
+ // Another manager's aren't readable.
1953
+ ...(ctx.predicted ? {} : { values: id ? Object.keys(await ctx.host.valuesOf(id)).length : 0 }),
1954
+ errors: errors.length,
1955
+ ...(errors.length > 0 ? { lastError: errors.at(-1).message } : {}),
1956
+ });
1957
+ const prompts = waitingPrompts(ctx).filter((r) => r.scriptId === id);
1958
+ if (prompts.length > 0) {
1959
+ scripts[scripts.length - 1].permissionPrompts = prompts.map((r) => ({ host: r.host, method: r.method, url: withoutQuery(r.url) }));
1960
+ }
1961
+ }
1962
+ const others = await otherScriptsJson(ctx, tab) ?? [];
1963
+ let run;
1964
+ if (ctx.runId && ctx.browserInfo) {
1965
+ const chosen = ctx.managerName ?? DEFAULT_MANAGER;
1966
+ const managers = await ctx.managers?.() ?? null;
1967
+ const collection = ctx.testCollection?.();
1968
+ run = {
1969
+ id: ctx.runId,
1970
+ pid: process.pid,
1971
+ browser: ctx.browserInfo,
1972
+ manager: chosen,
1973
+ ...(managers ? {
1974
+ sourceMonkey: managerState(managers, 'sourcemonkey'),
1975
+ managers: managers.map((m) => ({ manager: m.manager, name: m.name, version: m.version, id: m.id, enabled: m.enabled, userScripts: m.userScripts })),
1976
+ } : {}),
1977
+ ...(collection ? { testCollection: collection } : {}),
1978
+ ...(ctx.log ? { log: ctx.log } : {}),
1979
+ ...(ctx.downloadsDir ? { downloads: ctx.downloadsDir } : {}),
1980
+ };
1981
+ }
1982
+ return {
1983
+ ...(run ? { run } : {}),
1984
+ tabs: ctx.tabs().map((t) => tabJson(ctx, t)),
1985
+ ...(tab ? { tab: tabName(tab) } : {}),
1986
+ scripts: [...scripts, ...others],
1987
+ };
1988
+ }
1989
+ // A tab's menu commands, as JSON, numbered as `menu` numbers them.
1990
+ function menuJson(ctx, tab) {
1991
+ let n = 0;
1992
+ const commands = menuGroups(ctx, tab).flatMap(({ script, commands: registered }) => registered
1993
+ .map((c) => ({
1994
+ id: `M${++n}`, script: scriptNumber(ctx, script), name: c.name, ...('run' in c ? { builtin: 'run' } : {}),
1995
+ ...('record' in c && c.frameNumber !== undefined ? { frame: `F${c.frameNumber}` } : {}),
1996
+ })));
1997
+ return { tab: tabName(tab), url: tab.page.url(), commands };
1998
+ }
1999
+ // How long `closetab` waits for a tab to close, or to ask first.
2000
+ const CLOSE_TIMEOUT_MS = 5000;
2001
+ // `closetab [tab...|all] [--force]`: closes the current tab, the tabs
2002
+ // named, or all of them. Each is named before any closes, so a slip
2003
+ // closes none. The tabs' own close handlers say each closed.
2004
+ //
2005
+ // The page's beforeunload handlers run, as when a person closes a tab,
2006
+ // so a page that asks first ("Leave site?") shows its dialog, which the
2007
+ // dialog tracker reports for `accept` (leave) or `dismiss` (stay).
2008
+ // `--force` skips them.
2009
+ async function closeTabs(ctx, words, force, current, timeoutMs = CLOSE_TIMEOUT_MS) {
2010
+ let tabs;
2011
+ if (words.length === 1 && words[0] === 'all') {
2012
+ tabs = ctx.tabs();
2013
+ }
2014
+ else if (words.length === 0) {
2015
+ tabs = [current];
2016
+ }
2017
+ else {
2018
+ tabs = [];
2019
+ for (let i = 0; i < words.length; i++) {
2020
+ if (words[i] === 'all') {
2021
+ ctx.out(MISTAKE_PREFIX + 'The `closetab` command takes all by itself, to close all tabs');
2022
+ return false;
2023
+ }
2024
+ const picked = pickTab(ctx, words[i]);
2025
+ if (!picked)
2026
+ return;
2027
+ if (!tabs.includes(picked))
2028
+ tabs.push(picked);
2029
+ }
2030
+ }
2031
+ // A page stopped by a dialog can't run its beforeunload, so it's left
2032
+ // open, as `reload all` leaves it; the rest still close.
2033
+ const waiting = force ? [] : tabs.filter((t) => t.dialogs?.current);
2034
+ for (const t of waiting) {
2035
+ ctx.out(`[sm-dev] ${tabName(t)} is waiting on ${dialogKind(t.dialogs.current)} dialog, so it was not closed. Answer it, or use ${quoted(`closetab ${tabName(t)} --force`)}.`);
2036
+ }
2037
+ await Promise.all(tabs.filter((t) => !waiting.includes(t)).map((t) => closeTab(ctx, t, force, timeoutMs)));
2038
+ // Says where the page commands act now, if their tab closed.
2039
+ currentTab(ctx);
2040
+ }
2041
+ // Closes one tab for `closetab`, and says if it didn't close: it asked
2042
+ // first (its dialog, reported by the tracker, is waiting on an answer),
2043
+ // or it didn't close in time.
2044
+ async function closeTab(ctx, tab, force, timeoutMs) {
2045
+ const { page } = tab;
2046
+ if (force) {
2047
+ // A page stopped on a dialog never finishes closing, so the dialog
2048
+ // goes first: a beforeunload one accepted, which leaves the page,
2049
+ // any other dismissed.
2050
+ const dialog = tab.dialogs?.take();
2051
+ if (dialog)
2052
+ await (dialog.type() === 'beforeunload' ? dialog.accept() : dialog.dismiss()).catch(() => undefined);
2053
+ await page.close({ runBeforeUnload: false }).catch(() => undefined);
2054
+ return;
2055
+ }
2056
+ let timer;
2057
+ let onClose = () => undefined;
2058
+ const closedOrLate = new Promise((resolve) => {
2059
+ onClose = resolve;
2060
+ page.once('close', onClose);
2061
+ timer = setTimeout(resolve, timeoutMs);
2062
+ });
2063
+ // Watched from before the close, which may open it at once. Its
2064
+ // dialog, if it asks, is said by the tracker, along with what accept
2065
+ // and dismiss do with it.
2066
+ const asking = tab.dialogs?.untilDialog(closedOrLate.then(() => false)) ?? closedOrLate.then(() => false);
2067
+ // With beforeunload run, Playwright doesn't wait for the close.
2068
+ await page.close({ runBeforeUnload: true }).catch(() => undefined);
2069
+ const asked = await asking;
2070
+ clearTimeout(timer);
2071
+ page.off('close', onClose);
2072
+ if (!asked && !page.isClosed()) {
2073
+ ctx.out(`[sm-dev] ${tabName(tab)} didn't close within ${seconds(timeoutMs)}. Use ${quoted('closetab --force')} to close it without running its beforeunload handlers.`);
2074
+ }
2075
+ }
2076
+ // `newtab [page]...`: a tab of the run's own for each page (a URL, a
2077
+ // script's number, a file or a glob of them), controlled from the start.
2078
+ // The first becomes the current tab and comes to the front. `newtab all`
2079
+ // (one per script's page) leaves the current tab be.
2080
+ // False (said why) for a mistake, so a list of commands stops there.
2081
+ async function openNewTab(ctx, rest, given, cwd) {
2082
+ const flags = savedFlags(ctx, given);
2083
+ if (!flags)
2084
+ return false;
2085
+ // `--noscript`: a tab the scripts stay out of. `--script` is what a
2086
+ // new tab gets anyway.
2087
+ const noWait = given['--nowait'] === true;
2088
+ const timeoutMs = timeoutFlagMs(given, ctx.out);
2089
+ if (timeoutMs === null)
2090
+ return false;
2091
+ const mode = scriptsMode(ctx, 'newtab', given);
2092
+ if (mode === null)
2093
+ return false;
2094
+ const noScript = mode === true;
2095
+ if (!ctx.newTab) {
2096
+ ctx.out('[sm-dev] This run cannot open tabs');
2097
+ return;
2098
+ }
2099
+ if (rest.includes('all')) {
2100
+ if (rest.length > 1 || flags.fromSaved || flags.url !== undefined || flags.offline) {
2101
+ ctx.out(MISTAKE_PREFIX + 'The `newtab` command takes all by itself, to open each script\'s page');
2102
+ return false;
2103
+ }
2104
+ return openScriptPages(ctx, noScript, noWait, timeoutMs);
2105
+ }
2106
+ const tabRef = rest.find((w) => TAB_REF.test(w));
2107
+ if (tabRef) {
2108
+ ctx.out(`${MISTAKE_PREFIX}The ${quoted('newtab')} command opens a tab of its own, so it takes URLs, files or script numbers, not ${quoted(tabRef)}`);
2109
+ return false;
2110
+ }
2111
+ const named = [];
2112
+ for (const word of rest.flatMap((w) => expandPathArg(w, cwd))) {
2113
+ const url = await pageFor(ctx, word, cwd);
2114
+ if (!url)
2115
+ return false;
2116
+ named.push({ word, url });
2117
+ }
2118
+ const pages = await pagesToOpen(named, flags, cwd);
2119
+ const open = async (page) => {
2120
+ try {
2121
+ return await ctx.newTab(page?.url, { noScript, saved: page?.saved, timeoutMs });
2122
+ }
2123
+ catch (err) {
2124
+ ctx.out(`[sm-dev] The new tab could not be opened: ${err.message}`);
2125
+ return null;
2126
+ }
2127
+ };
2128
+ // All loading at once, numbered in call order as `newtab all`'s are.
2129
+ const opened = (await Promise.all((pages.length > 0 ? pages : [undefined]).map(open))).filter((t) => t !== null);
2130
+ const [tab] = opened;
2131
+ if (!tab)
2132
+ return;
2133
+ ctx.tab = tab;
2134
+ await focusTab(tab.page).catch(() => undefined);
2135
+ if (!noWait)
2136
+ await settleTabs(ctx.host, opened, ctx.out, timeoutMs);
2137
+ ctx.out(`[sm-dev] The page commands now act on ${tabName(tab)} (${tab.page.url()})`);
2138
+ }
2139
+ // `newtab all`: one tab per distinct script page; the scripts with
2140
+ // none, or whose tab failed, are listed at the end, after the tabs'
2141
+ // own output.
2142
+ async function openScriptPages(ctx, noScript, noWait, timeoutMs) {
2143
+ const pages = new Map();
2144
+ const skipped = [];
2145
+ if (ctx.scripts.length === 0) {
2146
+ ctx.out(`[sm-dev] ${NO_SCRIPTS}`);
2147
+ return;
2148
+ }
2149
+ ctx.scripts.forEach((script, i) => {
2150
+ const url = defaultPageUrl(script);
2151
+ if (!url)
2152
+ skipped[i] = skippedScriptLine(i + 1, script, NO_DEFAULT_PAGE);
2153
+ else
2154
+ pages.set(url, [...(pages.get(url) ?? []), script]);
2155
+ });
2156
+ if (pages.size === 0) {
2157
+ ctx.out('[sm-dev] No tab was opened, since no script\'s @match names a page to open');
2158
+ return;
2159
+ }
2160
+ // All loading at once; tabs are numbered in call order.
2161
+ const opened = [];
2162
+ await Promise.all([...pages].map(async ([url, scripts]) => {
2163
+ try {
2164
+ opened.push(await ctx.newTab(url, { noScript, timeoutMs }));
2165
+ }
2166
+ catch (err) {
2167
+ for (const script of scripts) {
2168
+ const i = ctx.scripts.indexOf(script);
2169
+ skipped[i] = skippedScriptLine(i + 1, script, `The tab could not be opened: ${err.message}`);
2170
+ }
2171
+ }
2172
+ }));
2173
+ if (!noWait)
2174
+ await settleTabs(ctx.host, opened, ctx.out, timeoutMs);
2175
+ for (const line of skipped)
2176
+ if (line)
2177
+ ctx.out(line);
2178
+ }
2179
+ // What `open` and `newtab` take: a script by its number, or an
2180
+ // installed one by its E-id, meaning its default page, or else a URL. A
2181
+ // bare number or `E3` can't be a URL, so they never overlap.
2182
+ // `cwd`: where a relative path is, the sender's for a sent command.
2183
+ async function pageFor(ctx, arg, cwd) {
2184
+ if (OTHER_LABEL.test(arg) && !ownByLabel(ctx, arg))
2185
+ return installedPage(ctx, arg.toUpperCase());
2186
+ if (!/^\d+$/.test(arg) && !OTHER_LABEL.test(arg))
2187
+ return withDefaultScheme(arg, cwd);
2188
+ const script = pickScript(ctx, arg);
2189
+ return script ? scriptPage(ctx, script) : null;
2190
+ }
2191
+ // An installed script's default page, from its `@match`, or null
2192
+ // having said why.
2193
+ async function installedPage(ctx, label) {
2194
+ if (!ctx.manager) {
2195
+ ctx.out(`${MISTAKE_PREFIX}${quoted(label)} names an installed script, which this run can't look up`);
2196
+ return null;
2197
+ }
2198
+ try {
2199
+ const found = (await ctx.manager.scripts()).find((s) => `E${s.number}` === label);
2200
+ const details = found && await ctx.manager.scriptDetails(found.id);
2201
+ if (!details) {
2202
+ ctx.out(`[sm-dev] ${noSuchScript(label)}`);
2203
+ return null;
2204
+ }
2205
+ const url = representativeMatch({ matches: details.match })?.linkUrl ?? null;
2206
+ if (!url)
2207
+ ctx.out(`[sm-dev] ${label}: ${details.name}: ${NO_DEFAULT_PAGE}. Give a URL instead.`);
2208
+ return url;
2209
+ }
2210
+ catch (err) {
2211
+ ctx.out(`[sm-dev] ${label} could not be looked up: ${firstLine(err)}`);
2212
+ return null;
2213
+ }
2214
+ }
2215
+ function scriptPage(ctx, script) {
2216
+ const url = defaultPageUrl(script);
2217
+ if (!url)
2218
+ ctx.out(`[sm-dev] ${scriptLabel(script)}: ${NO_DEFAULT_PAGE}. Give a URL instead.`);
2219
+ return url;
2220
+ }
2221
+ /** A script `--all` or `newtab all` opened no tab for, and why. */
2222
+ export function skippedScriptLine(n, script, reason) {
2223
+ return `[sm-dev] Skipped script ${n}, ${scriptLabel(script)}: ${reason}`;
2224
+ }
2225
+ // A tab reference `open` and `newtab` recognize: `T2` or `T2.3`.
2226
+ const TAB_REF = /^[Tt]\d+(\.\d+)?$/;
2227
+ /**
2228
+ * Splits a `T<n>.` prefix off a typed argument: `T2.3` is script 3 on
2229
+ * T2, `T1.M2` menu command M2 on T1, and `T2` alone the tab itself.
2230
+ * Without one, the current tab and the whole argument. Only a number
2231
+ * or `M<n>` after the dot, or for `menu` (`labels`) any label, so a
2232
+ * file name like `t1.user.js` stays a script. Null, reported, when the
2233
+ * named tab isn't open.
2234
+ */
2235
+ function tabRef(ctx, arg, current, labels = false) {
2236
+ const m = (labels ? /^[Tt](\d+)(?:\.(.+))?$/ : /^[Tt](\d+)(?:\.(\d+|[Mm]\d+))?$/).exec(arg);
2237
+ if (!m)
2238
+ return { tab: current, rest: arg, named: false };
2239
+ const tab = pickTab(ctx, `T${m[1]}`);
2240
+ return tab ? { tab, rest: (m[2] ?? '').trim(), named: true } : null;
2241
+ }
2242
+ // The `all` form of a command: each tab under a `Tab T<n>:` heading,
2243
+ // `*` on the current one, with `body`'s lines for it indented below.
2244
+ function printPerTab(ctx, current, body, empty) {
2245
+ for (const tab of ctx.tabs()) {
2246
+ ctx.out(ui `${selectMark(tab === current)}${S.heading('Tab')} ${S.id(tabName(tab))}: ${S.url(tab.page.url())}`);
2247
+ const lines = body(tab);
2248
+ if (lines.length === 0 && empty)
2249
+ ctx.out(` ${empty}`);
2250
+ for (const l of lines)
2251
+ ctx.out(` ${l}`);
2252
+ }
2253
+ }
2254
+ function runningOn(ctx, tab) {
2255
+ return ctx.scripts.filter((script) => activeOn(ctx, tab, script.id));
2256
+ }
2257
+ // ---------- Logs ----------
2258
+ /**
2259
+ * `logs [tab|all] [script]`: the run's log lines (see run-logs.ts), of
2260
+ * the sources asked for (by default all but the page's own), on the
2261
+ * current tab, the one named, or all of them, and for one script if
2262
+ * named. Then the position reached, for the next `--since`.
2263
+ */
2264
+ function logsCommand(ctx, parsed, current) {
2265
+ const mistake = (text) => {
2266
+ ctx.out(`${MISTAKE_PREFIX}${text}`);
2267
+ return false;
2268
+ };
2269
+ const logs = ctx.logs;
2270
+ if (!logs) {
2271
+ ctx.out('[sm-dev] Internal error: This run keeps no log');
2272
+ return false;
2273
+ }
2274
+ let all = false;
2275
+ let scriptWord;
2276
+ for (const word of parsed.words) {
2277
+ if (word === 'all' && !all && parsed.tab === undefined && scriptWord === undefined)
2278
+ all = true;
2279
+ else if (scriptWord === undefined)
2280
+ scriptWord = word;
2281
+ else
2282
+ return mistake('The `logs` command takes a tab or all, then a script, e.g. `logs T2 1`');
2283
+ }
2284
+ let tab;
2285
+ if (parsed.tab !== undefined) {
2286
+ const named = pickTab(ctx, parsed.tab);
2287
+ if (!named)
2288
+ return false;
2289
+ tab = named;
2290
+ }
2291
+ else if (!all) {
2292
+ if (!current) {
2293
+ ctx.out(NO_TABS);
2294
+ return false;
2295
+ }
2296
+ tab = current;
2297
+ }
2298
+ const script = scriptWord === undefined ? undefined : pickScript(ctx, scriptWord);
2299
+ if (script === null)
2300
+ return false;
2301
+ const { flags } = parsed;
2302
+ const sources = typeof flags['--log-source'] === 'string' ? parseLogSources(flags['--log-source']) : DEFAULT_LOG_SOURCES;
2303
+ if ('error' in sources)
2304
+ return mistake(sources.error);
2305
+ const find = typeof flags['--find'] === 'string' ? textMatcher(flags['--find']) : undefined;
2306
+ if (find && 'error' in find)
2307
+ return mistake(find.error);
2308
+ const since = typeof flags['--since'] === 'string' ? parsePosition(flags['--since']) : undefined;
2309
+ if (typeof since === 'object')
2310
+ return mistake(since.error);
2311
+ const tail = typeof flags['--tail'] === 'string' ? parseTail(flags['--tail']) : undefined;
2312
+ if (tail && 'error' in tail)
2313
+ return mistake(tail.error);
2314
+ const position = logs.position();
2315
+ // A tab's lines, and the lines of the scripts running there made
2316
+ // outside any tab (a refresh's, a failed @require's).
2317
+ const onTab = new Set(tab ? runningOn(ctx, tab).map((s) => s.id) : []);
2318
+ let records = (since === undefined ? [...logs.all()] : logs.since(since)).filter((r) => inSources(r, sources)
2319
+ && (!tab || r.tab === tab.n || (r.tab === undefined && r.scriptId !== undefined && onTab.has(r.scriptId)))
2320
+ && (!script || r.scriptId === script.id)
2321
+ && (!find || find.matches(streamText(r, true))));
2322
+ if (tail && 'lines' in tail)
2323
+ records = records.slice(-tail.lines);
2324
+ else if (tail) {
2325
+ const from = nowMicros() - tail.micros;
2326
+ records = records.filter((r) => r.id > from);
2327
+ }
2328
+ const lost = since === undefined ? undefined : logs.lostAfter(since);
2329
+ if (lost !== undefined)
2330
+ ctx.out(linesLost(lost));
2331
+ if (ctx.json) {
2332
+ ctx.out(jsonResult('logs', {
2333
+ ...(tab ? { tab: tabName(tab) } : {}),
2334
+ ...(script ? { script: scriptNumber(ctx, script) } : {}),
2335
+ lines: records.map((r) => logLineJson(ctx, r)),
2336
+ until: position,
2337
+ }));
2338
+ }
2339
+ else if (records.length === 0) {
2340
+ ctx.out(noLogLines(tab ? tabName(tab) : undefined));
2341
+ }
2342
+ else {
2343
+ for (const line of logsLines(records))
2344
+ ctx.out(line);
2345
+ }
2346
+ ctx.out(logsUpTo(position));
2347
+ }
2348
+ function logLineJson(ctx, record) {
2349
+ const script = record.scriptId === undefined ? undefined : ctx.scripts.find((s) => s.id === record.scriptId);
2350
+ return {
2351
+ id: record.id,
2352
+ time: isoTime(record.id),
2353
+ ...(record.tab !== undefined ? { tab: `T${record.tab}` } : {}),
2354
+ ...(record.frame !== undefined ? { frame: `F${record.frame}` } : {}),
2355
+ ...(script ? { script: scriptNumber(ctx, script) } : {}),
2356
+ source: record.source,
2357
+ ...(record.also ? { also: record.also } : {}),
2358
+ kind: record.kind,
2359
+ text: record.text,
2360
+ };
2361
+ }
2362
+ // A tab's menu commands under the script that registered each, in load
2363
+ // order. A manual script that can run on the tab's document (`@run-at
2364
+ // context-menu`) gets a group even before it has run, with "Run this
2365
+ // script" first. The M numbers `menu` prints count through this, so
2366
+ // firing by number reads the same order.
2367
+ function menuGroups(ctx, tab) {
2368
+ const all = tabMenuCommands(ctx, tab);
2369
+ const manual = new Set(ctx.host.manualScripts(tab.link));
2370
+ return ctx.scripts
2371
+ .map((script) => {
2372
+ const scriptId = script.id ?? '';
2373
+ const commands = all
2374
+ .filter((c) => c.scriptId === script.id)
2375
+ .map(({ record, frameNumber }) => ({ name: record.name, scriptId, record, frameNumber }));
2376
+ if (manual.has(scriptId))
2377
+ commands.unshift({ name: RUN_SCRIPT_LABEL, scriptId, run: true });
2378
+ return { script, label: scriptRow(ctx, script), commands };
2379
+ })
2380
+ .filter((group) => group.commands.length > 0);
2381
+ }
2382
+ // The menu commands a tab shows, as the extension's popup does: one per
2383
+ // script and caption, across the tab's frames. Firing it runs them all.
2384
+ function tabMenuCommands(ctx, tab) {
2385
+ const live = fromTab(ctx, tab, ctx.host.reports.menuCommands).filter((c) => menuLive(ctx, tab, c));
2386
+ const key = (c) => `${c.scriptId}\0${c.name}`;
2387
+ return visibleCommands(live, key).map((record) => {
2388
+ const docs = new Set(live.filter((c) => key(c) === key(record)).map((c) => c.documentId));
2389
+ return { scriptId: record.scriptId, record, ...(docs.size === 1 ? { frameNumber: record.frameNumber } : {}) };
2390
+ });
2391
+ }
2392
+ // Whether an iframe's menu command is still there: its document is one
2393
+ // of the tab's frames shows now. The extension sends nothing when a
2394
+ // frame goes. Kept when the frames haven't been read.
2395
+ function menuLive(ctx, tab, record) {
2396
+ const frames = ctx.frames?.get(tab);
2397
+ if (!record.frame || !frames || !record.documentId || ctx.predicted)
2398
+ return true;
2399
+ return frames.some((f) => f.documents.includes(record.documentId));
2400
+ }
2401
+ // A tab's menu commands as `menu` lists them, numbered M1, M2 under
2402
+ // each script; empty when it has none.
2403
+ function menuLines(ctx, tab) {
2404
+ const lines = [];
2405
+ let n = 0;
2406
+ for (const group of menuGroups(ctx, tab)) {
2407
+ lines.push(group.label);
2408
+ // One an iframe registered names it: each frame has its own copy.
2409
+ for (const c of group.commands)
2410
+ lines.push(ui ` ${S.id(`M${++n}`)}: ${S.name(c.name)}${'record' in c && c.frameNumber !== undefined ? ui ` (${S.id(`F${c.frameNumber}`)})` : ''}`);
2411
+ }
2412
+ return lines;
2413
+ }
2414
+ function noMenusMessage(ctx, tab) {
2415
+ if (ctx.tabs().length === 1)
2416
+ return '[sm-dev] No menu commands are registered';
2417
+ const others = ctx.tabs().filter((t) => t !== tab && menuGroups(ctx, t).length > 0);
2418
+ return others.length === 0
2419
+ ? '[sm-dev] No menu commands are registered on any tab'
2420
+ : `[sm-dev] No menu commands are registered on ${tabName(tab)}. ${listed(others.map(tabName))} ${others.length === 1 ? 'has' : 'have'} some; `
2421
+ + `use ${quoted('menu all')} or ${quoted(`menu ${tabName(others[0])}`)} to list them.`;
2422
+ }
2423
+ // A script registers its commands as it starts, and commands are only
2424
+ // read once one has started. So for piped input, a miss while nothing
2425
+ // is registered yet may just be early, and is worth waiting out. A
2426
+ // miss while others are registered is a wrong name, and is reported at
2427
+ // once, as is any miss typed at a terminal. A `--timeout-seconds`
2428
+ // given waits that long for it in any case.
2429
+ const MENU_WAIT_MS = 5000;
2430
+ // False when nothing was fired.
2431
+ async function fireMenu(ctx, tab, argument, timeoutMs) {
2432
+ const all = () => menuGroups(ctx, tab).flatMap((group) => group.commands);
2433
+ const numbered = /^[Mm](\d+)$/.exec(argument);
2434
+ const deadline = Date.now() + (timeoutMs ?? MENU_WAIT_MS);
2435
+ for (;;) {
2436
+ const commands = all();
2437
+ // A label wins over a number, for a command whose label is one.
2438
+ const byName = [...commands].reverse().find((c) => c.name === argument);
2439
+ const byNumber = numbered ? commands[Number(numbered[1]) - 1] : undefined;
2440
+ const entry = byName ?? byNumber;
2441
+ if (entry && 'run' in entry) {
2442
+ // Each manual script has one, so the label alone may not say which.
2443
+ if (!byNumber && commands.filter((c) => 'run' in c).length > 1) {
2444
+ ctx.out(`[sm-dev] More than one script here has "${RUN_SCRIPT_LABEL}". Use ${quoted('menu')} to list them, and fire one by its M number.`);
2445
+ return false;
2446
+ }
2447
+ return runManual(ctx, tab, entry.scriptId);
2448
+ }
2449
+ if (entry) {
2450
+ // Listed but not fired: the page that registered it is going
2451
+ // away, as in the middle of a navigation.
2452
+ if (!await fire(tab, ctx.host.fireMenuEntry(entry.record))) {
2453
+ ctx.out(`[sm-dev] The menu command "${entry.name}" could not be fired, since the page that registered it has gone away`);
2454
+ return false;
2455
+ }
2456
+ ctx.out(`[sm-dev] Fired "${entry.name}"`);
2457
+ // A MAIN-world handler runs in a later task than the push, so the
2458
+ // command ends once the page has run it, with what it printed.
2459
+ if (!tab.dialogs?.current)
2460
+ await fire(tab, tab.link.drain().then(() => true));
2461
+ return true;
2462
+ }
2463
+ // Registered on another tab is an answer too, not worth waiting past.
2464
+ const early = timeoutMs === undefined && (ctx.interactive || commands.length > 0);
2465
+ if (early || Date.now() >= deadline || elsewhereFor(ctx, tab, argument).length > 0)
2466
+ break;
2467
+ await new Promise((r) => setTimeout(r, 100));
2468
+ }
2469
+ const elsewhere = numbered ? [] : elsewhereFor(ctx, tab, argument);
2470
+ if (/^\d+$/.test(argument) && elsewhere.length === 0)
2471
+ ctx.out(`${MISTAKE_PREFIX}Menu commands are named M1, M2 and so on. Did you mean ${quoted(`M${argument}`)}?`);
2472
+ else if (elsewhere.length > 0) {
2473
+ ctx.out(`[sm-dev] "${argument}" is registered on ${listed(elsewhere.map(tabName))}, not ${tabName(tab)}. `
2474
+ + `Use ${quoted(`menu ${tabName(elsewhere[0])}.${argument}`)} to fire it ${elsewhere.length === 1 ? 'there' : 'on one of them'}.`);
2475
+ }
2476
+ else if (all().length === 0)
2477
+ ctx.out(noMenusMessage(ctx, tab));
2478
+ else if (numbered)
2479
+ ctx.out(`[sm-dev] There is no menu command M${numbered[1]}${onTab(ctx, tab)}. Use ${quoted('menu')} to list them.`);
2480
+ else
2481
+ ctx.out(`[sm-dev] No menu command named "${argument}" is registered${onTab(ctx, tab)}. Use ${quoted('menu')} to list them.`);
2482
+ return false;
2483
+ }
2484
+ // Run a manual script on a tab: `menu`'s "Run this script". The run's
2485
+ // start (or error) prints as any script's does. False when it didn't run.
2486
+ async function runManual(ctx, tab, scriptId) {
2487
+ const script = ctx.scripts.find((sc) => sc.id === scriptId);
2488
+ const name = script ? preparedScriptName(script) : scriptId;
2489
+ // Settled here either way: when the run opens a dialog, `fire` stops
2490
+ // waiting at the dialog, and a later failure has nobody else to catch it.
2491
+ const run = ctx.host.runManualScript(tab.link, scriptId).then(() => null, (err) => err);
2492
+ const failed = await fire(tab, run.then((err) => err === null)) === false ? await run : null;
2493
+ if (failed) {
2494
+ ctx.out(`${MISTAKE_PREFIX}${name} did not run: ${failed.message}`);
2495
+ return false;
2496
+ }
2497
+ ctx.out(`[sm-dev] Ran ${name}`);
2498
+ if (!tab.dialogs?.current)
2499
+ await fire(tab, tab.link.drain().then(() => true));
2500
+ return true;
2501
+ }
2502
+ // The other tabs with a menu command of that label.
2503
+ function elsewhereFor(ctx, tab, label) {
2504
+ return ctx.tabs().filter((t) => t !== tab && fromTab(ctx, t, ctx.host.reports.menuCommands).some((c) => c.name === label));
2505
+ }
2506
+ // Counts as done once a dialog the command opens is up, so `accept`
2507
+ // can be typed.
2508
+ function fire(tab, fired) {
2509
+ return tab.dialogs ? tab.dialogs.untilDialog(fired) : fired;
2510
+ }
2511
+ // ---------- status and values ----------
2512
+ // Each script's state on a tab: an open dialog there, then per script
2513
+ // where it is active, its counts, and its last error. With no `tab`,
2514
+ // only what doesn't depend on one: stored values and errors.
2515
+ // The lines under a script are indented to start under its name.
2516
+ async function statusLines(ctx, tab) {
2517
+ const { scripts } = ctx;
2518
+ const { reports } = ctx.host;
2519
+ // Another manager's say nothing per script, but whether each is
2520
+ // expected on the tab.
2521
+ if (ctx.predicted && tab && scripts.length > 0)
2522
+ return predictedLines(ctx, tab, 'Scripts');
2523
+ const lines = [];
2524
+ lines.push(ui `${S.heading('Scripts')}${activityHeading(tab)}:`);
2525
+ if (scripts.length === 0)
2526
+ lines.push(` ${NO_SCRIPTS}`);
2527
+ for (const script of scripts) {
2528
+ const id = script.id;
2529
+ lines.push(scriptRow(ctx, script));
2530
+ // First under the script, on any tab: the script is stuck until
2531
+ // it's answered.
2532
+ for (const prompt of waitingPrompts(ctx).filter((r) => r.scriptId === id)) {
2533
+ lines.push(message(`${DETAIL}*PERMISSION PROMPT* waiting: ${prompt.host} (${prompt.method} ${withoutQuery(prompt.url)}). Answer using ${quoted('connect')}.`));
2534
+ }
2535
+ if (!tab) {
2536
+ // Errors are the script's, from tabs since closed, so still told.
2537
+ const values = await valueCount(ctx, id);
2538
+ const errors = reports.errors.filter((r) => r.scriptId === id);
2539
+ const counts = [count(values, 'stored value'), count(errors.length, 'error')].filter((c) => !c.startsWith('0 '));
2540
+ if (counts.length > 0)
2541
+ lines.push(`${DETAIL}${counts.join(', ')}`);
2542
+ const last = errors.at(-1);
2543
+ if (last)
2544
+ lines.push(ui `${DETAIL}${S.heading('Last error')}: ${last.message}`);
2545
+ continue;
2546
+ }
2547
+ lines.push(activityLine(ctx, tab, id));
2548
+ const errors = fromTab(ctx, tab, reports.errors).filter((r) => r.scriptId === id);
2549
+ const menus = tabMenuCommands(ctx, tab).filter((r) => r.scriptId === id).length;
2550
+ // Stored values are the script's, shared by every tab.
2551
+ const values = await valueCount(ctx, id);
2552
+ // Only the counts that aren't zero, and no line when none is.
2553
+ const counts = [count(menus, 'menu command'), count(values, 'stored value'), count(errors.length, 'error')]
2554
+ .filter((c) => !c.startsWith('0 '));
2555
+ if (counts.length > 0)
2556
+ lines.push(`${DETAIL}${counts.join(', ')}`);
2557
+ const last = errors.at(-1);
2558
+ if (last)
2559
+ lines.push(ui `${DETAIL}${S.heading('Last error')}: ${last.message}`);
2560
+ }
2561
+ return lines;
2562
+ }
2563
+ // How many values a run's script has stored: none to count under
2564
+ // another manager, which doesn't say.
2565
+ async function valueCount(ctx, id) {
2566
+ return id && !ctx.predicted ? Object.keys(await ctx.host.valuesOf(id)).length : 0;
2567
+ }
2568
+ // Under another manager, the scripts as two lists: those whose match
2569
+ // rules cover `tab`'s page, and the rest. Each list only when it has
2570
+ // any.
2571
+ function predictedLines(ctx, tab, heading) {
2572
+ // In its top frame or any of its iframes, with where when not only
2573
+ // the top frame, as `tabs` shows.
2574
+ const expected = ctx.scripts.filter((s) => activeInTab(ctx, tab, s.id));
2575
+ const rest = ctx.scripts.filter((s) => !expected.includes(s));
2576
+ return [
2577
+ ui `${S.heading(heading)}:`,
2578
+ ...(expected.length > 0 ? [ui ` Expected on tab ${S.id(tabName(tab))} using match rules:`, ...expected.map((s) => ui ` ${scriptRow(ctx, s)}${framesSuffix(ctx, tab, s.id)}`)] : []),
2579
+ ...(rest.length > 0 ? [ui ` Not expected on tab ${S.id(tabName(tab))}:`, ...rest.map((s) => ui ` ${scriptRow(ctx, s)}`)] : []),
2580
+ ];
2581
+ }
2582
+ // Under a `scriptRow`, lined up with the script's name (`* 1: `) when
2583
+ // its number is one digit.
2584
+ const DETAIL = ' ';
2585
+ // A script list's heading names the tab its `activityLine`s are about.
2586
+ // None with no tab.
2587
+ function activityHeading(tab) {
2588
+ return tab ? ui ` (with status on active tab ${S.id(tabName(tab))})` : '';
2589
+ }
2590
+ // Whether a script is active on the tab the heading names, and on
2591
+ // which others: `Not active (Active on T1,T2.)`.
2592
+ //
2593
+ // Running anywhere but `tab`'s top frame alone, it names where:
2594
+ // `Active on T1 (T1, F1, F3), T2, T3 (F7)`, or, not on `tab`,
2595
+ // `Not active on T1. Active on T2.`.
2596
+ function activityLine(ctx, tab, id) {
2597
+ const active = activeInTab(ctx, tab, id);
2598
+ const tabs = ctx.tabs().filter((t) => activeInTab(ctx, t, id));
2599
+ const places = ui `${tabs.map((t) => ui `${S.id(tabName(t))}${framesSuffix(ctx, t, id)}`).join(', ')}`;
2600
+ if (active)
2601
+ return ui `${DETAIL}${tabs.length === 1 && framesWhere(ctx, tab, id) === undefined ? 'Active' : ui `Active on ${places}`}`;
2602
+ const here = waitingOn(ctx, tab, id, active) ? WAITING_FOR_MENU : 'Not active';
2603
+ if (tabs.length === 0)
2604
+ return `${DETAIL}${here}`;
2605
+ return ui `${DETAIL}${here === 'Not active' ? ui `Not active on ${S.id(tabName(tab))}` : here}. Active on ${places}.`;
2606
+ }
2607
+ async function valueLines(ctx, scripts) {
2608
+ const lines = [];
2609
+ for (const script of scripts) {
2610
+ lines.push(scriptRow(ctx, script));
2611
+ const id = script.id;
2612
+ lines.push(...valueEntries(id ? await ctx.host.valuesOf(id) : {}));
2613
+ }
2614
+ return lines;
2615
+ }
2616
+ // One script's values, a line each, under its row.
2617
+ function valueEntries(values) {
2618
+ const keys = Object.keys(values);
2619
+ if (keys.length === 0)
2620
+ return [' Nothing stored'];
2621
+ return keys.map((key) => ui ` ${S.code(key)} = ${S.code(String(JSON.stringify(values[key])))}`);
2622
+ }
2623
+ // `E1`: a SourceMonkey-managed script (see `OtherScript`).
2624
+ const OTHER_LABEL = /^E\d+$/i;
2625
+ // The SourceMonkey-managed script `label` names, or null having said
2626
+ // why. Lists the current tab's first if it isn't known yet, so it can
2627
+ // be named before anything listed it.
2628
+ async function pickOther(ctx, label, tab) {
2629
+ const name = label.toUpperCase();
2630
+ if (!ctx.others) {
2631
+ ctx.out(`${MISTAKE_PREFIX}${quoted(name)} names a SourceMonkey-managed script, which only a run lists`);
2632
+ return null;
2633
+ }
2634
+ if (!ctx.others.byLabel(name) && tab) {
2635
+ try {
2636
+ await ctx.others.onTab(tab);
2637
+ }
2638
+ catch (err) {
2639
+ ctx.out(`[sm-dev] ${othersKind(ctx)} on ${tabName(tab)} could not be listed: ${firstLine(err)}`);
2640
+ return null;
2641
+ }
2642
+ }
2643
+ // Not on the tab's page: any installed script can still be picked.
2644
+ const found = ctx.others.byLabel(name) ?? await installedOther(ctx, name);
2645
+ if (!found)
2646
+ ctx.out(`[sm-dev] ${noSuchScript(name)}`);
2647
+ return found;
2648
+ }
2649
+ // An installed script anywhere, by its E-id, or null for none.
2650
+ async function installedOther(ctx, label) {
2651
+ if (!ctx.manager || !ctx.others)
2652
+ return null;
2653
+ // A manager that can't list them knows only those on the tab.
2654
+ const listed = await Promise.all([ctx.manager.scripts(), ctx.manager.collections()]).catch(() => null);
2655
+ if (!listed)
2656
+ return null;
2657
+ const [scripts, collections] = listed;
2658
+ const script = scripts.find((s) => `E${s.number}` === label);
2659
+ if (!script)
2660
+ return null;
2661
+ const collection = collections.find((c) => c.id === script.collectionId)?.name ?? '';
2662
+ return ctx.others.installed(script.id, label, script.name, collection);
2663
+ }
2664
+ // Whether a picked installed script is among those on `tab`'s page.
2665
+ async function otherOnTab(ctx, other, tab) {
2666
+ if (!tab || !ctx.others)
2667
+ return false;
2668
+ return (await ctx.others.onTab(tab).catch(() => [])).includes(other);
2669
+ }
2670
+ // How `scripts` and `status` show an extension script's badge status.
2671
+ // In the run's tabs, which the run claims, NOT_LOADED is the claim
2672
+ // keeping it out.
2673
+ const OTHER_STATUS = {
2674
+ OK: 'Running',
2675
+ ERROR: 'Threw an error',
2676
+ REJECTED: 'Refused by Chrome',
2677
+ MISSING: 'Not started',
2678
+ UNEXPECTED: 'Running, though it doesn\'t match',
2679
+ RELOAD: 'Changed since it started',
2680
+ NOT_LOADED: 'SourceMonkey script not loaded in sm-dev tab',
2681
+ UNTRACKED: 'Unknown: the page loaded before SourceMonkey started',
2682
+ // Another manager's, by its match rules (`predicted`).
2683
+ EXPECTED: 'Expected using match rules',
2684
+ };
2685
+ // `E1: Name (Collection)`, without the parentheses for a script whose
2686
+ // collection isn't known (one deleted since the page loaded).
2687
+ function otherLabelUi(o) {
2688
+ return ui `${S.id(o.label)}: ${S.name(o.name)}${o.collection ? ui ` (${S.name(o.collection)})` : ''}`;
2689
+ }
2690
+ // How the other scripts' list is headed.
2691
+ function othersKind(ctx) {
2692
+ return ctx.others?.kind ?? 'SourceMonkey-managed scripts';
2693
+ }
2694
+ // SourceMonkey-managed scripts on `tab`, for `scripts` and `status`, or
2695
+ // another manager's. None when there are none.
2696
+ async function otherLines(ctx, tab) {
2697
+ if (!ctx.others)
2698
+ return [];
2699
+ // With no tab, only the picked one, which is on no page.
2700
+ if (!tab)
2701
+ return ctx.currentOther ? [ui `${S.heading(othersKind(ctx))}:`, ui `${selectMark(true)}${otherLabelUi(ctx.currentOther)}: No tab is open`] : [];
2702
+ let others;
2703
+ try {
2704
+ others = await ctx.others.onTab(tab);
2705
+ }
2706
+ catch (err) {
2707
+ return [`[sm-dev] ${othersKind(ctx)} on ${tabName(tab)} could not be listed: ${firstLine(err)}`];
2708
+ }
2709
+ // The picked one, when it isn't on this page.
2710
+ const away = ctx.currentOther && !others.includes(ctx.currentOther) ? ctx.currentOther : undefined;
2711
+ if (others.length === 0 && !away)
2712
+ return [];
2713
+ return [
2714
+ ui `${S.heading(othersKind(ctx))} on ${S.id(tabName(tab))}:`,
2715
+ // By label: each listing gets the scripts afresh.
2716
+ ...others.map((o) => ui `${selectMark(o.label === ctx.currentOther?.label)}${otherLabelUi(o)}: ${OTHER_STATUS[o.status] ?? o.status}`),
2717
+ ...(away ? [ui `${selectMark(true)}${otherLabelUi(away)}: Not on this page`] : []),
2718
+ ];
2719
+ }
2720
+ // Activates the tab, un-minimizing its window first, since activating
2721
+ // a tab in a minimized window leaves it out of sight.
2722
+ async function focusTab(page) {
2723
+ const browser = page.context().browser();
2724
+ if (browser) {
2725
+ const pageCdp = await page.context().newCDPSession(page);
2726
+ const { targetInfo } = await pageCdp.send('Target.getTargetInfo')
2727
+ .finally(() => pageCdp.detach().catch(() => undefined));
2728
+ const cdp = await browser.newBrowserCDPSession();
2729
+ try {
2730
+ const { windowId, bounds } = await cdp.send('Browser.getWindowForTarget', { targetId: targetInfo.targetId });
2731
+ if (bounds.windowState === 'minimized') {
2732
+ await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
2733
+ }
2734
+ }
2735
+ finally {
2736
+ await cdp.detach();
2737
+ }
2738
+ }
2739
+ await page.bringToFront();
2740
+ }
2741
+ // ---------- Dialogs ----------
2742
+ /**
2743
+ * Keeps Playwright from dismissing dialogs in `context`'s pages.
2744
+ *
2745
+ * - Playwright dismisses a dialog nobody listens for, on the page or its
2746
+ * context, in every tab it's attached to: on a connected browser, the
2747
+ * person's own tabs too.
2748
+ * - And when the dialog is gone before its dismiss lands, the error is
2749
+ * unhandled, and ends the process.
2750
+ * - A listener that does nothing leaves each dialog to whoever does
2751
+ * answer it: the person, or the run's DialogTracker in its own tabs.
2752
+ * Also for a Playwright client that isn't the run's own.
2753
+ */
2754
+ export function leaveDialogsOpen(context) {
2755
+ context.on('dialog', () => undefined);
2756
+ }
2757
+ /**
2758
+ * The page's `alert`, `confirm`, `prompt` and `beforeunload` dialogs.
2759
+ * Playwright dismisses a dialog nobody listens for, which would answer
2760
+ * a script's `prompt` with Cancel before anyone saw it. So each one is
2761
+ * reported and left open, for the person at the browser or a typed
2762
+ * `accept` / `dismiss`. Only when neither can answer (a headless
2763
+ * browser, no command input) is it dismissed, since it would otherwise
2764
+ * hang the page for good.
2765
+ */
2766
+ export class DialogTracker {
2767
+ say;
2768
+ visible;
2769
+ tabRef;
2770
+ /** The dialog open now, if any. */
2771
+ current = null;
2772
+ /**
2773
+ * Whether typed commands are, or are about to be, read, so `accept`
2774
+ * can answer. Set before the page loads when they will be, or a
2775
+ * prompt the script opens on load would be dismissed first.
2776
+ */
2777
+ commandsListening;
2778
+ waiters = new Set();
2779
+ // Dialogs open one at a time and close in order, so the nth close
2780
+ // on the protocol session is the nth dialog Playwright reported. The
2781
+ // two come over separate sessions, in no fixed order between them,
2782
+ // so a close is matched by count rather than applied to whatever is
2783
+ // current, which may be the next dialog already.
2784
+ openedCount = 0;
2785
+ closedCount = 0;
2786
+ currentOrdinal = 0;
2787
+ constructor(say, visible, commandsComing, tabRef) {
2788
+ this.say = say;
2789
+ this.visible = visible;
2790
+ this.tabRef = tabRef;
2791
+ this.commandsListening = commandsComing;
2792
+ }
2793
+ /**
2794
+ * `say`: where a dialog opening is told (a run logs it as an event).
2795
+ * `visible`: whether the browser is on screen, for a person to
2796
+ * answer. `commandsComing`: whether typed commands will be read.
2797
+ * `tabRef`: the tab's name for `accept` and `dismiss` to take, when
2798
+ * the run has several tabs and a bare one could reach another.
2799
+ */
2800
+ static async attach(page, say, { visible, commandsComing = false, tabRef = () => undefined }) {
2801
+ const tracker = new DialogTracker(say, visible, commandsComing, tabRef);
2802
+ page.on('dialog', (dialog) => tracker.opened(dialog));
2803
+ // Answered in the browser, a dialog closes without Playwright
2804
+ // saying so; the protocol's own event does.
2805
+ try {
2806
+ const cdp = await page.context().newCDPSession(page);
2807
+ await cdp.send('Page.enable');
2808
+ cdp.on('Page.javascriptDialogClosed', () => tracker.closed());
2809
+ }
2810
+ catch {
2811
+ // Then `status` may name a dialog already answered, and
2812
+ // `accept` says it was closed.
2813
+ }
2814
+ return tracker;
2815
+ }
2816
+ /**
2817
+ * `work`, or true as soon as a dialog opens while it runs. A menu
2818
+ * command that opens a dialog blocks the page, and the push that
2819
+ * fired it with it, until the dialog is answered.
2820
+ */
2821
+ async untilDialog(work) {
2822
+ let wake;
2823
+ const opened = new Promise((resolve) => { wake = () => resolve(true); });
2824
+ this.waiters.add(wake);
2825
+ try {
2826
+ return await Promise.race([work, opened]);
2827
+ }
2828
+ finally {
2829
+ this.waiters.delete(wake);
2830
+ }
2831
+ }
2832
+ /** The open dialog, now claimed by whoever answers it. */
2833
+ take() {
2834
+ const dialog = this.current;
2835
+ this.current = null;
2836
+ return dialog;
2837
+ }
2838
+ putBack(dialog) {
2839
+ this.current ??= dialog;
2840
+ }
2841
+ closed() {
2842
+ this.closedCount++;
2843
+ if (this.currentOrdinal <= this.closedCount)
2844
+ this.current = null;
2845
+ }
2846
+ opened(dialog) {
2847
+ const ordinal = ++this.openedCount;
2848
+ for (const wake of this.waiters)
2849
+ wake();
2850
+ const message = dialog.message();
2851
+ // As markup, so backticks in the page's text are only text.
2852
+ const what = ui `The page opened ${dialogKind(dialog)} dialog${message ? `: ${JSON.stringify(message)}` : ''}`;
2853
+ if (!this.visible && !this.commandsListening) {
2854
+ this.say(ui `${what}. Dismissed it, since nothing can answer it in a headless run that takes no commands.`);
2855
+ void dialog.dismiss().catch(() => undefined);
2856
+ return;
2857
+ }
2858
+ this.current = dialog;
2859
+ this.currentOrdinal = ordinal;
2860
+ const tab = this.tabRef();
2861
+ const accept = tab ? `accept ${tab}` : 'accept';
2862
+ const dismiss = tab ? `dismiss ${tab}` : 'dismiss';
2863
+ const answer = dialog.type() === 'beforeunload' ? `use ${quoted(accept)} to leave the page or ${quoted(dismiss)} to stay`
2864
+ : `use ${quoted(dialog.type() === 'prompt' ? `${accept} [text]` : accept)} or ${quoted(dismiss)}`;
2865
+ const how = !this.visible ? `${answer[0].toUpperCase()}${answer.slice(1)}`
2866
+ : this.commandsListening ? `Answer it in the browser, or ${answer}`
2867
+ : 'Answer it in the browser';
2868
+ this.say(ui `${what}. ${S.message(how)}.`);
2869
+ }
2870
+ }
2871
+ // A script's `GM_setValue` reaches the worker a message after the page
2872
+ // makes it, and a menu command's own work may take a moment to get
2873
+ // there, so a read issued straight after firing one would print what was
2874
+ // there before it. A short wait is the difference between "fire, then
2875
+ // read" telling the truth and telling yesterday's truth.
2876
+ const WRITE_FLUSH_MS = 250;
2877
+ function settleWrites() {
2878
+ return new Promise((r) => setTimeout(r, WRITE_FLUSH_MS));
2879
+ }
2880
+ function completeFrom(names, line) {
2881
+ if (/\s/.test(line.trimStart()))
2882
+ return [[], line];
2883
+ const word = line.trimStart();
2884
+ return [names.filter((name) => name.startsWith(word)), word];
2885
+ }
2886
+ // Set by the global --json: other log messages print to stderr, and
2887
+ // `--json=single` or `=multi` picks how results are laid out.
2888
+ let jsonLogs = false;
2889
+ let jsonLayout;
2890
+ /**
2891
+ * For the global --json: sends other log messages to stderr from now
2892
+ * on, and lays results out on one line or multi-line (`layout`),
2893
+ * rather than by whether stdout is a terminal.
2894
+ */
2895
+ export function setJsonOutput(layout) {
2896
+ jsonLogs = true;
2897
+ jsonLayout = layout;
2898
+ }
2899
+ // The prompt readline is drawing while a person types at a terminal,
2900
+ // or null when there is none (a pipe, or before commands are armed).
2901
+ let activePrompt = null;
2902
+ const PROMPT = '> ';
2903
+ // While a {{ block is open.
2904
+ const BLOCK_PROMPT = '... ';
2905
+ /**
2906
+ * Print one line of run output. With a prompt showing, the prompt and
2907
+ * whatever is half-typed are erased, the line printed above them, and
2908
+ * both redrawn with the cursor where it was, so streaming output never
2909
+ * lands in the middle of a command being edited.
2910
+ */
2911
+ export function writeOutputLine(line) {
2912
+ const marked = markedLine(line, jsonLayout ?? (process.stdout.isTTY ? 'multi' : 'single'));
2913
+ // In JSON mode, other log messages go to stderr, keeping stdout for
2914
+ // the results (see docs/sm-dev-json.md).
2915
+ const out = marked?.stderr || (!marked && jsonLogs && line.startsWith(S.SM_DEV_PREFIX)) ? process.stderr : process.stdout;
2916
+ if (marked)
2917
+ line = marked.text;
2918
+ line = paintOutput(line, streamHasColor(out));
2919
+ const rl = activePrompt;
2920
+ if (!rl) {
2921
+ out.write(line + '\n');
2922
+ return;
2923
+ }
2924
+ // A long typed line can wrap onto several rows; erase from its top.
2925
+ const { rows } = rl.getCursorPos();
2926
+ readline.moveCursor(process.stdout, 0, -rows);
2927
+ readline.cursorTo(process.stdout, 0);
2928
+ readline.clearScreenDown(process.stdout);
2929
+ // readline's redraw starts by moving up as many rows as it last
2930
+ // recorded the cursor being below the prompt's top, so pad the output
2931
+ // by that much. Its record, not `rows`: it goes stale when pasted
2932
+ // text wraps without a redraw. `prevRows` is internal, hence
2933
+ // the fallback.
2934
+ const recorded = rl.prevRows ?? rows;
2935
+ out.write(line + '\n');
2936
+ process.stdout.write('\n'.repeat(recorded));
2937
+ rl.prompt(true);
2938
+ }
2939
+ // `answer`: where the command's own output goes instead of `ctx.out`
2940
+ // (see commandOutput). Resolves to the command's exit code.
2941
+ // Commands after which nothing is left to remind about.
2942
+ const ENDS_RUN = new Set(['exit', 'stop']);
2943
+ export function commandRunner(ctx) {
2944
+ let queue = Promise.resolve();
2945
+ return (line, hooks) => {
2946
+ // The exit code: 2 when it said it was invalid, wherever in it
2947
+ // that was said; else what the command returned (a number, or false
2948
+ // for 1); else 0. A throw is 2 for an invalid command (a file that
2949
+ // isn't there), 1 otherwise. Commands run one at a time, so the
2950
+ // mistake watch holds only for this one.
2951
+ let code = 0;
2952
+ const run = async () => {
2953
+ let mistaken = false;
2954
+ const out = ctx.out;
2955
+ // In JSON mode, the command's other lines (a note beside its
2956
+ // result, or its failure in place of one) go to stderr as text.
2957
+ ctx.json = hasJsonForm(line) ? hooks?.json === true : undefined;
2958
+ ctx.out = (l) => {
2959
+ if (l.startsWith(MISTAKE_PREFIX))
2960
+ mistaken = true;
2961
+ if (!ctx.json)
2962
+ out(l);
2963
+ else if (isJsonLine(l))
2964
+ out(ctx.jsonLayout ? withLayout(l, ctx.jsonLayout) : l);
2965
+ else
2966
+ out(STDERR_LINE + l);
2967
+ };
2968
+ const before = waitingState(ctx);
2969
+ const said = [];
2970
+ const shown = ctx.out;
2971
+ ctx.out = (l) => {
2972
+ said.push(l);
2973
+ shown(l);
2974
+ };
2975
+ try {
2976
+ // Typed at a person's prompt, `menu` needn't wait for a command
2977
+ // still to register: the person can see what there is.
2978
+ ctx.interactive = hooks?.interactive === true;
2979
+ const result = await runConsoleCommand(line, ctx, { cwd: hooks?.cwd, words: hooks?.words });
2980
+ // Not after a command that ends the run, nor a line saying the
2981
+ // same thing already (a command refused for an open dialog).
2982
+ if (!ENDS_RUN.has(line.trim().split(/\s+/)[0])) {
2983
+ for (const reminder of waitingReminders(ctx, before, said))
2984
+ ctx.out(reminder);
2985
+ }
2986
+ code = result === false ? 1 : typeof result === 'number' ? result : 0;
2987
+ // An invalid command is 2, even when it also said it failed.
2988
+ if (mistaken)
2989
+ code = 2;
2990
+ }
2991
+ catch (err) {
2992
+ // A failure, apart from a mistake in the command: in error color too.
2993
+ ctx.out(err instanceof UsageError ? `${MISTAKE_PREFIX}${err.message}` : `[sm-dev] ${errorLine(err.message)}`);
2994
+ code = err instanceof UsageError ? 2 : 1;
2995
+ }
2996
+ finally {
2997
+ ctx.json = undefined;
2998
+ ctx.jsonLayout = undefined;
2999
+ ctx.out = out;
3000
+ }
3001
+ };
3002
+ const done = queue.then(async () => {
3003
+ hooks?.start?.();
3004
+ const answer = hooks?.answer && { line: hooks.answer, live: true };
3005
+ // Where this command began, for the next `wait log`. Not for one
3006
+ // that only looks or waits, or an empty line (a message's turn):
3007
+ // what the command before it set off still counts.
3008
+ const began = ctx.logs?.position();
3009
+ const name = line.trim().split(/\s+/)[0];
3010
+ try {
3011
+ await inCommand.run({ inspects: onlyInspects(line) }, () => (answer ? commandOutput.run(answer, run) : run()));
3012
+ }
3013
+ finally {
3014
+ if (answer)
3015
+ answer.live = false;
3016
+ else if (name && name !== 'wait' && !onlyInspects(line))
3017
+ ctx.previousStart = began;
3018
+ hooks?.end?.();
3019
+ }
3020
+ });
3021
+ queue = done;
3022
+ return done.then(() => code);
3023
+ };
3024
+ }
3025
+ /**
3026
+ * Whether dialogs wait for a typed (or sent) `accept`, rather than
3027
+ * being dismissed in a headless run. On while commands are read.
3028
+ */
3029
+ export function setCommandsListening(tabs, listening) {
3030
+ for (const tab of tabs)
3031
+ if (tab.dialogs)
3032
+ tab.dialogs.commandsListening = listening;
3033
+ }
3034
+ /**
3035
+ * The `> ` prompt of `sm-dev --interactive`, reading the terminal: line
3036
+ * editing and history (readline's own keys: arrows, Ctrl-A/E, Ctrl-U
3037
+ * and so on), Tab completion of command names, and `... ` inside an
3038
+ * `{{` block (see command-lines.ts), edited as one buffer at a terminal
3039
+ * (see prompt-editor.ts). A block is one history entry, and history is
3040
+ * kept across sessions (see prompt-history.ts). Ctrl-C clears the
3041
+ * typed command, and on an empty line it or Ctrl-D leaves. Each command goes to `runLine` once typed, in order.
3042
+ * Returns a function that closes the prompt.
3043
+ */
3044
+ export function readPromptCommands(io, runLine) {
3045
+ io.out('[sm-dev] Ready for commands. Use `help commands` to list them.');
3046
+ // A dumb terminal can't move the cursor, so writeOutputLine couldn't
3047
+ // redraw around a prompt there.
3048
+ const terminal = Boolean(process.stdout.isTTY) && process.env.TERM !== 'dumb';
3049
+ const blocks = new CommandAssembler();
3050
+ // Set once the prompt is closed on purpose, rather than by Ctrl-D.
3051
+ let closing = false;
3052
+ // A message in the commands' turn, after the ones before it. The
3053
+ // empty line runs nothing.
3054
+ const say = (message) => {
3055
+ void runLine('', { start: () => io.out(`${MISTAKE_PREFIX}${message}`) });
3056
+ };
3057
+ const names = [...COMMAND_NAMES].sort();
3058
+ // Kept across sessions (see prompt-history.ts). Newest first.
3059
+ const file = terminal ? historyFile() : null;
3060
+ const history = file ? loadHistory(file) : [];
3061
+ const lines = readline.createInterface({
3062
+ input: process.stdin,
3063
+ output: process.stdout,
3064
+ terminal,
3065
+ prompt: PROMPT,
3066
+ history,
3067
+ // A lone Esc clears the command (see prompt-editor.ts). readline
3068
+ // waits this long for more of an escape sequence before taking it
3069
+ // as one: 500ms by default, which made Esc feel slow.
3070
+ escapeCodeTimeout: 100,
3071
+ // One over, so readline never drops an entry for the typed line
3072
+ // it adds, which the 'history' handler below takes back out.
3073
+ historySize: HISTORY_SIZE + 1,
3074
+ // Tab completes a command name, except in a {{ block, where the
3075
+ // text is the block's (JavaScript or JSON). Only at a terminal,
3076
+ // where the editor holds a block as one buffer.
3077
+ completer: (line) => (line.includes('\n') ? [[], line] : completeFrom(names, line)),
3078
+ });
3079
+ // At a terminal a {{ block is typed and edited as one buffer, sent
3080
+ // once its }} line ends it (see prompt-editor.ts). Without one, each
3081
+ // line is sent as typed, and `blocks` collects them.
3082
+ // A command of several lines (a {{ block, or lines joined with
3083
+ // Alt-Enter) cleared by Ctrl-C or Esc is kept, unfinished, to take
3084
+ // up again. Not one brought back from the history unchanged.
3085
+ // The prompts and typed commands in color, apart from the output.
3086
+ const paint = streamHasColor(process.stdout) ? { prompt: (t) => paintAs('prompt', t), text: (t) => paintAs('typed', t) } : undefined;
3087
+ const editor = terminal ? editMultiLine(lines, BLOCK_PROMPT, endsOutsideBlock, (text) => {
3088
+ if (text.includes('\n') && !history.includes(text))
3089
+ remember(text);
3090
+ }, paint) : null;
3091
+ // readline adds each typed line to the history itself, but a {{
3092
+ // block should be one entry of all its lines, so its additions are
3093
+ // taken back out here and `remember` adds whole entries instead.
3094
+ // This leans on how readline (checked on Node 22) adds a line:
3095
+ // - It skips a blank line, and one equal to the newest entry, so the
3096
+ // newest entry changing (from `newest`, ours) means it added one.
3097
+ // - It reads the line it hands to 'line' before this event, so
3098
+ // removing it here doesn't change what is run.
3099
+ let newest = history[0];
3100
+ lines.on('history', (entries) => {
3101
+ if (entries.length > 0 && entries[0] !== newest)
3102
+ entries.shift();
3103
+ });
3104
+ let saveFailed = false;
3105
+ const remember = (entry) => {
3106
+ if (!entry.trim() || entry === newest)
3107
+ return;
3108
+ history.unshift(entry);
3109
+ if (history.length > HISTORY_SIZE)
3110
+ history.pop();
3111
+ newest = entry;
3112
+ if (!file || saveFailed)
3113
+ return;
3114
+ try {
3115
+ appendHistory(file, entry);
3116
+ }
3117
+ catch (err) {
3118
+ saveFailed = true;
3119
+ io.out(`[sm-dev] Command history isn't being saved: Could not write ${file}: ${writeProblem(err) ?? errorSentence(err)}`);
3120
+ }
3121
+ };
3122
+ // The lines of the {{ block being typed, for its history entry.
3123
+ let blockLines = [];
3124
+ // Typed commands not yet done. The `> ` prompt waits for them, as a
3125
+ // shell's does, so a command's output lands before the next prompt
3126
+ // rather than under it. Typing ahead still works, on a bare line.
3127
+ let running = 0;
3128
+ // Set by a Ctrl-C while a command runs, so a second one leaves.
3129
+ let exitArmed = false;
3130
+ // `blocks` is only open without a terminal, where lines are sent one
3131
+ // by one.
3132
+ const promptText = () => (blocks.open ? BLOCK_PROMPT : running > 0 ? '' : PROMPT);
3133
+ const showPrompt = (preserveCursor) => {
3134
+ lines.setPrompt(promptText());
3135
+ lines.prompt(preserveCursor);
3136
+ };
3137
+ const take = (line) => {
3138
+ const inBlock = blocks.open;
3139
+ const got = blocks.push(line);
3140
+ if (inBlock || blocks.open)
3141
+ blockLines.push(line);
3142
+ if (!blocks.open) {
3143
+ remember(blockLines.length > 0 ? blockLines.join('\n') : line);
3144
+ blockLines = [];
3145
+ }
3146
+ if (got && 'error' in got)
3147
+ say(got.error);
3148
+ else if (got) {
3149
+ running++;
3150
+ void runLine(got.command.text).finally(() => {
3151
+ running--;
3152
+ if (running === 0)
3153
+ exitArmed = false;
3154
+ if (running === 0 && activePrompt === lines)
3155
+ showPrompt(true);
3156
+ });
3157
+ }
3158
+ };
3159
+ lines.on('line', (input) => {
3160
+ // At a terminal, a block arrives as one line holding its lines.
3161
+ for (const line of input.split('\n'))
3162
+ take(line);
3163
+ showPrompt(false);
3164
+ });
3165
+ // Erases the prompt and stops drawing output around it. Before the
3166
+ // interface closes, or after readline closes it itself on Ctrl-D, so
3167
+ // later output neither redraws a dead prompt nor resumes stdin.
3168
+ const dropPrompt = () => {
3169
+ if (activePrompt !== lines)
3170
+ return;
3171
+ activePrompt = null;
3172
+ if (!terminal)
3173
+ return;
3174
+ readline.moveCursor(process.stdout, 0, -lines.getCursorPos().rows);
3175
+ readline.cursorTo(process.stdout, 0);
3176
+ readline.clearScreenDown(process.stdout);
3177
+ };
3178
+ // readline puts the terminal in raw mode, so Ctrl-C arrives here
3179
+ // rather than as the process's SIGINT. As in a shell, it first
3180
+ // clears a half-typed line, and only leaves on an empty one.
3181
+ // Only at a terminal, so with the editor, where a {{ block is the
3182
+ // typed buffer rather than lines `blocks` holds.
3183
+ lines.on('SIGINT', () => {
3184
+ // With no prompt showing, Ctrl-C looks like it would cancel the
3185
+ // command. It can't, so the first one only says so.
3186
+ if (!lines.line && running > 0 && !exitArmed) {
3187
+ exitArmed = true;
3188
+ io.out('[sm-dev] A command is still running, and cannot be cancelled. Press Ctrl-C again to exit.');
3189
+ return;
3190
+ }
3191
+ if (!lines.line) {
3192
+ // With a command still running, at once rather than after it.
3193
+ io.exit(running > 0);
3194
+ return;
3195
+ }
3196
+ editor?.clear();
3197
+ });
3198
+ // readline handles Ctrl-Z itself. On `fg` it pauses its input and
3199
+ // emits this event, but never resumes the input. Without this the
3200
+ // prompt stays frozen until some output line happens to redraw it.
3201
+ lines.on('SIGCONT', () => lines.resume());
3202
+ lines.on('close', () => {
3203
+ dropPrompt();
3204
+ if (!closing)
3205
+ io.exit();
3206
+ });
3207
+ if (terminal)
3208
+ activePrompt = lines;
3209
+ lines.prompt();
3210
+ return () => {
3211
+ closing = true;
3212
+ dropPrompt();
3213
+ lines.close();
3214
+ process.stdin.pause();
3215
+ };
3216
+ }