browser-debugger-cli 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.claude/skills/bdg/SKILL.md +4 -4
  2. package/README.md +1 -0
  3. package/dist/commands/cdp.d.ts +22 -1
  4. package/dist/commands/cdp.js +100 -43
  5. package/dist/commands/console.d.ts +12 -0
  6. package/dist/commands/console.js +62 -12
  7. package/dist/commands/dom/DomElementResolver.d.ts +3 -1
  8. package/dist/commands/dom/DomElementResolver.js +10 -3
  9. package/dist/commands/dom/a11y.js +3 -2
  10. package/dist/commands/dom/eval.d.ts +3 -2
  11. package/dist/commands/dom/eval.js +11 -5
  12. package/dist/commands/dom/form.js +10 -9
  13. package/dist/commands/dom/formInteraction.js +8 -7
  14. package/dist/commands/dom/get.js +8 -8
  15. package/dist/commands/dom/helpers/index.d.ts +1 -1
  16. package/dist/commands/dom/helpers/index.js +1 -1
  17. package/dist/commands/dom/helpers/query.d.ts +27 -3
  18. package/dist/commands/dom/helpers/query.js +152 -64
  19. package/dist/commands/dom/helpers/screenshot.js +13 -13
  20. package/dist/commands/dom/index.js +4 -2
  21. package/dist/commands/dom/query.d.ts +19 -2
  22. package/dist/commands/dom/query.js +37 -6
  23. package/dist/commands/dom/screenshot.js +2 -1
  24. package/dist/commands/dom/semanticUtils.d.ts +3 -2
  25. package/dist/commands/dom/semanticUtils.js +40 -9
  26. package/dist/commands/helpJson.d.ts +82 -19
  27. package/dist/commands/helpJson.js +111 -40
  28. package/dist/commands/helpTopic.d.ts +16 -1
  29. package/dist/commands/helpTopic.js +59 -1
  30. package/dist/commands/installSkill.d.ts +15 -5
  31. package/dist/commands/installSkill.js +86 -16
  32. package/dist/commands/network/list.js +22 -12
  33. package/dist/commands/optionBehaviors.js +33 -11
  34. package/dist/commands/shared/daemonErrorHandler.d.ts +5 -2
  35. package/dist/commands/shared/daemonErrorHandler.js +20 -9
  36. package/dist/commands/shared/dataFetcher.d.ts +12 -4
  37. package/dist/commands/shared/dataFetcher.js +12 -4
  38. package/dist/commands/shared/followMode.d.ts +9 -1
  39. package/dist/commands/shared/followMode.js +22 -4
  40. package/dist/commands/shared/optionTypes.d.ts +4 -1
  41. package/dist/commands/shared/outputFile.js +6 -1
  42. package/dist/commands/start.d.ts +7 -5
  43. package/dist/commands/start.js +65 -21
  44. package/dist/commands/stop.d.ts +11 -0
  45. package/dist/commands/stop.js +24 -1
  46. package/dist/commands.js +1 -1
  47. package/dist/connection/cdp.d.ts +7 -0
  48. package/dist/connection/cdp.js +9 -0
  49. package/dist/connection/launcher.js +3 -2
  50. package/dist/daemon/SessionController.js +6 -1
  51. package/dist/daemon/launcher.d.ts +3 -2
  52. package/dist/daemon/launcher.js +47 -3
  53. package/dist/daemon/session/Session.d.ts +4 -1
  54. package/dist/daemon/session/Session.js +33 -2
  55. package/dist/daemon/session/TelemetryStore.d.ts +8 -1
  56. package/dist/daemon/session/TelemetryStore.js +13 -1
  57. package/dist/daemon/session/commandRegistry.js +29 -13
  58. package/dist/daemon/session/interactions.d.ts +2 -1
  59. package/dist/daemon/session/interactions.js +13 -1
  60. package/dist/daemon/session/plugins.js +16 -2
  61. package/dist/daemon/session/teardown.js +1 -1
  62. package/dist/daemon.js +1622 -748
  63. package/dist/errors/messages.d.ts +54 -11
  64. package/dist/errors/messages.js +109 -22
  65. package/dist/index.js +13733 -8796
  66. package/dist/ipc/client.d.ts +18 -2
  67. package/dist/ipc/client.js +26 -5
  68. package/dist/ipc/protocol/auditTypes.d.ts +8 -2
  69. package/dist/ipc/protocol/commands.d.ts +12 -0
  70. package/dist/ipc/protocol/domTypes.d.ts +12 -0
  71. package/dist/ipc/protocol/inspectTypes.d.ts +2 -0
  72. package/dist/ipc/session/types.d.ts +2 -0
  73. package/dist/runtime/dom/actionEffects.d.ts +5 -1
  74. package/dist/runtime/dom/actionEffects.js +26 -14
  75. package/dist/runtime/dom/audit.js +3 -2
  76. package/dist/runtime/dom/auditModel.js +6 -1
  77. package/dist/runtime/dom/auditScripts.d.ts +9 -3
  78. package/dist/runtime/dom/auditScripts.js +41 -5
  79. package/dist/runtime/dom/elementGeometry.d.ts +10 -3
  80. package/dist/runtime/dom/elementGeometry.js +27 -4
  81. package/dist/runtime/dom/elementInfo.d.ts +74 -18
  82. package/dist/runtime/dom/elementInfo.js +187 -40
  83. package/dist/runtime/dom/evalHelpers.d.ts +12 -2
  84. package/dist/runtime/dom/evalHelpers.js +67 -7
  85. package/dist/runtime/dom/formDiscovery.d.ts +6 -2
  86. package/dist/runtime/dom/formDiscovery.js +20 -3
  87. package/dist/runtime/dom/formFillHelpers/fill.js +7 -11
  88. package/dist/runtime/dom/formFillHelpers/pressKey.js +2 -2
  89. package/dist/runtime/dom/formFillHelpers/shared.d.ts +16 -10
  90. package/dist/runtime/dom/formFillHelpers/shared.js +19 -52
  91. package/dist/runtime/dom/formSubmitHelpers.js +4 -3
  92. package/dist/runtime/dom/frameLayout.js +1 -0
  93. package/dist/runtime/dom/inspect.js +5 -6
  94. package/dist/runtime/dom/inspectAllStyles.js +1 -0
  95. package/dist/runtime/dom/inspectHints.d.ts +1 -1
  96. package/dist/runtime/dom/inspectModel.d.ts +2 -1
  97. package/dist/runtime/dom/inspectModel.js +7 -3
  98. package/dist/runtime/dom/inspectPaintModel.d.ts +2 -0
  99. package/dist/runtime/dom/inspectPaintModel.js +3 -1
  100. package/dist/runtime/dom/inspectScripts.d.ts +29 -2
  101. package/dist/runtime/dom/inspectScripts.js +49 -10
  102. package/dist/runtime/dom/layout.js +9 -7
  103. package/dist/runtime/dom/reactEventHelpers.d.ts +14 -4
  104. package/dist/runtime/dom/reactEventHelpers.js +63 -27
  105. package/dist/runtime/dom/targetNode.d.ts +18 -5
  106. package/dist/runtime/dom/targetNode.js +268 -8
  107. package/dist/runtime/dom/wait.js +2 -1
  108. package/dist/runtime/page/bdgWorld.d.ts +57 -0
  109. package/dist/runtime/page/bdgWorld.js +180 -0
  110. package/dist/runtime/page/replacedBuiltins.d.ts +28 -0
  111. package/dist/runtime/page/replacedBuiltins.js +136 -0
  112. package/dist/session/QueryCacheManager.d.ts +4 -1
  113. package/dist/session/QueryCacheManager.js +5 -2
  114. package/dist/session/chrome.d.ts +4 -1
  115. package/dist/session/chrome.js +7 -1
  116. package/dist/session/cleanup/staleSession.d.ts +21 -4
  117. package/dist/session/cleanup/staleSession.js +79 -9
  118. package/dist/session/cleanup/userCommands.d.ts +4 -1
  119. package/dist/session/cleanup/userCommands.js +10 -5
  120. package/dist/session/daemonSocket.d.ts +10 -0
  121. package/dist/session/daemonSocket.js +22 -0
  122. package/dist/session/lastSession.d.ts +6 -3
  123. package/dist/session/lastSession.js +11 -5
  124. package/dist/session/paths.d.ts +3 -1
  125. package/dist/session/paths.js +5 -5
  126. package/dist/session/portClaims.js +4 -3
  127. package/dist/session/sessionList.d.ts +13 -5
  128. package/dist/session/sessionList.js +31 -7
  129. package/dist/telemetry/a11y.js +2 -2
  130. package/dist/telemetry/console.d.ts +2 -1
  131. package/dist/telemetry/console.js +30 -21
  132. package/dist/telemetry/pageCrash.d.ts +26 -0
  133. package/dist/telemetry/pageCrash.js +53 -0
  134. package/dist/types.d.ts +16 -0
  135. package/dist/ui/formatters/audit.js +14 -5
  136. package/dist/ui/formatters/cdp.d.ts +138 -0
  137. package/dist/ui/formatters/cdp.js +131 -0
  138. package/dist/ui/formatters/console/chronological.js +3 -1
  139. package/dist/ui/formatters/console/follow.d.ts +2 -1
  140. package/dist/ui/formatters/console/follow.js +2 -2
  141. package/dist/ui/formatters/console/json.d.ts +2 -2
  142. package/dist/ui/formatters/console/json.js +11 -5
  143. package/dist/ui/formatters/console/shared.d.ts +30 -0
  144. package/dist/ui/formatters/console/shared.js +16 -0
  145. package/dist/ui/formatters/console/summarize.d.ts +9 -2
  146. package/dist/ui/formatters/console/summarize.js +40 -9
  147. package/dist/ui/formatters/console.d.ts +2 -1
  148. package/dist/ui/formatters/console.js +7 -5
  149. package/dist/ui/formatters/details.js +3 -1
  150. package/dist/ui/formatters/dom.d.ts +1 -1
  151. package/dist/ui/formatters/dom.js +5 -6
  152. package/dist/ui/formatters/helpFormatters.js +1 -1
  153. package/dist/ui/formatters/inspect.js +9 -3
  154. package/dist/ui/formatters/installSkill.d.ts +9 -1
  155. package/dist/ui/formatters/installSkill.js +32 -6
  156. package/dist/ui/formatters/layout.js +2 -1
  157. package/dist/ui/formatters/networkList.d.ts +1 -1
  158. package/dist/ui/formatters/networkList.js +1 -2
  159. package/dist/ui/formatters/preview.d.ts +2 -0
  160. package/dist/ui/formatters/preview.js +17 -7
  161. package/dist/ui/formatters/sessions.d.ts +2 -2
  162. package/dist/ui/formatters/sessions.js +9 -2
  163. package/dist/ui/logging/logger.d.ts +1 -1
  164. package/dist/ui/messages/commands.d.ts +124 -4
  165. package/dist/ui/messages/commands.js +162 -7
  166. package/dist/ui/messages/consoleMessages.d.ts +24 -0
  167. package/dist/ui/messages/consoleMessages.js +32 -0
  168. package/dist/ui/messages/preview.d.ts +6 -0
  169. package/dist/ui/messages/preview.js +9 -1
  170. package/dist/ui/messages/session.d.ts +13 -2
  171. package/dist/ui/messages/session.js +22 -3
  172. package/dist/utils/directories.d.ts +34 -0
  173. package/dist/utils/directories.js +88 -0
  174. package/dist/utils/display.d.ts +16 -0
  175. package/dist/utils/display.js +42 -0
  176. package/dist/utils/exitCodes.d.ts +1 -0
  177. package/dist/utils/exitCodes.js +6 -0
  178. package/dist/utils/process.d.ts +12 -0
  179. package/dist/utils/process.js +25 -0
  180. package/package.json +1 -1
@@ -90,19 +90,25 @@ export async function fetchPreviewData(query = {}) {
90
90
  * Fetch all captured network requests from daemon.
91
91
  *
92
92
  * @param withHeaders - Include request/response headers (needed by header filters)
93
- * @returns Requests or a fetch error
93
+ * @returns Requests, and when the page crashed (while it is not loaded
94
+ * again), or a fetch error
94
95
  */
95
96
  export async function fetchNetworkRequests(withHeaders = false) {
96
97
  const result = await fetchPreviewData({ lastN: 0, only: 'network', withHeaders });
97
98
  if (!result.success)
98
99
  return result;
99
- return { success: true, data: result.data.network };
100
+ return {
101
+ success: true,
102
+ data: { requests: result.data.network, pageCrashedAt: result.data.output.pageCrashedAt },
103
+ };
100
104
  }
101
105
  /**
102
106
  * Fetch all console messages from daemon.
103
107
  *
104
- * @returns Messages (with their session-wide index) and the navigation id of
105
- * the page currently loaded
108
+ * @returns Messages (with their session-wide index), the navigation id of
109
+ * the page currently loaded, how many of the oldest messages the session
110
+ * dropped at its limit and when the page crashed (while it is not loaded
111
+ * again)
106
112
  */
107
113
  export async function fetchConsoleMessages() {
108
114
  const result = await fetchPreviewData({ lastN: 0, only: 'console' });
@@ -113,6 +119,8 @@ export async function fetchConsoleMessages() {
113
119
  data: {
114
120
  messages: result.data.console,
115
121
  currentNavigationId: result.data.output.currentNavigationId,
122
+ dropped: result.data.output.totals?.consoleDropped ?? 0,
123
+ pageCrashedAt: result.data.output.pageCrashedAt,
116
124
  },
117
125
  };
118
126
  }
@@ -23,6 +23,14 @@ export declare function followFetchFailure(failure: {
23
23
  json?: boolean | undefined;
24
24
  retryIntervalMs: number;
25
25
  }): FollowPoll;
26
+ /**
27
+ * Report each page crash of a stream once: a page loaded again that crashes
28
+ * again is a new crash.
29
+ *
30
+ * @returns Function taking the crash time a refresh fetched, returning it
31
+ * when that crash was not reported yet
32
+ */
33
+ export declare function newPageCrashes(): (crashedAt: number | undefined) => number | undefined;
26
34
  /**
27
35
  * Options for configuring follow mode behavior.
28
36
  */
@@ -42,7 +50,7 @@ export interface FollowModeOptions {
42
50
  * - Initial display of start message
43
51
  * - First refresh call (awaited)
44
52
  * - Periodic interval-based refresh
45
- * - SIGINT handler for graceful shutdown
53
+ * - SIGINT/SIGTERM handlers that stop with 130/143, as shells expect
46
54
  * - Stopping with the exit code a refresh returns (e.g. the session is gone)
47
55
  *
48
56
  * @param refreshFn - Async function to call on each refresh cycle
@@ -27,6 +27,22 @@ export function followFetchFailure(failure, options) {
27
27
  ? { exitCode: result.exitCode ?? EXIT_CODES.RESOURCE_NOT_FOUND }
28
28
  : undefined;
29
29
  }
30
+ /**
31
+ * Report each page crash of a stream once: a page loaded again that crashes
32
+ * again is a new crash.
33
+ *
34
+ * @returns Function taking the crash time a refresh fetched, returning it
35
+ * when that crash was not reported yet
36
+ */
37
+ export function newPageCrashes() {
38
+ let reported;
39
+ return (crashedAt) => {
40
+ if (crashedAt === undefined || crashedAt === reported)
41
+ return undefined;
42
+ reported = crashedAt;
43
+ return crashedAt;
44
+ };
45
+ }
30
46
  /**
31
47
  * Sets up follow mode with periodic refresh and graceful shutdown.
32
48
  *
@@ -35,7 +51,7 @@ export function followFetchFailure(failure, options) {
35
51
  * - Initial display of start message
36
52
  * - First refresh call (awaited)
37
53
  * - Periodic interval-based refresh
38
- * - SIGINT handler for graceful shutdown
54
+ * - SIGINT/SIGTERM handlers that stop with 130/143, as shells expect
39
55
  * - Stopping with the exit code a refresh returns (e.g. the session is gone)
40
56
  *
41
57
  * @param refreshFn - Async function to call on each refresh cycle
@@ -71,10 +87,12 @@ export async function setupFollowMode(refreshFn, options) {
71
87
  console.error(genericError(getErrorMessage(error)));
72
88
  });
73
89
  }, intervalMs);
74
- process.on('SIGINT', () => {
90
+ const stop = (exitCode) => {
75
91
  clearInterval(intervalId);
76
92
  console.error(stopMessage());
77
- process.exit(EXIT_CODES.SUCCESS);
78
- });
93
+ process.exit(exitCode);
94
+ };
95
+ process.on('SIGINT', () => stop(EXIT_CODES.INTERRUPTED));
96
+ process.on('SIGTERM', () => stop(EXIT_CODES.TERMINATED));
79
97
  }
80
98
  //# sourceMappingURL=followMode.js.map
@@ -142,7 +142,10 @@ export type DetailsCommandOptions = BaseOptions & {
142
142
  id: string;
143
143
  };
144
144
  /** Options for DOM query command */
145
- export type DomQueryCommandOptions = BaseOptions;
145
+ export type DomQueryCommandOptions = BaseOptions & {
146
+ /** Matches listed (0 = all); default 50, or 1000 with --json */
147
+ limit?: number;
148
+ };
146
149
  /** Options for DOM get command */
147
150
  export type DomGetCommandOptions = BaseOptions & RawOptions & SelectionOptions & {
148
151
  /** All of the element's text instead of its first 500 characters (semantic output) */
@@ -7,6 +7,7 @@ import * as path from 'path';
7
7
  import { CommandError } from '../../errors/index.js';
8
8
  import { emptyOutputPathError, outputFileError } from '../../errors/messages.js';
9
9
  import { AtomicFileWriter } from '../../utils/atomicFile.js';
10
+ import { makeDirectory } from '../../utils/directories.js';
10
11
  import { EXIT_CODES } from '../../utils/exitCodes.js';
11
12
  /** What went wrong with a path, by error code */
12
13
  const PATH_PROBLEMS = {
@@ -19,6 +20,10 @@ const PATH_PROBLEMS = {
19
20
  ENAMETOOLONG: { reason: 'the name is too long', exitCode: EXIT_CODES.INVALID_ARGUMENTS },
20
21
  ENOENT: { reason: 'the directory cannot be created', exitCode: EXIT_CODES.INVALID_ARGUMENTS },
21
22
  ENOSPC: { reason: 'no space left on the device', exitCode: EXIT_CODES.SESSION_FILE_ERROR },
23
+ EPSEUDOFS: {
24
+ reason: 'it is on a pseudo-filesystem (/proc, /sys)',
25
+ exitCode: EXIT_CODES.INVALID_ARGUMENTS,
26
+ },
22
27
  };
23
28
  /**
24
29
  * A file-system error as a user-facing error about the given path.
@@ -64,7 +69,7 @@ export async function writeOutputFile(filePath, data, extension) {
64
69
  assertFilePath(filePath, extension);
65
70
  const absolutePath = path.resolve(filePath);
66
71
  try {
67
- fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
72
+ makeDirectory(path.dirname(absolutePath));
68
73
  if (typeof data === 'string')
69
74
  await AtomicFileWriter.writeAsync(absolutePath, data);
70
75
  else
@@ -84,15 +84,17 @@ export declare function parseViewport(value: string): ViewportSize;
84
84
  */
85
85
  export declare function parseColorScheme(value: string): ColorScheme;
86
86
  /**
87
- * Reject a subcommand typed without its group (`bdg query x` for
88
- * `bdg dom query x`) before Commander reads it as a start URL with extra
89
- * arguments.
87
+ * Reject a mistyped command before Commander reads it as a start URL with
88
+ * extra arguments (`too many arguments`): a subcommand typed without its
89
+ * group (`bdg query x` for `bdg dom query x`), or a word close to a command
90
+ * followed by more words (`bdg netwrk list`). A single word is left to the
91
+ * start command, which checks it the same way.
90
92
  *
91
93
  * @param program - Root command with all commands registered
92
94
  * @param argv - Process arguments
93
- * @throws CommandError (81) naming the full command
95
+ * @throws CommandError (81) naming the command meant
94
96
  */
95
- export declare function assertNotGroupSubcommand(program: Command, argv: string[]): void;
97
+ export declare function assertNotMistypedCommand(program: Command, argv: string[]): void;
96
98
  /**
97
99
  * Register the start command
98
100
  *
@@ -9,19 +9,12 @@ import { PORT_OPTION_DESCRIPTION } from '../constants.js';
9
9
  import { CommandError } from '../errors/index.js';
10
10
  import { chromeWsUrlConflictError, externalChromeUnreachableError, invalidChromeFlagError, notDevToolsEndpointError, invalidColorSchemeError, invalidUserDataDirError, invalidViewportError, missingStartUrlError, unknownCommandError, } from '../errors/messages.js';
11
11
  import { startCommandHelpMessage } from '../ui/messages/commands.js';
12
+ import { directoryProblem } from '../utils/directories.js';
13
+ import { hasDisplay } from '../utils/display.js';
12
14
  import { EXIT_CODES } from '../utils/exitCodes.js';
13
15
  import { probeDevToolsEndpoint } from '../utils/http.js';
14
16
  import { findSimilar } from '../utils/suggestions.js';
15
17
  import { devToolsHttpEndpoint, validateChromeWsUrl, validateUrl } from '../utils/url.js';
16
- /**
17
- * Check if a display server (X11 or Wayland) is available.
18
- * Used to determine default headless mode.
19
- */
20
- function hasDisplay() {
21
- const display = process.env['DISPLAY'];
22
- const wayland = process.env['WAYLAND_DISPLAY'];
23
- return (display !== undefined && display !== '') || (wayland !== undefined && wayland !== '');
24
- }
25
18
  /**
26
19
  * Expand a leading `~/` in a path to the user's home directory.
27
20
  * Chrome itself does not expand `~`, so bdg normalizes it for users.
@@ -74,7 +67,7 @@ export function applyCollectorOptions(command) {
74
67
  .option('-a, --all', 'Include all data: no tracking/analytics filtering, and capture every response body (incl. binary)', false)
75
68
  .option('-m, --max-body-size <megabytes>', 'Maximum response body size in MB', '5')
76
69
  .addOption(new Option('--compact', 'No effect; kept for compatibility').hideHelp())
77
- .option('--headless', 'Run Chrome without a window (default unless DISPLAY or WAYLAND_DISPLAY is set)', defaultHeadless)
70
+ .option('--headless', 'Run Chrome without a window (default without a display: Linux without DISPLAY or WAYLAND_DISPLAY, macOS over SSH or in CI)', defaultHeadless)
78
71
  .option('--no-headless', 'Show browser window')
79
72
  .option('--chrome-ws-url <url>', 'Connect to an existing Chrome: its DevTools port (9222, host:port, http://host:port), or a WebSocket URL: browser (ws://host:port/devtools/browser/<id>, uses the first tab) or page (.../devtools/page/<id>)')
80
73
  .option('-q, --quiet', 'Quiet mode - minimal output for AI agents', false)
@@ -180,6 +173,8 @@ function buildSessionOptions(options) {
180
173
  }
181
174
  /** Telemetry collected by every session. */
182
175
  const SESSION_TELEMETRY = ['dom', 'network', 'console'];
176
+ /** A word that cannot be a URL: no dot, colon or slash */
177
+ const BARE_WORD = /^[a-z][a-z0-9_-]*$/i;
183
178
  /** Flags users type as commands (`bdg version`). */
184
179
  const FLAG_WORDS = { version: '--version', help: '--help' };
185
180
  /**
@@ -194,29 +189,59 @@ const FLAG_WORDS = { version: '--version', help: '--help' };
194
189
  * @throws CommandError (81) for a bare word other than `localhost`
195
190
  */
196
191
  function assertNotCommandTypo(arg, commandNames) {
197
- if (!/^[a-z][a-z0-9_-]*$/i.test(arg) || arg.toLowerCase() === 'localhost')
192
+ if (!BARE_WORD.test(arg) || arg.toLowerCase() === 'localhost')
198
193
  return;
199
194
  const flag = FLAG_WORDS[arg.toLowerCase()];
200
195
  const err = unknownCommandError(arg, flag ? [flag] : findSimilar(arg, commandNames));
201
196
  throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.INVALID_ARGUMENTS);
202
197
  }
203
198
  /**
204
- * Reject a subcommand typed without its group (`bdg query x` for
205
- * `bdg dom query x`) before Commander reads it as a start URL with extra
206
- * arguments.
199
+ * The words of the command line that are not options or option values of
200
+ * the root command (`bdg --session a netwrk list` gives `netwrk`, `list`).
201
+ *
202
+ * @param program - Root command
203
+ * @param args - Arguments after the executable and script
204
+ * @returns Positional words, in order
205
+ */
206
+ function positionalWords(program, args) {
207
+ const words = [];
208
+ for (let i = 0; i < args.length; i++) {
209
+ const arg = args[i] ?? '';
210
+ if (!arg.startsWith('-')) {
211
+ words.push(arg);
212
+ continue;
213
+ }
214
+ const option = program.options.find((o) => o.long === arg || o.short === arg);
215
+ if (option && (option.required || option.optional))
216
+ i++;
217
+ }
218
+ return words;
219
+ }
220
+ /**
221
+ * Reject a mistyped command before Commander reads it as a start URL with
222
+ * extra arguments (`too many arguments`): a subcommand typed without its
223
+ * group (`bdg query x` for `bdg dom query x`), or a word close to a command
224
+ * followed by more words (`bdg netwrk list`). A single word is left to the
225
+ * start command, which checks it the same way.
207
226
  *
208
227
  * @param program - Root command with all commands registered
209
228
  * @param argv - Process arguments
210
- * @throws CommandError (81) naming the full command
229
+ * @throws CommandError (81) naming the command meant
211
230
  */
212
- export function assertNotGroupSubcommand(program, argv) {
213
- const [first] = argv.slice(2).filter((arg) => !arg.startsWith('-'));
214
- if (first === undefined || program.commands.some((command) => command.name() === first))
231
+ export function assertNotMistypedCommand(program, argv) {
232
+ const [first, ...rest] = positionalWords(program, argv.slice(2));
233
+ const commandNames = program.commands.map((command) => command.name());
234
+ if (first === undefined || commandNames.includes(first))
215
235
  return;
216
236
  const group = program.commands.find((command) => command.commands.some((sub) => sub.name() === first));
217
- if (!group)
237
+ const similar = group
238
+ ? [`${group.name()} ${first}`]
239
+ : rest.length > 0 && BARE_WORD.test(first)
240
+ ? findSimilar(first, commandNames)
241
+ : [];
242
+ if (similar.length === 0)
218
243
  return;
219
- const err = unknownCommandError(first, [`${group.name()} ${first}`]);
244
+ const err = unknownCommandError(first, similar);
220
245
  throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.INVALID_ARGUMENTS);
221
246
  }
222
247
  /**
@@ -247,7 +272,10 @@ function validateStartInput(url, options, program) {
247
272
  ...(process.env['BDG_CHROME_FLAGS']?.split(' ') ?? []),
248
273
  ...(options.chromeFlags?.split(' ') ?? []),
249
274
  ]);
250
- return { url, sessionOptions: buildSessionOptions(options) };
275
+ const sessionOptions = buildSessionOptions(options);
276
+ if (sessionOptions.userDataDir !== undefined)
277
+ assertUsableProfile(sessionOptions.userDataDir);
278
+ return { url, sessionOptions };
251
279
  }
252
280
  /**
253
281
  * Register the start command
@@ -346,6 +374,22 @@ function assertUserDataDir(value) {
346
374
  const err = invalidUserDataDirError(value, reason);
347
375
  throw new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.INVALID_ARGUMENTS);
348
376
  }
377
+ /**
378
+ * Check that Chrome can create and write its profile directory (given with
379
+ * `-u` or in `--chrome-flags`), before a daemon is spawned: a profile under
380
+ * `/proc` spun the daemon at full CPU and wedged the session.
381
+ *
382
+ * @param dir - Profile directory, `~/` expanded
383
+ * @throws CommandError (81) for a path that cannot hold a directory, (82)
384
+ * when its nearest existing directory is not writable
385
+ */
386
+ function assertUsableProfile(dir) {
387
+ const problem = directoryProblem(dir);
388
+ if (!problem)
389
+ return;
390
+ const err = invalidUserDataDirError(dir, problem.reason);
391
+ throw new CommandError(err.message, { suggestion: err.suggestion }, problem.denied ? EXIT_CODES.PERMISSION_DENIED : EXIT_CODES.INVALID_ARGUMENTS);
392
+ }
349
393
  /**
350
394
  * Options given that only apply to a Chrome bdg launches. `--headless` has a
351
395
  * default, so it counts only when given on the command line.
@@ -1,4 +1,15 @@
1
1
  import type { Command } from 'commander';
2
+ /**
3
+ * Wait until the stopped session's daemon has exited: it removes the
4
+ * session's files on the way out, and a command run right after (`bdg
5
+ * sessions`, a new start) would otherwise still see the session. The wait is
6
+ * bounded, as after a failed start.
7
+ *
8
+ * @param pid - Daemon PID read before the stop, or null when unknown
9
+ * @param waitMs - Milliseconds to wait at most
10
+ * @returns Warning when the daemon still runs after the wait
11
+ */
12
+ export declare function waitForDaemonExit(pid: number | null, waitMs?: number): Promise<string | undefined>;
2
13
  /**
3
14
  * Register stop command
4
15
  *
@@ -3,13 +3,33 @@ import { jsonOption } from './shared/commonOptions.js';
3
3
  import { stopSession } from '../ipc/client.js';
4
4
  import { IPCErrorCode } from '../ipc/index.js';
5
5
  import { IPCTimeoutError } from '../ipc/transport/index.js';
6
+ import { readLiveDaemonPid } from '../session/cleanup/staleSession.js';
6
7
  import { joinLines } from '../ui/formatting.js';
7
8
  import { chromeClosedMessage, orphanedDaemonsCleanedMessage, warningMessage, } from '../ui/messages/commands.js';
8
- import { sessionStopped, STOP_MESSAGES, stopFailedError } from '../ui/messages/session.js';
9
+ import { daemonStillExitingHint, daemonStillExitingSuggestion, sessionStopped, STOP_MESSAGES, stopFailedError, } from '../ui/messages/session.js';
9
10
  import { noActiveSessionMessage, sessionCommand, startSessionSuggestion, } from '../ui/messages/sessionCommand.js';
11
+ import { waitUntil } from '../utils/async.js';
10
12
  import { getExitCodeForIPCError, isDaemonNotRunningError } from '../utils/errorMapping.js';
11
13
  import { getErrorMessage } from '../utils/errors.js';
12
14
  import { EXIT_CODES } from '../utils/exitCodes.js';
15
+ import { isProcessAlive } from '../utils/process.js';
16
+ /** How long `bdg stop` waits for the session's daemon to exit */
17
+ const DAEMON_EXIT_WAIT_MS = 3000;
18
+ /**
19
+ * Wait until the stopped session's daemon has exited: it removes the
20
+ * session's files on the way out, and a command run right after (`bdg
21
+ * sessions`, a new start) would otherwise still see the session. The wait is
22
+ * bounded, as after a failed start.
23
+ *
24
+ * @param pid - Daemon PID read before the stop, or null when unknown
25
+ * @param waitMs - Milliseconds to wait at most
26
+ * @returns Warning when the daemon still runs after the wait
27
+ */
28
+ export async function waitForDaemonExit(pid, waitMs = DAEMON_EXIT_WAIT_MS) {
29
+ if (pid === null || (await waitUntil(() => !isProcessAlive(pid), waitMs)))
30
+ return undefined;
31
+ return `${daemonStillExitingHint(pid, waitMs)}; ${daemonStillExitingSuggestion()}`;
32
+ }
13
33
  /**
14
34
  * Format stop result for human-readable output.
15
35
  *
@@ -36,8 +56,10 @@ export function registerStopCommand(program) {
36
56
  .action(async (options) => {
37
57
  await runCommand(async () => {
38
58
  try {
59
+ const daemonPid = readLiveDaemonPid();
39
60
  const response = await stopSession();
40
61
  if (response.status === 'ok') {
62
+ const warning = await waitForDaemonExit(daemonPid);
41
63
  return {
42
64
  success: true,
43
65
  data: {
@@ -48,6 +70,7 @@ export function registerStopCommand(program) {
48
70
  },
49
71
  orphanedDaemonsCount: 0,
50
72
  message: response.message ?? STOP_MESSAGES.SUCCESS,
73
+ ...(warning && { warnings: [warning] }),
51
74
  },
52
75
  };
53
76
  }
package/dist/commands.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { registerCdpCommand } from './commands/cdp.js';
2
2
  import { registerCleanupCommand } from './commands/cleanup.js';
3
3
  import { registerConsoleCommand } from './commands/console.js';
4
+ import { registerCssCommands } from './commands/css.js';
4
5
  import { registerDetailsCommand } from './commands/details.js';
5
6
  import { registerFormInteractionCommands } from './commands/dom/formInteraction.js';
6
7
  import { registerDomCommands } from './commands/dom/index.js';
7
8
  import { registerInstallSkillCommand } from './commands/installSkill.js';
8
9
  import { registerNetworkCommands } from './commands/network/index.js';
9
- import { registerCssCommands } from './commands/css.js';
10
10
  import { registerPageCommands } from './commands/page.js';
11
11
  import { registerPeekCommand } from './commands/peek.js';
12
12
  import { registerSessionsCommand } from './commands/sessions.js';
@@ -181,6 +181,13 @@ export declare class CDPConnection implements CDPEventSource {
181
181
  * @returns Calculated delay in milliseconds (base delay * 2^attempt, capped at maxDelay)
182
182
  */
183
183
  private calculateBackoffDelay;
184
+ /**
185
+ * Fail every command still waiting for an answer, e.g. after the page's
186
+ * renderer crashed: commands it was running are never answered.
187
+ *
188
+ * @param error - Error the waiting commands fail with
189
+ */
190
+ rejectPending(error: Error): void;
184
191
  /**
185
192
  * Clear all pending command promises with the given error.
186
193
  *
@@ -390,6 +390,15 @@ export class CDPConnection {
390
390
  calculateBackoffDelay(attempt, maxDelay) {
391
391
  return Math.min(this.config.baseRetryDelay * Math.pow(2, attempt), maxDelay);
392
392
  }
393
+ /**
394
+ * Fail every command still waiting for an answer, e.g. after the page's
395
+ * renderer crashed: commands it was running are never answered.
396
+ *
397
+ * @param error - Error the waiting commands fail with
398
+ */
399
+ rejectPending(error) {
400
+ this.clearPendingMessages(error);
401
+ }
393
402
  /**
394
403
  * Clear all pending command promises with the given error.
395
404
  *
@@ -3,6 +3,7 @@ import * as os from 'os';
3
3
  import * as path from 'path';
4
4
  import * as chromeLauncher from 'chrome-launcher';
5
5
  import { BDG_CHROME_PREFS, DEFAULT_CDP_PORT, CHROME_PROFILE_DIR, DEFAULT_CHROME_LOG_LEVEL, DEFAULT_CHROME_HANDLE_SIGINT, } from '../constants.js';
6
+ import { makeDirectory } from '../utils/directories.js';
6
7
  import { getErrorMessage } from '../utils/errors.js';
7
8
  import { filterDefined } from '../utils/objects.js';
8
9
  import { isProcessAlive } from '../utils/process.js';
@@ -62,7 +63,7 @@ export async function launchChrome(options = {}) {
62
63
  const userDataDir = options.userDataDir ?? getPersistentUserDataDir(options.baseDir ?? options.sessionDir);
63
64
  if (!fs.existsSync(userDataDir)) {
64
65
  try {
65
- fs.mkdirSync(userDataDir, { recursive: true });
66
+ makeDirectory(userDataDir);
66
67
  }
67
68
  catch (error) {
68
69
  throw new ChromeLaunchError(`Failed to create user data directory`, {
@@ -160,7 +161,7 @@ function getPersistentUserDataDir(baseDir) {
160
161
  const userDataDir = path.join(dir, CHROME_PROFILE_DIR);
161
162
  if (!fs.existsSync(userDataDir)) {
162
163
  try {
163
- fs.mkdirSync(userDataDir, { recursive: true });
164
+ makeDirectory(userDataDir);
164
165
  }
165
166
  catch (error) {
166
167
  throw new ChromeLaunchError(`Failed to create user data directory`, {
@@ -241,8 +241,13 @@ export class SessionController {
241
241
  duration: data.duration,
242
242
  target: data.target,
243
243
  data: { network: data.network, console: data.console },
244
- totals: { network: data.totalNetwork, console: data.totalConsole },
244
+ totals: {
245
+ network: data.totalNetwork,
246
+ console: data.totalConsole,
247
+ ...(data.droppedConsole && { consoleDropped: data.droppedConsole }),
248
+ },
245
249
  currentNavigationId: data.currentNavigationId,
250
+ ...(data.pageCrashedAt !== undefined && { pageCrashedAt: data.pageCrashedAt }),
246
251
  partial: true,
247
252
  },
248
253
  },
@@ -24,8 +24,9 @@ export declare function launchDaemon(): Promise<SpawnedDaemon | undefined>;
24
24
  * Check that the session directory can hold the daemon's files before
25
25
  * spawning it (otherwise the daemon dies and only its log says why).
26
26
  *
27
- * @throws SessionDirError (103) for a file, (81) for a too-long path like a
28
- * named session's, (82) when not writable
27
+ * @throws SessionDirError (103) for a file or a path that cannot hold a
28
+ * directory (a pseudo-filesystem like `/proc`), (81) for a too-long path
29
+ * like a named session's, (82) when not writable
29
30
  */
30
31
  export declare function assertUsableSessionDir(): void;
31
32
  //# sourceMappingURL=launcher.d.ts.map
@@ -11,13 +11,16 @@ import fs from 'fs';
11
11
  import { join } from 'path';
12
12
  import { DaemonStartupError, SessionDirError } from './errors.js';
13
13
  import { sessionDirIsFileError, sessionDirNotWritableError, socketPathTooLongError, } from '../errors/messages.js';
14
- import { isDaemonAlive } from '../session/daemonSocket.js';
14
+ import { killSessionChromes, readLiveDaemonPid } from '../session/cleanup/staleSession.js';
15
+ import { isDaemonAlive, isDaemonSocketGone } from '../session/daemonSocket.js';
15
16
  import { MAX_DAEMON_SOCKET_PATH_BYTES, ensureSessionDir, getSessionDir, getSessionFilePath, } from '../session/paths.js';
16
17
  import { createLogger } from '../ui/logging/index.js';
17
18
  import { delay } from '../utils/async.js';
19
+ import { directoryProblem } from '../utils/directories.js';
18
20
  import { getErrorMessage } from '../utils/errors.js';
19
21
  import { EXIT_CODES } from '../utils/exitCodes.js';
20
22
  import { DAEMON_SCRIPT_PATH } from '../utils/packageRoot.js';
23
+ import { isProcessAlive } from '../utils/process.js';
21
24
  const log = createLogger('launcher');
22
25
  const DAEMON_READY_TIMEOUT_MS = 5000;
23
26
  const DAEMON_READY_POLL_MS = 20;
@@ -37,6 +40,7 @@ export async function launchDaemon() {
37
40
  throw new DaemonStartupError(`Daemon script not found at ${DAEMON_SCRIPT_PATH}. Did you run 'npm run build'?`, 'DAEMON_SCRIPT_NOT_FOUND');
38
41
  }
39
42
  assertUsableSessionDir();
43
+ await stopUnreachableDaemon();
40
44
  const logPath = join(getSessionDir(), 'daemon.log');
41
45
  rotateLog(logPath);
42
46
  const logFd = fs.openSync(logPath, 'a');
@@ -54,12 +58,48 @@ export async function launchDaemon() {
54
58
  await waitForDaemonReady(() => exited);
55
59
  return { pid: daemon.pid, hasExited: () => exited };
56
60
  }
61
+ /** How long a daemon that lost its socket gets to end its session before it is killed */
62
+ const UNREACHABLE_DAEMON_EXIT_MS = 8000;
63
+ /**
64
+ * Stop a daemon of this session directory that still runs but can no longer
65
+ * be reached (its socket was removed, or refuses connections; one that is
66
+ * only slow to answer is left alone), before a new one starts: two daemons
67
+ * would launch two Chromes and remove each other's files. It is asked to end
68
+ * its session (closing its Chrome), and killed with what it launched if it
69
+ * does not exit in time.
70
+ */
71
+ async function stopUnreachableDaemon() {
72
+ const pid = readLiveDaemonPid();
73
+ if (pid === null || !(await isDaemonSocketGone()))
74
+ return;
75
+ log.info(`Stopping daemon ${pid}, which can no longer be reached (its socket is gone)`);
76
+ try {
77
+ process.kill(pid, 'SIGTERM');
78
+ }
79
+ catch (error) {
80
+ log.debug(`Daemon ${pid} not signalled: ${getErrorMessage(error)}`);
81
+ return;
82
+ }
83
+ const deadline = Date.now() + UNREACHABLE_DAEMON_EXIT_MS;
84
+ while (isProcessAlive(pid) && Date.now() < deadline)
85
+ await delay(100);
86
+ if (!isProcessAlive(pid))
87
+ return;
88
+ try {
89
+ process.kill(pid, 'SIGKILL');
90
+ }
91
+ catch (error) {
92
+ log.debug(`Daemon ${pid} not killed: ${getErrorMessage(error)}`);
93
+ }
94
+ killSessionChromes();
95
+ }
57
96
  /**
58
97
  * Check that the session directory can hold the daemon's files before
59
98
  * spawning it (otherwise the daemon dies and only its log says why).
60
99
  *
61
- * @throws SessionDirError (103) for a file, (81) for a too-long path like a
62
- * named session's, (82) when not writable
100
+ * @throws SessionDirError (103) for a file or a path that cannot hold a
101
+ * directory (a pseudo-filesystem like `/proc`), (81) for a too-long path
102
+ * like a named session's, (82) when not writable
63
103
  */
64
104
  export function assertUsableSessionDir() {
65
105
  const dir = getSessionDir();
@@ -72,6 +112,10 @@ export function assertUsableSessionDir() {
72
112
  if (Buffer.byteLength(socketPath) > MAX_DAEMON_SOCKET_PATH_BYTES) {
73
113
  fail(socketPathTooLongError(socketPath, MAX_DAEMON_SOCKET_PATH_BYTES), EXIT_CODES.INVALID_ARGUMENTS);
74
114
  }
115
+ const problem = directoryProblem(dir);
116
+ if (problem) {
117
+ fail(sessionDirNotWritableError(dir, problem.reason), problem.denied ? EXIT_CODES.PERMISSION_DENIED : EXIT_CODES.SESSION_FILE_ERROR);
118
+ }
75
119
  try {
76
120
  ensureSessionDir();
77
121
  fs.accessSync(dir, fs.constants.W_OK);
@@ -80,7 +80,10 @@ export declare class Session {
80
80
  */
81
81
  launch(): Promise<void>;
82
82
  /**
83
- * Execute a registered command against this session.
83
+ * Execute a registered command against this session. After a renderer
84
+ * crash only {@link RUN_ON_CRASHED_PAGE} commands run (`bdg cdp` only for
85
+ * {@link CRASH_SAFE_CDP} methods); others fail with exit 107 instead of
86
+ * waiting for a page that cannot answer.
84
87
  *
85
88
  * @param name - Command name
86
89
  * @param params - Command parameters