@everydaydevopsio/pilot 0.6.2 → 0.7.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 (107) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +105 -11
  4. package/dist/browser/browser-manager.d.ts +7 -1
  5. package/dist/browser/browser-manager.d.ts.map +1 -1
  6. package/dist/browser/browser-manager.js +196 -40
  7. package/dist/browser/browser-manager.js.map +1 -1
  8. package/dist/browser/chrome-launcher.d.ts +13 -2
  9. package/dist/browser/chrome-launcher.d.ts.map +1 -1
  10. package/dist/browser/chrome-launcher.js +33 -4
  11. package/dist/browser/chrome-launcher.js.map +1 -1
  12. package/dist/browser/driver/capabilities.d.ts +22 -0
  13. package/dist/browser/driver/capabilities.d.ts.map +1 -0
  14. package/dist/browser/driver/capabilities.js +70 -0
  15. package/dist/browser/driver/capabilities.js.map +1 -0
  16. package/dist/browser/driver/cdp-driver.d.ts +77 -0
  17. package/dist/browser/driver/cdp-driver.d.ts.map +1 -0
  18. package/dist/browser/driver/cdp-driver.js +190 -0
  19. package/dist/browser/driver/cdp-driver.js.map +1 -0
  20. package/dist/browser/driver/factory.d.ts +10 -0
  21. package/dist/browser/driver/factory.d.ts.map +1 -0
  22. package/dist/browser/driver/factory.js +23 -0
  23. package/dist/browser/driver/factory.js.map +1 -0
  24. package/dist/browser/driver/index.d.ts +6 -0
  25. package/dist/browser/driver/index.d.ts.map +1 -0
  26. package/dist/browser/driver/index.js +5 -0
  27. package/dist/browser/driver/index.js.map +1 -0
  28. package/dist/browser/driver/types.d.ts +106 -0
  29. package/dist/browser/driver/types.d.ts.map +1 -0
  30. package/dist/browser/driver/types.js +25 -0
  31. package/dist/browser/driver/types.js.map +1 -0
  32. package/dist/browser/sessions.d.ts +49 -0
  33. package/dist/browser/sessions.d.ts.map +1 -0
  34. package/dist/browser/sessions.js +249 -0
  35. package/dist/browser/sessions.js.map +1 -0
  36. package/dist/browser/types.d.ts +13 -0
  37. package/dist/browser/types.d.ts.map +1 -1
  38. package/dist/cli/skill-template.d.ts +1 -1
  39. package/dist/cli/skill-template.d.ts.map +1 -1
  40. package/dist/cli/skill-template.js +1 -1
  41. package/dist/cli/skill-template.js.map +1 -1
  42. package/dist/commands/connect.d.ts +2 -2
  43. package/dist/commands/connect.d.ts.map +1 -1
  44. package/dist/commands/connect.js +2 -2
  45. package/dist/commands/connect.js.map +1 -1
  46. package/dist/mcp/server.d.ts +9 -15
  47. package/dist/mcp/server.d.ts.map +1 -1
  48. package/dist/mcp/server.js +32 -74
  49. package/dist/mcp/server.js.map +1 -1
  50. package/dist/mcp/tools/browser.d.ts +2 -3
  51. package/dist/mcp/tools/browser.d.ts.map +1 -1
  52. package/dist/mcp/tools/browser.js +85 -70
  53. package/dist/mcp/tools/browser.js.map +1 -1
  54. package/dist/mcp/tools/connect.d.ts +2 -3
  55. package/dist/mcp/tools/connect.d.ts.map +1 -1
  56. package/dist/mcp/tools/connect.js +9 -9
  57. package/dist/mcp/tools/connect.js.map +1 -1
  58. package/dist/mcp/tools/driver-context.d.ts +14 -0
  59. package/dist/mcp/tools/driver-context.d.ts.map +1 -0
  60. package/dist/mcp/tools/driver-context.js +43 -0
  61. package/dist/mcp/tools/driver-context.js.map +1 -0
  62. package/dist/mcp/tools/files.d.ts.map +1 -1
  63. package/dist/mcp/tools/files.js +5 -15
  64. package/dist/mcp/tools/files.js.map +1 -1
  65. package/dist/mcp/tools/interaction.d.ts.map +1 -1
  66. package/dist/mcp/tools/interaction.js +7 -26
  67. package/dist/mcp/tools/interaction.js.map +1 -1
  68. package/dist/mcp/tools/names.d.ts +1 -1
  69. package/dist/mcp/tools/names.d.ts.map +1 -1
  70. package/dist/mcp/tools/names.js +2 -0
  71. package/dist/mcp/tools/names.js.map +1 -1
  72. package/dist/mcp/tools/network.d.ts.map +1 -1
  73. package/dist/mcp/tools/network.js +3 -11
  74. package/dist/mcp/tools/network.js.map +1 -1
  75. package/dist/mcp/tools/performance.d.ts.map +1 -1
  76. package/dist/mcp/tools/performance.js +2 -12
  77. package/dist/mcp/tools/performance.js.map +1 -1
  78. package/dist/mcp/tools/snapshot.d.ts.map +1 -1
  79. package/dist/mcp/tools/snapshot.js +3 -14
  80. package/dist/mcp/tools/snapshot.js.map +1 -1
  81. package/dist/mcp/tools/styles.d.ts.map +1 -1
  82. package/dist/mcp/tools/styles.js +2 -11
  83. package/dist/mcp/tools/styles.js.map +1 -1
  84. package/package.json +1 -1
  85. package/skills/pilot/SKILL.md +9 -0
  86. package/src/browser/browser-manager.ts +248 -41
  87. package/src/browser/chrome-launcher.ts +40 -4
  88. package/src/browser/driver/capabilities.ts +88 -0
  89. package/src/browser/driver/cdp-driver.ts +260 -0
  90. package/src/browser/driver/factory.ts +33 -0
  91. package/src/browser/driver/index.ts +22 -0
  92. package/src/browser/driver/types.ts +156 -0
  93. package/src/browser/sessions.ts +299 -0
  94. package/src/browser/types.ts +14 -0
  95. package/src/cli/skill-template.ts +1 -1
  96. package/src/commands/connect.ts +3 -3
  97. package/src/mcp/server.ts +48 -91
  98. package/src/mcp/tools/browser.ts +105 -87
  99. package/src/mcp/tools/connect.ts +10 -11
  100. package/src/mcp/tools/driver-context.ts +56 -0
  101. package/src/mcp/tools/files.ts +5 -16
  102. package/src/mcp/tools/interaction.ts +7 -27
  103. package/src/mcp/tools/names.ts +2 -0
  104. package/src/mcp/tools/network.ts +2 -12
  105. package/src/mcp/tools/performance.ts +2 -13
  106. package/src/mcp/tools/snapshot.ts +3 -15
  107. package/src/mcp/tools/styles.ts +2 -12
@@ -1,6 +1,8 @@
1
1
  import type { Client } from 'chrome-remote-interface';
2
2
  import type { ChildProcess } from 'child_process';
3
3
  import { mkdirSync } from 'fs';
4
+ import { hostname } from 'os';
5
+ import { basename, join } from 'path';
4
6
  import { getLogger } from '../util/logger.js';
5
7
  import type { Config } from '../util/config.js';
6
8
  import type { ViewportConfig } from '../viewport.js';
@@ -9,6 +11,7 @@ import type {
9
11
  BrowserEventCallback,
10
12
  BrowserState,
11
13
  LaunchOptions,
14
+ SessionInfo,
12
15
  TabInfo
13
16
  } from './types.js';
14
17
  import {
@@ -18,7 +21,9 @@ import {
18
21
  launchChrome,
19
22
  resolveUserDataDir,
20
23
  waitForChromeReady,
21
- cleanupLockFiles
24
+ cleanupLockFiles,
25
+ profileLockOwner,
26
+ DEFAULT_STARTUP_TIMEOUT_MS
22
27
  } from './chrome-launcher.js';
23
28
  import { assertLinuxBrowserPrerequisites } from './preflight.js';
24
29
  import {
@@ -92,6 +97,30 @@ export class BrowserManager {
92
97
  return this.external;
93
98
  }
94
99
 
100
+ getSessionInfo(): SessionInfo {
101
+ if (this.external) {
102
+ return {
103
+ mode: 'external',
104
+ connected: this.client !== null,
105
+ host: this.externalHost ?? undefined,
106
+ cdpPort: this.externalPort ?? undefined
107
+ };
108
+ }
109
+ if (this.chromeProcess) {
110
+ return {
111
+ mode: 'launched',
112
+ connected: this.client !== null,
113
+ pid: this.chromeProcess.pid,
114
+ cdpPort: this.launchedCdpPort ?? undefined,
115
+ userDataDir: this.launchedUserDataDir ?? undefined,
116
+ profile: this.launchedUserDataDir
117
+ ? basename(this.launchedUserDataDir)
118
+ : undefined
119
+ };
120
+ }
121
+ return { mode: 'none', connected: false };
122
+ }
123
+
95
124
  getViewportConfig(): ViewportConfig | null {
96
125
  return this.viewportConfig;
97
126
  }
@@ -159,9 +188,7 @@ export class BrowserManager {
159
188
  const userDataDir = resolveUserDataDir(profileName);
160
189
 
161
190
  if (isProfileLocked(userDataDir)) {
162
- throw new Error(
163
- `Profile "${profileName}" is already in use by another browser instance.`
164
- );
191
+ throw new Error(describeProfileConflict(profileName, userDataDir));
165
192
  }
166
193
 
167
194
  const viewportPreset = opts.viewport ?? this.config.viewport;
@@ -198,18 +225,30 @@ export class BrowserManager {
198
225
  this.launchedUserDataDir = userDataDir;
199
226
 
200
227
  const stderrChunks: string[] = [];
201
- this.chromeProcess.stderr?.on('data', (data: Buffer) => {
228
+ const proc = this.chromeProcess;
229
+ let spawnError: Error | null = null;
230
+ let exitStatus: string | null = null;
231
+
232
+ proc.stderr?.on('data', (data: Buffer) => {
202
233
  stderrChunks.push(data.toString());
203
234
  });
204
235
 
205
- this.chromeProcess.on('exit', (code, signal) => {
236
+ proc.on('error', (err) => {
237
+ spawnError = err;
238
+ getLogger().warn({ err, chromePath }, 'Chrome process failed to start');
239
+ // A failed spawn does not necessarily emit 'exit'.
240
+ if (this.chromeProcess === proc && proc.pid === undefined) {
241
+ this.chromeProcess = null;
242
+ this.launchedCdpPort = null;
243
+ this.launchedUserDataDir = null;
244
+ }
245
+ });
246
+
247
+ proc.on('exit', (code, signal) => {
206
248
  const logger = getLogger();
207
249
  const stderr = stderrChunks.join('').trim();
208
- const stderrFiltered = stderr
209
- .split('\n')
210
- .filter((l) => !l.includes('dbus/bus.cc'))
211
- .join('\n')
212
- .trim();
250
+ const stderrFiltered = filterChromeStderr(stderr);
251
+ exitStatus = signal ? `signal ${signal}` : `exit code ${code}`;
213
252
  logger.warn(
214
253
  {
215
254
  code,
@@ -219,17 +258,78 @@ export class BrowserManager {
219
258
  },
220
259
  'Chrome process exited'
221
260
  );
222
- this.chromeProcess = null;
223
- this.launchedCdpPort = null;
224
- this.cleanupUserDataDir();
261
+ if (this.chromeProcess === proc) {
262
+ this.chromeProcess = null;
263
+ this.launchedCdpPort = null;
264
+ this.cleanupUserDataDir(proc.pid);
265
+ }
225
266
  });
226
267
 
227
- await waitForChromeReady(port, opts.startupTimeoutMs);
268
+ const startupTimeoutMs =
269
+ opts.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
270
+ const earlyFailure = (): Error | null => {
271
+ if (spawnError) {
272
+ return new Error(
273
+ `Chrome failed to start (${chromePath}): ${spawnError.message}`,
274
+ { cause: spawnError }
275
+ );
276
+ }
277
+ if (exitStatus) {
278
+ const stderr = filterChromeStderr(stderrChunks.join('').trim());
279
+ return new Error(
280
+ `Chrome exited (${exitStatus}) before DevTools became ready on port ${port}.` +
281
+ (stderr ? ` Chrome stderr: ${stderr.slice(-1000)}` : '')
282
+ );
283
+ }
284
+ return null;
285
+ };
286
+
287
+ try {
288
+ await waitForChromeReady(port, startupTimeoutMs, earlyFailure);
289
+ } catch (err) {
290
+ const timedOut = earlyFailure() === null;
291
+ const cleanup = await this.abortLaunch(proc, userDataDir);
292
+ throw new Error(
293
+ describeLaunchFailure({
294
+ reason: (err as Error).message,
295
+ timedOut,
296
+ startupTimeoutMs,
297
+ chromePath,
298
+ profileName,
299
+ userDataDir,
300
+ headless,
301
+ port,
302
+ cleanup
303
+ }),
304
+ { cause: err }
305
+ );
306
+ }
228
307
  await this.connect();
229
308
 
230
309
  return { headless, viewport: viewportPreset };
231
310
  }
232
311
 
312
+ /**
313
+ * Stop a Chrome process whose launch failed so it does not keep holding
314
+ * the profile lock while this manager forgets about it.
315
+ */
316
+ private async abortLaunch(
317
+ proc: ChildProcess,
318
+ userDataDir: string
319
+ ): Promise<LaunchCleanup> {
320
+ if (this.chromeProcess === proc) {
321
+ this.chromeProcess = null;
322
+ this.launchedCdpPort = null;
323
+ }
324
+ this.launchedUserDataDir = null;
325
+ const pid = proc.pid;
326
+ const stopped = await stopChromeProcess(proc);
327
+ if (stopped && pid !== undefined) {
328
+ cleanupLockFiles(userDataDir, pid);
329
+ }
330
+ return { pid, stopped };
331
+ }
332
+
233
333
  async connect(): Promise<void> {
234
334
  if (this.destroyed) return;
235
335
  const logger = getLogger();
@@ -404,9 +504,9 @@ export class BrowserManager {
404
504
  this.retryTimer.unref();
405
505
  }
406
506
 
407
- private cleanupUserDataDir(): void {
408
- if (this.launchedUserDataDir) {
409
- cleanupLockFiles(this.launchedUserDataDir);
507
+ private cleanupUserDataDir(ownerPid: number | undefined): void {
508
+ if (this.launchedUserDataDir && ownerPid !== undefined) {
509
+ cleanupLockFiles(this.launchedUserDataDir, ownerPid);
410
510
  }
411
511
  this.launchedUserDataDir = null;
412
512
  }
@@ -436,30 +536,137 @@ export class BrowserManager {
436
536
  const proc = this.chromeProcess;
437
537
  this.chromeProcess = null;
438
538
  this.launchedCdpPort = null;
439
- try {
440
- proc.kill();
441
- } catch {
442
- // process may have already exited
539
+ if (await stopChromeProcess(proc)) {
540
+ this.cleanupUserDataDir(proc.pid);
541
+ } else {
542
+ getLogger().warn(
543
+ { pid: proc.pid },
544
+ 'Chrome did not exit after SIGKILL; leaving its profile lock in place'
545
+ );
546
+ this.launchedUserDataDir = null;
443
547
  }
444
- await new Promise<void>((resolve) => {
445
- if (proc.exitCode !== null) {
446
- resolve();
447
- return;
448
- }
449
- const timer = setTimeout(() => {
450
- try {
451
- proc.kill('SIGKILL');
452
- } catch {
453
- // process may have already exited
454
- }
455
- resolve();
456
- }, 5000);
457
- proc.once('exit', () => {
458
- clearTimeout(timer);
459
- resolve();
460
- });
461
- });
462
- this.cleanupUserDataDir();
463
548
  }
464
549
  }
465
550
  }
551
+
552
+ function filterChromeStderr(stderr: string): string {
553
+ return stderr
554
+ .split('\n')
555
+ .filter((l) => !l.includes('dbus/bus.cc'))
556
+ .join('\n')
557
+ .trim();
558
+ }
559
+
560
+ interface LaunchCleanup {
561
+ pid: number | undefined;
562
+ stopped: boolean;
563
+ }
564
+
565
+ /** Terminate a Chrome process, escalating to SIGKILL. Resolves true once it has exited. */
566
+ async function stopChromeProcess(proc: ChildProcess): Promise<boolean> {
567
+ const hasExited = () => proc.exitCode !== null || proc.signalCode !== null;
568
+ if (proc.pid === undefined || hasExited()) return true;
569
+
570
+ const waitForExit = (ms: number) =>
571
+ new Promise<boolean>((resolve) => {
572
+ if (hasExited()) {
573
+ resolve(true);
574
+ return;
575
+ }
576
+ const timer = setTimeout(() => {
577
+ proc.off('exit', onExit);
578
+ resolve(hasExited());
579
+ }, ms);
580
+ const onExit = () => {
581
+ clearTimeout(timer);
582
+ resolve(true);
583
+ };
584
+ proc.once('exit', onExit);
585
+ });
586
+
587
+ try {
588
+ proc.kill();
589
+ } catch {
590
+ // process may have already exited
591
+ }
592
+ if (await waitForExit(5000)) return true;
593
+ try {
594
+ proc.kill('SIGKILL');
595
+ } catch {
596
+ // process may have already exited
597
+ }
598
+ return waitForExit(2000);
599
+ }
600
+
601
+ function describeProfileConflict(
602
+ profileName: string,
603
+ userDataDir: string
604
+ ): string {
605
+ const lockPath = join(userDataDir, 'SingletonLock');
606
+ const owner = profileLockOwner(userDataDir);
607
+ const message = [
608
+ `Profile "${profileName}" is already in use by another browser instance.`
609
+ ];
610
+ if (owner && owner.lockHost === hostname()) {
611
+ message.push(
612
+ `Chrome pid ${owner.pid} on this host holds ${lockPath}. This Pilot session is not tracking that browser, so browser_stop will not stop it.`,
613
+ `Close that Chrome window or stop pid ${owner.pid}, or call browser_start with a different profileName.`
614
+ );
615
+ } else if (owner) {
616
+ message.push(
617
+ `The lock ${lockPath} was created by pid ${owner.pid} on host "${owner.lockHost}", not this host ("${hostname()}"), so Pilot cannot tell whether that browser is still running.`,
618
+ `Call browser_start with a different profileName. If no browser on "${owner.lockHost}" uses this profile any more, delete the lock file and retry.`
619
+ );
620
+ } else {
621
+ message.push(
622
+ `Pilot could not identify the owner of the lock in ${userDataDir}.`,
623
+ `Call browser_start with a different profileName. If no Chrome is using that directory, delete its SingletonLock and lockfile and retry.`
624
+ );
625
+ }
626
+ return message.join(' ');
627
+ }
628
+
629
+ function describeLaunchFailure(details: {
630
+ reason: string;
631
+ timedOut: boolean;
632
+ startupTimeoutMs: number;
633
+ chromePath: string;
634
+ profileName: string;
635
+ userDataDir: string;
636
+ headless: boolean;
637
+ port: number;
638
+ cleanup: LaunchCleanup;
639
+ }): string {
640
+ const { cleanup } = details;
641
+ const display =
642
+ process.env.DISPLAY || process.env.WAYLAND_DISPLAY || '(not set)';
643
+ const launch = [
644
+ `chrome=${details.chromePath}`,
645
+ `profile="${details.profileName}" (${details.userDataDir})`,
646
+ `mode=${details.headless ? 'headless' : 'visible'}`,
647
+ `devtools port=${details.port}`,
648
+ ...(process.platform === 'linux' && !details.headless
649
+ ? [`display=${display}`]
650
+ : [])
651
+ ].join(', ');
652
+
653
+ let cleanupText: string;
654
+ if (cleanup.pid === undefined) {
655
+ cleanupText = 'No Chrome process was left running.';
656
+ } else if (cleanup.stopped) {
657
+ cleanupText = `Pilot stopped the Chrome process it launched (pid ${cleanup.pid}); profile "${details.profileName}" is free to reuse.`;
658
+ } else {
659
+ cleanupText = `Chrome (pid ${cleanup.pid}) did not stop and may still hold profile "${details.profileName}". Stop it (kill -9 ${cleanup.pid}) or call browser_start with a different profileName.`;
660
+ }
661
+
662
+ const nextSteps = details.timedOut
663
+ ? `Chrome can be slow on a first run or with software rendering; retry with a larger startupTimeoutMs (this attempt used ${details.startupTimeoutMs}ms). Run \`pilot check\` to diagnose the environment.`
664
+ : `Run \`pilot check\` to diagnose the environment${details.headless ? '' : ', or retry with headless: true if no graphical display is available'}.`;
665
+
666
+ return [
667
+ details.reason,
668
+ `Launch: ${launch}.`,
669
+ `Cleanup: ${cleanupText}`,
670
+ `Next: ${nextSteps}`
671
+ ].join('\n');
672
+ }
@@ -188,12 +188,21 @@ export function formatPortBindError(error: NodeJS.ErrnoException): Error {
188
188
  return error;
189
189
  }
190
190
 
191
+ // Cold starts on Linux servers (first-run profile setup, software rendering
192
+ // under Xvfb/VNC) can take well over 30s. A healthy start returns as soon as
193
+ // DevTools answers, and a Chrome that exits is detected immediately, so the
194
+ // longer ceiling only matters when Chrome is genuinely slow.
195
+ export const DEFAULT_STARTUP_TIMEOUT_MS = 90000;
196
+
191
197
  export async function waitForChromeReady(
192
198
  port: number,
193
- timeoutMs = 30000
199
+ timeoutMs = DEFAULT_STARTUP_TIMEOUT_MS,
200
+ earlyFailure?: () => Error | null
194
201
  ): Promise<void> {
195
202
  const deadline = Date.now() + timeoutMs;
196
203
  while (Date.now() < deadline) {
204
+ const failure = earlyFailure?.();
205
+ if (failure) throw failure;
197
206
  try {
198
207
  await CDP.List({ host: '127.0.0.1', port });
199
208
  return;
@@ -201,6 +210,8 @@ export async function waitForChromeReady(
201
210
  await new Promise((r) => setTimeout(r, 200));
202
211
  }
203
212
  }
213
+ const failure = earlyFailure?.();
214
+ if (failure) throw failure;
204
215
  throw new Error(
205
216
  `Chrome did not become ready on port ${port} within ${timeoutMs}ms`
206
217
  );
@@ -271,12 +282,37 @@ export function launchChrome(opts: {
271
282
  });
272
283
  }
273
284
 
274
- export function cleanupLockFiles(userDataDir: string): void {
285
+ export function profileLockOwner(
286
+ userDataDir: string
287
+ ): { pid: number; lockHost: string } | null {
288
+ return extractLockPid(join(userDataDir, 'SingletonLock'));
289
+ }
290
+
291
+ /**
292
+ * Remove the profile lock files left by a Chrome process Pilot launched.
293
+ * A SingletonLock that names a different process is left alone: if Chrome
294
+ * handed off to another instance already using the profile, that lock
295
+ * belongs to the live browser. Returns false when a lock was left in place.
296
+ */
297
+ export function cleanupLockFiles(
298
+ userDataDir: string,
299
+ ownerPid: number
300
+ ): boolean {
301
+ const owner = profileLockOwner(userDataDir);
302
+ if (
303
+ fileOrSymlinkExists(join(userDataDir, 'SingletonLock')) &&
304
+ (!owner || owner.pid !== ownerPid || owner.lockHost !== hostname())
305
+ ) {
306
+ return false;
307
+ }
308
+ let removed = true;
275
309
  for (const lockName of ['SingletonLock', 'lockfile']) {
276
310
  try {
277
311
  unlinkSync(join(userDataDir, lockName));
278
- } catch {
279
- // lock file may not exist — that's fine
312
+ } catch (err) {
313
+ // A lock file that does not exist is already gone.
314
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') removed = false;
280
315
  }
281
316
  }
317
+ return removed;
282
318
  }
@@ -0,0 +1,88 @@
1
+ import type { ToolName } from '../../mcp/tools/names.js';
2
+ import type { BrowserKind, Capability } from './types.js';
3
+
4
+ /**
5
+ * Capabilities each MCP tool needs from the active driver. Tools mapped to an
6
+ * empty list either manage the session itself (start/stop/status) or only read
7
+ * buffers that every driver fills.
8
+ */
9
+ export const TOOL_CAPABILITIES: Readonly<
10
+ Record<ToolName, readonly Capability[]>
11
+ > = {
12
+ browser_start: [],
13
+ browser_stop: [],
14
+ browser_status: [],
15
+ browser_stop_all: [],
16
+ browser_connect: ['connectExisting'],
17
+ browser_screenshot: ['screenshot'],
18
+ browser_navigate: ['navigation'],
19
+ browser_click: ['input'],
20
+ browser_type: ['input'],
21
+ browser_evaluate: ['evaluate'],
22
+ browser_wait: ['evaluate'],
23
+ browser_page_info: ['evaluate'],
24
+ browser_list_tabs: ['tabs'],
25
+ browser_new_tab: ['tabs'],
26
+ browser_close_tab: ['tabs'],
27
+ browser_switch_tab: ['tabs'],
28
+ browser_tabs: ['tabs'],
29
+ browser_viewport_resize: ['viewport'],
30
+ browser_get_console_logs: ['console'],
31
+ browser_get_errors: ['console'],
32
+ browser_clear_errors: ['console'],
33
+ browser_console: ['console'],
34
+ browser_errors: ['console'],
35
+ browser_snapshot: ['accessibilitySnapshot'],
36
+ browser_find: ['accessibilitySnapshot'],
37
+ browser_fill: ['input'],
38
+ browser_hover: ['input'],
39
+ browser_press_key: ['input'],
40
+ browser_select: ['input'],
41
+ browser_check: ['input'],
42
+ browser_scroll: ['input'],
43
+ browser_network: ['network'],
44
+ browser_styles: ['styles'],
45
+ browser_dialog: ['dialogs'],
46
+ browser_upload: ['uploads'],
47
+ browser_downloads: ['downloads'],
48
+ browser_performance: ['performanceTrace']
49
+ };
50
+
51
+ export class CapabilityError extends Error {
52
+ constructor(
53
+ readonly tool: ToolName,
54
+ readonly browser: BrowserKind,
55
+ readonly missing: readonly Capability[]
56
+ ) {
57
+ super(
58
+ `${tool} is not supported by ${browser} (missing capabilities: ${missing.join(', ')}).`
59
+ );
60
+ this.name = 'CapabilityError';
61
+ }
62
+ }
63
+
64
+ export function missingCapabilities(
65
+ tool: ToolName,
66
+ capabilities: ReadonlySet<Capability>
67
+ ): Capability[] {
68
+ return TOOL_CAPABILITIES[tool].filter((c) => !capabilities.has(c));
69
+ }
70
+
71
+ export function assertToolSupported(
72
+ tool: ToolName,
73
+ driver: { kind: BrowserKind; capabilities: ReadonlySet<Capability> }
74
+ ): void {
75
+ const missing = missingCapabilities(tool, driver.capabilities);
76
+ if (missing.length > 0) {
77
+ throw new CapabilityError(tool, driver.kind, missing);
78
+ }
79
+ }
80
+
81
+ /** Tools the given capability set cannot run, for status and docs output. */
82
+ export function unsupportedTools(
83
+ capabilities: ReadonlySet<Capability>
84
+ ): ToolName[] {
85
+ return (Object.keys(TOOL_CAPABILITIES) as ToolName[]).filter(
86
+ (tool) => missingCapabilities(tool, capabilities).length > 0
87
+ );
88
+ }