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,1973 @@
1
+ // `sm-dev start`'s run: connect to a browser, push one or more scripts
2
+ // from disk into SourceMonkey there, load the page, and log what
3
+ // happens, taking commands from clients over its socket until stopped.
4
+ //
5
+ // Kept apart from cli.ts so the Node-only commands (`validate`,
6
+ // `match`) never load Playwright, which is an optional peer dependency.
7
+ import { watch } from 'node:fs';
8
+ import { readFile, rename, stat, writeFile } from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { shellWord } from './command-lines.js';
12
+ import { commandOutput, commandRunner, DialogTracker, gotoInTab, leaveDialogsOpen, onlyInspects, reloadTab, reloadable, serveSavedPage, setCommandsListening, skippedScriptLine, targetIdSuffix, withoutQuery, } from './console-commands.js';
13
+ import { applyLang, langLaunchArgs } from './browser-lang.js';
14
+ import { watchDownloads } from './download-watch.js';
15
+ import { managerState, managersIn, otherManagersWarning, stateLabel } from './browser-extensions.js';
16
+ import { DROP_BROWSER_MODE } from './extension-backend.js';
17
+ import { ExtensionHost } from './extension-host.js';
18
+ import { launchExtensionBrowser } from './extension-launch.js';
19
+ import { fetchManager, fetchedVersion } from './manager-fetch.js';
20
+ import { DEFAULT_MANAGER, MANAGERS, managerFlag } from './managers.js';
21
+ import { ExtensionLink, linkRunId } from './extension-link.js';
22
+ import { ExternalRunScripts, externalControl, externalOff, externalOthers, openExternalManager } from './external-manager.js';
23
+ import { attachPage, targetIdOf } from './page-watch.js';
24
+ import { pageSays } from './page-commands.js';
25
+ import { consoleSource, consoleText, decodeOwnRefs, formatConsoleArgs, isPreludeLine, pageErrorSource } from './page-output.js';
26
+ import { loadPlaywright } from './playwright-chromium.js';
27
+ import { FrameSessions } from './frame-sessions.js';
28
+ import { defaultPageUrl, explainMatch, isDirectoryMessage, NO_DEFAULT_PAGE, noSuchFileMessage, preparedNoframes, preparedScriptPath, prepareScript, pushedFiles, shownPath, warningLines, } from './prepare.js';
29
+ import { DEFAULT_LOG_SOURCES, inSources, RunLog, streamLine } from './run-logs.js';
30
+ import { runDownloadsDir } from './run-log.js';
31
+ import { comparableUrl, openingSavedLine } from './saved-pages.js';
32
+ import { echoLine, INVALID_PREFIX, plainLine, quoted } from './output-style.js';
33
+ // The roles for marked-up output, as S.id(...): their names are common locals here.
34
+ import * as S from './output-style.js';
35
+ import { browserSentence, checkRunsDir, ownRunsDir, processAlive, RunControl, runTabsPath } from './runs.js';
36
+ import { cspBlocksPageScripts, sandboxedPageNote, sandboxedPageWarning } from '../sandboxed-page.js';
37
+ import { waitForStuckTabs } from './stuck-tabs.js';
38
+ import { NO_TABS } from './tab-refs.js';
39
+ import { loadPage, settleTabs } from './wait.js';
40
+ /**
41
+ * A `--browser` value as a URL Playwright can connect to: a port alone
42
+ * (`9222`, `:9222`) means this machine, and a host with no scheme gets
43
+ * `http://`. Also the key runs record their tabs under, so the port
44
+ * and no-scheme spellings match the URL they stand for; `localhost`
45
+ * and `127.0.0.1` still differ. An IPv6 host needs brackets
46
+ * (`[::1]:9222`), as in a URL.
47
+ */
48
+ export function cdpEndpoint(value) {
49
+ const v = value.trim().replace(/\/+$/, '');
50
+ const port = /^:?(\d+)$/.exec(v);
51
+ if (port)
52
+ return `http://127.0.0.1:${port[1]}`;
53
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(v) ? v : `http://${v}`;
54
+ }
55
+ /**
56
+ * Whether two `--browser` values name the same browser: as
57
+ * `cdpEndpoint` writes them, with `localhost` and `[::1]` the same as
58
+ * `127.0.0.1`.
59
+ */
60
+ export function sameBrowser(a, b) {
61
+ const key = (value) => cdpEndpoint(value.trim()).replace(/^(\w+:\/\/)(localhost|\[::1\])(?=[:/]|$)/i, '$1127.0.0.1');
62
+ return key(a) === key(b);
63
+ }
64
+ /**
65
+ * The browser to use: `--browser` (`given`), else $SM_DEV_BROWSER, else
66
+ * port 9233. Empty counts as unset, so an exported-but-blank variable
67
+ * doesn't become `http://`.
68
+ */
69
+ export function browserSetting(given) {
70
+ return cdpEndpoint(given?.trim() || process.env.SM_DEV_BROWSER?.trim() || '9233');
71
+ }
72
+ function cdpSetting(opts) {
73
+ return browserSetting(opts.cdp);
74
+ }
75
+ /**
76
+ * ` --existing-browser`, with the address (`addressArg`), for advice to
77
+ * run a command against the browser at `endpoint`. Empty for a launched
78
+ * one (`launched`), which closes with the run, so the advice goes to
79
+ * the run.
80
+ */
81
+ export function browserArg(endpoint, launched = false) {
82
+ return launched ? '' : ` --existing-browser${addressArg(endpoint)}`;
83
+ }
84
+ /** ` --browser <address>`, unless `endpoint` is the default browser. */
85
+ export function addressArg(endpoint) {
86
+ return sameBrowser(endpoint, browserSetting()) ? '' : ` --browser ${endpoint.replace(/^https?:\/\//, '')}`;
87
+ }
88
+ /**
89
+ * Where the browser setting came from, for a message about it: the
90
+ * variable when `--browser` wasn't given. $SM_DEV_BROWSER used to name
91
+ * the Chrome binary, so a stale one shouldn't be blamed on the flag.
92
+ */
93
+ export function browserSettingName(given) {
94
+ return !given?.trim() && process.env.SM_DEV_BROWSER?.trim() ? '$SM_DEV_BROWSER' : '--browser';
95
+ }
96
+ /**
97
+ * `runTab`: in a connected browser, open the run's tab with
98
+ * `openRunTab`, which closes the selected tab when an earlier,
99
+ * finished run opened it.
100
+ */
101
+ export async function connectBrowser(opts, out, { runTab = false } = {}) {
102
+ // Plain text: its lines may hold markup, which this caller doesn't paint.
103
+ const plain = (line) => out(plainLine(line));
104
+ const session = await openBrowser(opts, plain, { runTab, openTab: true });
105
+ session?.notes.forEach(plain);
106
+ return session;
107
+ }
108
+ /**
109
+ * The browser at `--browser` (`cdp`), with no run and no tab, for the
110
+ * commands that only talk to SourceMonkey (`collection list`). Null,
111
+ * having said why, when it couldn't connect.
112
+ */
113
+ export function connectWithoutRun(cdp, out) {
114
+ return openBrowser({ cdp }, out, { runTab: false, openTab: false });
115
+ }
116
+ // Whether a connected browser runs without a window: its user agent
117
+ // then says HeadlessChrome. (The product name does only in the old
118
+ // headless mode, not in --headless=new.) Unknown counts as not.
119
+ async function isHeadless(browser) {
120
+ try {
121
+ const cdp = await browser.newBrowserCDPSession();
122
+ const { userAgent } = await cdp.send('Browser.getVersion');
123
+ await cdp.detach().catch(() => undefined);
124
+ return /\bHeadlessChrome\//.test(userAgent);
125
+ }
126
+ catch {
127
+ return false;
128
+ }
129
+ }
130
+ async function openBrowser(opts, out, { runTab, openTab }) {
131
+ const { chromium } = await loadPlaywright();
132
+ let browser;
133
+ // Set for a connected browser only.
134
+ let endpoint = null;
135
+ // A launched one's port, which the run connects to, and other tools
136
+ // (a debugger, a second Playwright) can too; `list` says where.
137
+ let launchedAt = null;
138
+ // Closes a launched browser, which the run reaches over a CDP
139
+ // connection of its own (see extension-launch.ts).
140
+ let closeLaunched;
141
+ let downloadsDir;
142
+ if (opts.tempBrowser) {
143
+ let launched;
144
+ downloadsDir = runDownloadsDir(opts.log);
145
+ const manager = opts.manager ?? DEFAULT_MANAGER;
146
+ const { label } = MANAGERS[manager];
147
+ try {
148
+ // Fetched the first time it's wanted, as `manager fetch` would.
149
+ if (manager !== 'sourcemonkey' && fetchedVersion(manager) === null)
150
+ await fetchManager(manager, out);
151
+ // Only a port given outright: $SM_DEV_BROWSER names a lasting browser.
152
+ const port = opts.cdp === undefined ? undefined : Number(new URL(cdpEndpoint(opts.cdp)).port);
153
+ launched = await launchExtensionBrowser({ port, headed: opts.headed === true, muted: opts.muted, args: langLaunchArgs(opts.lang), connectedElsewhere: true, downloadsDir, manager });
154
+ }
155
+ catch (err) {
156
+ out(S.asError(`Could not launch a browser with ${label}: ${err.message}`));
157
+ return null;
158
+ }
159
+ closeLaunched = launched.close;
160
+ try {
161
+ browser = await chromium.connectOverCDP(launched.endpoint);
162
+ }
163
+ catch (err) {
164
+ await launched.close();
165
+ out(S.asError(`Could not connect to the browser launched with ${label}: ${err.message}`));
166
+ return null;
167
+ }
168
+ launchedAt = launched.endpoint;
169
+ }
170
+ else {
171
+ endpoint = cdpSetting(opts);
172
+ try {
173
+ // Playwright's connection waits on every tab, so a tab that
174
+ // doesn't answer is named first, and waited for.
175
+ if (!await waitForStuckTabs(endpoint, out, opts.until))
176
+ return null;
177
+ // `noDefaults` keeps Playwright's test-friendly overrides off the
178
+ // developer's own profile: downloads rerouted to a temp folder
179
+ // that is deleted on disconnect, every tab reporting focus, and
180
+ // a forced light color scheme. The script should see the browser
181
+ // as its user would. A Playwright older than the option ignores
182
+ // it and keeps the overrides.
183
+ browser = await chromium.connectOverCDP(endpoint, { noDefaults: true });
184
+ }
185
+ catch (err) {
186
+ const message = err.message;
187
+ if (/Timeout \d+ms exceeded/.test(message) && /<ws connected>/.test(message)) {
188
+ // The browser answered, and the tabs' setup didn't finish: a tab
189
+ // that stopped responding after the check, or very many tabs.
190
+ out(S.asError(`Could not connect to the browser at ${endpoint}: Connecting timed out. A tab may have stopped responding (a dialog open, or a busy page), or the browser has very many tabs. Run again to check the tabs.`));
191
+ return null;
192
+ }
193
+ const from = browserSettingName(opts.cdp) === '$SM_DEV_BROWSER' ? ' (from $SM_DEV_BROWSER)' : '';
194
+ out(S.asError(`Could not connect to a browser at ${endpoint}${from}: ${message}`));
195
+ out(S.err(`Use \`--open-browser\` to start one there, or ${DROP_BROWSER_MODE}`));
196
+ return null;
197
+ }
198
+ }
199
+ const context = browser.contexts()[0] ?? await browser.newContext();
200
+ if (endpoint !== null)
201
+ browser.contexts().forEach(leaveDialogsOpen);
202
+ const notes = [];
203
+ const page = !openTab ? null
204
+ // The one blank tab a launch leaves for the run.
205
+ : closeLaunched ? context.pages()[0] ?? await context.newPage()
206
+ : runTab && endpoint !== null
207
+ ? await openRunTab(browser, context, endpoint, (line) => notes.push(line)).catch(() => context.newPage())
208
+ : await context.newPage();
209
+ return {
210
+ browser,
211
+ endpoint: endpoint ?? launchedAt,
212
+ launched: endpoint === null,
213
+ headless: endpoint === null ? !opts.headed : await isHeadless(browser),
214
+ ...(downloadsDir ? { downloadsDir } : {}),
215
+ context,
216
+ page,
217
+ notes,
218
+ // On a connected browser `close()` only drops the connection; the
219
+ // browser and its tabs stay as they are.
220
+ done: async () => {
221
+ try {
222
+ await browser.close();
223
+ }
224
+ finally {
225
+ await closeLaunched?.();
226
+ }
227
+ },
228
+ };
229
+ }
230
+ /**
231
+ * The tab for a new run: always a new one, so the run starts on a clean
232
+ * page. The selected tab is closed when an earlier run opened it and
233
+ * has since ended, so re-running from a terminal doesn't pile up tabs.
234
+ * "Selected" is the tab showing in its window: the terminal has the
235
+ * focus, so no tab would count as focused. Records the tab for the
236
+ * next run.
237
+ */
238
+ async function openRunTab(browser, context, endpoint, out) {
239
+ const records = await readRunTabs();
240
+ const cdp = await browser.newBrowserCDPSession();
241
+ const { targetInfos } = await cdp.send('Target.getTargets');
242
+ await cdp.detach();
243
+ const live = new Set(targetInfos.filter((t) => t.type === 'page').map((t) => t.targetId));
244
+ const free = new Set(records
245
+ .filter((r) => r.endpoint === endpoint && live.has(r.tabTargetId) && !processAlive(r.pid))
246
+ .map((r) => r.tabTargetId));
247
+ let old = null;
248
+ // Only when one could be free, since reading a page's target id
249
+ // means a protocol session per tab.
250
+ if (free.size > 0) {
251
+ for (const candidate of context.pages()) {
252
+ const id = await targetIdOf(candidate);
253
+ if (id && free.has(id) && await isSelected(candidate)) {
254
+ old = candidate;
255
+ break;
256
+ }
257
+ }
258
+ }
259
+ const page = await context.newPage();
260
+ // Closed only when the new tab opened in its window, so the window
261
+ // isn't left empty (and closed with it). With several windows, the
262
+ // new tab opens in the last focused one, which may be another.
263
+ if (old && await sameWindow(browser, old, page)) {
264
+ const oldId = await targetIdOf(old);
265
+ const opener = records.find((r) => r.endpoint === endpoint && r.tabTargetId === oldId)?.run;
266
+ const url = old.url();
267
+ // Without its unload handlers, which could open a "leave site?"
268
+ // prompt nobody is there to answer.
269
+ await old.close({ runBeforeUnload: false }).then(() => out(`[sm-dev] Closed the selected tab (${url}), which ${opener ? `run ${opener}` : 'an earlier run'} opened. That run has ended.`), () => undefined);
270
+ }
271
+ const id = await targetIdOf(page);
272
+ if (!id)
273
+ return page;
274
+ // Re-read just before writing, so a parallel run's entry written
275
+ // meanwhile survives.
276
+ const latest = await readRunTabs();
277
+ // Drops this browser's closed tabs. Another browser's entries are
278
+ // left as they are: a parallel session may be using that browser, and
279
+ // its tabs can't be checked from here.
280
+ const kept = latest.filter((r) => r.endpoint !== endpoint || (live.has(r.tabTargetId) && r.tabTargetId !== id));
281
+ kept.push({ endpoint, tabTargetId: id, pid: process.pid });
282
+ await writeRunTabs(kept).catch(() => undefined);
283
+ return page;
284
+ }
285
+ // Whether two tabs are in one window. False when that can't be told.
286
+ async function sameWindow(browser, a, b) {
287
+ const [idA, idB] = await Promise.all([targetIdOf(a), targetIdOf(b)]);
288
+ if (!idA || !idB)
289
+ return false;
290
+ const cdp = await browser.newBrowserCDPSession();
291
+ try {
292
+ const [winA, winB] = await Promise.all([idA, idB].map((targetId) => cdp.send('Browser.getWindowForTarget', { targetId })));
293
+ return winA.windowId === winB.windowId;
294
+ }
295
+ catch {
296
+ return false;
297
+ }
298
+ finally {
299
+ await cdp.detach().catch(() => undefined);
300
+ }
301
+ }
302
+ // The run's name on the tab `openRunTab` recorded for it, which it
303
+ // gets only once the run is open, after the tab was opened.
304
+ async function nameRunTab(endpoint, page, run) {
305
+ const id = await targetIdOf(page);
306
+ if (!id)
307
+ return;
308
+ const tabs = await readRunTabs();
309
+ const mine = tabs.find((r) => r.endpoint === endpoint && r.tabTargetId === id && r.pid === process.pid);
310
+ if (!mine)
311
+ return;
312
+ mine.run = run;
313
+ await writeRunTabs(tabs).catch(() => undefined);
314
+ }
315
+ // Written whole and renamed into place, so a run reading it meanwhile
316
+ // never sees half a file, which it would take for an empty one and
317
+ // then write back over everyone's entries.
318
+ async function writeRunTabs(tabs) {
319
+ // One tab per line, for someone reading the file.
320
+ const text = `[\n${tabs.map((r) => ` ${JSON.stringify(r)}`).join(',\n')}\n]\n`;
321
+ const file = runTabsPath();
322
+ await ownRunsDir();
323
+ const temp = `${file}.${process.pid}`;
324
+ await writeFile(temp, text);
325
+ await rename(temp, file);
326
+ }
327
+ async function readRunTabs() {
328
+ try {
329
+ // Not trusted from a directory another user could write in.
330
+ if (!await checkRunsDir())
331
+ return [];
332
+ const parsed = JSON.parse(await readFile(runTabsPath(), 'utf8'));
333
+ return Array.isArray(parsed) ? parsed : [];
334
+ }
335
+ catch {
336
+ return [];
337
+ }
338
+ }
339
+ // Every window's showing tab counts, so with several windows open the
340
+ // first free one found wins. A page blocked on a dialog never answers,
341
+ // hence the time limit.
342
+ function isSelected(page) {
343
+ return pageSays(page, () => document.visibilityState === 'visible');
344
+ }
345
+ const NO_URL = 'No URL was given, and the first script\'s @match names no page to open. Give a URL.';
346
+ const NO_URL_ALL = 'None of the scripts\' @match names a page to open. Give a URL instead of --all.';
347
+ function startPages(opts, prepared) {
348
+ if (opts.noTab)
349
+ return { urls: [], saved: [], skipped: [] };
350
+ // Each page once. Two saved files can be served at one URL, in a tab each.
351
+ const pairs = new Map();
352
+ (opts.urls ?? []).forEach((url, i) => {
353
+ const saved = opts.saved?.[i] ?? null;
354
+ pairs.set(`${url}\n${saved?.file ?? ''}`, { url, saved });
355
+ });
356
+ const given = { urls: [...pairs.values()].map((p) => p.url), saved: [...pairs.values()].map((p) => p.saved), given: true, skipped: [] };
357
+ // No scripts: the tab as it opened, blank, unless URLs are given.
358
+ if (prepared.length === 0)
359
+ return given;
360
+ if (!opts.all) {
361
+ if (given.urls.length > 0)
362
+ return given;
363
+ const url = defaultPageUrl(prepared[0]);
364
+ if (!url)
365
+ opts.out(S.err(NO_URL));
366
+ return url ? { urls: [url], saved: [null], skipped: [] } : null;
367
+ }
368
+ const urls = new Set();
369
+ const skipped = [];
370
+ prepared.forEach((script, i) => {
371
+ const url = defaultPageUrl(script);
372
+ if (url)
373
+ urls.add(url);
374
+ else
375
+ skipped.push({ n: i + 1, script, reason: NO_DEFAULT_PAGE });
376
+ });
377
+ if (urls.size === 0) {
378
+ opts.out(NO_URL_ALL);
379
+ return null;
380
+ }
381
+ return { urls: [...urls], saved: [...urls].map(() => null), skipped };
382
+ }
383
+ // A page whose tab failed to open. A URL that was given is named; a
384
+ // script's own page (`--all`) is reported as every script it was for.
385
+ function tabFailed(opts, start, prepared, url, err) {
386
+ if (start.given) {
387
+ opts.out(`[sm-dev] The tab for ${url} could not be opened: ${err.message}`);
388
+ return;
389
+ }
390
+ const reason = `The tab could not be opened: ${err.message}`;
391
+ prepared.forEach((script, i) => {
392
+ if (defaultPageUrl(script) === url)
393
+ start.skipped.push({ n: i + 1, script, reason });
394
+ });
395
+ }
396
+ function printSkipped(opts, start) {
397
+ for (const s of [...start.skipped].sort((a, b) => a.n - b.n))
398
+ opts.out(skippedScriptLine(s.n, s.script, s.reason));
399
+ }
400
+ export async function run(opts) {
401
+ const given = opts.out;
402
+ const print = opts.markup ? given : (line) => given(plainLine(line));
403
+ const remote = new Remote(print, opts.logSources ?? DEFAULT_LOG_SOURCES, opts.onStop, opts.onActivity, opts.primaryPid);
404
+ // Every line also goes to whoever sent the command running now.
405
+ opts = { ...opts, out: remote.out, emit: remote.emit, typed: new Map(opts.files.map((file, i) => [file, opts.given?.[i] ?? file])) };
406
+ const session = await openBrowser(opts, opts.out, { runTab: true, openTab: !opts.noTab });
407
+ if (!session)
408
+ return 1;
409
+ // Which browser, for the stream and the log.
410
+ const browserIs = { browser: session.endpoint, owned: session.launched, headless: session.headless };
411
+ const manager = opts.manager ?? DEFAULT_MANAGER;
412
+ const managers = await managersIn(session.endpoint);
413
+ const state = managers ? managerState(managers, manager) : null;
414
+ const extension = state ? stateLabel(state, manager) : '';
415
+ opts.out(`[sm-dev] ${capitalized(browserSentence(browserIs, session.launched ? 'launched' : 'connected to'))} (Chrome ${session.browser.version()}${extension ? `, ${extension}` : ''})`);
416
+ for (const line of otherManagersWarning(managers ?? [], manager, browserArg(session.endpoint, session.launched)))
417
+ opts.out(`[sm-dev] ${line}`);
418
+ session.notes.forEach(opts.out);
419
+ opts.onConnected?.();
420
+ // Only a run that waits for commands can be sent any.
421
+ if (opts.until) {
422
+ await remote.open({
423
+ pid: process.pid,
424
+ started: remote.startedAt,
425
+ browser: session.endpoint,
426
+ ...(session.launched ? { owned: true } : {}),
427
+ ...(session.headless ? { headless: true } : {}),
428
+ scripts: opts.files,
429
+ ...(manager !== DEFAULT_MANAGER ? { manager } : {}),
430
+ ...(opts.detached ? { detached: true } : {}),
431
+ ...(opts.log ? { log: opts.log } : {}),
432
+ }, opts.runId);
433
+ if (remote.id && session.page && !session.launched)
434
+ await nameRunTab(session.endpoint, session.page, remote.id).catch(() => undefined);
435
+ }
436
+ // The browser closing (or its connection dropping) ends the run: it
437
+ // has nothing left to stream from. Without this the run would wait
438
+ // on with nothing keeping Node alive, and quit without its last line.
439
+ let ending = false;
440
+ session.browser.on('disconnected', () => {
441
+ if (ending)
442
+ return;
443
+ opts.out('[sm-dev] The browser closed, or its connection dropped');
444
+ opts.onStop?.();
445
+ });
446
+ const held = {};
447
+ try {
448
+ return await runInBrowser(opts, session, held, remote, state);
449
+ }
450
+ finally {
451
+ ending = true;
452
+ // No sent command runs against tabs being let go.
453
+ remote.disarm();
454
+ // Also on a throw partway through, so a failed run leaves no
455
+ // attachment behind in a browser the developer keeps using.
456
+ await held.downloads?.stop();
457
+ await held.tabs?.dispose().catch(() => undefined);
458
+ await held.attachment?.dispose().catch(() => undefined);
459
+ // Its tabs run the extension's scripts again from their next load,
460
+ // and the extension removes its test collection.
461
+ await held.extension?.close();
462
+ if (held.external) {
463
+ // Not from a browser that's gone: a throwaway one takes them with
464
+ // it, and the next run in a person's own removes them as it starts.
465
+ if (!session.launched && session.browser.isConnected()) {
466
+ await held.external.scripts.removeAll().catch((err) => opts.out(`[sm-dev] The run's scripts could not be removed from ${held.external.manager.label}: ${err.message}`));
467
+ }
468
+ await held.external.manager.close().catch(() => undefined);
469
+ }
470
+ await session.done().catch(() => undefined);
471
+ // On every way out, so a page tested after this is never taken
472
+ // for one the run still controls. Its registrations are gone, but
473
+ // script code already running in an open page runs on.
474
+ const run = remote.id ? S.ui `Run ${S.id(remote.id)}` : 'The run';
475
+ opts.out(`[sm-dev] ${run} ended`);
476
+ await remote.close();
477
+ }
478
+ }
479
+ /**
480
+ * How a sent command is headed in the log and to the clients following
481
+ * the run: who sent it (`primary`, the client that started the run, or
482
+ * `remote`, any other) and how it got it (`arg`, `stdin`, `prompt`), as
483
+ * `primary:arg >`. A client heads its own without the who.
484
+ */
485
+ export function commandMark(sent, primaryPid) {
486
+ const who = sent.pid !== undefined && sent.pid === primaryPid ? 'primary' : 'remote';
487
+ return `${who}:${sent.source} >`;
488
+ }
489
+ /**
490
+ * A run's side of `sm-dev <command>` and `sm-dev stop`: runs each sent line
491
+ * as a typed command, and answers the sender with the output while it
492
+ * runs. Done when the command is: each command waits out what it sets
493
+ * off that has an end, such as the scripts starting after a reload.
494
+ * A command that only inspects answers the sender alone; one that
495
+ * changes things also shows in the run's stream, with what the page and
496
+ * scripts print meanwhile. Lines arriving before the run is ready for
497
+ * commands wait.
498
+ */
499
+ class Remote {
500
+ print;
501
+ sources;
502
+ onStop;
503
+ onActivity;
504
+ primaryPid;
505
+ control = null;
506
+ runner = null;
507
+ ending = false;
508
+ ready;
509
+ armed = new Promise((resolve) => { this.ready = resolve; });
510
+ // Who gets the stream, each with the log sources it shows; undefined
511
+ // for the run's own.
512
+ listeners = new Map();
513
+ /** The run's log lines, for `logs` and `wait log`. */
514
+ logs = new RunLog();
515
+ /** When the run started, in epoch ms, as its record says. */
516
+ startedAt = Date.now();
517
+ // The run's stream is its log, and every command comes from a
518
+ // client, so a command that changes something is marked there as
519
+ // `> command`, for where it happened among what the page printed.
520
+ // `sources`: the log sources the log file shows, and the clients
521
+ // that didn't ask for others. `primaryPid`: the client that started
522
+ // the run, whose commands are marked as the primary's.
523
+ constructor(print, sources, onStop, onActivity, primaryPid) {
524
+ this.print = print;
525
+ this.sources = sources;
526
+ this.onStop = onStop;
527
+ this.onActivity = onActivity;
528
+ this.primaryPid = primaryPid;
529
+ }
530
+ /**
531
+ * Prints a line, and copies it to the senders listening now. An
532
+ * inspecting sent command's own lines go to its sender alone.
533
+ */
534
+ out = (line) => {
535
+ const answer = commandOutput.getStore();
536
+ if (answer?.live) {
537
+ answer.line(line);
538
+ return;
539
+ }
540
+ this.print(line);
541
+ for (const listener of this.listeners.keys())
542
+ listener(line);
543
+ };
544
+ /**
545
+ * Logs a line of one of the log sources, and prints it where its
546
+ * source is shown: the log file, and each listener, by the sources
547
+ * it asked for.
548
+ */
549
+ emit = (fields) => {
550
+ const record = this.logs.add(fields);
551
+ if (record.streamed === false)
552
+ return;
553
+ const line = streamLine(record);
554
+ const shows = (sources) => record.always === true || inSources(record, sources);
555
+ if (shows(this.sources))
556
+ this.print(line);
557
+ for (const [listener, sources] of this.listeners)
558
+ if (shows(sources ?? this.sources))
559
+ listener(line);
560
+ };
561
+ /** The run's id, once other shells can reach it. */
562
+ get id() {
563
+ return this.control?.id;
564
+ }
565
+ /** Whether sent commands can arrive, for dialogs to wait on one. */
566
+ get reachable() {
567
+ return this.control !== null;
568
+ }
569
+ async open(record, wanted) {
570
+ try {
571
+ this.control = await RunControl.open(record, { command: this.handle, follow: this.follow }, wanted);
572
+ const { id } = this.control;
573
+ if (record.log) {
574
+ const { linkRunLog } = await import('./run-log.js');
575
+ await linkRunLog(record.log, id, this.out);
576
+ }
577
+ this.out(`[sm-dev] ${S.ui `Started as run ${S.id(id)}. Other shells reach it with ${S.cmd(`sm-dev --run ${id} <command>`)}, or ${S.cmd(`sm-dev attach ${id}`)}, and stop it with ${S.cmd(`sm-dev stop ${id}`)}.`}`);
578
+ }
579
+ catch (err) {
580
+ this.out(`[sm-dev] ${S.asError(`Other shells can't reach this run: ${err.message}`)}`);
581
+ }
582
+ }
583
+ /** Ready for sent commands, run through `runner`. */
584
+ arm(runner) {
585
+ this.runner = runner;
586
+ this.ready();
587
+ }
588
+ /**
589
+ * From now on, sent lines are refused, and ones still waiting for the
590
+ * run to be ready are let go: it is ending.
591
+ */
592
+ disarm() {
593
+ this.ending = true;
594
+ this.ready();
595
+ }
596
+ /** Records the loaded scripts, after `load` adds one. */
597
+ setScripts(files) {
598
+ void this.control?.update({ scripts: files });
599
+ }
600
+ /** Records how many tabs the run controls, as they open and close. */
601
+ setTabs(tabs) {
602
+ void this.control?.update({ tabs });
603
+ }
604
+ async close() {
605
+ await this.control?.close();
606
+ }
607
+ // The connections following the run's stream.
608
+ followers = new Set();
609
+ follow = (reply, sources) => {
610
+ this.listeners.set(reply, sources);
611
+ this.followers.add(reply);
612
+ this.onActivity?.(true);
613
+ return () => {
614
+ this.listeners.delete(reply);
615
+ this.followers.delete(reply);
616
+ this.onActivity?.(this.followers.size > 0);
617
+ };
618
+ };
619
+ handle = async (line, reply, sent) => {
620
+ this.onActivity?.(this.followers.size > 0);
621
+ try {
622
+ return await this.run(line, reply, sent);
623
+ }
624
+ finally {
625
+ this.onActivity?.(this.followers.size > 0);
626
+ }
627
+ };
628
+ run = async (line, reply, sent) => {
629
+ const inspects = onlyInspects(line);
630
+ // A command that changes something, in the stream: not copied to
631
+ // other senders, as it is no part of their commands. A {{ block's
632
+ // code lines as the prompt shows them. One that only looks answers
633
+ // its sender alone.
634
+ if (!inspects) {
635
+ // The sender's own words quoted as it gave them, so the command
636
+ // reads as it was typed.
637
+ const shown = sent.words ? sent.words.map(shellWord).join(' ') : line;
638
+ const [first, ...more] = shown.split('\n');
639
+ // Headed by who sent it and how (see commandMark). A {{ block
640
+ // as it was typed: its code between `{{` and `}}`.
641
+ const mark = commandMark(sent, this.primaryPid);
642
+ const marked = more.length === 0 ? [echoLine(mark, first)] : [echoLine(mark, `${first} {{`), ...more.map((m) => echoLine('...', m)), echoLine('...', '}}')];
643
+ // To the log and the clients following the run, but not back to
644
+ // the one that sent it, which heads its output itself.
645
+ for (const l of marked) {
646
+ this.print(l);
647
+ for (const follower of this.followers)
648
+ if (follower !== reply)
649
+ follower(l);
650
+ }
651
+ }
652
+ // Answered straight away, even before the run is ready, and the
653
+ // sender kept listening until the run has ended, so it sees that.
654
+ if (/^\s*stop\s*$/.test(line)) {
655
+ // First, so `sm-dev stop` knows it was heard, and waits for the
656
+ // teardown rather than taking the wait for no answer.
657
+ reply(`[sm-dev] ${S.ui `Stopping run ${S.id(this.id ?? '')}`}`);
658
+ if (!sent.follows)
659
+ this.listeners.set(reply, undefined);
660
+ this.onStop?.();
661
+ return 'hold';
662
+ }
663
+ if (!this.runner && !this.ending)
664
+ reply('[sm-dev] The run is still starting. The command runs once it is ready.');
665
+ await this.armed;
666
+ if (this.ending || !this.runner) {
667
+ reply('[sm-dev] The run is ending, so the command was not run');
668
+ return 1;
669
+ }
670
+ const hooks = { cwd: sent.cwd, words: sent.words, interactive: sent.source === 'prompt', json: sent.json };
671
+ // One that only looks answers its sender alone. One that changes
672
+ // things prints to the stream, which a following sender gets
673
+ // anyway; another gets it while the command runs.
674
+ return this.runner(line, inspects ? { ...hooks, answer: reply }
675
+ : sent.follows ? hooks
676
+ : { ...hooks, start: () => this.listeners.set(reply, undefined), end: () => this.listeners.delete(reply) });
677
+ };
678
+ }
679
+ async function runInBrowser(opts, session, held, remote,
680
+ // Whether the browser has the run's manager, as the run's first line says.
681
+ managerNow) {
682
+ const { page } = session;
683
+ // The real extension runs the scripts: the run pushes them into it,
684
+ // claims its tabs so only they run there, and its host is filled from
685
+ // the extension's events (extension-host.ts). See
686
+ // docs/sm-dev-design.md.
687
+ // Told apart up front: its worker can't be found either, which would
688
+ // read as not installed.
689
+ const manager = opts.manager ?? DEFAULT_MANAGER;
690
+ const { label } = MANAGERS[manager];
691
+ if (manager === 'sourcemonkey' && managerNow === 'disabled') {
692
+ opts.out(`[sm-dev] SourceMonkey is turned off in this browser. Turn it on with ${quoted(`sm-dev${browserArg(session.endpoint, session.launched)} manager enable`)}, or ${DROP_BROWSER_MODE}, which loads it.`);
693
+ return 1;
694
+ }
695
+ // Under another manager, the manager runs every script it has, and
696
+ // the run's tabs are plain browser tabs. The run's scripts go into
697
+ // it too (external-manager.ts).
698
+ if (manager !== 'sourcemonkey' && managerNow !== 'installed' && managerNow !== null) {
699
+ const fix = managerNow === 'disabled' ? 'manager enable' : 'manager install';
700
+ opts.out(`[sm-dev] ${label} is ${managerNow === 'disabled' ? 'turned off' : 'not installed'} in this browser. Use ${quoted(`sm-dev${browserArg(session.endpoint, session.launched)} ${managerFlag(manager)} ${fix}`)}.`);
701
+ if (opts.files.length > 0)
702
+ return 1;
703
+ }
704
+ if (manager !== 'sourcemonkey' && (managerNow === 'installed' || managerNow === null)) {
705
+ let opened;
706
+ try {
707
+ opened = await openExternalManager(manager, session.endpoint, browserArg(session.endpoint, session.launched));
708
+ }
709
+ catch (err) {
710
+ opts.out(`[sm-dev] ${err.message}`);
711
+ return 1;
712
+ }
713
+ const scripts = new ExternalRunScripts(opened, { id: remote.id ?? 'unnamed', started: new Date(remote.startedAt).toISOString() });
714
+ held.external = { manager: opened, scripts };
715
+ try {
716
+ // A throwaway browser's settings are the run's own, so local
717
+ // test pages run scripts, as under SourceMonkey.
718
+ if (session.launched)
719
+ await opened.allowFilePages();
720
+ for (const line of await scripts.removeLeftovers())
721
+ opts.out(`[sm-dev] ${line}`);
722
+ }
723
+ catch (err) {
724
+ opts.out(`[sm-dev] ${err.message}`);
725
+ return 1;
726
+ }
727
+ }
728
+ const external = held.external;
729
+ let link;
730
+ try {
731
+ link = manager === 'sourcemonkey' ? await ExtensionLink.open(session.context, linkRunId(remote.id))
732
+ : ExtensionLink.without(session.context, linkRunId(remote.id), runUses(manager));
733
+ }
734
+ catch (err) {
735
+ opts.out(`[sm-dev] ${err.message}`);
736
+ return 1;
737
+ }
738
+ held.extension = link;
739
+ if (link.versionNote)
740
+ opts.out(`[sm-dev] ${link.versionNote}`);
741
+ // `tabs` is set before any entry can come in: nothing awaits between.
742
+ const host = new ExtensionHost(link, ({ scriptId, name, level, message, page }) => opts.emit({
743
+ source: 'sm', kind: level, scriptId, tab: page && tabs.numberOf(page), text: `${name}: ${message}`,
744
+ }));
745
+ if (external) {
746
+ host.external = {
747
+ idOf: (url) => {
748
+ const id = external.manager.scriptIdOf(url);
749
+ return id === undefined ? undefined : String(id);
750
+ },
751
+ prefix: external.manager.scriptUrlPrefix,
752
+ ...(external.manager.remapStack ? { remap: (text) => external.manager.remapStack(text) } : {}),
753
+ };
754
+ }
755
+ link.onRestart((restored) => {
756
+ opts.out('[sm-dev] SourceMonkey\'s background script restarted, so what the scripts did meanwhile may be missing here, apart from their Log entries, which are read back.'
757
+ + (restored > 0 ? ` It had lost its hold on ${restored === 1 ? 'one of the run\'s tabs' : `${restored} of the run's tabs`}, which it has again.` : ''));
758
+ void host.catchUpLogs();
759
+ });
760
+ // Commands are only read once the page has loaded, but a prompt
761
+ // opened before then can still wait for a typed or sent accept.
762
+ const commandsComing = opts.until !== undefined && remote.reachable;
763
+ const tabs = new FollowedTabs(host, opts, session.context, commandsComing, link, () => remote.setTabs(tabs.list().length));
764
+ held.tabs = tabs;
765
+ wireHostOutput(host, opts.emit, (record) => tabs.tabOf(record));
766
+ held.downloads = await watchDownloads(session.browser, () => tabs.list(), (n, text) => tabEvent(opts.emit, n, 'download')(text), opts.out, { downloadsDir: session.downloadsDir });
767
+ // T1, unless `--notab`; then the first tab `newtab` opens is T1.
768
+ if (page) {
769
+ // Claimed first, before it loads the run's page; the run can't do
770
+ // without it.
771
+ const tabId = await claimTab(link, page, opts.out);
772
+ if (tabId === undefined)
773
+ return 1;
774
+ wirePageOutput(page, 1, opts.emit, host, link.origin, () => tabs.firstTabScripts);
775
+ wireSandboxCheck(page, () => tabs.firstTabScripts, opts.out, link.linked);
776
+ const dialogs = await DialogTracker.attach(page, tabEvent(opts.emit, 1, 'dialog'), {
777
+ visible: browserVisible(opts), commandsComing, tabRef: () => (tabs.list().length > 1 ? 'T1' : undefined),
778
+ });
779
+ await applyLang(page, opts.lang, opts.out);
780
+ const attachment = await attachPage(page, host, tabs.attachOptions);
781
+ host.addTab(attachment, tabId);
782
+ held.attachment = attachment;
783
+ await tabs.start(page, attachment, dialogs);
784
+ }
785
+ const prepared = await prepareAll(opts);
786
+ if (!prepared)
787
+ return 1;
788
+ const start = startPages(opts, prepared);
789
+ if (!start)
790
+ return 2;
791
+ const { urls } = start;
792
+ // The loaded scripts, and the files they came from, in step: `load`
793
+ // adds to both, `update` and `--watch` replace scripts in place.
794
+ let scripts = prepared;
795
+ const files = [...opts.files];
796
+ // When each script's files were last modified, as it was read.
797
+ const stamps = new Map();
798
+ for (const script of prepared)
799
+ stamps.set(script, await filesStamp(script));
800
+ let commands = null;
801
+ let watcher = null;
802
+ // Set once the run is ending, so work still queued changes nothing.
803
+ let stopping = false;
804
+ // `load`, `update` and `--watch` saves, one at a time: each reads the
805
+ // script list, awaits, and then replaces it, so two at once would
806
+ // lose one's change.
807
+ let queue = Promise.resolve();
808
+ const serial = (work) => {
809
+ const done = queue.then(work);
810
+ queue = done.catch(() => undefined);
811
+ return done;
812
+ };
813
+ // The scripts' text into the run's test collection in the extension,
814
+ // which the run's tabs run. False,
815
+ // having said why, when the extension refused one; whatever was
816
+ // pushed before it stays. `seed`: newly loaded, so `--values` seeds
817
+ // their storage, before any tab can run them.
818
+ let pushedAny = false;
819
+ const push = async (list, seed = false) => {
820
+ for (const script of list) {
821
+ const file = fileURLToPath(script.script.fileUrl);
822
+ // The extension keeps the run's scripts by this: the path as
823
+ // typed to start or load, so files of the same name stay apart.
824
+ const name = typedName(opts, file);
825
+ const text = await readFile(file, 'utf8');
826
+ if (manager !== 'sourcemonkey' && !external) {
827
+ opts.out(`[sm-dev] ${label} was not on in this browser when the run started, so the run can't load scripts into it. Start a new run once it's on.`);
828
+ return false;
829
+ }
830
+ if (external) {
831
+ let installed;
832
+ try {
833
+ installed = await external.scripts.push(script, text);
834
+ }
835
+ catch (err) {
836
+ opts.out(`[sm-dev] ${name} could not be loaded into ${label}: ${err.message}`);
837
+ return false;
838
+ }
839
+ pushedAny = true;
840
+ host.setPushed(script.id, String(installed.id));
841
+ opts.out(`[sm-dev] ${S.ui `Installed in ${label} as ${S.id(`E${installed.id}`)}: ${S.url(preparedScriptPath(script))}${installed.replaced ? ' (replaced)' : ''}`}`);
842
+ continue;
843
+ }
844
+ const result = await link.push(text, name, pushedFiles(script), script.script.fileUrl);
845
+ pushedAny = true;
846
+ if (result.status !== 'ok') {
847
+ opts.out(`${INVALID_PREFIX}${typedName(opts, file)}`);
848
+ opts.out(S.asError(` ${result.errorMessage ?? ''}`));
849
+ return false;
850
+ }
851
+ host.setPushed(script.id, result.scriptId);
852
+ // An extension without the call leaves its stack lines as they are.
853
+ host.setLayout(result.scriptId, await link.stackLayout(result.scriptId).catch(() => null));
854
+ if (seed && opts.values)
855
+ await host.setValues(script.id, opts.values);
856
+ opts.out(`[sm-dev] ${S.ui `Pushed to SourceMonkey: ${S.url(preparedScriptPath(script))}${result.replaced ? ' (replaced)' : ''}`}`);
857
+ }
858
+ return true;
859
+ };
860
+ // Put `next` in place everywhere, and reload the tabs `changed` affects.
861
+ // `timeoutMs`: each reload's, from the command's --timeout-seconds.
862
+ const apply = async (next, changed, none, pushed = false, timeoutMs) => {
863
+ if (!pushed && !(await push(changed.map((c) => c.next))))
864
+ return;
865
+ scripts = next;
866
+ // The typed commands' list too, keeping the selected script by its place.
867
+ if (commands) {
868
+ const i = commands.current ? commands.scripts.indexOf(commands.current) : -1;
869
+ commands.scripts = next;
870
+ commands.current = next[i];
871
+ }
872
+ // Before the reloads, so a save made while they run is seen; a
873
+ // watcher that fails to start costs the tabs nothing.
874
+ try {
875
+ watcher?.watch(next);
876
+ }
877
+ catch (err) {
878
+ opts.out(`[sm-dev] The files could not be watched: ${err.message}`);
879
+ }
880
+ await tabs.apply(next, changed, none, timeoutMs);
881
+ };
882
+ // `update`, and a `--watch` save: re-read the scripts at `which`
883
+ // (every one, when not given) whose files have been modified since
884
+ // they were read; a touch counts. One that no longer prepares leaves
885
+ // every one as it was.
886
+ const update = (which, timeoutMs) => serial(async () => {
887
+ const indices = which ?? scripts.map((_, i) => i);
888
+ const next = [...scripts];
889
+ const changed = [];
890
+ for (const i of indices) {
891
+ const old = scripts[i];
892
+ const stamp = await filesStamp(old);
893
+ if (stamp === stamps.get(old))
894
+ continue;
895
+ const fresh = await prepareAll(opts, [files[i]]);
896
+ if (!fresh || stopping)
897
+ return;
898
+ stamps.set(fresh[0], stamp);
899
+ next[i] = fresh[0];
900
+ changed.push({ old, next: fresh[0] });
901
+ }
902
+ if (changed.length === 0) {
903
+ opts.out(indices.length === 1
904
+ ? `[sm-dev] Script ${indices[0] + 1} hasn't changed on disk, so nothing was reloaded`
905
+ : '[sm-dev] No script has changed on disk, so nothing was reloaded');
906
+ return;
907
+ }
908
+ await apply(next, changed, 'No open tab runs or matches the updated scripts, so none was reloaded', false, timeoutMs);
909
+ });
910
+ const load = (input, timeoutMs) => serial(async () => {
911
+ const file = path.resolve(input);
912
+ const info = await stat(file).catch(() => null);
913
+ if (!info) {
914
+ opts.out(`[sm-dev] ${noSuchFileMessage(input)}`);
915
+ return;
916
+ }
917
+ if (info.isDirectory()) {
918
+ opts.out(`[sm-dev] ${isDirectoryMessage(input)}`);
919
+ return;
920
+ }
921
+ const already = files.indexOf(file);
922
+ if (already >= 0) {
923
+ opts.out(`[sm-dev] ${input} is already loaded, as script ${already + 1}. Use ${quoted(`update ${already + 1}`)} to re-read it.`);
924
+ return;
925
+ }
926
+ opts.typed?.set(file, input);
927
+ const fresh = await prepareAll(opts, [file]);
928
+ if (!fresh || stopping)
929
+ return;
930
+ const script = fresh[0];
931
+ const n = scripts.length + 1;
932
+ // Before it counts as loaded, so a push the extension refuses
933
+ // leaves the run as it was.
934
+ if (!(await push([script], true)) || stopping)
935
+ return;
936
+ files.push(file);
937
+ remote.setScripts(files);
938
+ stamps.set(script, await filesStamp(script));
939
+ opts.out(`[sm-dev] ${S.ui `Loaded ${S.url(input)} as script ${S.id(n)}`}`);
940
+ await apply([...scripts, script], [{ next: script }], `No open tab matches it, so none was reloaded. Use ${quoted(`open ${n}`)} to open its page.`, true, timeoutMs);
941
+ });
942
+ // Seeded once, as pushed. An update keeps the store, so values the
943
+ // script wrote since survive it.
944
+ if (!(await push(prepared, true)))
945
+ return 1;
946
+ await tabs.setScripts(prepared);
947
+ if (page) {
948
+ const t1 = tabs.list()[0];
949
+ // The other URLs given, or with --all the other scripts' pages, each
950
+ // get a tab of their own.
951
+ // All are set up first (numbered in call order), then announced in
952
+ // order, then loaded at once.
953
+ const more = await Promise.all(urls.slice(1).map((url, i) => tabs.createTab().then((tab) => ({ tab, url, saved: start.saved[i + 1] }), (err) => {
954
+ tabFailed(opts, start, prepared, url, err);
955
+ return null;
956
+ })));
957
+ const opened = more.filter((m) => m !== null);
958
+ // Each saved page served before its tab is announced, which then
959
+ // names the file.
960
+ for (const [tab, saved] of [[t1, start.saved[0]], ...opened.map((o) => [o.tab, o.saved])]) {
961
+ if (tab && saved)
962
+ await serveSavedPage(tab, saved, opts.out, prepared.length === 0);
963
+ }
964
+ // T1 is gone only if a page closed it on its own already.
965
+ if (t1)
966
+ tabs.announce(t1, urls[0]);
967
+ for (const { tab, url } of opened)
968
+ tabs.announce(tab, url);
969
+ await Promise.all([
970
+ ...(t1 && urls[0] ? [tabs.navigate(t1, urls[0], opts.timeoutMs)] : []),
971
+ ...opened.map(({ tab, url }) => tabs.navigate(tab, url, opts.timeoutMs)),
972
+ ]);
973
+ printSkipped(opts, start);
974
+ }
975
+ else {
976
+ opts.out(NO_TABS);
977
+ }
978
+ try {
979
+ if (opts.watch) {
980
+ watcher = watchFiles(opts, prepared, async () => {
981
+ opts.out('[sm-dev] Change detected, re-pushing');
982
+ await update();
983
+ });
984
+ }
985
+ if (opts.until) {
986
+ // Typed commands drive the script while it runs: fire a menu
987
+ // command again, read what it stored, write a value back. Only
988
+ // worth arming for a run that is waiting on something.
989
+ //
990
+ // Armed once the script has started (or shortly after, if it
991
+ // never does): a command read before then would run against a
992
+ // page whose script has not registered anything yet. A piped
993
+ // sequence of commands would otherwise hit exactly that.
994
+ // Nothing will start with no scripts, or no tab to run in.
995
+ if (prepared.length > 0 && page)
996
+ await waitForReports(host, tabs.list());
997
+ // Then for the tabs to settle (see wait.ts), so the first command,
998
+ // or `--detach`'s return, sees the page as its scripts left it.
999
+ if (!opts.noWait)
1000
+ await settleTabs(host, tabs.list(), opts.out, opts.timeoutMs);
1001
+ const ctx = {
1002
+ host,
1003
+ tabs: () => tabs.list(),
1004
+ // T1 to start with, or whichever is open if T1 has closed.
1005
+ tab: tabs.list()[0],
1006
+ newTab: (url, options) => tabs.newTab(url, options),
1007
+ // In turn with load, update and --watch saves, which also
1008
+ // change what a tab has.
1009
+ setTabScripts: (tab, noScript) => serial(() => tabs.setNoScript(tab, noScript)),
1010
+ loadScript: load,
1011
+ // By place, so an update queued behind another still finds them.
1012
+ updateScripts: (which, timeoutMs) => update(which?.map((s) => scripts.indexOf(s)).filter((i) => i >= 0), timeoutMs),
1013
+ scripts,
1014
+ out: opts.out,
1015
+ stop: () => opts.onStop?.(),
1016
+ runId: remote.id,
1017
+ ...(opts.log ? { log: opts.log } : {}),
1018
+ ...(session.downloadsDir ? { downloadsDir: session.downloadsDir } : {}),
1019
+ browser: browserSentence({ browser: session.endpoint, owned: session.launched, headless: session.headless }),
1020
+ browserInfo: { address: session.endpoint.replace(/^https?:\/\//, ''), owned: session.launched === true, headless: session.headless === true },
1021
+ testCollection: () => (pushedAny && !external ? link.runId : undefined),
1022
+ managerName: manager,
1023
+ browserArg: browserArg(session.endpoint, session.launched),
1024
+ managers: () => managersIn(session.endpoint),
1025
+ // The first push makes the test collection.
1026
+ backend: () => (external ? `${label} ${external.manager.version}, with the run's scripts installed in it until the run ends`
1027
+ : manager !== 'sourcemonkey' ? `${label}, which the run loads no scripts into`
1028
+ : pushedAny
1029
+ ? `SourceMonkey extension, test collection ${link.runId}`
1030
+ : `SourceMonkey extension, run ${link.runId}, with no test collection until a script is pushed`),
1031
+ // A throwaway browser's data is the run's own. Under another
1032
+ // manager, there's no SourceMonkey to list.
1033
+ ...(manager === 'sourcemonkey' ? { others: host.otherScripts(!session.launched), manager: link.manager }
1034
+ : external ? {
1035
+ others: externalOthers(external.manager, () => new Set(scripts.flatMap((p) => (p.id ? [host.extensionIdOf(p.id)] : []))), !session.launched),
1036
+ manager: externalControl(external.manager),
1037
+ // Nothing reports what ran, so the run's scripts' rules say.
1038
+ predicted: true,
1039
+ predictedOff: externalOff(external.manager),
1040
+ } : {}),
1041
+ extensionIdOf: (script) => (script.id === undefined ? undefined : host.extensionIdOf(script.id)),
1042
+ logs: remote.logs,
1043
+ roots: { collectionRoot: opts.collectionRoot, localRoots: opts.localRoots },
1044
+ };
1045
+ commands = ctx;
1046
+ const runner = commandRunner(ctx);
1047
+ if (commandsComing)
1048
+ setCommandsListening(ctx.tabs(), true);
1049
+ remote.arm(runner);
1050
+ opts.onReady?.(remote.id);
1051
+ await opts.until;
1052
+ }
1053
+ else {
1054
+ // One load's worth of output, then leave the page as it is. Not
1055
+ // the page's own timer: the script may have closed it.
1056
+ await sleep(500);
1057
+ }
1058
+ }
1059
+ finally {
1060
+ // Also on a throw, or stdin and the file watcher would keep the
1061
+ // process alive. Queued work still running finishes first, and
1062
+ // changes nothing once it sees `stopping`.
1063
+ stopping = true;
1064
+ remote.disarm();
1065
+ setCommandsListening(tabs.list(), false);
1066
+ watcher?.stop();
1067
+ await queue;
1068
+ }
1069
+ return host.reports.errors.length === 0 ? 0 : 1;
1070
+ }
1071
+ // Claims `page`'s tab for the run (see `ExtensionLink.claimPage`).
1072
+ // Undefined, having said why, when it can't be.
1073
+ async function claimTab(link, page, out) {
1074
+ try {
1075
+ return await link.claimPage(page);
1076
+ }
1077
+ catch (err) {
1078
+ out(`[sm-dev] The tab could not be set up for the run: ${err.message}`);
1079
+ return undefined;
1080
+ }
1081
+ }
1082
+ // The run's tabs: T1, the tabs `newtab` and `--all` open, and the tabs
1083
+ // those open, by a link, `window.open` or `GM_openInTab`. The extension
1084
+ // claims an opened tab for the run itself and says so (`tab-opened`),
1085
+ // so its scripts run there from its first load; the run then finds its
1086
+ // page and watches it. Tabs the person opens themselves have no opener,
1087
+ // and are left alone.
1088
+ class FollowedTabs {
1089
+ host;
1090
+ opts;
1091
+ context;
1092
+ commandsComing;
1093
+ link;
1094
+ onTabsChanged;
1095
+ // T1, the run's own tab; set by `start`, unless the run opened none.
1096
+ first = null;
1097
+ firstAttachment = null;
1098
+ // The scripts every tab gets: the latest after `load`, `update` and
1099
+ // `--watch` saves.
1100
+ scripts = [];
1101
+ // What T1 runs now: none once `open --noscript` switched it off.
1102
+ get firstTabScripts() {
1103
+ return this.first?.noScript ? [] : this.scripts;
1104
+ }
1105
+ // The rest, while they are open.
1106
+ tabs = new Map();
1107
+ // Every tab's number by its attachment, kept after it closes, so a
1108
+ // report that arrives late (a request finishing) still says whose.
1109
+ numbers = new WeakMap();
1110
+ // Tabs still being set up, so dispose can wait for them.
1111
+ adopting = new Set();
1112
+ // Without a T1 of the run's own (`--notab`), the first tab opened is T1.
1113
+ nextNumber = 1;
1114
+ disposed = false;
1115
+ // Pages that were open before the run, or that it opened itself,
1116
+ // which a tab-opened event can't be about.
1117
+ notOpenedByScripts = new WeakSet();
1118
+ constructor(host, opts, context,
1119
+ // Whether typed commands will be read, so a new tab's dialog waits
1120
+ // for a typed answer rather than being dismissed.
1121
+ commandsComing,
1122
+ // The run's connection to SourceMonkey, which claims its tabs and
1123
+ // says which tabs they open.
1124
+ link,
1125
+ // Called as tabs join the list or close.
1126
+ onTabsChanged = () => undefined) {
1127
+ this.host = host;
1128
+ this.opts = opts;
1129
+ this.context = context;
1130
+ this.commandsComing = commandsComing;
1131
+ this.link = link;
1132
+ this.onTabsChanged = onTabsChanged;
1133
+ for (const p of context.pages())
1134
+ this.notOpenedByScripts.add(p);
1135
+ link.onEvent((event) => {
1136
+ if (event.kind === 'tab-opened')
1137
+ void this.adoptOpened(event);
1138
+ });
1139
+ }
1140
+ /** How the run's tabs are attached: watched, with the extension running the scripts. */
1141
+ get attachOptions() {
1142
+ return { watchOnly: { extensionOrigin: this.link.origin } };
1143
+ }
1144
+ /** Register the run's own tab as T1, and follow the tabs it opens. */
1145
+ async start(page, attachment, dialogs) {
1146
+ this.notOpenedByScripts.add(page);
1147
+ this.first = { n: 1, page, link: attachment, dialogs, targetId: await targetIdOf(page) };
1148
+ this.firstAttachment = attachment;
1149
+ this.nextNumber = 2;
1150
+ this.numbers.set(attachment, 1);
1151
+ page.on('close', () => {
1152
+ if (this.disposed)
1153
+ return;
1154
+ tabEvent(this.opts.emit, 1, 'tab-closed')('Tab closed');
1155
+ this.onTabsChanged();
1156
+ });
1157
+ this.onTabsChanged();
1158
+ }
1159
+ /** The controlled tabs still open, by number. */
1160
+ list() {
1161
+ // Sorted: tabs opened together finish setting up in any order.
1162
+ const all = [...(this.first ? [this.first] : []), ...[...this.tabs.values()].map((t) => t.tab)];
1163
+ return all.filter((t) => !t.page.isClosed()).sort((a, b) => a.n - b.n);
1164
+ }
1165
+ /** The number of the tab a host report came from, if known. */
1166
+ tabOf(record) {
1167
+ const link = this.host.pageOf(record);
1168
+ return link ? this.numberOf(link) : undefined;
1169
+ }
1170
+ /** The number of the tab a page link (its attachment) is, if known. */
1171
+ numberOf(link) {
1172
+ return this.numbers.get(link);
1173
+ }
1174
+ /** The scripts to start with, before T1 has loaded anything. */
1175
+ async setScripts(scripts) {
1176
+ this.scripts = scripts;
1177
+ for (const { attachment } of this.open())
1178
+ await this.swapScripts(attachment, scripts);
1179
+ }
1180
+ /**
1181
+ * Put `next` in place in every open tab, for `update`, `load` and a
1182
+ * `--watch` save, and reload the tabs `changed` affects: those where
1183
+ * a changed script is running, or where its rules now match. All at
1184
+ * once, so one slow page holds up no other. `none` is what to say
1185
+ * when no tab is affected.
1186
+ */
1187
+ async apply(next, changed, none, timeoutMs) {
1188
+ this.scripts = next;
1189
+ const open = this.open();
1190
+ // Running in the top frame or any iframe: a script for a page's
1191
+ // embedded player may not match the page itself.
1192
+ const affected = open.filter(({ tab }) => changed.some((c) => (c.old?.id && (this.host.isRunning(tab.link, c.old.id) || this.host.ranInFrames(tab.link, c.old.id, tab.link.documentFirstStart())))
1193
+ || explainMatch(c.next.matching, tab.page.url()).matches));
1194
+ if (affected.length === 0)
1195
+ this.opts.out(`[sm-dev] ${none}`);
1196
+ const reload = reloadable(affected.map(({ tab }) => tab), this.opts.out);
1197
+ // Only a `--watch` save gets here with a dialog open; the typed
1198
+ // commands refuse. The page takes no new scripts until it is
1199
+ // answered, so neither does this.
1200
+ for (const { tab } of open) {
1201
+ if (tab.dialogs?.current)
1202
+ this.opts.out(`[sm-dev] T${tab.n} gets the new scripts once its dialog is answered`);
1203
+ }
1204
+ if (reload.length > 0)
1205
+ this.opts.out(`[sm-dev] Reloading ${reload.map((t) => `T${t.n}`).join(', ')}`);
1206
+ // Scripts before the first changed one stay as they are, so a tab
1207
+ // that isn't reloaded keeps them attached throughout; from there on
1208
+ // they are re-added, keeping the load order they run in.
1209
+ const from = Math.min(...changed.map((c) => next.indexOf(c.next)));
1210
+ await Promise.all(open.map(async ({ tab, attachment }) => {
1211
+ try {
1212
+ await this.swapScripts(attachment, next, from);
1213
+ }
1214
+ catch (err) {
1215
+ // A tab closing meanwhile needs no word; its close says it.
1216
+ if (!tab.page.isClosed())
1217
+ this.opts.out(`[sm-dev] T${tab.n} could not be updated: ${err.message}`);
1218
+ return;
1219
+ }
1220
+ if (reload.includes(tab))
1221
+ await reloadTab(tab, this.host, this.opts.out, timeoutMs);
1222
+ }));
1223
+ }
1224
+ // The open tabs with their attachments, T1 included.
1225
+ open() {
1226
+ const first = this.first && this.firstAttachment && !this.first.noScript ? [{ tab: this.first, attachment: this.firstAttachment }] : [];
1227
+ // A tab still being set up is left out: its setup catches up on
1228
+ // what it missed, and two swaps at once could add a script twice.
1229
+ // A tab without scripts is no part of changing them.
1230
+ const ready = [...this.tabs.values()].filter((t) => t.ready && !t.tab.noScript);
1231
+ return [...first, ...ready].filter(({ tab }) => !tab.page.isClosed());
1232
+ }
1233
+ async dispose() {
1234
+ this.disposed = true;
1235
+ // Settled, not all: one failed setup must not skip the others'
1236
+ // cleanup.
1237
+ await Promise.allSettled(this.adopting);
1238
+ // The tabs stay open, only no longer watched.
1239
+ for (const { attachment } of this.tabs.values())
1240
+ await attachment.dispose().catch(() => undefined);
1241
+ this.tabs.clear();
1242
+ }
1243
+ // Sets up `page`, a tab tab `opener` opened, as a run tab.
1244
+ adopt(page, opener, tabId) {
1245
+ if (this.disposed || this.tabs.has(page))
1246
+ return;
1247
+ this.notOpenedByScripts.add(page);
1248
+ const n = this.nextNumber++;
1249
+ // A tab without scripts opens more of its kind.
1250
+ const noScript = this.list().find((t) => t.n === opener)?.noScript === true;
1251
+ this.track(this.setUp(page, n, opener, true, noScript, tabId).catch((err) => {
1252
+ // Closed while being set up is the usual cause, and says nothing.
1253
+ if (page.isClosed())
1254
+ return;
1255
+ this.opts.out(`[sm-dev] T${n} could not be set up: ${err.message}`);
1256
+ }));
1257
+ }
1258
+ /**
1259
+ * A new tab for `newtab` and `--all`: controlled from the start, so
1260
+ * its first page loads with the scripts as the extension would run
1261
+ * them. Numbered before anything is awaited, so tabs opened together
1262
+ * are numbered in call order. Stays open when the run ends, like the
1263
+ * tabs the page opens. `noScript`: one the scripts stay out of, for
1264
+ * `newtab --noscript`.
1265
+ */
1266
+ async newTab(url, { noScript = false, saved, timeoutMs } = {}) {
1267
+ const tab = await this.createTab(noScript);
1268
+ if (saved)
1269
+ await serveSavedPage(tab, saved, this.opts.out, noScript || this.scripts.length === 0);
1270
+ this.announce(tab, url);
1271
+ if (url)
1272
+ await this.navigate(tab, url, timeoutMs);
1273
+ return tab;
1274
+ }
1275
+ /**
1276
+ * `newTab` without the announcing and loading, for `--all` to set
1277
+ * its tabs up together and then say so in order.
1278
+ */
1279
+ async createTab(noScript = false) {
1280
+ if (this.disposed)
1281
+ throw new Error('The run is ending');
1282
+ const n = this.nextNumber++;
1283
+ const setUp = (async () => {
1284
+ const page = await this.context.newPage();
1285
+ this.notOpenedByScripts.add(page);
1286
+ try {
1287
+ const tabId = await this.link.claimPage(page, noScript);
1288
+ await this.setUp(page, n, undefined, false, noScript, tabId);
1289
+ }
1290
+ catch (err) {
1291
+ // Nothing controls it, so it would only be in the way.
1292
+ await page.close().catch(() => undefined);
1293
+ throw err;
1294
+ }
1295
+ return page;
1296
+ })();
1297
+ this.track(setUp);
1298
+ const page = await setUp;
1299
+ const entry = this.tabs.get(page);
1300
+ if (!entry)
1301
+ throw new Error(`T${n} closed before it could be set up`);
1302
+ return entry.tab;
1303
+ }
1304
+ /**
1305
+ * The line a tab the run opens gets: where it's going, and its id; an
1306
+ * event for a blank one, as for a tab a page opens.
1307
+ */
1308
+ announce(tab, url) {
1309
+ const saved = url && tab.saved && comparableUrl(url) === comparableUrl(tab.saved.url) ? tab.saved : undefined;
1310
+ if (!url) {
1311
+ tabEvent(this.opts.emit, tab.n, 'tab-opened')(`Tab opened${targetIdSuffix(tab.targetId)}`);
1312
+ return;
1313
+ }
1314
+ this.opts.out(saved ? `${openingSavedLine(saved.given, url, `T${tab.n}`)}${targetIdSuffix(tab.targetId)}`
1315
+ : `[sm-dev] ${S.ui `Opening ${S.url(url)} in ${S.id(`T${tab.n}`)}${targetIdSuffix(tab.targetId)}`}`);
1316
+ }
1317
+ async navigate(tab, url, timeoutMs) {
1318
+ await gotoInTab(tab, this.host, url, this.opts.out, timeoutMs);
1319
+ }
1320
+ track(work) {
1321
+ const settled = work.catch(() => undefined).finally(() => this.adopting.delete(settled));
1322
+ this.adopting.add(settled);
1323
+ }
1324
+ // `opener` is the tab that opened this one; none for `newtab`.
1325
+ // `announce`: say it opened; the run's own tabs are announced by
1326
+ // their caller instead, with where they are going. `noScript`: its
1327
+ // claim runs no scripts, now or later.
1328
+ async setUp(page, n, opener, announce, noScript, tabId) {
1329
+ // T1 goes unprefixed, as the run's own does, when `--notab` makes
1330
+ // the first tab opened T1.
1331
+ const out = tabOutput(this.opts.out, n);
1332
+ // First, so a tab that closes during the awaits below is still
1333
+ // noticed.
1334
+ let announced = false;
1335
+ page.on('close', () => {
1336
+ this.tabs.delete(page);
1337
+ if (!announced || this.disposed)
1338
+ return;
1339
+ tabEvent(this.opts.emit, n, 'tab-closed')('Tab closed');
1340
+ this.onTabsChanged();
1341
+ });
1342
+ // Read from the tab once it exists, since `open --script` switches
1343
+ // what it runs.
1344
+ let tabNow = null;
1345
+ const tabScripts = () => ((tabNow?.noScript ?? noScript) ? [] : this.scripts);
1346
+ wirePageOutput(page, n, this.opts.emit, this.host, this.link.origin, tabScripts);
1347
+ // Not for a tab with no scripts, which gets none either way.
1348
+ wireSandboxCheck(page, tabScripts, out, this.link.linked);
1349
+ const dialogs = await DialogTracker.attach(page, tabEvent(this.opts.emit, n, 'dialog'), {
1350
+ visible: browserVisible(this.opts),
1351
+ commandsComing: this.commandsComing,
1352
+ // T2 on is named even while it is set up, before list() has it.
1353
+ tabRef: () => (n > 1 || this.list().length > 1 ? `T${n}` : undefined),
1354
+ });
1355
+ await applyLang(page, this.opts.lang, out);
1356
+ // An opened tab may have loaded already, with its events from that
1357
+ // document waiting.
1358
+ const shown = page.url();
1359
+ const attachment = await attachPage(page, this.host, this.attachOptions);
1360
+ this.numbers.set(attachment, n);
1361
+ // Read before the check below, so a tab closing meanwhile is caught
1362
+ // by it rather than listed after its close has passed.
1363
+ const targetId = await targetIdOf(page);
1364
+ if (page.isClosed()) {
1365
+ await attachment.dispose().catch(() => undefined);
1366
+ return;
1367
+ }
1368
+ const entry = { tab: { n, page, link: attachment, dialogs, openedBy: opener, targetId, noScript }, attachment, ready: false };
1369
+ tabNow = entry.tab;
1370
+ this.tabs.set(page, entry);
1371
+ this.onTabsChanged();
1372
+ const url = page.url();
1373
+ const loaded = url !== '' && url !== 'about:blank';
1374
+ announced = true;
1375
+ if (announce)
1376
+ tabEvent(this.opts.emit, n, 'tab-opened')(`Tab opened${loaded ? ` at ${url}` : ''}${noScript ? ' (no scripts)' : ''}${opener ? `, from T${opener}` : ''}${targetIdSuffix(targetId)}`);
1377
+ // After it's announced, so the events it had waiting print under it.
1378
+ this.host.addTab(attachment, tabId, opener !== undefined && shown !== '' && shown !== 'about:blank', noScript);
1379
+ if (noScript) {
1380
+ entry.ready = true;
1381
+ return;
1382
+ }
1383
+ let scripts = this.scripts;
1384
+ // Recorded, for the waits and reports to know what to expect; the
1385
+ // extension runs them.
1386
+ for (const script of scripts)
1387
+ await attachment.inject(script, { immediately: true });
1388
+ // A `load`, `update` or `--watch` save while this tab was being set
1389
+ // up passed it by. Brought up to date here, before it is ready for
1390
+ // the next one.
1391
+ while (this.scripts !== scripts) {
1392
+ scripts = this.scripts;
1393
+ await this.swapScripts(attachment, scripts);
1394
+ await loadPage(page, (o) => page.reload(o)).catch(() => undefined);
1395
+ }
1396
+ entry.ready = true;
1397
+ }
1398
+ /**
1399
+ * Switches a tab to running no scripts, or back to running them, for
1400
+ * `open --noscript` / `--script`. It lasts: every page the tab loads
1401
+ * from then on goes the new way, until switched again. Its claim
1402
+ * changes here; the page it's showing keeps what it has until the
1403
+ * next load.
1404
+ */
1405
+ async setNoScript(tab, noScript) {
1406
+ if ((tab.noScript === true) === noScript)
1407
+ return;
1408
+ // Its setup would put back what this takes off.
1409
+ if (tab !== this.first && [...this.tabs.values()].some((t) => t.tab === tab && !t.ready)) {
1410
+ throw new Error('It\'s still being set up. Try again in a moment.');
1411
+ }
1412
+ const tabId = this.host.tabIdOf(tab.link);
1413
+ if (tabId !== undefined)
1414
+ await this.link.setTabScriptsOff(tabId, noScript);
1415
+ await this.swapScripts(tab.link, noScript ? [] : this.scripts);
1416
+ // Once done, so a failed switch leaves the tab as it was for `load`
1417
+ // and `update`.
1418
+ tab.noScript = noScript;
1419
+ }
1420
+ /**
1421
+ * A tab one of the run's tabs opened, as the
1422
+ * extension says (a link, `window.open`, `GM_openInTab`), which it has
1423
+ * already claimed for the run. Its page is one not otherwise accounted
1424
+ * for, opened by the opener's page (`window.open`, as Playwright
1425
+ * knows), or else at the event's URL. Never a guess past that: a
1426
+ * page the person opened mustn't be taken for it. Waits a few seconds
1427
+ * for Playwright to report it, and for the opener's own setup.
1428
+ */
1429
+ async adoptOpened(event) {
1430
+ // Its events wait for the setup.
1431
+ this.host.expectTab(event.tabId);
1432
+ // Which page is that tab: the one showing the same document as the
1433
+ // extension reads in it, by URL and `performance.timeOrigin`. Read
1434
+ // again until both sides agree, since the tab may be navigating.
1435
+ const deadline = Date.now() + 10_000;
1436
+ let opener;
1437
+ while (Date.now() < deadline && !this.disposed) {
1438
+ opener = this.list().find((t) => this.host.tabIdOf(t.link) === event.sourceTabId);
1439
+ const shown = opener ? await this.link.tabDocument(event.tabId).catch(() => null) : null;
1440
+ if (opener && shown) {
1441
+ const candidates = this.context.pages().filter((p) => !this.notOpenedByScripts.has(p) && !this.tabs.has(p) && !p.isClosed());
1442
+ for (const p of candidates) {
1443
+ // Not held up by a page stopped on a dialog.
1444
+ const doc = await Promise.race([
1445
+ p.evaluate(() => ({ url: location.href, timeOrigin: performance.timeOrigin })).catch(() => null),
1446
+ sleep(1000).then(() => null),
1447
+ ]);
1448
+ if (doc && doc.timeOrigin === shown.timeOrigin && doc.url === shown.url) {
1449
+ this.adopt(p, opener.n, event.tabId);
1450
+ return;
1451
+ }
1452
+ }
1453
+ }
1454
+ await sleep(100);
1455
+ }
1456
+ // Still the run's (its claim came from its opener), but unseen here.
1457
+ if (!this.disposed && opener) {
1458
+ tabEvent(this.opts.emit, opener.n, 'tab-opened')(`Opened a tab sm-dev couldn't follow (${event.url}). Only the run's scripts run in it, but their output there isn't shown.`);
1459
+ }
1460
+ }
1461
+ // `scripts` in place of what `attachment` has, from index `from` on.
1462
+ async swapScripts(attachment, scripts, from = 0) {
1463
+ const keep = new Set(scripts.slice(0, from).map((s) => s.id));
1464
+ for (const old of attachment.injectedScripts()) {
1465
+ if (!keep.has(old.id))
1466
+ await attachment.remove(old.id);
1467
+ }
1468
+ for (const script of scripts.slice(from))
1469
+ await attachment.inject(script);
1470
+ }
1471
+ }
1472
+ // Resolve once each script in each tab's page has had its word there:
1473
+ // started, skipped (a URL its rules don't cover) or thrown. Or after a
1474
+ // grace period, for one that never says (a page that never loads).
1475
+ async function waitForReports(host, tabs, timeoutMs = 5000) {
1476
+ const giveUp = new AbortController();
1477
+ const timer = setTimeout(() => giveUp.abort(), timeoutMs);
1478
+ const gaveUp = new Promise((resolve) => giveUp.signal.addEventListener('abort', () => resolve()));
1479
+ try {
1480
+ await Promise.race([
1481
+ // Not one whose load already gave up, and said so.
1482
+ Promise.all(tabs.filter((tab) => !tab.stalled).map((tab) => {
1483
+ const ids = tab.link.scriptsInDocument().filter((s) => s.id).map((s) => s.id);
1484
+ return host.untilReported(tab.link, ids, tab.link.documentFirstStart(), giveUp.signal);
1485
+ })),
1486
+ gaveUp,
1487
+ ]);
1488
+ }
1489
+ finally {
1490
+ clearTimeout(timer);
1491
+ giveUp.abort();
1492
+ }
1493
+ }
1494
+ function capitalized(text) {
1495
+ return text.charAt(0).toUpperCase() + text.slice(1);
1496
+ }
1497
+ function sleep(ms) {
1498
+ return new Promise((resolve) => setTimeout(resolve, ms));
1499
+ }
1500
+ // Whether a person can see the browser, and so answer its dialogs.
1501
+ function browserVisible(opts) {
1502
+ return !opts.tempBrowser || opts.headed === true;
1503
+ }
1504
+ // When a script's files (its own and local @requires) were last
1505
+ // modified, as one comparable string; a missing file counts too.
1506
+ async function filesStamp(script) {
1507
+ const paths = [script.script.fileUrl, ...script.requires.map((r) => r.localUrl ?? '')]
1508
+ .filter((u) => u.startsWith('file:')).map((u) => fileURLToPath(u));
1509
+ const times = await Promise.all(paths.map((p) => stat(p).then((st) => st.mtimeMs, () => -1)));
1510
+ return paths.map((p, i) => `${p}:${times[i]}`).join('|');
1511
+ }
1512
+ // A script file as the person typed it, for an error to name it as
1513
+ // they did; else as paths are shown.
1514
+ function typedName(opts, file) {
1515
+ return opts.typed?.get(file) ?? shownPath(file);
1516
+ }
1517
+ async function prepareAll(opts, files = opts.files) {
1518
+ const out = [];
1519
+ for (const file of files) {
1520
+ const p = await prepareScript({ file, collectionRoot: opts.collectionRoot, localRoots: opts.localRoots });
1521
+ if (p.status !== 'ok') {
1522
+ opts.out(`${INVALID_PREFIX}${typedName(opts, file)}`);
1523
+ // Its own line, so the client colors it as an error too.
1524
+ opts.out(S.asError(` ${p.diagnostic}`));
1525
+ return null;
1526
+ }
1527
+ // As markup, so backticks from the script's own header stay text.
1528
+ for (const line of warningLines(p))
1529
+ opts.out(`[sm-dev] ${line.startsWith(S.WARNING_LEVEL) ? S.asWarning(line) : S.ui `${line}`}`);
1530
+ out.push(p);
1531
+ }
1532
+ return out;
1533
+ }
1534
+ // `prefix` tags each report with the tab it came from.
1535
+ function wireHostOutput(host, emit, tabOf) {
1536
+ // A report or menu command from an iframe names it (`T1.F3`).
1537
+ const event = (record, kind, text, also) => emit({
1538
+ source: 'event', kind, tab: tabOf(record), ...(record.frameNumber !== undefined ? { frame: record.frameNumber } : {}), scriptId: record.scriptId, text, ...(also ? { also } : {}),
1539
+ });
1540
+ // SourceMonkey's Log has an entry for each of these, logged as the
1541
+ // report rather than twice; not for a rule miss, which only the run
1542
+ // finds. A throw is the script's own error too, and the page's console
1543
+ // shows it.
1544
+ // A report from an iframe says so: its URL is the frame's, not the page's.
1545
+ const on = (r) => `${r.frame ? 'in an iframe ' : ''}on ${r.url}`;
1546
+ host.on('start', (r) => event(r, 'started', `${r.name} started ${on(r)}`, ['sm']));
1547
+ host.on('skip', (r) => event(r, 'skipped', `${r.name} skipped ${on(r)}: ${r.reason}`, r.ruleMiss ? undefined : ['sm']));
1548
+ host.on('manual', (r) => event(r, 'manual', `${r.name} is waiting to be run on ${r.url}, since it has @run-at context-menu. Use menu to run it.`));
1549
+ host.on('script-error', (r) => {
1550
+ const frames = r.stack ? r.stack.split('\n').slice(1, 4).map((line) => `\n ${decodeOwnRefs(line.trim())}`).join('') : '';
1551
+ event(r, 'threw', `${r.name} threw: ${decodeOwnRefs(r.message)}${frames}`, ['sm', 'script', 'page']);
1552
+ });
1553
+ host.on('request', (r) => event(r, 'request', `GM_xmlhttpRequest ${r.method} ${r.url}: ${r.outcome}${r.status ? ' ' + r.status : ''}${r.error ? ' ' + r.error : ''}`));
1554
+ host.on('tab', (r) => event(r, 'open-tab', `GM_openInTab ${r.url}`));
1555
+ host.on('menu-command', (r) => event(r, 'menu-command', `GM_registerMenuCommand "${r.name}"`));
1556
+ host.on('notification', (r) => event(r, 'notification', `GM_notification "${r.title || r.text}"`));
1557
+ // Only the asking: the answer is the script's Log entry. Set apart and
1558
+ // streamed whatever the sources: the run waits on an answer, and the
1559
+ // line is easy to miss among the page's own output.
1560
+ host.on('connect-prompt', (r) => {
1561
+ if (r.answer)
1562
+ return;
1563
+ emit({
1564
+ source: 'event', kind: 'permission-prompt', tab: tabOf(r), scriptId: r.scriptId, always: true,
1565
+ text: `*PERMISSION PROMPT*: ${r.name} requests permission to connect to ${r.host} (${r.method} ${withoutQuery(r.url)}). Answer using connect once|cancel|allow|deny|allow-all.`,
1566
+ });
1567
+ });
1568
+ host.on('download', (r) => event(r, 'download', `GM_download ${r.url}: ${r.outcome}${r.filename ? ' -> ' + r.filename : ''}${r.error ? ' ' + r.error : ''}`));
1569
+ host.on('value-write', (r) => {
1570
+ for (const w of r.writes) {
1571
+ event(r, 'value', w.deleted
1572
+ ? `GM_deleteValue ${w.key}`
1573
+ : `GM_setValue ${w.key} = ${shortValue(w.value)}`);
1574
+ }
1575
+ });
1576
+ }
1577
+ // Longer stored values are cut in `GM_setValue` lines, so a cache a
1578
+ // script stores doesn't bury the rest of the output. `values` and
1579
+ // `get` show them whole.
1580
+ const VALUE_SHOWN_CHARS = 100;
1581
+ function shortValue(value) {
1582
+ const json = JSON.stringify(value) ?? String(value);
1583
+ if (json.length <= VALUE_SHOWN_CHARS)
1584
+ return json;
1585
+ // By code point, so an emoji isn't cut in half.
1586
+ return `${Array.from(json).slice(0, VALUE_SHOWN_CHARS).join('')}... (${json.length} chars; get shows it all)`;
1587
+ }
1588
+ // A page whose CSP sandboxes it without allow-scripts gets no MAIN-world
1589
+ // userscripts in a real manager, and Chrome says nothing about it.
1590
+ // SourceMonkey runs them isolated there instead. Said on each load of
1591
+ // one that the run's MAIN-world scripts match. See
1592
+ // src/sandboxed-page.ts.
1593
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
1594
+ // `isolates`: the manager runs such a page's MAIN-world scripts
1595
+ // isolated instead (SourceMonkey does), so the line is a note rather
1596
+ // than a warning.
1597
+ function wireSandboxCheck(page, scripts, out, isolates) {
1598
+ page.on('response', (response) => {
1599
+ try {
1600
+ // A redirect's own headers don't apply to the page it leads to.
1601
+ // Not every 3xx: a 304 is the page itself, revalidated.
1602
+ if (REDIRECT_STATUSES.has(response.status()))
1603
+ return;
1604
+ if (!response.request().isNavigationRequest() || response.frame() !== page.mainFrame())
1605
+ return;
1606
+ }
1607
+ catch {
1608
+ // A service worker's response has no frame.
1609
+ return;
1610
+ }
1611
+ const url = response.url();
1612
+ void response.headerValue('content-security-policy').then((csp) => {
1613
+ if (!csp || !cspBlocksPageScripts(csp))
1614
+ return;
1615
+ const blocked = scripts().filter((s) => s.world === 'MAIN' && explainMatch(s.matching, url).matches);
1616
+ if (blocked.length === 0)
1617
+ return;
1618
+ const paths = blocked.map(preparedScriptPath);
1619
+ out(`[sm-dev] ${isolates ? sandboxedPageNote(paths) : sandboxedPageWarning(paths)}`);
1620
+ }, () => undefined);
1621
+ });
1622
+ }
1623
+ // `page` is everything the page's console shows, so a script's console
1624
+ // line or uncaught error is the page's too.
1625
+ const SCRIPT_IN_PAGE = { script: { also: ['page'] }, page: {} };
1626
+ // What happened in tab `n`, said as an `event` line of `kind`. A dialog
1627
+ // box is streamed whatever the sources, as the page waits on an answer.
1628
+ function tabEvent(emit, n, kind) {
1629
+ return (text) => emit({ source: 'event', kind, tab: n, text, ...(kind === 'dialog' ? { always: true } : {}) });
1630
+ }
1631
+ // A tab's lines are tagged `[T<n>]`, except T1's, as the run's own are.
1632
+ function tabOutput(out, n) {
1633
+ return n === 1 ? out : (line) => out(`[T${n}] ${line}`);
1634
+ }
1635
+ // Logs tab `n`'s console lines and uncaught errors, each as the run's
1636
+ // scripts' (`script`) or the page's own (`page`), and its page loads
1637
+ // (`event`, for `logs` only). Whose a line is goes by its whole stack,
1638
+ // read from the protocol's own copy of the call (`ConsoleStacks`), so a
1639
+ // script logging through a page helper still counts as the script's.
1640
+ // `host.remapStack` points stack frames in a logged line
1641
+ // (`console.error(err)` prints the stack) at the `@require` or script
1642
+ // line they came from. It runs before `decodeOwnRefs`, since it matches
1643
+ // the scripts' URLs as the page wrote them (still encoded).
1644
+ // `scripts`: the scripts the tab runs now.
1645
+ function wirePageOutput(page, n, emit, host, extensionOrigin, scripts) {
1646
+ // Given up on after a while, so a session that never opens (a hung tab,
1647
+ // a slow remote browser) can't hold up the tab's lines for good.
1648
+ // Under another manager, its worlds are the scripts'.
1649
+ const origin = host.external ? host.external.prefix.replace(/\/$/, '') : extensionOrigin;
1650
+ const stacks = Promise.race([ConsoleStacks.attach(page, origin), sleep(STACKS_ATTACH_MS).then(() => null)]);
1651
+ // A line from an iframe names it (`T1.F3`).
1652
+ const inFrame = (frame) => (frame && frame !== page.mainFrame() && !frame.isDetached() ? { frame: host.frameIds.number(frame) } : {});
1653
+ const log = async (msg, text, call) => {
1654
+ const body = decodeOwnRefs(host.remapStack(text));
1655
+ const where = inFrame(await call?.frame?.().catch(() => undefined));
1656
+ // SourceMonkey's own console lines repeat what the run logs from its
1657
+ // reports, so they're only part of the page's whole console.
1658
+ if (isPreludeLine(body)) {
1659
+ emit({ source: 'page', kind: msg.type(), tab: n, ...where, text: body });
1660
+ return;
1661
+ }
1662
+ const url = msg.location().url;
1663
+ const frames = call?.frames ?? [];
1664
+ const source = consoleSource({ type: msg.type(), text: body, url, frames, extensionWorld: call?.extensionWorld, argCount: msg.args().length }, host.external?.prefix);
1665
+ emit({ source, ...SCRIPT_IN_PAGE[source], kind: msg.type(), tab: n, ...where, scriptId: source === 'script' ? host.scriptOf([url, ...frames]) : undefined, text: body });
1666
+ };
1667
+ // In order: a line that logs a DOM node is described from the page,
1668
+ // which takes a round trip, and each waits for its stack.
1669
+ let pending = Promise.resolve();
1670
+ page.on('console', (msg) => {
1671
+ const args = msg.args();
1672
+ // `%c` styling and the like applied; Playwright's text has them raw.
1673
+ const text = args.some((arg) => arg.asElement())
1674
+ ? consoleText(msg).catch(() => msg.text())
1675
+ : Promise.resolve(args.length > 1 ? formatConsoleArgs(args.map((arg) => arg.toString())) : msg.text());
1676
+ // The browser's own entries (no values) are never a console call.
1677
+ // An older Playwright has no `timestamp()`, and a failure here must
1678
+ // not lose the line: the line then goes by its call site.
1679
+ const call = args.length === 0 || typeof msg.timestamp !== 'function' ? Promise.resolve(null)
1680
+ : stacks.then((s) => s?.call(msg.timestamp()) ?? null).catch(() => null);
1681
+ // Never rejects, as the queue has no one to report to: a line that
1682
+ // fails is lost.
1683
+ pending = pending.then(async () => log(msg, await text, await call)).catch(() => undefined);
1684
+ });
1685
+ // Which page load the lines after it came from, and an iframe's
1686
+ // loads, under its own id. Playwright's `framenavigated` also fires
1687
+ // for an iframe's hash changes, which aren't loads: a change of the
1688
+ // fragment alone is skipped. (A frame's `pushState` still reads as a
1689
+ // load; rare in frames.)
1690
+ const frameUrls = new WeakMap();
1691
+ page.on('framenavigated', (frame) => {
1692
+ // In the lines' queue, so what the last page logged or threw comes
1693
+ // before it.
1694
+ const url = frame.url();
1695
+ if (frame === page.mainFrame()) {
1696
+ pending = pending.then(() => emit({ source: 'event', kind: 'load', tab: n, text: `Loaded ${url}`, streamed: false }));
1697
+ return;
1698
+ }
1699
+ const before = frameUrls.get(frame);
1700
+ frameUrls.set(frame, url);
1701
+ if (before !== undefined && before !== url && before.split('#')[0] === url.split('#')[0])
1702
+ return;
1703
+ // A new frame's empty first document, before the one it loads. Not
1704
+ // numbered for it either: its id waits for its URL (FrameIds).
1705
+ if (before === undefined && url === 'about:blank')
1706
+ return;
1707
+ const where = inFrame(frame);
1708
+ pending = pending.then(() => emit({ source: 'event', kind: 'load', tab: n, ...where, text: `Loaded ${url}`, streamed: false }));
1709
+ });
1710
+ // The uncaught error's stack is not mapped, and must not be:
1711
+ // `pageErrorSource` drops a script's throw the host already reported by
1712
+ // finding the script's `.user.js` URL in it (`SCRIPT_BODY_FRAME`), and
1713
+ // a throw inside a `@require` carries that URL only unmapped. Only the
1714
+ // message is printed anyway.
1715
+ //
1716
+ // The frame and world it was thrown in come from the protocol's copy.
1717
+ // A stackless error (no telling whose from its stack) is the page's
1718
+ // unless a script could have thrown it there (`stacklessIsScripts`).
1719
+ page.on('pageerror', (err) => {
1720
+ const stack = err.stack ?? err.message;
1721
+ // Its protocol copy is claimed even for an error not shown, so a
1722
+ // later one with the same message doesn't take it.
1723
+ const thrownIn = stacks.then((s) => s?.thrownIn(err.message)).catch(() => undefined);
1724
+ // Another manager reports no errors, so each is the page's to show.
1725
+ let source = pageErrorSource(stack, !host.external, host.external?.prefix);
1726
+ if (!source)
1727
+ return;
1728
+ // As they were when it was thrown, not after a `load` meanwhile.
1729
+ const tabScripts = scripts();
1730
+ pending = pending.then(async () => {
1731
+ const thrown = await thrownIn;
1732
+ const where = inFrame(thrown?.frame);
1733
+ if (source === 'script' && !/\n\s+at /.test(stack) && !stacklessIsScripts(thrown, thrown?.frame !== page.mainFrame(), tabScripts, !!host.external))
1734
+ source = 'page';
1735
+ emit({ source: source, ...SCRIPT_IN_PAGE[source], kind: 'error', tab: n, ...where, scriptId: source === 'script' ? host.scriptOf([stack]) : undefined, text: decodeOwnRefs(err.message) });
1736
+ }).catch(() => undefined);
1737
+ });
1738
+ }
1739
+ /**
1740
+ * Whether an uncaught error with no stack could be one of `scripts`':
1741
+ * thrown in their world, or in the page's where one of them runs in it
1742
+ * (`@grant none`, say). Pages throw these too: a site's own
1743
+ * `Promise.reject(x)`, an aborted fetch, a parse error. `inFrame`:
1744
+ * thrown in an iframe. `external`: under another manager, which picks
1745
+ * the worlds itself (Violentmonkey runs scripts in the page's by
1746
+ * default), so any script matching there could have.
1747
+ */
1748
+ function stacklessIsScripts(where, inFrame, scripts, external) {
1749
+ if (scripts.length === 0)
1750
+ return false;
1751
+ if (where?.extensionWorld)
1752
+ return true;
1753
+ // No copy, or its frame not found: no telling where.
1754
+ if (!where?.url)
1755
+ return true;
1756
+ return scripts.some((s) => (external || s.world === 'MAIN') && explainMatch(s.matching, where.url).matches && !(inFrame && preparedNoframes(s)));
1757
+ }
1758
+ // How long an uncaught error heard over the protocol waits for
1759
+ // Playwright's copy.
1760
+ const THROWN_KEPT_MS = 5000;
1761
+ // How long a console line waits for the protocol's copy of its call,
1762
+ // and how many unclaimed copies are kept.
1763
+ const CALL_WAIT_MS = 250;
1764
+ // How long the tab's protocol session has to open before lines stop
1765
+ // waiting for it.
1766
+ const STACKS_ATTACH_MS = 3000;
1767
+ const CALLS_KEPT = 500;
1768
+ /**
1769
+ * A tab's console calls as the protocol (CDP) reports them on a session
1770
+ * of the run's own, for what Playwright's console event leaves out: the
1771
+ * whole stack, and the JavaScript world the call ran in. Paired with
1772
+ * Playwright's event by the call's timestamp, which both carry.
1773
+ */
1774
+ class ConsoleStacks {
1775
+ extensionOrigin;
1776
+ // Copies not yet claimed, and lines waiting for theirs, by timestamp.
1777
+ calls = new Map();
1778
+ waiting = new Map();
1779
+ // Uncaught errors heard lately, for `thrownIn`, and errors waiting for
1780
+ // theirs. Each one's frame and its URL then, looked up as it's heard.
1781
+ thrown = [];
1782
+ thrownWaiting = new Set();
1783
+ // When each session started listening: what it reports from before
1784
+ // is a replay of what came before it, not a new error.
1785
+ opened = new WeakMap();
1786
+ constructor(extensionOrigin) {
1787
+ this.extensionOrigin = extensionOrigin;
1788
+ }
1789
+ /**
1790
+ * Listens on each of the tab's sessions: the page's, and each
1791
+ * out-of-process iframe's (frame-sessions.ts), whose lines the page's
1792
+ * never hears. Null when the tab can't be read so (closed already).
1793
+ */
1794
+ static async attach(page, extensionOrigin) {
1795
+ const stacks = new ConsoleStacks(extensionOrigin);
1796
+ let sessions = null;
1797
+ sessions = await FrameSessions.attach(page, (session) => {
1798
+ stacks.opened.set(session, Date.now());
1799
+ session.cdp.on('Runtime.consoleAPICalled', (event) => stacks.heard(session, event, () => sessions));
1800
+ session.cdp.on('Runtime.exceptionThrown', ({ timestamp, exceptionDetails: d }) => stacks.threw(session, timestamp, d, () => sessions));
1801
+ }).catch(() => null);
1802
+ return sessions ? stacks : null;
1803
+ }
1804
+ /** The call Playwright reported at `timestamp`; null when its copy doesn't come in time. */
1805
+ call(timestamp) {
1806
+ const known = this.calls.get(timestamp);
1807
+ if (known) {
1808
+ const call = known.shift();
1809
+ if (known.length === 0)
1810
+ this.calls.delete(timestamp);
1811
+ return Promise.resolve(call);
1812
+ }
1813
+ return new Promise((resolve) => {
1814
+ const take = (call) => {
1815
+ clearTimeout(timer);
1816
+ resolve(call);
1817
+ };
1818
+ const timer = setTimeout(() => {
1819
+ const list = this.waiting.get(timestamp);
1820
+ list?.splice(list.indexOf(take), 1);
1821
+ if (list?.length === 0)
1822
+ this.waiting.delete(timestamp);
1823
+ resolve(null);
1824
+ }, CALL_WAIT_MS);
1825
+ this.waiting.set(timestamp, [...this.waiting.get(timestamp) ?? [], take]);
1826
+ });
1827
+ }
1828
+ /**
1829
+ * Where an uncaught error with `message` was thrown (its frame, the
1830
+ * frame's URL then, and its world), from the protocol's copy, which
1831
+ * this claims; undefined when none comes in time. One whose message is empty can't
1832
+ * be told from another, so isn't looked for.
1833
+ */
1834
+ async thrownIn(message) {
1835
+ if (!message)
1836
+ return undefined;
1837
+ // The copy whose first line ends with the message, else one that
1838
+ // has it anywhere, oldest first.
1839
+ const take = () => {
1840
+ let i = this.thrown.findIndex((t) => t.first.endsWith(message));
1841
+ if (i < 0)
1842
+ i = this.thrown.findIndex((t) => t.texts.some((text) => text.includes(message)));
1843
+ return i < 0 ? undefined : this.thrown.splice(i, 1)[0].where;
1844
+ };
1845
+ const found = take() ?? await new Promise((resolve) => {
1846
+ const check = () => {
1847
+ const where = take();
1848
+ if (where)
1849
+ finish(where);
1850
+ };
1851
+ const finish = (where) => {
1852
+ clearTimeout(timer);
1853
+ this.thrownWaiting.delete(check);
1854
+ resolve(where);
1855
+ };
1856
+ const timer = setTimeout(() => finish(), CALL_WAIT_MS);
1857
+ this.thrownWaiting.add(check);
1858
+ });
1859
+ return found;
1860
+ }
1861
+ // The frame a context of `session` is in, looked up when asked.
1862
+ frameOf(session, contextId, sessions) {
1863
+ const frameId = session.contexts.get(contextId)?.frameId;
1864
+ let found;
1865
+ return () => (found ??= frameId ? sessions()?.frameOf(session, frameId) ?? Promise.resolve(undefined) : Promise.resolve(undefined));
1866
+ }
1867
+ heard(session, event, sessions) {
1868
+ const frames = [];
1869
+ for (let trace = event.stackTrace; trace; trace = trace.parent)
1870
+ frames.push(...trace.callFrames.map((f) => f.url));
1871
+ const call = {
1872
+ frames,
1873
+ extensionWorld: session.contexts.get(event.executionContextId)?.origin === this.extensionOrigin,
1874
+ frame: this.frameOf(session, event.executionContextId, sessions),
1875
+ };
1876
+ const waiter = this.waiting.get(event.timestamp)?.shift();
1877
+ if (waiter) {
1878
+ if (this.waiting.get(event.timestamp)?.length === 0)
1879
+ this.waiting.delete(event.timestamp);
1880
+ waiter(call);
1881
+ return;
1882
+ }
1883
+ // Enabling the protocol's console replays the calls before it,
1884
+ // which no line will claim: the oldest go past a cap.
1885
+ this.calls.set(event.timestamp, [...this.calls.get(event.timestamp) ?? [], call]);
1886
+ if (this.calls.size > CALLS_KEPT)
1887
+ this.calls.delete(this.calls.keys().next().value);
1888
+ }
1889
+ threw(session, at, details, sessions) {
1890
+ // Replayed as the session opened: Playwright reported it before.
1891
+ if (at < (this.opened.get(session) ?? 0))
1892
+ return;
1893
+ const texts = [details.exception?.description, details.text, details.exception?.value === undefined ? undefined : String(details.exception.value)]
1894
+ .filter((t) => typeof t === 'string');
1895
+ const now = Date.now();
1896
+ // Looked up now, while the frame still shows the page that threw.
1897
+ const frame = this.frameOf(session, details.executionContextId ?? 0, sessions);
1898
+ const extensionWorld = session.contexts.get(details.executionContextId ?? 0)?.origin === this.extensionOrigin;
1899
+ const where = frame().then((f) => ({ frame: f, url: f?.url() ?? '', extensionWorld }), () => ({ frame: undefined, url: '', extensionWorld }));
1900
+ this.thrown.push({ first: (texts[0] ?? '').split('\n')[0], texts, where, at: now });
1901
+ // Only the latest matter: Playwright's copy follows within moments.
1902
+ while (this.thrown.length > 0 && (this.thrown.length > CALLS_KEPT || now - this.thrown[0].at > THROWN_KEPT_MS))
1903
+ this.thrown.shift();
1904
+ for (const check of [...this.thrownWaiting])
1905
+ check();
1906
+ }
1907
+ }
1908
+ function watchFiles(opts, prepared, onChange) {
1909
+ let timer = null;
1910
+ let stopped = false;
1911
+ let watchers = [];
1912
+ // One change at a time: a save landing while the last is still being
1913
+ // applied waits for it.
1914
+ let running = Promise.resolve();
1915
+ const trigger = () => {
1916
+ if (timer)
1917
+ clearTimeout(timer);
1918
+ timer = setTimeout(() => {
1919
+ timer = null;
1920
+ if (stopped)
1921
+ return;
1922
+ running = running.then(onChange).catch((err) => opts.out(`[sm-dev] Re-inject failed: ${err.message}`));
1923
+ }, 150);
1924
+ };
1925
+ const watcher = {
1926
+ watch(scripts) {
1927
+ // Not again once stopped, or its handles would keep the process
1928
+ // alive.
1929
+ if (stopped)
1930
+ return 0;
1931
+ for (const w of watchers)
1932
+ w.close();
1933
+ const files = new Set();
1934
+ for (const p of scripts) {
1935
+ if (p.script.fileUrl.startsWith('file:'))
1936
+ files.add(fileURLToPath(p.script.fileUrl));
1937
+ for (const r of p.requires) {
1938
+ if (r.localUrl?.startsWith('file:'))
1939
+ files.add(fileURLToPath(r.localUrl));
1940
+ }
1941
+ }
1942
+ const dirs = new Map();
1943
+ for (const file of files) {
1944
+ const dir = path.dirname(file);
1945
+ if (!dirs.has(dir))
1946
+ dirs.set(dir, new Set());
1947
+ dirs.get(dir).add(path.basename(file));
1948
+ }
1949
+ watchers = [...dirs].map(([dir, names]) => watch(dir, (_event, name) => {
1950
+ if (typeof name === 'string' && names.has(name))
1951
+ trigger();
1952
+ }));
1953
+ return files.size;
1954
+ },
1955
+ stop() {
1956
+ stopped = true;
1957
+ if (timer)
1958
+ clearTimeout(timer);
1959
+ for (const w of watchers)
1960
+ w.close();
1961
+ },
1962
+ };
1963
+ const count = watcher.watch(prepared);
1964
+ opts.out(`[sm-dev] Watching ${count} file(s). Press Ctrl-C to stop.`);
1965
+ return watcher;
1966
+ }
1967
+ /**
1968
+ * What a call into SourceMonkey throws in a run under another manager,
1969
+ * which has no SourceMonkey to call.
1970
+ */
1971
+ export function runUses(manager) {
1972
+ return `This needs SourceMonkey, and the run uses ${MANAGERS[manager].label} (${managerFlag(manager)})`;
1973
+ }