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.
- package/LICENSE +21 -0
- package/README.md +87 -2
- package/dist/bytes.js +37 -0
- package/dist/channel-placeholder.js +5 -0
- package/dist/collection-scan.js +17 -0
- package/dist/collections.html +353 -0
- package/dist/compare.html +358 -0
- package/dist/composed-source.js +205 -0
- package/dist/connect-prompt.html +47 -0
- package/dist/debug/icons.html +211 -0
- package/dist/debug/storage.html +163 -0
- package/dist/debug-log.js +75 -0
- package/dist/diagnostic-location.js +64 -0
- package/dist/eval-hook.js +308 -0
- package/dist/extension/background.js +5263 -0
- package/dist/extension/channel.js +133 -0
- package/dist/extension/code-links.js +187 -0
- package/dist/extension/code-tabs.js +460 -0
- package/dist/extension/collection-dialog.js +619 -0
- package/dist/extension/collection-display.js +322 -0
- package/dist/extension/collections.js +2905 -0
- package/dist/extension/compare.js +867 -0
- package/dist/extension/concurrency.js +186 -0
- package/dist/extension/connect-grants.js +109 -0
- package/dist/extension/connect-prompt-page.js +70 -0
- package/dist/extension/connect-prompt.js +266 -0
- package/dist/extension/copy-button.js +63 -0
- package/dist/extension/copy-dialog.js +502 -0
- package/dist/extension/copy-preference.js +66 -0
- package/dist/extension/debug/icons.js +203 -0
- package/dist/extension/debug/storage.js +365 -0
- package/dist/extension/deep-link.js +77 -0
- package/dist/extension/delete-dialog.js +432 -0
- package/dist/extension/delete-icon.js +40 -0
- package/dist/extension/details-tooltips.js +66 -0
- package/dist/extension/directories.js +679 -0
- package/dist/extension/export-backup.js +407 -0
- package/dist/extension/export-dialog.js +142 -0
- package/dist/extension/file-access.js +74 -0
- package/dist/extension/file-changes.js +346 -0
- package/dist/extension/frame-info.js +85 -0
- package/dist/extension/github-provider.js +336 -0
- package/dist/extension/glyph.js +84 -0
- package/dist/extension/gm-bindings.js +601 -0
- package/dist/extension/gm-download.js +419 -0
- package/dist/extension/gm-menu.js +228 -0
- package/dist/extension/gm-notification.js +141 -0
- package/dist/extension/gm-originator-map.js +23 -0
- package/dist/extension/gm-tab-data.js +121 -0
- package/dist/extension/gm-tab.js +160 -0
- package/dist/extension/gm-url-change.js +111 -0
- package/dist/extension/gm-url-scheme.js +27 -0
- package/dist/extension/gm-values.js +176 -0
- package/dist/extension/gm-window-control.js +104 -0
- package/dist/extension/gm-xhr-headers.js +333 -0
- package/dist/extension/gm-xhr.js +584 -0
- package/dist/extension/injector.js +3145 -0
- package/dist/extension/install-capture.js +277 -0
- package/dist/extension/line-diff.js +233 -0
- package/dist/extension/local-copy.js +560 -0
- package/dist/extension/local-delete.js +276 -0
- package/dist/extension/location-kinds.js +108 -0
- package/dist/extension/log-format.js +158 -0
- package/dist/extension/managed-changes.js +361 -0
- package/dist/extension/managed-items.js +57 -0
- package/dist/extension/match-index.js +375 -0
- package/dist/extension/missing-scripts.js +66 -0
- package/dist/extension/more-menu.js +109 -0
- package/dist/extension/move-dialog.js +197 -0
- package/dist/extension/offscreen.js +37 -0
- package/dist/extension/options.js +504 -0
- package/dist/extension/page-heading.js +170 -0
- package/dist/extension/page-links.js +199 -0
- package/dist/extension/parse-check-client.js +70 -0
- package/dist/extension/parse-check.js +161 -0
- package/dist/extension/parse-sandbox-host.js +172 -0
- package/dist/extension/parse-sandbox.js +175 -0
- package/dist/extension/popup-commands-open.js +41 -0
- package/dist/extension/popup-view.js +387 -0
- package/dist/extension/popup.js +703 -0
- package/dist/extension/prism-userscript.js +53 -0
- package/dist/extension/refresh-error.js +136 -0
- package/dist/extension/refresh-targets.js +160 -0
- package/dist/extension/related-versions.js +1144 -0
- package/dist/extension/resize-observer-loop.js +19 -0
- package/dist/extension/row-cache.js +387 -0
- package/dist/extension/script-blobs.js +413 -0
- package/dist/extension/script-details.js +658 -0
- package/dist/extension/script-enablement.js +285 -0
- package/dist/extension/script-identity.js +310 -0
- package/dist/extension/script-keys.js +54 -0
- package/dist/extension/script-log-id.js +155 -0
- package/dist/extension/script-log.js +290 -0
- package/dist/extension/script-rejection.js +104 -0
- package/dist/extension/script-sites.js +158 -0
- package/dist/extension/script-store.js +3507 -0
- package/dist/extension/script-updater.js +991 -0
- package/dist/extension/script-url-hash.js +61 -0
- package/dist/extension/select-all.js +37 -0
- package/dist/extension/select-open-guard.js +90 -0
- package/dist/extension/site-icon.js +1261 -0
- package/dist/extension/sm-dev-link.js +302 -0
- package/dist/extension/solo-click.js +58 -0
- package/dist/extension/sticky-numbers.js +188 -0
- package/dist/extension/storage-breakdown.js +253 -0
- package/dist/extension/storage-scan.js +86 -0
- package/dist/extension/storage-usage.js +194 -0
- package/dist/extension/structural-keys.js +29 -0
- package/dist/extension/sw-teardown.js +36 -0
- package/dist/extension/tab-status.js +403 -0
- package/dist/extension/toolbar-summary.js +105 -0
- package/dist/extension/user-scripts-access.js +43 -0
- package/dist/extension/userscript.js +3672 -0
- package/dist/extension/value-store.js +204 -0
- package/dist/extension/version-probe-fetch.js +68 -0
- package/dist/extension/worker-client.js +109 -0
- package/dist/extension/worker-ops.js +171 -0
- package/dist/extension/zip-writer.js +24 -0
- package/dist/external-files.js +878 -0
- package/dist/file-loader.js +436 -0
- package/dist/file-locations.js +439 -0
- package/dist/generated-wrappers.js +5 -0
- package/dist/github-auth.js +148 -0
- package/dist/github-urls.js +46 -0
- package/dist/gm-api-catalog.js +217 -0
- package/dist/gm-connect.js +273 -0
- package/dist/gm-xhr-types.js +5 -0
- package/dist/http-read.js +142 -0
- package/dist/icons/icon-128.png +0 -0
- package/dist/icons/icon-16.png +0 -0
- package/dist/icons/icon-48.png +0 -0
- package/dist/local-file-overrides.js +558 -0
- package/dist/manifest.json +38 -0
- package/dist/markup-response.js +126 -0
- package/dist/match-targets.js +305 -0
- package/dist/menu-captions.js +22 -0
- package/dist/offscreen.html +39 -0
- package/dist/options.html +197 -0
- package/dist/parse-check-protocol.js +51 -0
- package/dist/parse-location.js +171 -0
- package/dist/parse-sandbox.html +56 -0
- package/dist/phase-timing.js +155 -0
- package/dist/popup.html +197 -0
- package/dist/public-suffixes.js +6957 -0
- package/dist/run-context.js +31 -0
- package/dist/runtime-messages.js +34 -0
- package/dist/sandboxed-page.js +35 -0
- package/dist/script-build.js +787 -0
- package/dist/script-display.js +383 -0
- package/dist/script-icon.js +43 -0
- package/dist/script-links.js +204 -0
- package/dist/script-source-url.js +214 -0
- package/dist/script-world.js +65 -0
- package/dist/sm-dev-protocol.js +84 -0
- package/dist/source-map.js +177 -0
- package/dist/stack-link.js +138 -0
- package/dist/styles.css +825 -0
- package/dist/unsafe-window-use.js +229 -0
- package/dist/url-match.js +734 -0
- package/dist/userscript-header.js +248 -0
- package/dist/userscript.html +1165 -0
- package/dist/vendor/fflate.LICENSE +21 -0
- package/dist/vendor/fflate.js +2692 -0
- package/dist/vendor/prism/prism-line-numbers.css +1 -0
- package/dist/vendor/prism/prism-line-numbers.js +1 -0
- package/dist/vendor/prism/prism.css +1 -0
- package/dist/vendor/prism/prism.js +1946 -0
- package/dist/version-compare.js +141 -0
- package/dist/version-probe.js +120 -0
- package/lib/build-info.json +1 -0
- package/lib/bytes.d.ts +3 -0
- package/lib/bytes.js +37 -0
- package/lib/channel-placeholder.d.ts +1 -0
- package/lib/channel-placeholder.js +5 -0
- package/lib/collection-scan.d.ts +8 -0
- package/lib/collection-scan.js +17 -0
- package/lib/composed-source.d.ts +28 -0
- package/lib/composed-source.js +205 -0
- package/lib/debug-log.d.ts +9 -0
- package/lib/debug-log.js +75 -0
- package/lib/diagnostic-location.d.ts +8 -0
- package/lib/diagnostic-location.js +64 -0
- package/lib/eval-hook.d.ts +34 -0
- package/lib/eval-hook.js +308 -0
- package/lib/external-files.d.ts +111 -0
- package/lib/external-files.js +878 -0
- package/lib/file-loader.d.ts +48 -0
- package/lib/file-loader.js +436 -0
- package/lib/file-locations.d.ts +44 -0
- package/lib/file-locations.js +439 -0
- package/lib/generated-wrappers.d.ts +11 -0
- package/lib/generated-wrappers.js +5 -0
- package/lib/github-auth.d.ts +12 -0
- package/lib/github-auth.js +148 -0
- package/lib/github-urls.d.ts +1 -0
- package/lib/github-urls.js +46 -0
- package/lib/gm-api-catalog.d.ts +24 -0
- package/lib/gm-api-catalog.js +217 -0
- package/lib/gm-connect.d.ts +34 -0
- package/lib/gm-connect.js +273 -0
- package/lib/gm-xhr-types.d.ts +68 -0
- package/lib/gm-xhr-types.js +5 -0
- package/lib/harness/browser-extensions.d.ts +73 -0
- package/lib/harness/browser-extensions.js +285 -0
- package/lib/harness/browser-lang.d.ts +20 -0
- package/lib/harness/browser-lang.js +86 -0
- package/lib/harness/browser-launch.d.ts +14 -0
- package/lib/harness/browser-launch.js +184 -0
- package/lib/harness/checks.d.ts +110 -0
- package/lib/harness/checks.js +321 -0
- package/lib/harness/cli.d.ts +2 -0
- package/lib/harness/cli.js +2259 -0
- package/lib/harness/command-args.d.ts +91 -0
- package/lib/harness/command-args.js +369 -0
- package/lib/harness/command-lines.d.ts +80 -0
- package/lib/harness/command-lines.js +180 -0
- package/lib/harness/command-list.d.ts +206 -0
- package/lib/harness/command-list.js +1475 -0
- package/lib/harness/console-commands.d.ts +463 -0
- package/lib/harness/console-commands.js +3216 -0
- package/lib/harness/download-watch.d.ts +21 -0
- package/lib/harness/download-watch.js +156 -0
- package/lib/harness/extension-backend.d.ts +41 -0
- package/lib/harness/extension-backend.js +100 -0
- package/lib/harness/extension-host.d.ts +112 -0
- package/lib/harness/extension-host.js +552 -0
- package/lib/harness/extension-launch.d.ts +67 -0
- package/lib/harness/extension-launch.js +285 -0
- package/lib/harness/extension-link.d.ts +315 -0
- package/lib/harness/extension-link.js +607 -0
- package/lib/harness/external-manager.d.ts +146 -0
- package/lib/harness/external-manager.js +414 -0
- package/lib/harness/fixture.d.ts +165 -0
- package/lib/harness/fixture.js +461 -0
- package/lib/harness/frame-sessions.d.ts +71 -0
- package/lib/harness/frame-sessions.js +248 -0
- package/lib/harness/frames.d.ts +68 -0
- package/lib/harness/frames.js +216 -0
- package/lib/harness/help-format.d.ts +17 -0
- package/lib/harness/help-format.js +127 -0
- package/lib/harness/host.d.ts +352 -0
- package/lib/harness/host.js +423 -0
- package/lib/harness/index.d.ts +8 -0
- package/lib/harness/index.js +10 -0
- package/lib/harness/input-file.d.ts +18 -0
- package/lib/harness/input-file.js +61 -0
- package/lib/harness/json-output.d.ts +565 -0
- package/lib/harness/json-output.js +71 -0
- package/lib/harness/manager-actions.d.ts +21 -0
- package/lib/harness/manager-actions.js +129 -0
- package/lib/harness/manager-commands.d.ts +142 -0
- package/lib/harness/manager-commands.js +1196 -0
- package/lib/harness/manager-fetch.d.ts +33 -0
- package/lib/harness/manager-fetch.js +239 -0
- package/lib/harness/managers.d.ts +24 -0
- package/lib/harness/managers.js +27 -0
- package/lib/harness/output-style.d.ts +125 -0
- package/lib/harness/output-style.js +322 -0
- package/lib/harness/page-commands.d.ts +134 -0
- package/lib/harness/page-commands.js +1141 -0
- package/lib/harness/page-output.d.ts +67 -0
- package/lib/harness/page-output.js +139 -0
- package/lib/harness/page-watch.d.ts +171 -0
- package/lib/harness/page-watch.js +607 -0
- package/lib/harness/parse-check-node.d.ts +3 -0
- package/lib/harness/parse-check-node.js +132 -0
- package/lib/harness/path-args.d.ts +22 -0
- package/lib/harness/path-args.js +148 -0
- package/lib/harness/playwright-chromium.d.ts +16 -0
- package/lib/harness/playwright-chromium.js +86 -0
- package/lib/harness/prepare.d.ts +168 -0
- package/lib/harness/prepare.js +364 -0
- package/lib/harness/prompt-editor.d.ts +17 -0
- package/lib/harness/prompt-editor.js +354 -0
- package/lib/harness/prompt-history.d.ts +13 -0
- package/lib/harness/prompt-history.js +89 -0
- package/lib/harness/rebuild-advice.d.ts +6 -0
- package/lib/harness/rebuild-advice.js +39 -0
- package/lib/harness/run-log.d.ts +47 -0
- package/lib/harness/run-log.js +140 -0
- package/lib/harness/run-logs.d.ts +132 -0
- package/lib/harness/run-logs.js +241 -0
- package/lib/harness/run.d.ts +194 -0
- package/lib/harness/run.js +1973 -0
- package/lib/harness/runs.d.ts +229 -0
- package/lib/harness/runs.js +726 -0
- package/lib/harness/save-html.d.ts +39 -0
- package/lib/harness/save-html.js +89 -0
- package/lib/harness/saved-pages.d.ts +120 -0
- package/lib/harness/saved-pages.js +275 -0
- package/lib/harness/script-mark.d.ts +17 -0
- package/lib/harness/script-mark.js +56 -0
- package/lib/harness/snapshot.d.ts +143 -0
- package/lib/harness/snapshot.js +474 -0
- package/lib/harness/stuck-tabs.d.ts +19 -0
- package/lib/harness/stuck-tabs.js +131 -0
- package/lib/harness/tab-refs.d.ts +20 -0
- package/lib/harness/tab-refs.js +80 -0
- package/lib/harness/tampermonkey.d.ts +71 -0
- package/lib/harness/tampermonkey.js +356 -0
- package/lib/harness/tm-ids.d.ts +18 -0
- package/lib/harness/tm-ids.js +88 -0
- package/lib/harness/violentmonkey.d.ts +34 -0
- package/lib/harness/violentmonkey.js +162 -0
- package/lib/harness/wait.d.ts +77 -0
- package/lib/harness/wait.js +458 -0
- package/lib/http-read.d.ts +30 -0
- package/lib/http-read.js +142 -0
- package/lib/local-file-overrides.d.ts +35 -0
- package/lib/local-file-overrides.js +558 -0
- package/lib/manifest.json +38 -0
- package/lib/markup-response.d.ts +11 -0
- package/lib/markup-response.js +126 -0
- package/lib/match-targets.d.ts +18 -0
- package/lib/match-targets.js +305 -0
- package/lib/menu-captions.d.ts +1 -0
- package/lib/menu-captions.js +22 -0
- package/lib/parse-check-protocol.d.ts +27 -0
- package/lib/parse-check-protocol.js +51 -0
- package/lib/parse-location.d.ts +12 -0
- package/lib/parse-location.js +171 -0
- package/lib/phase-timing.d.ts +12 -0
- package/lib/phase-timing.js +155 -0
- package/lib/public-suffixes.d.ts +1 -0
- package/lib/public-suffixes.js +6957 -0
- package/lib/run-context.d.ts +2 -0
- package/lib/run-context.js +31 -0
- package/lib/runtime-messages.d.ts +10 -0
- package/lib/runtime-messages.js +34 -0
- package/lib/sandboxed-page.d.ts +5 -0
- package/lib/sandboxed-page.js +35 -0
- package/lib/script-build.d.ts +111 -0
- package/lib/script-build.js +787 -0
- package/lib/script-display.d.ts +51 -0
- package/lib/script-display.js +383 -0
- package/lib/script-icon.d.ts +16 -0
- package/lib/script-icon.js +43 -0
- package/lib/script-links.d.ts +24 -0
- package/lib/script-links.js +204 -0
- package/lib/script-source-url.d.ts +8 -0
- package/lib/script-source-url.js +214 -0
- package/lib/script-world.d.ts +6 -0
- package/lib/script-world.js +65 -0
- package/lib/sm-dev-protocol.d.ts +251 -0
- package/lib/sm-dev-protocol.js +84 -0
- package/lib/source-map.d.ts +32 -0
- package/lib/source-map.js +177 -0
- package/lib/stack-link.d.ts +15 -0
- package/lib/stack-link.js +138 -0
- package/lib/unsafe-window-use.d.ts +1 -0
- package/lib/unsafe-window-use.js +229 -0
- package/lib/url-match.d.ts +40 -0
- package/lib/url-match.js +734 -0
- package/lib/userscript-header.d.ts +33 -0
- package/lib/userscript-header.js +248 -0
- package/lib/version-compare.d.ts +6 -0
- package/lib/version-compare.js +141 -0
- package/lib/version-probe.d.ts +17 -0
- package/lib/version-probe.js +120 -0
- package/package.json +55 -3
- package/skills/SourceMonkey-dev/SKILL.md +868 -0
- package/skills/SourceMonkey-dev/references/cdp.md +293 -0
- package/skills/SourceMonkey-dev/references/commands.md +297 -0
- package/skills/SourceMonkey-dev/references/other-managers.md +63 -0
- package/skills/SourceMonkey-dev/references/specs.md +46 -0
- package/skills/SourceMonkey-friction/SKILL.md +103 -0
- 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
|
+
}
|