browser-debugger-cli 0.9.0 → 0.11.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 (139) hide show
  1. package/.claude/skills/bdg/SKILL.md +268 -0
  2. package/README.md +15 -1
  3. package/dist/commands/dom/a11y.js +2 -1
  4. package/dist/commands/dom/formInteraction.js +56 -25
  5. package/dist/commands/dom/helpers/keyAttributes.d.ts +20 -0
  6. package/dist/commands/dom/helpers/keyAttributes.js +54 -0
  7. package/dist/commands/dom/helpers/query.d.ts +1 -1
  8. package/dist/commands/dom/helpers/query.js +66 -19
  9. package/dist/commands/dom/helpers/runElementCommand.js +4 -3
  10. package/dist/commands/dom/helpers/screenshot.js +85 -12
  11. package/dist/commands/dom/index.d.ts +1 -0
  12. package/dist/commands/dom/index.js +8 -3
  13. package/dist/commands/dom/inspect.d.ts +15 -0
  14. package/dist/commands/dom/inspect.js +82 -0
  15. package/dist/commands/dom/layout.js +2 -2
  16. package/dist/commands/dom/listeners.js +2 -2
  17. package/dist/commands/dom/semanticUtils.d.ts +14 -1
  18. package/dist/commands/dom/semanticUtils.js +44 -3
  19. package/dist/commands/installSkill.d.ts +20 -0
  20. package/dist/commands/installSkill.js +87 -0
  21. package/dist/commands/network/list.js +13 -2
  22. package/dist/commands/optionBehaviors.js +48 -6
  23. package/dist/commands/page.d.ts +1 -1
  24. package/dist/commands/page.js +62 -3
  25. package/dist/commands/shared/commonOptions.d.ts +4 -0
  26. package/dist/commands/shared/commonOptions.js +9 -0
  27. package/dist/commands/shared/optionTypes.d.ts +21 -0
  28. package/dist/commands/shared/startHelpers.d.ts +66 -0
  29. package/dist/commands/shared/startHelpers.js +91 -10
  30. package/dist/commands/shared/validation.d.ts +11 -0
  31. package/dist/commands/shared/validation.js +16 -0
  32. package/dist/commands.js +3 -0
  33. package/dist/daemon/launcher.d.ts +8 -1
  34. package/dist/daemon/launcher.js +3 -1
  35. package/dist/daemon/session/Session.d.ts +7 -0
  36. package/dist/daemon/session/Session.js +23 -1
  37. package/dist/daemon/session/commandRegistry.d.ts +14 -1
  38. package/dist/daemon/session/commandRegistry.js +65 -9
  39. package/dist/daemon/session/interactions.d.ts +18 -5
  40. package/dist/daemon/session/interactions.js +22 -12
  41. package/dist/daemon.js +3565 -329
  42. package/dist/errors/messages.d.ts +85 -0
  43. package/dist/errors/messages.js +128 -1
  44. package/dist/index.js +2151 -960
  45. package/dist/ipc/client.d.ts +9 -0
  46. package/dist/ipc/client.js +13 -0
  47. package/dist/ipc/protocol/commands.d.ts +56 -1
  48. package/dist/ipc/protocol/commands.js +2 -0
  49. package/dist/ipc/protocol/domTypes.d.ts +35 -2
  50. package/dist/ipc/protocol/inspectTypes.d.ts +388 -0
  51. package/dist/ipc/protocol/inspectTypes.js +10 -0
  52. package/dist/runtime/dom/actionEffects.d.ts +94 -15
  53. package/dist/runtime/dom/actionEffects.js +173 -27
  54. package/dist/runtime/dom/actionEffectsScripts.d.ts +52 -14
  55. package/dist/runtime/dom/actionEffectsScripts.js +224 -32
  56. package/dist/runtime/dom/elementInfo.d.ts +26 -0
  57. package/dist/runtime/dom/elementInfo.js +65 -0
  58. package/dist/runtime/dom/eventListeners.js +14 -4
  59. package/dist/runtime/dom/formFillHelpers/fill.d.ts +3 -4
  60. package/dist/runtime/dom/formFillHelpers/fill.js +77 -28
  61. package/dist/runtime/dom/frameSelection.d.ts +11 -0
  62. package/dist/runtime/dom/frameSelection.js +20 -1
  63. package/dist/runtime/dom/frames.d.ts +38 -5
  64. package/dist/runtime/dom/frames.js +136 -21
  65. package/dist/runtime/dom/inspect.d.ts +28 -0
  66. package/dist/runtime/dom/inspect.js +557 -0
  67. package/dist/runtime/dom/inspectAllStyles.d.ts +62 -0
  68. package/dist/runtime/dom/inspectAllStyles.js +385 -0
  69. package/dist/runtime/dom/inspectCascade.d.ts +94 -0
  70. package/dist/runtime/dom/inspectCascade.js +371 -0
  71. package/dist/runtime/dom/inspectCascadeModel.d.ts +39 -0
  72. package/dist/runtime/dom/inspectCascadeModel.js +232 -0
  73. package/dist/runtime/dom/inspectHints.d.ts +62 -0
  74. package/dist/runtime/dom/inspectHints.js +305 -0
  75. package/dist/runtime/dom/inspectLayoutModel.d.ts +87 -0
  76. package/dist/runtime/dom/inspectLayoutModel.js +346 -0
  77. package/dist/runtime/dom/inspectModel.d.ts +74 -0
  78. package/dist/runtime/dom/inspectModel.js +184 -0
  79. package/dist/runtime/dom/inspectPaintModel.d.ts +157 -0
  80. package/dist/runtime/dom/inspectPaintModel.js +461 -0
  81. package/dist/runtime/dom/inspectRules.d.ts +37 -0
  82. package/dist/runtime/dom/inspectRules.js +101 -0
  83. package/dist/runtime/dom/inspectScripts.d.ts +132 -0
  84. package/dist/runtime/dom/inspectScripts.js +263 -0
  85. package/dist/runtime/dom/inspectTree.d.ts +40 -0
  86. package/dist/runtime/dom/inspectTree.js +134 -0
  87. package/dist/runtime/dom/inspectVariables.d.ts +33 -0
  88. package/dist/runtime/dom/inspectVariables.js +94 -0
  89. package/dist/runtime/dom/inspectWhyModel.d.ts +20 -0
  90. package/dist/runtime/dom/inspectWhyModel.js +134 -0
  91. package/dist/runtime/dom/layout.d.ts +5 -1
  92. package/dist/runtime/dom/layout.js +10 -3
  93. package/dist/runtime/dom/listenerPageScripts.d.ts +11 -5
  94. package/dist/runtime/dom/listenerPageScripts.js +95 -9
  95. package/dist/runtime/dom/listenerSummary.d.ts +4 -0
  96. package/dist/runtime/dom/listenerSummary.js +26 -9
  97. package/dist/runtime/dom/reactEventHelpers.d.ts +5 -0
  98. package/dist/runtime/dom/reactEventHelpers.js +12 -4
  99. package/dist/runtime/page/emulation.d.ts +20 -0
  100. package/dist/runtime/page/emulation.js +37 -0
  101. package/dist/telemetry/a11y.d.ts +10 -0
  102. package/dist/telemetry/a11y.js +78 -1
  103. package/dist/telemetry/console.d.ts +1 -0
  104. package/dist/telemetry/console.js +100 -5
  105. package/dist/telemetry/network.js +3 -1
  106. package/dist/types.d.ts +40 -0
  107. package/dist/ui/formatters/details.d.ts +8 -0
  108. package/dist/ui/formatters/details.js +59 -3
  109. package/dist/ui/formatters/dom.d.ts +2 -1
  110. package/dist/ui/formatters/dom.js +25 -9
  111. package/dist/ui/formatters/inspect.d.ts +39 -0
  112. package/dist/ui/formatters/inspect.js +596 -0
  113. package/dist/ui/formatters/installSkill.d.ts +11 -0
  114. package/dist/ui/formatters/installSkill.js +31 -0
  115. package/dist/ui/formatters/keyAttributes.d.ts +19 -0
  116. package/dist/ui/formatters/keyAttributes.js +84 -0
  117. package/dist/ui/formatters/layout.js +2 -2
  118. package/dist/ui/formatters/networkHeaders.d.ts +13 -0
  119. package/dist/ui/formatters/networkHeaders.js +23 -3
  120. package/dist/ui/formatters/networkList.d.ts +29 -1
  121. package/dist/ui/formatters/networkList.js +86 -20
  122. package/dist/ui/formatters/status.js +1 -1
  123. package/dist/ui/formatting.d.ts +9 -0
  124. package/dist/ui/formatting.js +6 -3
  125. package/dist/ui/messages/commands.d.ts +123 -7
  126. package/dist/ui/messages/commands.js +181 -10
  127. package/dist/ui/messages/networkMessages.d.ts +14 -0
  128. package/dist/ui/messages/networkMessages.js +18 -0
  129. package/dist/ui/messages/session.d.ts +14 -0
  130. package/dist/ui/messages/session.js +20 -0
  131. package/dist/utils/async.d.ts +9 -0
  132. package/dist/utils/async.js +17 -0
  133. package/dist/utils/color.d.ts +84 -0
  134. package/dist/utils/color.js +376 -0
  135. package/dist/utils/cssValues.d.ts +109 -0
  136. package/dist/utils/cssValues.js +236 -0
  137. package/dist/utils/selectorFilters.d.ts +12 -0
  138. package/dist/utils/selectorFilters.js +29 -0
  139. package/package.json +2 -1
@@ -17,14 +17,21 @@ import { getSessionName } from '../../session/paths.js';
17
17
  import { OutputBuilder, buildSuccessResponse } from '../../ui/OutputBuilder.js';
18
18
  import { escapeControlChars, joinLines } from '../../ui/formatting.js';
19
19
  import { createLogger } from '../../ui/logging/index.js';
20
- import { startNotices } from '../../ui/messages/session.js';
20
+ import { daemonStillExitingHint, daemonStillExitingSuggestion, startNotices, } from '../../ui/messages/session.js';
21
21
  import { noActiveSessionMessage } from '../../ui/messages/sessionCommand.js';
22
- import { delay } from '../../utils/async.js';
22
+ import { delay, waitUntil } from '../../utils/async.js';
23
23
  import { getExitCodeForIPCError } from '../../utils/errorMapping.js';
24
24
  import { getErrorMessage } from '../../utils/errors.js';
25
25
  import { EXIT_CODES } from '../../utils/exitCodes.js';
26
26
  import { filterDefined } from '../../utils/objects.js';
27
27
  const log = createLogger('bdg');
28
+ /** How long a failed start waits for the daemons it spawned to exit */
29
+ export const SPAWNED_DAEMON_EXIT_WAIT_MS = 3000;
30
+ const DEFAULT_START_DEPS = {
31
+ launch: launchDaemon,
32
+ send: sendStartSessionRequest,
33
+ exitWaitMs: SPAWNED_DAEMON_EXIT_WAIT_MS,
34
+ };
28
35
  /**
29
36
  * Start a session via the daemon and report the result.
30
37
  *
@@ -38,13 +45,69 @@ const log = createLogger('bdg');
38
45
  export async function startSessionViaDaemon(url, options, telemetry) {
39
46
  process.once('SIGINT', () => reportStartOutcome(interruptedOutcome('SIGINT'), options));
40
47
  process.once('SIGTERM', () => reportStartOutcome(interruptedOutcome('SIGTERM'), options));
41
- let outcome = await requestSession(url, options, telemetry);
48
+ reportStartOutcome(await attemptStart(url, options, telemetry), options);
49
+ }
50
+ /**
51
+ * Start a session, retrying while the previous session shuts down. After a
52
+ * failure the daemon reported (or a dropped connection), it waits for every
53
+ * daemon the attempts spawned to exit ({@link afterSpawnedDaemonExit}).
54
+ *
55
+ * @param url - Target URL
56
+ * @param options - Session options
57
+ * @param telemetry - Telemetry types
58
+ * @param deps - How to reach the daemon (tests replace it)
59
+ * @returns Start outcome, ready to report
60
+ */
61
+ export async function attemptStart(url, options, telemetry, deps = DEFAULT_START_DEPS) {
62
+ const spawned = [];
63
+ const attempt = async () => {
64
+ const outcome = await requestSession(url, options, telemetry, deps);
65
+ if (!outcome.ok && outcome.spawned)
66
+ spawned.push(outcome.spawned);
67
+ return outcome;
68
+ };
69
+ let outcome = await attempt();
42
70
  const deadline = Date.now() + SHUTDOWN_WAIT_MS;
43
71
  while (isShuttingDown(outcome) && Date.now() < deadline) {
44
72
  await delay(SHUTDOWN_POLL_MS);
45
- outcome = await requestSession(url, options, telemetry);
73
+ outcome = await attempt();
74
+ }
75
+ return afterSpawnedDaemonExit(outcome, spawned, deps.exitWaitMs);
76
+ }
77
+ /**
78
+ * Let the daemons a failed start spawned finish exiting before the error is
79
+ * reported: a daemon removes its session files on the way out, and a command
80
+ * run right after (`bdg sessions`, another start) would otherwise still see
81
+ * the session as starting. The wait is bounded; a daemon still running after
82
+ * it is reported (`details.daemonStillRunning`, `daemonPid`, a suggestion).
83
+ *
84
+ * @param outcome - Start outcome
85
+ * @param spawned - Exiting daemons the attempts spawned
86
+ * @param waitMs - Milliseconds to wait at most for all of them
87
+ * @returns The failure (without internal fields), with a hint when a daemon did not exit in time
88
+ */
89
+ export async function afterSpawnedDaemonExit(outcome, spawned, waitMs) {
90
+ if (outcome.ok)
91
+ return outcome;
92
+ const { spawned: _spawned, daemonExiting: _exiting, ...failure } = outcome;
93
+ if (spawned.length === 0)
94
+ return failure;
95
+ if (await waitUntil(() => spawned.every((daemon) => daemon.hasExited()), waitMs)) {
96
+ return failure;
46
97
  }
47
- reportStartOutcome(outcome, options);
98
+ const running = spawned.filter((daemon) => !daemon.hasExited()).pop();
99
+ const suggestion = daemonStillExitingSuggestion();
100
+ const previous = failure.details?.['suggestion'];
101
+ return {
102
+ ...failure,
103
+ human: joinLines(failure.human, `${daemonStillExitingHint(running?.pid, waitMs)}; ${suggestion}`),
104
+ details: {
105
+ ...failure.details,
106
+ daemonStillRunning: true,
107
+ ...(running?.pid !== undefined && { daemonPid: running.pid }),
108
+ suggestion: typeof previous === 'string' && previous ? `${previous}; ${suggestion}` : suggestion,
109
+ },
110
+ };
48
111
  }
49
112
  /**
50
113
  * The outcome of a start interrupted with Ctrl-C (the daemon notices the
@@ -83,9 +146,10 @@ function isShuttingDown(outcome) {
83
146
  * @param telemetry - Telemetry types
84
147
  * @returns Start outcome
85
148
  */
86
- async function requestSession(url, options, telemetry) {
149
+ async function requestSession(url, options, telemetry, deps) {
150
+ let spawned;
87
151
  try {
88
- await launchDaemon();
152
+ spawned = await deps.launch();
89
153
  }
90
154
  catch (error) {
91
155
  if (error instanceof SessionDirError) {
@@ -101,9 +165,24 @@ async function requestSession(url, options, telemetry) {
101
165
  const exitCode = error instanceof DaemonError ? error.exitCode : EXIT_CODES.SOFTWARE_ERROR;
102
166
  return { ok: false, error: message, human: genericError(message), exitCode };
103
167
  }
168
+ const outcome = await sendStart(url, options, telemetry, deps.send);
169
+ return !outcome.ok && outcome.daemonExiting && spawned ? { ...outcome, spawned } : outcome;
170
+ }
171
+ /**
172
+ * Ask the running daemon to start a session.
173
+ *
174
+ * @param url - Target URL
175
+ * @param options - Session options
176
+ * @param telemetry - Telemetry types
177
+ * @param send - Sends the request
178
+ * @returns Start outcome; `daemonExiting` for a failure the daemon reported
179
+ * or a dropped connection, not for a timeout or an unexpected error (the
180
+ * daemon may still be starting the session then)
181
+ */
182
+ async function sendStart(url, options, telemetry, send) {
104
183
  try {
105
184
  log.debug('Connecting to daemon...');
106
- const response = await sendStartSessionRequest(url, filterDefined({
185
+ const response = await send(url, filterDefined({
107
186
  port: options.port,
108
187
  timeout: options.timeout,
109
188
  telemetry: telemetry.length > 0 ? telemetry : undefined,
@@ -116,8 +195,9 @@ async function requestSession(url, options, telemetry) {
116
195
  viewport: options.viewport,
117
196
  colorScheme: options.colorScheme,
118
197
  }));
119
- if (response.status === 'error')
120
- return describeStartFailure(response, options);
198
+ if (response.status === 'error') {
199
+ return { ...describeStartFailure(response, options), daemonExiting: true };
200
+ }
121
201
  if (!response.data) {
122
202
  const message = 'Invalid response from daemon: missing data';
123
203
  return {
@@ -147,6 +227,7 @@ async function requestSession(url, options, telemetry) {
147
227
  human: daemonNotRunningError({ suggestStatus: true, suggestRetry: true }),
148
228
  exitCode: EXIT_CODES.RESOURCE_NOT_FOUND,
149
229
  retryable: true,
230
+ daemonExiting: true,
150
231
  };
151
232
  }
152
233
  const message = getErrorMessage(error);
@@ -57,5 +57,16 @@ export declare function consoleLevelOption(value: string): ConsoleLevel;
57
57
  * @throws InvalidArgumentError (exit 81) when no type is given
58
58
  */
59
59
  export declare function eventTypesOption(value: string, previous?: string[]): string[];
60
+ /**
61
+ * Commander parser for `dom inspect --props`: comma-separated CSS property
62
+ * names, lowercased except custom properties (`--brand`), which are
63
+ * case-sensitive; repeated options add up.
64
+ *
65
+ * @param value - Raw option value, e.g. "padding-top,color"
66
+ * @param previous - Names from earlier `--props` options
67
+ * @returns Property names
68
+ * @throws InvalidArgumentError (exit 81) when no name is given
69
+ */
70
+ export declare function cssPropertiesOption(value: string, previous?: string[]): string[];
60
71
  export declare function resourceTypeRule(): ValidationRule<Protocol.Network.ResourceType[]>;
61
72
  //# sourceMappingURL=validation.d.ts.map
@@ -158,6 +158,22 @@ export function eventTypesOption(value, previous = []) {
158
158
  return [...new Set([...previous, ...types])];
159
159
  throw new InvalidArgumentError('Give at least one event type, e.g. click or click,keydown.');
160
160
  }
161
+ /**
162
+ * Commander parser for `dom inspect --props`: comma-separated CSS property
163
+ * names, lowercased except custom properties (`--brand`), which are
164
+ * case-sensitive; repeated options add up.
165
+ *
166
+ * @param value - Raw option value, e.g. "padding-top,color"
167
+ * @param previous - Names from earlier `--props` options
168
+ * @returns Property names
169
+ * @throws InvalidArgumentError (exit 81) when no name is given
170
+ */
171
+ export function cssPropertiesOption(value, previous = []) {
172
+ const names = parseCommaSeparated(value).map((name) => name.startsWith('--') ? name : name.toLowerCase());
173
+ if (names.length > 0)
174
+ return [...new Set([...previous, ...names])];
175
+ throw new InvalidArgumentError('Give at least one CSS property, e.g. color or padding-top,color.');
176
+ }
161
177
  function parseCommaSeparated(value) {
162
178
  return value
163
179
  .split(',')
package/dist/commands.js CHANGED
@@ -4,6 +4,7 @@ import { registerConsoleCommand } from './commands/console.js';
4
4
  import { registerDetailsCommand } from './commands/details.js';
5
5
  import { registerFormInteractionCommands } from './commands/dom/formInteraction.js';
6
6
  import { registerDomCommands } from './commands/dom/index.js';
7
+ import { registerInstallSkillCommand } from './commands/installSkill.js';
7
8
  import { registerNetworkCommands } from './commands/network/index.js';
8
9
  import { registerPageCommands } from './commands/page.js';
9
10
  import { registerPeekCommand } from './commands/peek.js';
@@ -44,5 +45,7 @@ export const commandRegistry = [
44
45
  registerNetworkCommands,
45
46
  addCommandGroup('Console Commands:'),
46
47
  registerConsoleCommand,
48
+ addCommandGroup('Agent Setup:'),
49
+ registerInstallSkillCommand,
47
50
  ];
48
51
  //# sourceMappingURL=commands.js.map
@@ -6,13 +6,20 @@
6
6
  * SocketServer), so concurrent launches are safe: a losing daemon exits and
7
7
  * the CLI talks to the winner.
8
8
  */
9
+ /** A daemon this process spawned */
10
+ export interface SpawnedDaemon {
11
+ pid: number | undefined;
12
+ /** Whether it has exited (from the child process's exit event) */
13
+ hasExited: () => boolean;
14
+ }
9
15
  /**
10
16
  * Ensure a daemon is running, spawning one if needed.
11
17
  *
18
+ * @returns The daemon it spawned, or undefined when one was already running
12
19
  * @throws DaemonStartupError if the daemon script is missing or the daemon
13
20
  * does not accept connections in time
14
21
  */
15
- export declare function launchDaemon(): Promise<void>;
22
+ export declare function launchDaemon(): Promise<SpawnedDaemon | undefined>;
16
23
  /**
17
24
  * Check that the session directory can hold the daemon's files before
18
25
  * spawning it (otherwise the daemon dies and only its log says why).
@@ -24,13 +24,14 @@ const DAEMON_READY_POLL_MS = 20;
24
24
  /**
25
25
  * Ensure a daemon is running, spawning one if needed.
26
26
  *
27
+ * @returns The daemon it spawned, or undefined when one was already running
27
28
  * @throws DaemonStartupError if the daemon script is missing or the daemon
28
29
  * does not accept connections in time
29
30
  */
30
31
  export async function launchDaemon() {
31
32
  if (await isDaemonAlive()) {
32
33
  log.debug('Daemon already running');
33
- return;
34
+ return undefined;
34
35
  }
35
36
  if (!fs.existsSync(DAEMON_SCRIPT_PATH)) {
36
37
  throw new DaemonStartupError(`Daemon script not found at ${DAEMON_SCRIPT_PATH}. Did you run 'npm run build'?`, 'DAEMON_SCRIPT_NOT_FOUND');
@@ -51,6 +52,7 @@ export async function launchDaemon() {
51
52
  });
52
53
  daemon.unref();
53
54
  await waitForDaemonReady(() => exited);
55
+ return { pid: daemon.pid, hasExited: () => exited };
54
56
  }
55
57
  /**
56
58
  * Check that the session directory can hold the daemon's files before
@@ -99,6 +99,13 @@ export declare class Session {
99
99
  * @returns Session metadata
100
100
  */
101
101
  metadata(): SessionStatusMetadata;
102
+ /**
103
+ * Record a page emulation changed mid-session, so screenshots restore it
104
+ * and `bdg status` reports it.
105
+ *
106
+ * @param emulation - Viewport and color scheme now emulated
107
+ */
108
+ private setEmulation;
102
109
  /**
103
110
  * Whether stop() has been called.
104
111
  *
@@ -21,6 +21,7 @@ import { getSessionPort } from '../../session/port.js';
21
21
  import { createLogger } from '../../ui/logging/index.js';
22
22
  import { formatChromeNotice } from '../../ui/messages/chrome.js';
23
23
  import { delay } from '../../utils/async.js';
24
+ import { getErrorMessage } from '../../utils/errors.js';
24
25
  import { EXIT_CODES } from '../../utils/exitCodes.js';
25
26
  import { filterDefined } from '../../utils/objects.js';
26
27
  import { isProcessAlive } from '../../utils/process.js';
@@ -55,7 +56,10 @@ export class Session {
55
56
  config;
56
57
  onEnded;
57
58
  store = new TelemetryStore();
58
- registry = createCommandRegistry(this.store);
59
+ registry = createCommandRegistry(this.store, {
60
+ get: () => filterDefined({ viewport: this.config.viewport, colorScheme: this.config.colorScheme }),
61
+ set: (emulation) => this.setEmulation(emulation),
62
+ });
59
63
  notify = (notice) => log.info(formatChromeNotice(notice));
60
64
  chrome = null;
61
65
  cdp = null;
@@ -174,6 +178,24 @@ export class Session {
174
178
  }),
175
179
  };
176
180
  }
181
+ /**
182
+ * Record a page emulation changed mid-session, so screenshots restore it
183
+ * and `bdg status` reports it.
184
+ *
185
+ * @param emulation - Viewport and color scheme now emulated
186
+ */
187
+ setEmulation(emulation) {
188
+ const { viewport: _viewport, colorScheme: _colorScheme, ...rest } = this.config;
189
+ this.config = { ...rest, ...emulation };
190
+ if (!this.started)
191
+ return;
192
+ try {
193
+ writeSessionMetadata(this.metadata());
194
+ }
195
+ catch (error) {
196
+ log.debug(`Session metadata not updated: ${getErrorMessage(error)}`);
197
+ }
198
+ }
177
199
  /**
178
200
  * Whether stop() has been called.
179
201
  *
@@ -1,10 +1,23 @@
1
1
  import type { TelemetryStore } from './TelemetryStore.js';
2
2
  import type { CDPConnection } from '../../connection/cdp.js';
3
3
  import type { CommandName, CommandSchemas } from '../../ipc/index.js';
4
+ import { type SessionEmulation } from '../../runtime/page/emulation.js';
4
5
  type Handler<K extends CommandName> = (cdp: CDPConnection, params: CommandSchemas[K]['requestSchema']) => Promise<CommandSchemas[K]['responseSchema']>;
5
6
  export type CommandRegistry = {
6
7
  [K in CommandName]: Handler<K>;
7
8
  };
8
- export declare function createCommandRegistry(store: TelemetryStore): CommandRegistry;
9
+ /** The session's page emulation, which `page emulate` reads and changes */
10
+ export interface EmulationState {
11
+ get: () => SessionEmulation;
12
+ set: (emulation: SessionEmulation) => void;
13
+ }
14
+ /**
15
+ * The handlers of the session commands.
16
+ *
17
+ * @param store - Telemetry of the session
18
+ * @param emulation - The session's page emulation
19
+ * @returns Command registry
20
+ */
21
+ export declare function createCommandRegistry(store: TelemetryStore, emulation: EmulationState): CommandRegistry;
9
22
  export {};
10
23
  //# sourceMappingURL=commandRegistry.d.ts.map
@@ -1,9 +1,9 @@
1
- import { CDPConnectionError } from '../../connection/errors.js';
1
+ import { CDPConnectionError, CDPProtocolError } from '../../connection/errors.js';
2
2
  import { PatternDetector } from '../patternDetector.js';
3
3
  import { createInteractionRunner } from './interactions.js';
4
4
  import { withTriggeredRequestCount } from './triggeredRequests.js';
5
5
  import { CommandError } from '../../errors/index.js';
6
- import { formDiscoveryFailedError } from '../../errors/messages.js';
6
+ import { cdpCallError, formDiscoveryFailedError } from '../../errors/messages.js';
7
7
  import { evaluateScript, withBusyPageRecovery } from '../../runtime/dom/evalHelpers.js';
8
8
  import { inspectEventListeners } from '../../runtime/dom/eventListeners.js';
9
9
  import { FORM_DISCOVERY_SCRIPT, isRawFormData } from '../../runtime/dom/formDiscovery.js';
@@ -11,10 +11,11 @@ import { fillElement, clickElement, pressKeyElement, scrollPage, withActionStabi
11
11
  import { exceptionSummary } from '../../runtime/dom/formFillHelpers/shared.js';
12
12
  import { submitForm } from '../../runtime/dom/formSubmitHelpers.js';
13
13
  import { evaluateInFrame, listFrames } from '../../runtime/dom/frames.js';
14
+ import { inspectElement } from '../../runtime/dom/inspect.js';
14
15
  import { inspectLayout } from '../../runtime/dom/layout.js';
15
16
  import { onScriptTarget } from '../../runtime/dom/targetNode.js';
16
17
  import { waitForCondition } from '../../runtime/dom/wait.js';
17
- import { pageAppearance } from '../../runtime/page/emulation.js';
18
+ import { emulatePage, pageAppearance } from '../../runtime/page/emulation.js';
18
19
  import { readDocumentReadyState } from '../../runtime/page/loadingState.js';
19
20
  import { navigatePage } from '../../runtime/page/navigation.js';
20
21
  import { skippedBodyReason } from '../../telemetry/network.js';
@@ -62,6 +63,8 @@ function mapNetworkRequestToPreview(req, withHeaders = false) {
62
63
  return filterDefined({
63
64
  requestId: req.requestId,
64
65
  timestamp: req.timestamp,
66
+ sentTime: req.sentTime,
67
+ navigationId: req.navigationId,
65
68
  method: req.method,
66
69
  url: req.url,
67
70
  status: req.status,
@@ -260,9 +263,45 @@ function pageWebSocketUrl(store) {
260
263
  throw new CDPConnectionError('No page target');
261
264
  return url;
262
265
  }
263
- export function createCommandRegistry(store) {
266
+ /** Chrome's "server error" code, used for failures on the page's state (a missing node, a bad id…) */
267
+ const CDP_SERVER_ERROR = -32000;
268
+ /**
269
+ * A `bdg cdp` failure that is the caller's: wrong parameters (81), or an
270
+ * id of a node, target or frame that does not exist (83). Other failures
271
+ * (internal errors, a detached page) stay software errors.
272
+ *
273
+ * @param method - CDP method
274
+ * @param error - What the call threw
275
+ * @returns The error to report, or undefined to keep the original
276
+ */
277
+ function callerError(method, error) {
278
+ if (!(error instanceof CDPProtocolError))
279
+ return undefined;
280
+ const err = cdpCallError(method, error.message);
281
+ if (error.isRequestError()) {
282
+ return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.INVALID_ARGUMENTS);
283
+ }
284
+ if (error.code === CDP_SERVER_ERROR && err.notFound) {
285
+ return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.RESOURCE_NOT_FOUND);
286
+ }
287
+ if (error.code === CDP_SERVER_ERROR &&
288
+ /must be specified|invalid|expected|missing/i.test(error.message)) {
289
+ return new CommandError(err.message, { suggestion: err.suggestion }, EXIT_CODES.INVALID_ARGUMENTS);
290
+ }
291
+ return undefined;
292
+ }
293
+ /**
294
+ * The handlers of the session commands.
295
+ *
296
+ * @param store - Telemetry of the session
297
+ * @param emulation - The session's page emulation
298
+ * @returns Command registry
299
+ */
300
+ export function createCommandRegistry(store, emulation) {
264
301
  const patternDetector = new PatternDetector();
265
302
  const interact = createInteractionRunner(store);
303
+ /** Frame id behind each index of the last `dom frames` listing */
304
+ let listedFrameIds;
266
305
  return {
267
306
  session_peek: async (_cdp, params) => {
268
307
  const lastN = calculateLastN(params.lastN);
@@ -360,7 +399,9 @@ export function createCommandRegistry(store) {
360
399
  });
361
400
  },
362
401
  cdp_call: async (cdp, params) => {
363
- const result = await withBusyPageRecovery(cdp, cdp.send(params.method, params.params ?? {}));
402
+ const result = await withBusyPageRecovery(cdp, cdp.send(params.method, params.params ?? {}).catch((error) => {
403
+ throw callerError(params.method, error) ?? error;
404
+ }));
364
405
  const detectionResult = patternDetector.trackCommand(params.method);
365
406
  let hint;
366
407
  if (detectionResult.shouldShow && detectionResult.pattern) {
@@ -376,11 +417,21 @@ export function createCommandRegistry(store) {
376
417
  },
377
418
  dom_eval: async (cdp, params) => params.frame === undefined
378
419
  ? evaluateScript(cdp, params.script)
379
- : evaluateInFrame(cdp, pageWebSocketUrl(store), params.script, params.frame),
380
- dom_frames: async (cdp) => ({ frames: await listFrames(cdp, pageWebSocketUrl(store)) }),
420
+ : evaluateInFrame(cdp, pageWebSocketUrl(store), params.script, params.frame, listedFrameIds),
421
+ dom_frames: async (cdp) => {
422
+ const { frames, frameIds } = await listFrames(cdp, pageWebSocketUrl(store));
423
+ listedFrameIds = frameIds;
424
+ return { frames };
425
+ },
381
426
  dom_fill: async (cdp, params) => interact(cdp, async () => onScriptTarget(cdp, params, (target) => withActionStability(cdp, () => fillElement(target.cdp, target.selector, params.value, filterDefined({ index: target.index, blur: params.blur, cwd: params.cwd })), params.wait !== false))),
382
- dom_click: async (cdp, params) => interact(cdp, async () => onScriptTarget(cdp, params, (target) => withActionStability(cdp, () => clickElement(target.cdp, target.selector, filterDefined({ index: target.index, action: params.action })), params.wait !== false)), {
427
+ dom_click: async (cdp, params) => interact(cdp, async () => onScriptTarget(cdp, params, (target) => withActionStability(cdp, () => clickElement(target.cdp, target.selector, filterDefined({
428
+ index: target.index,
429
+ action: params.action,
430
+ strict: params.strict,
431
+ })), params.wait !== false)), {
383
432
  detectNoEffect: params.wait !== false && params.action !== 'hover' && params.action !== 'right',
433
+ reportShown: params.action === 'hover',
434
+ detectUnsettled: params.wait !== false && params.action !== 'hover',
384
435
  }),
385
436
  dom_submit: async (cdp, params) => withTriggeredRequestCount(await interact(cdp, async () => onScriptTarget(cdp, params, (target) => submitForm(target.cdp, target.selector, {
386
437
  ...filterDefined({
@@ -395,7 +446,7 @@ export function createCommandRegistry(store) {
395
446
  index: target.index,
396
447
  times: params.times,
397
448
  modifiers: params.modifiers,
398
- })), params.wait !== false))),
449
+ })), params.wait !== false)), { reportShown: true, detectUnsettled: params.wait !== false }),
399
450
  dom_scroll: async (cdp, params) => interact(cdp, async () => onScriptTarget(cdp, params, (target) => withActionStability(cdp, () => scrollPage(target.cdp, target.selector || undefined, filterDefined({
400
451
  index: target.index,
401
452
  down: params.down,
@@ -407,11 +458,16 @@ export function createCommandRegistry(store) {
407
458
  })), params.wait !== false))),
408
459
  dom_listeners: async (cdp, params) => withBusyPageRecovery(cdp, inspectEventListeners(cdp, params)),
409
460
  dom_layout: async (cdp, params) => withBusyPageRecovery(cdp, inspectLayout(cdp, params)),
461
+ dom_inspect: async (cdp, params) => withBusyPageRecovery(cdp, inspectElement(cdp, params)),
410
462
  dom_wait: async (cdp, params) => waitForCondition(cdp, params),
411
463
  page_navigate: async (cdp, params) => interact(cdp, () => navigatePage(cdp, params.action, {
412
464
  ...filterDefined({ url: params.url, wait: params.wait }),
413
465
  pendingRequests: () => store.pendingNetworkRequests.values(),
414
466
  }), { reportRequests: false, reportEffects: false }),
467
+ page_emulate: async (cdp, params) => {
468
+ const emulated = await emulatePage(cdp, emulation.get(), params, emulation.set);
469
+ return { emulated, ...(await pageAppearance(cdp)) };
470
+ },
415
471
  dom_form_discover: async (cdp) => {
416
472
  const response = await withBusyPageRecovery(cdp, cdp.send('Runtime.evaluate', {
417
473
  expression: FORM_DISCOVERY_SCRIPT,
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Running page interactions (fill, click, submit, pressKey, scroll) one at a
3
- * time, reporting the dialogs, network requests and page changes they caused.
3
+ * time, reporting the dialogs, network requests and page changes they caused,
4
+ * and whether the page was still changing when they returned.
4
5
  */
5
6
  import type { TelemetryStore } from './TelemetryStore.js';
6
7
  import type { CDPConnection } from '../../connection/cdp.js';
@@ -28,6 +29,17 @@ export interface InteractionOptions {
28
29
  * whose effect shows in the DOM (click, submit) and that waited for it
29
30
  */
30
31
  detectNoEffect?: boolean;
32
+ /**
33
+ * List the elements it showed (`shown`): for hover and key presses, whose
34
+ * effect is often a menu, tooltip or new item rather than a message
35
+ */
36
+ reportShown?: boolean;
37
+ /**
38
+ * Say when the page was still changing as it returned (`settled: false`
39
+ * with `pending`): for actions that start app transitions (click, key
40
+ * press) and waited for the network
41
+ */
42
+ detectUnsettled?: boolean;
31
43
  }
32
44
  /** Runs one interaction after the previous one finished */
33
45
  export type InteractionRunner = <T extends object>(cdp: CDPConnection, action: () => Promise<T>, options?: InteractionOptions) => Promise<T & InteractionReport>;
@@ -40,10 +52,11 @@ export type InteractionRunner = <T extends object>(cdp: CDPConnection, action: (
40
52
  * from the page (without waiting: during a pending navigation that takes until
41
53
  * the new page commits), and the dialogs it opened, what it changed on the
42
54
  * page (see {@link watchActionEffects}) and the network requests it
43
- * triggered (see {@link watchTriggeredRequests}) are added to its result.
44
- * They are attributed by time: a dialog or request started by a page timer
45
- * or a navigation started earlier is reported by whichever interaction is
46
- * running then.
55
+ * triggered (see {@link watchTriggeredRequests}) are added to its result,
56
+ * and, when asked, what the page was still working on (see
57
+ * {@link pendingChanges}). They are attributed by time: a dialog or request
58
+ * started by a page timer or a navigation started earlier is reported by
59
+ * whichever interaction is running then.
47
60
  *
48
61
  * @param store - Session store recording accepted dialogs and network requests
49
62
  * @returns Interaction runner
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Running page interactions (fill, click, submit, pressKey, scroll) one at a
3
- * time, reporting the dialogs, network requests and page changes they caused.
3
+ * time, reporting the dialogs, network requests and page changes they caused,
4
+ * and whether the page was still changing when they returned.
4
5
  */
5
- import { watchActionEffects } from '../../runtime/dom/actionEffects.js';
6
+ import { pendingChanges, watchActionEffects } from '../../runtime/dom/actionEffects.js';
6
7
  import { UNBIND_TARGET_SCRIPT } from '../../runtime/dom/targetNode.js';
7
8
  import { createLogger } from '../../ui/logging/index.js';
8
9
  import { getErrorMessage } from '../../utils/errors.js';
@@ -26,10 +27,11 @@ function succeeded(result) {
26
27
  * from the page (without waiting: during a pending navigation that takes until
27
28
  * the new page commits), and the dialogs it opened, what it changed on the
28
29
  * page (see {@link watchActionEffects}) and the network requests it
29
- * triggered (see {@link watchTriggeredRequests}) are added to its result.
30
- * They are attributed by time: a dialog or request started by a page timer
31
- * or a navigation started earlier is reported by whichever interaction is
32
- * running then.
30
+ * triggered (see {@link watchTriggeredRequests}) are added to its result,
31
+ * and, when asked, what the page was still working on (see
32
+ * {@link pendingChanges}). They are attributed by time: a dialog or request
33
+ * started by a page timer or a navigation started earlier is reported by
34
+ * whichever interaction is running then.
33
35
  *
34
36
  * @param store - Session store recording accepted dialogs and network requests
35
37
  * @returns Interaction runner
@@ -44,17 +46,25 @@ export function createInteractionRunner(store) {
44
46
  try {
45
47
  const result = await action();
46
48
  const dialogs = store.dialogs.slice(firstDialog);
47
- const changes = succeeded(result)
48
- ? await effects?.collect({
49
- dialogs: dialogs.length,
50
- detectNoEffect: options.detectNoEffect === true,
51
- })
49
+ if (!succeeded(result))
50
+ return { ...result, ...(dialogs.length > 0 && { dialogs }) };
51
+ const collected = await effects?.collect({
52
+ dialogs: dialogs.length,
53
+ detectNoEffect: options.detectNoEffect === true,
54
+ reportShown: options.reportShown === true,
55
+ detectUnsettled: options.detectUnsettled === true,
56
+ });
57
+ const { work, ...changes } = collected ?? {};
58
+ const requests = collectRequests?.();
59
+ const pending = options.detectUnsettled && work
60
+ ? pendingChanges(work, requests?.triggeredRequests)
52
61
  : undefined;
53
62
  return {
54
63
  ...result,
55
64
  ...(dialogs.length > 0 && { dialogs }),
56
65
  ...changes,
57
- ...(succeeded(result) && collectRequests?.()),
66
+ ...(pending && { settled: false, pending }),
67
+ ...requests,
58
68
  };
59
69
  }
60
70
  finally {