playwriter 0.0.89 → 0.0.103

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 (137) hide show
  1. package/dist/aria-snapshot.d.ts +7 -3
  2. package/dist/aria-snapshot.d.ts.map +1 -1
  3. package/dist/aria-snapshot.js +76 -18
  4. package/dist/aria-snapshot.js.map +1 -1
  5. package/dist/aria-snapshot.test.js +94 -0
  6. package/dist/aria-snapshot.test.js.map +1 -1
  7. package/dist/bippy.js +1 -1
  8. package/dist/browser-config.d.ts +12 -1
  9. package/dist/browser-config.d.ts.map +1 -1
  10. package/dist/browser-config.js +101 -38
  11. package/dist/browser-config.js.map +1 -1
  12. package/dist/browser-launch.d.ts +17 -0
  13. package/dist/browser-launch.d.ts.map +1 -0
  14. package/dist/browser-launch.js +44 -0
  15. package/dist/browser-launch.js.map +1 -0
  16. package/dist/cdp-relay.d.ts.map +1 -1
  17. package/dist/cdp-relay.js +34 -10
  18. package/dist/cdp-relay.js.map +1 -1
  19. package/dist/channel-owner-inspect.test.d.ts +2 -0
  20. package/dist/channel-owner-inspect.test.d.ts.map +1 -0
  21. package/dist/channel-owner-inspect.test.js +75 -0
  22. package/dist/channel-owner-inspect.test.js.map +1 -0
  23. package/dist/chrome-discovery.d.ts +65 -0
  24. package/dist/chrome-discovery.d.ts.map +1 -0
  25. package/dist/chrome-discovery.js +165 -0
  26. package/dist/chrome-discovery.js.map +1 -0
  27. package/dist/chrome-discovery.test.d.ts +2 -0
  28. package/dist/chrome-discovery.test.d.ts.map +1 -0
  29. package/dist/chrome-discovery.test.js +41 -0
  30. package/dist/chrome-discovery.test.js.map +1 -0
  31. package/dist/cli.js +357 -53
  32. package/dist/cli.js.map +1 -1
  33. package/dist/executor.d.ts +44 -11
  34. package/dist/executor.d.ts.map +1 -1
  35. package/dist/executor.js +121 -55
  36. package/dist/executor.js.map +1 -1
  37. package/dist/executor.unit.test.js +29 -1
  38. package/dist/executor.unit.test.js.map +1 -1
  39. package/dist/extension/background.js +1704 -0
  40. package/dist/extension/icons/SolarCursorSquareBold.png +0 -0
  41. package/dist/extension/icons/icon-black-128.png +0 -0
  42. package/dist/extension/icons/icon-black-16.png +0 -0
  43. package/dist/extension/icons/icon-black-32.png +0 -0
  44. package/dist/extension/icons/icon-black-48.png +0 -0
  45. package/dist/extension/icons/icon-gray-128.png +0 -0
  46. package/dist/extension/icons/icon-gray-16.png +0 -0
  47. package/dist/extension/icons/icon-gray-32.png +0 -0
  48. package/dist/extension/icons/icon-gray-48.png +0 -0
  49. package/dist/extension/icons/icon-green-128.png +0 -0
  50. package/dist/extension/icons/icon-green-16.png +0 -0
  51. package/dist/extension/icons/icon-green-32.png +0 -0
  52. package/dist/extension/icons/icon-green-48.png +0 -0
  53. package/dist/extension/icons/playwriter-icon-black.png +0 -0
  54. package/dist/extension/icons/playwriter-icon-gray-disabled.png +0 -0
  55. package/dist/extension/icons/playwriter-icon-gray-disabled.svg +1 -0
  56. package/dist/extension/manifest.json +40 -0
  57. package/dist/extension/offscreen.js +214 -0
  58. package/dist/extension/src/offscreen.html +9 -0
  59. package/dist/extension/src/prism-bash.min.js +1 -0
  60. package/dist/extension/src/prism.min.js +1 -0
  61. package/dist/extension/src/welcome.html +399 -0
  62. package/dist/kitty-graphics.d.ts +22 -0
  63. package/dist/kitty-graphics.d.ts.map +1 -0
  64. package/dist/kitty-graphics.js +71 -0
  65. package/dist/kitty-graphics.js.map +1 -0
  66. package/dist/kitty-graphics.test.d.ts +2 -0
  67. package/dist/kitty-graphics.test.d.ts.map +1 -0
  68. package/dist/kitty-graphics.test.js +125 -0
  69. package/dist/kitty-graphics.test.js.map +1 -0
  70. package/dist/mcp.d.ts.map +1 -1
  71. package/dist/mcp.js +76 -12
  72. package/dist/mcp.js.map +1 -1
  73. package/dist/package-paths.d.ts +3 -0
  74. package/dist/package-paths.d.ts.map +1 -0
  75. package/dist/package-paths.js +29 -0
  76. package/dist/package-paths.js.map +1 -0
  77. package/dist/prompt.md +4 -2
  78. package/dist/readability.js +1 -1
  79. package/dist/relay-core.test.js +14 -4
  80. package/dist/relay-core.test.js.map +1 -1
  81. package/dist/relay-navigation.test.d.ts.map +1 -1
  82. package/dist/relay-navigation.test.js +95 -1
  83. package/dist/relay-navigation.test.js.map +1 -1
  84. package/dist/scoped-fs.d.ts +5 -4
  85. package/dist/scoped-fs.d.ts.map +1 -1
  86. package/dist/scoped-fs.js +11 -9
  87. package/dist/scoped-fs.js.map +1 -1
  88. package/dist/scoped-fs.test.d.ts +5 -0
  89. package/dist/scoped-fs.test.d.ts.map +1 -0
  90. package/dist/scoped-fs.test.js +70 -0
  91. package/dist/scoped-fs.test.js.map +1 -0
  92. package/dist/screen-recording.d.ts +3 -6
  93. package/dist/screen-recording.d.ts.map +1 -1
  94. package/dist/screen-recording.js +7 -21
  95. package/dist/screen-recording.js.map +1 -1
  96. package/dist/selector-generator.js +1 -1
  97. package/dist/snapshot-tools.test.js +3 -4
  98. package/dist/snapshot-tools.test.js.map +1 -1
  99. package/dist/test-utils.d.ts +3 -1
  100. package/dist/test-utils.d.ts.map +1 -1
  101. package/dist/test-utils.js +24 -12
  102. package/dist/test-utils.js.map +1 -1
  103. package/package.json +6 -5
  104. package/src/aria-snapshot.test.ts +110 -0
  105. package/src/aria-snapshot.ts +86 -19
  106. package/src/aria-snapshots/contenteditable-interactive.txt +7 -0
  107. package/src/aria-snapshots/contenteditable-raw.txt +14 -0
  108. package/src/aria-snapshots/github-interactive.txt +0 -2
  109. package/src/aria-snapshots/github-raw.txt +0 -4
  110. package/src/aria-snapshots/hackernews-interactive.txt +240 -237
  111. package/src/aria-snapshots/hackernews-raw.txt +262 -258
  112. package/src/aria-snapshots/prosemirror-interactive.txt +63 -0
  113. package/src/aria-snapshots/prosemirror-raw.txt +141 -0
  114. package/src/assets/aria-labels-hacker-news.png +0 -0
  115. package/src/browser-config.ts +143 -37
  116. package/src/browser-launch.ts +75 -0
  117. package/src/cdp-relay.ts +46 -11
  118. package/src/channel-owner-inspect.test.ts +94 -0
  119. package/src/chrome-discovery.test.ts +50 -0
  120. package/src/chrome-discovery.ts +211 -0
  121. package/src/cli.ts +432 -59
  122. package/src/executor.ts +171 -72
  123. package/src/executor.unit.test.ts +32 -1
  124. package/src/kitty-graphics.test.ts +139 -0
  125. package/src/kitty-graphics.ts +79 -0
  126. package/src/mcp.ts +82 -12
  127. package/src/package-paths.ts +36 -0
  128. package/src/relay-core.test.ts +14 -4
  129. package/src/relay-navigation.test.ts +106 -1
  130. package/src/scoped-fs.test.ts +77 -0
  131. package/src/scoped-fs.ts +12 -9
  132. package/src/screen-recording.ts +7 -22
  133. package/src/skill.md +32 -2
  134. package/src/snapshot-tools.test.ts +3 -4
  135. package/src/snapshots/shadcn-ui-accessibility-full.md +181 -183
  136. package/src/snapshots/shadcn-ui-accessibility-interactive.md +119 -121
  137. package/src/test-utils.ts +25 -11
@@ -0,0 +1,41 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { parseDevToolsActivePort } from './chrome-discovery.js';
3
+ describe('parseDevToolsActivePort', () => {
4
+ test('parses valid contents', () => {
5
+ const result = parseDevToolsActivePort('9222\n/devtools/browser/abc-123-def\n');
6
+ expect(result).toMatchInlineSnapshot(`
7
+ {
8
+ "port": 9222,
9
+ "wsPath": "/devtools/browser/abc-123-def",
10
+ }
11
+ `);
12
+ });
13
+ test('parses with extra whitespace', () => {
14
+ const result = parseDevToolsActivePort(' 9222 \n /devtools/browser/abc \n');
15
+ expect(result).toMatchInlineSnapshot(`
16
+ {
17
+ "port": 9222,
18
+ "wsPath": "/devtools/browser/abc",
19
+ }
20
+ `);
21
+ });
22
+ test('returns null for single line', () => {
23
+ expect(parseDevToolsActivePort('9222')).toBeNull();
24
+ });
25
+ test('returns null for empty string', () => {
26
+ expect(parseDevToolsActivePort('')).toBeNull();
27
+ });
28
+ test('returns null for invalid port', () => {
29
+ expect(parseDevToolsActivePort('abc\n/devtools/browser/123')).toBeNull();
30
+ });
31
+ test('returns null for port 0', () => {
32
+ expect(parseDevToolsActivePort('0\n/devtools/browser/123')).toBeNull();
33
+ });
34
+ test('returns null for port > 65535', () => {
35
+ expect(parseDevToolsActivePort('99999\n/devtools/browser/123')).toBeNull();
36
+ });
37
+ test('returns null for invalid ws path', () => {
38
+ expect(parseDevToolsActivePort('9222\n/some/other/path')).toBeNull();
39
+ });
40
+ });
41
+ //# sourceMappingURL=chrome-discovery.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"chrome-discovery.test.js","sourceRoot":"","sources":["../src/chrome-discovery.test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC/C,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAA;AAE/D,QAAQ,CAAC,yBAAyB,EAAE,GAAG,EAAE;IACvC,IAAI,CAAC,uBAAuB,EAAE,GAAG,EAAE;QACjC,MAAM,MAAM,GAAG,uBAAuB,CAAC,uCAAuC,CAAC,CAAA;QAC/E,MAAM,CAAC,MAAM,CAAC,CAAC,qBAAqB,CAAC;;;;;KAKpC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,8BAA8B,EAAE,GAAG,EAAE;QACxC,MAAM,MAAM,GAAG,uBAAuB,CAAC,uCAAuC,CAAC,CAAA;QAC/E,MAAM,CAAC,MAAM,CAAC,CAAC,qBAAqB,CAAC;;;;;KAKpC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,8BAA8B,EAAE,GAAG,EAAE;QACxC,MAAM,CAAC,uBAAuB,CAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;IACpD,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,+BAA+B,EAAE,GAAG,EAAE;QACzC,MAAM,CAAC,uBAAuB,CAAC,EAAE,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;IAChD,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,+BAA+B,EAAE,GAAG,EAAE;QACzC,MAAM,CAAC,uBAAuB,CAAC,4BAA4B,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;IAC1E,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,yBAAyB,EAAE,GAAG,EAAE;QACnC,MAAM,CAAC,uBAAuB,CAAC,0BAA0B,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;IACxE,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,+BAA+B,EAAE,GAAG,EAAE;QACzC,MAAM,CAAC,uBAAuB,CAAC,8BAA8B,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;IAC5E,CAAC,CAAC,CAAA;IAEF,IAAI,CAAC,kCAAkC,EAAE,GAAG,EAAE;QAC5C,MAAM,CAAC,uBAAuB,CAAC,wBAAwB,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAA;IACtE,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
package/dist/cli.js CHANGED
@@ -3,25 +3,83 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import util from 'node:util';
5
5
  import { fileURLToPath } from 'node:url';
6
- import { cac } from '@xmorse/cac';
6
+ import { goke } from 'goke';
7
+ import { z } from 'zod';
7
8
  import pc from 'picocolors';
9
+ import { getBrowserLaunchArgs, getDefaultBrowserUserDataDir, startBrowserProcess, } from './browser-launch.js';
10
+ import { resolveBrowserExecutablePath, shouldUseHeadlessByDefault } from './browser-config.js';
11
+ import { getBundledExtensionPath } from './package-paths.js';
8
12
  // Prevent Buffers from dumping hex bytes in util.inspect output.
9
13
  Buffer.prototype[util.inspect.custom] = function () {
10
14
  return `<Buffer ${this.length} bytes>`;
11
15
  };
12
16
  import { killPortProcess } from './kill-port.js';
17
+ import { canEmitKittyGraphics, emitKittyImage } from './kitty-graphics.js';
13
18
  import { VERSION, LOG_FILE_PATH, LOG_CDP_FILE_PATH, parseRelayHost } from './utils.js';
14
19
  import { ensureRelayServer, RELAY_PORT, waitForConnectedExtensions, getExtensionOutdatedWarning, getExtensionStatus, } from './relay-client.js';
20
+ import { discoverChromeInstances, resolveDirectInput } from './chrome-discovery.js';
15
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
22
  const cliRelayEnv = { PLAYWRITER_AUTO_ENABLE: '1' };
17
- const cli = cac('playwriter');
23
+ const cli = goke('playwriter');
24
+ cli
25
+ .command('browser start [binaryPath]', 'Start Chromium or Chrome for Testing with the bundled Playwriter extension')
26
+ .hidden()
27
+ .option('--user-data-dir <dir>', 'Persistent browser profile directory used for the managed browser')
28
+ .option('--headless', 'Run the browser in headless mode')
29
+ .option('--headed', 'Force headed mode even on Linux without DISPLAY/WAYLAND_DISPLAY')
30
+ .option('--disable-sandbox', 'Disable the browser sandbox, useful on some VPS setups')
31
+ .action(async (binaryPath, options) => {
32
+ if (options.headless && options.headed) {
33
+ console.error('Error: --headless and --headed cannot be used together.');
34
+ process.exit(1);
35
+ }
36
+ try {
37
+ await ensureRelayServer({ logger: console, env: cliRelayEnv });
38
+ const browserPath = resolveBrowserExecutablePath({ browserPath: binaryPath });
39
+ const extensionPath = getBundledExtensionPath();
40
+ const userDataDir = path.resolve(options.userDataDir || getDefaultBrowserUserDataDir());
41
+ const headless = options.headed ? false : options.headless ? true : shouldUseHeadlessByDefault();
42
+ const args = getBrowserLaunchArgs({
43
+ extensionPath,
44
+ userDataDir,
45
+ headless,
46
+ noSandbox: options.disableSandbox,
47
+ });
48
+ const { pid } = startBrowserProcess({
49
+ browserPath,
50
+ args,
51
+ userDataDir,
52
+ });
53
+ const connectedExtensions = await waitForConnectedExtensions({
54
+ timeoutMs: 15000,
55
+ pollIntervalMs: 250,
56
+ logger: console,
57
+ });
58
+ console.log(`Browser started (pid ${pid}).`);
59
+ console.log(` Binary: ${browserPath}`);
60
+ console.log(` Extension: ${extensionPath}`);
61
+ console.log(` Profile: ${userDataDir}`);
62
+ console.log(` Mode: ${headless ? 'headless' : 'headed'}`);
63
+ console.log(' Permissions: recording/tabCapture flags enabled');
64
+ if (connectedExtensions.length > 0) {
65
+ console.log('Playwriter extension connected to the relay server.');
66
+ return;
67
+ }
68
+ console.log('Browser started, but the extension has not connected yet.');
69
+ console.log(`Check logs at: ${LOG_FILE_PATH}`);
70
+ }
71
+ catch (error) {
72
+ console.error(`Error: ${error.message}`);
73
+ process.exit(1);
74
+ }
75
+ });
18
76
  cli
19
77
  .command('', 'Start the MCP server or controls the browser with -e')
20
78
  .option('--host <host>', 'Remote relay server host to connect to (or use PLAYWRITER_HOST env var)')
21
79
  .option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
22
80
  .option('-s, --session <name>', 'Session ID (required for -e, get one with `playwriter session new`)')
23
81
  .option('-e, --eval <code>', 'Execute JavaScript code and exit, read https://playwriter.dev/SKILL.md for usage')
24
- .option('--timeout <ms>', 'Execution timeout in milliseconds', { default: 10000 })
82
+ .option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
25
83
  .action(async (options) => {
26
84
  // If -e flag is provided, execute code via relay server
27
85
  if (options.eval) {
@@ -35,6 +93,7 @@ cli
35
93
  return;
36
94
  }
37
95
  // Otherwise start the MCP server
96
+ // For direct CDP in MCP mode, use PLAYWRITER_DIRECT env var
38
97
  const { startMcp } = await import('./mcp.js');
39
98
  await startMcp({
40
99
  host: options.host,
@@ -140,9 +199,33 @@ async function executeCode(options) {
140
199
  console.log(result.text);
141
200
  }
142
201
  }
143
- // Note: images are base64 encoded, we could save them to files if needed
144
- if (result.images && result.images.length > 0) {
145
- console.log(`\n${result.images.length} screenshot(s) captured`);
202
+ // Emit images via Kitty Graphics Protocol when AGENT_GRAPHICS=kitty.
203
+ // Agents with kitty-graphics-agent intercept these escape sequences and pass
204
+ // the PNG images to the LLM as media parts — no extra tool call needed.
205
+ const kittyEnabled = canEmitKittyGraphics();
206
+ // Track emitted base64 to avoid duplicates (screenshots appear in both
207
+ // result.screenshots and result.images from the same screenshotCollector)
208
+ const emittedImages = new Set();
209
+ if (result.screenshots && result.screenshots.length > 0) {
210
+ for (const s of result.screenshots) {
211
+ if (kittyEnabled && s.base64) {
212
+ emitKittyImage({ base64: s.base64 });
213
+ emittedImages.add(s.base64);
214
+ }
215
+ console.log(`\nScreenshot saved to: ${s.path}`);
216
+ console.log(`Labels shown: ${s.labelCount}\n`);
217
+ console.log(`Accessibility snapshot:\n${s.snapshot}`);
218
+ }
219
+ }
220
+ // Emit resized images from resizeImageForAgent() calls that aren't
221
+ // already emitted as part of labeled screenshots
222
+ if (kittyEnabled && result.images && result.images.length > 0) {
223
+ for (const img of result.images) {
224
+ if (img.data && !emittedImages.has(img.data)) {
225
+ emitKittyImage({ base64: img.data });
226
+ emittedImages.add(img.data);
227
+ }
228
+ }
146
229
  }
147
230
  if (result.isError) {
148
231
  process.exit(1);
@@ -160,13 +243,83 @@ async function executeCode(options) {
160
243
  process.exit(1);
161
244
  }
162
245
  }
163
- // Session management commands
164
246
  cli
165
247
  .command('session new', 'Create a new session and print the session ID')
166
248
  .option('--host <host>', 'Remote relay server host')
167
- .option('--browser <stableKey>', 'Stable browser key when multiple browsers are connected')
249
+ .option('--browser <key>', 'Browser key when multiple browsers are available')
250
+ .option('--direct [endpoint]', 'Use direct CDP connection without the extension. Enable debugging first at chrome://inspect/#remote-debugging or launch Chrome with --remote-debugging-port=9222. Auto-discovers instances or accepts an explicit ws:// endpoint')
168
251
  .action(async (options) => {
169
252
  const isLocal = !options.host && !process.env.PLAYWRITER_HOST;
253
+ const directEndpoint = typeof options.direct === 'string' ? options.direct : null;
254
+ // If --direct with explicit endpoint, resolve it (handles host:port → ws://) then skip discovery
255
+ if (directEndpoint) {
256
+ let cdpEndpoint;
257
+ try {
258
+ cdpEndpoint = await resolveDirectInput(directEndpoint);
259
+ }
260
+ catch (error) {
261
+ console.error(`Error: ${error.message}`);
262
+ process.exit(1);
263
+ }
264
+ await ensureRelayForSessionCreation(isLocal);
265
+ const serverUrl = await getServerUrl(options.host);
266
+ const result = await createDirectSession({ serverUrl, cdpEndpoint });
267
+ console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`);
268
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'));
269
+ return;
270
+ }
271
+ // If --direct with no endpoint, discover Chrome instances
272
+ if (options.direct === true) {
273
+ if (!isLocal) {
274
+ console.error('Error: --direct auto-discovery only works locally.');
275
+ console.error('For remote relay, pass an explicit endpoint reachable from the relay host:');
276
+ console.error(' playwriter session new --host <host> --direct ws://relay-host:9222/devtools/browser/...');
277
+ process.exit(1);
278
+ }
279
+ await ensureRelayForSessionCreation(isLocal);
280
+ console.log(pc.dim('Discovering Chrome instances with debugging enabled...'));
281
+ const instances = await discoverChromeInstances();
282
+ if (instances.length === 0) {
283
+ console.error('No Chrome instances with debugging enabled found.');
284
+ console.error('');
285
+ console.error('Enable debugging in one of these ways:');
286
+ console.error(' 1. Open chrome://inspect/#remote-debugging in Chrome');
287
+ console.error(' 2. Launch Chrome with: chrome --remote-debugging-port=9222');
288
+ process.exit(1);
289
+ }
290
+ if (instances.length === 1 && !options.browser) {
291
+ const instance = instances[0];
292
+ const serverUrl = await getServerUrl(options.host);
293
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles });
294
+ const profileLabel = formatInstanceProfiles(instance);
295
+ console.log(`Session ${result.id} created (direct CDP, ${instance.browser}${profileLabel}). Use with: playwriter -s ${result.id} -e "..."`);
296
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'));
297
+ return;
298
+ }
299
+ // Multiple instances or --browser specified
300
+ const directOptions = instances.map((instance) => {
301
+ return instanceToBrowserOption(instance);
302
+ });
303
+ if (options.browser) {
304
+ const selected = directOptions.find((opt) => {
305
+ return opt.key === options.browser;
306
+ });
307
+ if (!selected) {
308
+ console.error(`Browser not found: ${options.browser}`);
309
+ console.error('Available: ' + directOptions.map((opt) => opt.key).join(', '));
310
+ process.exit(1);
311
+ }
312
+ const serverUrl = await getServerUrl(options.host);
313
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl, browser: selected.browser, profiles: selected.profiles });
314
+ console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`);
315
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'));
316
+ return;
317
+ }
318
+ printBrowserTable(directOptions);
319
+ console.log('\nRun again with --browser <key>.');
320
+ process.exit(1);
321
+ }
322
+ // Default mode: extension-based (existing behavior)
170
323
  let extensions = [];
171
324
  if (isLocal) {
172
325
  await ensureRelayServer({ logger: console, env: cliRelayEnv });
@@ -189,6 +342,7 @@ cli
189
342
  }
190
343
  if (extensions.length === 0) {
191
344
  console.error('No connected browsers detected. Click the Playwriter extension icon.');
345
+ console.error(pc.dim('Tip: Use --direct to connect via Chrome DevTools Protocol instead.'));
192
346
  process.exit(1);
193
347
  }
194
348
  // Warn if any connected extension was built with an older playwriter version
@@ -199,58 +353,156 @@ cli
199
353
  break;
200
354
  }
201
355
  }
202
- let selectedExtension = null;
203
- if (extensions.length === 1) {
204
- selectedExtension = extensions[0];
205
- }
206
- else if (!options.browser) {
207
- console.log('Multiple browsers detected:\n');
208
- console.log('KEY BROWSER PROFILE');
209
- console.log('----------------------- ------- -------');
210
- for (const extension of extensions) {
211
- const label = extension.profile?.email || '(not signed in)';
212
- const stableKey = extension.stableKey || '-';
213
- console.log(`${stableKey.padEnd(23)} ${(extension.browser || 'Chrome').padEnd(7)} ${label}`);
356
+ // Single extension: auto-select (unchanged behavior)
357
+ if (extensions.length === 1 && !options.browser) {
358
+ const selectedExtension = extensions[0];
359
+ try {
360
+ const serverUrl = await getServerUrl(options.host);
361
+ const extensionId = selectedExtension.extensionId === 'default'
362
+ ? null
363
+ : selectedExtension.stableKey || selectedExtension.extensionId;
364
+ const cwd = process.cwd();
365
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
366
+ method: 'POST',
367
+ headers: { 'Content-Type': 'application/json' },
368
+ body: JSON.stringify({ extensionId, cwd }),
369
+ });
370
+ if (!response.ok) {
371
+ const text = await response.text();
372
+ console.error(`Error: ${response.status} ${text}`);
373
+ process.exit(1);
374
+ }
375
+ const result = (await response.json());
376
+ console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`);
214
377
  }
215
- console.log('\nRun again with --browser <stableKey>.');
216
- process.exit(1);
217
- }
218
- else {
219
- const browserArg = options.browser;
220
- selectedExtension = extensions.find((extension) => extension.stableKey === browserArg) || null;
221
- if (!selectedExtension) {
222
- console.error(`Browser not found: ${browserArg}`);
378
+ catch (error) {
379
+ console.error(`Error: ${error.message}`);
223
380
  process.exit(1);
224
381
  }
382
+ return;
225
383
  }
226
- if (!selectedExtension) {
227
- console.error('Unable to determine browser identity.');
228
- process.exit(1);
229
- }
230
- try {
231
- const serverUrl = await getServerUrl(options.host);
232
- const extensionId = selectedExtension.extensionId === 'default'
233
- ? null
234
- : selectedExtension.stableKey || selectedExtension.extensionId;
235
- const cwd = process.cwd();
236
- const response = await fetch(`${serverUrl}/cli/session/new`, {
237
- method: 'POST',
238
- headers: { 'Content-Type': 'application/json' },
239
- body: JSON.stringify({ extensionId, cwd }),
384
+ // Multiple extensions: also discover direct CDP instances and show unified table.
385
+ // Only discover locally — remote relay can't reach local Chrome debug ports.
386
+ const directInstances = isLocal ? await (async () => {
387
+ console.log(pc.dim('Discovering additional Chrome instances...'));
388
+ return await discoverChromeInstances();
389
+ })() : [];
390
+ const allOptions = [
391
+ ...extensions.map((ext) => {
392
+ return {
393
+ key: ext.stableKey || ext.extensionId,
394
+ type: 'extension',
395
+ browser: ext.browser || 'Chrome',
396
+ profile: ext.profile?.email || '(not signed in)',
397
+ extensionId: ext.extensionId === 'default' ? null : ext.stableKey || ext.extensionId,
398
+ };
399
+ }),
400
+ ...directInstances.map((instance) => {
401
+ return instanceToBrowserOption(instance);
402
+ }),
403
+ ];
404
+ if (options.browser) {
405
+ const selected = allOptions.find((opt) => {
406
+ return opt.key === options.browser;
240
407
  });
241
- if (!response.ok) {
242
- const text = await response.text();
243
- console.error(`Error: ${response.status} ${text}`);
408
+ if (!selected) {
409
+ console.error(`Browser not found: ${options.browser}`);
410
+ console.error('Available: ' + allOptions.map((opt) => opt.key).join(', '));
244
411
  process.exit(1);
245
412
  }
246
- const result = (await response.json());
247
- console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`);
248
- }
249
- catch (error) {
250
- console.error(`Error: ${error.message}`);
251
- process.exit(1);
413
+ try {
414
+ const serverUrl = await getServerUrl(options.host);
415
+ if (selected.type === 'direct') {
416
+ const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl, browser: selected.browser, profiles: selected.profiles });
417
+ console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`);
418
+ console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'));
419
+ }
420
+ else {
421
+ const cwd = process.cwd();
422
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
423
+ method: 'POST',
424
+ headers: { 'Content-Type': 'application/json' },
425
+ body: JSON.stringify({ extensionId: selected.extensionId, cwd }),
426
+ });
427
+ if (!response.ok) {
428
+ const text = await response.text();
429
+ console.error(`Error: ${response.status} ${text}`);
430
+ process.exit(1);
431
+ }
432
+ const result = (await response.json());
433
+ console.log(`Session ${result.id} created. Use with: playwriter -s ${result.id} -e "..."`);
434
+ }
435
+ }
436
+ catch (error) {
437
+ console.error(`Error: ${error.message}`);
438
+ process.exit(1);
439
+ }
440
+ return;
252
441
  }
442
+ // Show unified table
443
+ console.log('\nMultiple browsers detected:\n');
444
+ printBrowserTable(allOptions);
445
+ console.log('\nRun again with --browser <key>.');
446
+ process.exit(1);
253
447
  });
448
+ async function ensureRelayForSessionCreation(isLocal) {
449
+ if (isLocal) {
450
+ await ensureRelayServer({ logger: console, env: cliRelayEnv });
451
+ }
452
+ }
453
+ async function createDirectSession({ serverUrl, cdpEndpoint, browser, profiles, }) {
454
+ const cwd = process.cwd();
455
+ const response = await fetch(`${serverUrl}/cli/session/new`, {
456
+ method: 'POST',
457
+ headers: { 'Content-Type': 'application/json' },
458
+ body: JSON.stringify({ cdpEndpoint, cwd, browser, profiles }),
459
+ });
460
+ if (!response.ok) {
461
+ const text = await response.text();
462
+ throw new Error(`${response.status} ${text}`);
463
+ }
464
+ return (await response.json());
465
+ }
466
+ function instanceToBrowserOption(instance) {
467
+ return {
468
+ key: `direct:${instance.port}`,
469
+ type: 'direct',
470
+ browser: instance.browser,
471
+ profile: formatInstanceProfiles(instance),
472
+ wsUrl: instance.wsUrl,
473
+ profiles: instance.profiles,
474
+ };
475
+ }
476
+ function formatInstanceProfiles(instance) {
477
+ if (instance.profiles.length === 0) {
478
+ return '(unknown)';
479
+ }
480
+ return instance.profiles
481
+ .map((p) => {
482
+ return p.email ? `${p.name} (${p.email})` : p.name;
483
+ })
484
+ .join(', ');
485
+ }
486
+ function printBrowserTable(options) {
487
+ const typeLabels = options.map((opt) => {
488
+ return opt.type === 'direct' ? '--direct' : opt.type;
489
+ });
490
+ const keyWidth = Math.max(3, ...options.map((opt) => opt.key.length));
491
+ const typeWidth = Math.max(4, ...typeLabels.map((t) => t.length));
492
+ const browserWidth = Math.max(7, ...options.map((opt) => opt.browser.length));
493
+ console.log('KEY'.padEnd(keyWidth) + ' ' + 'TYPE'.padEnd(typeWidth) + ' ' + 'BROWSER'.padEnd(browserWidth) + ' ' + 'PROFILE');
494
+ console.log('-'.repeat(keyWidth + typeWidth + browserWidth + 20));
495
+ for (let i = 0; i < options.length; i++) {
496
+ const opt = options[i];
497
+ console.log(opt.key.padEnd(keyWidth) +
498
+ ' ' +
499
+ typeLabels[i].padEnd(typeWidth) +
500
+ ' ' +
501
+ opt.browser.padEnd(browserWidth) +
502
+ ' ' +
503
+ opt.profile);
504
+ }
505
+ }
254
506
  cli
255
507
  .command('session list', 'List all active sessions')
256
508
  .option('--host <host>', 'Remote relay server host')
@@ -283,6 +535,7 @@ cli
283
535
  const browserWidth = Math.max(7, ...sessions.map((session) => (session.browser || 'Chrome').length));
284
536
  const profileWidth = Math.max(7, ...sessions.map((session) => (session.profile?.email || '').length || 1));
285
537
  const extensionWidth = Math.max(2, ...sessions.map((session) => (session.extensionId || '').length || 1));
538
+ const cwdWidth = Math.max(3, ...sessions.map((session) => (session.cwd || '').length || 1));
286
539
  const stateWidth = Math.max(10, ...sessions.map((session) => session.stateKeys.join(', ').length || 1));
287
540
  console.log('ID'.padEnd(idWidth) +
288
541
  ' ' +
@@ -292,11 +545,14 @@ cli
292
545
  ' ' +
293
546
  'EXT'.padEnd(extensionWidth) +
294
547
  ' ' +
548
+ 'CWD'.padEnd(cwdWidth) +
549
+ ' ' +
295
550
  'STATE KEYS');
296
- console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + stateWidth + 8));
551
+ console.log('-'.repeat(idWidth + browserWidth + profileWidth + extensionWidth + cwdWidth + stateWidth + 10));
297
552
  for (const session of sessions) {
298
553
  const stateStr = session.stateKeys.length > 0 ? session.stateKeys.join(', ') : '-';
299
554
  const profileLabel = session.profile?.email || '-';
555
+ const cwdLabel = session.cwd || '-';
300
556
  console.log(String(session.id).padEnd(idWidth) +
301
557
  ' ' +
302
558
  (session.browser || 'Chrome').padEnd(browserWidth) +
@@ -305,6 +561,8 @@ cli
305
561
  ' ' +
306
562
  (session.extensionId || '-').padEnd(extensionWidth) +
307
563
  ' ' +
564
+ cwdLabel.padEnd(cwdWidth) +
565
+ ' ' +
308
566
  stateStr);
309
567
  }
310
568
  });
@@ -364,7 +622,7 @@ cli
364
622
  });
365
623
  cli
366
624
  .command('serve', `Start the relay server on this machine (must be the same host where Chrome is running). Remote clients (Docker, other machines) connect via PLAYWRITER_HOST. Use --host localhost for Docker (no token needed) — containers reach it via host.docker.internal. Use --host 0.0.0.0 for LAN/internet access (requires --token).`)
367
- .option('--host <host>', 'Host to bind to (use "localhost" for Docker, "0.0.0.0" for remote access)', { default: '0.0.0.0' })
625
+ .option('--host [host]', z.string().default('0.0.0.0').describe('Host to bind to (use "localhost" for Docker, "0.0.0.0" for remote access)'))
368
626
  .option('--token <token>', 'Authentication token, required when --host is 0.0.0.0 (or use PLAYWRITER_TOKEN env var)')
369
627
  .option('--replace', 'Kill existing server if running')
370
628
  .action(async (options) => {
@@ -443,6 +701,52 @@ cli
443
701
  process.exit(0);
444
702
  });
445
703
  });
704
+ cli
705
+ .command('browser list', 'List all available browsers: extension-connected and direct CDP on port 9222')
706
+ .option('--host <host>', z.string().describe('Remote relay server host'))
707
+ .action(async (options) => {
708
+ const isLocal = !options.host && !process.env.PLAYWRITER_HOST;
709
+ // Start relay if local so the extension can connect, then fetch in parallel
710
+ if (isLocal) {
711
+ await ensureRelayServer({ logger: console, env: cliRelayEnv });
712
+ }
713
+ const [extensions, directInstances] = await Promise.all([
714
+ isLocal
715
+ ? waitForConnectedExtensions({ timeoutMs: 2000, pollIntervalMs: 200, logger: console })
716
+ : fetchExtensionsStatus(options.host),
717
+ isLocal ? discoverChromeInstances() : Promise.resolve([]),
718
+ ]);
719
+ const allOptions = [
720
+ ...extensions.map((ext) => {
721
+ return {
722
+ key: ext.stableKey || ext.extensionId,
723
+ type: 'extension',
724
+ browser: ext.browser || 'Chrome',
725
+ profile: ext.profile?.email || '(not signed in)',
726
+ extensionId: ext.extensionId === 'default' ? null : ext.stableKey || ext.extensionId,
727
+ };
728
+ }),
729
+ ...directInstances.map(instanceToBrowserOption),
730
+ ];
731
+ if (allOptions.length === 0) {
732
+ console.log('No browsers detected.\n');
733
+ console.log(' Extension: click the Playwriter icon on a tab to connect');
734
+ console.log(' Direct: open chrome://inspect/#remote-debugging in Chrome');
735
+ return;
736
+ }
737
+ printBrowserTable(allOptions);
738
+ console.log('');
739
+ const hasDirectInstances = allOptions.some((opt) => {
740
+ return opt.type === 'direct';
741
+ });
742
+ if (hasDirectInstances) {
743
+ console.log(pc.dim('Connect with: playwriter session new --direct'));
744
+ console.log(pc.dim('Chrome may ask to approve the debugging connection.'));
745
+ }
746
+ else {
747
+ console.log(pc.dim('Use with: playwriter session new [--browser <key>]'));
748
+ }
749
+ });
446
750
  cli.command('logfile', 'Print the path to the relay server log file').action(() => {
447
751
  console.log(`relay: ${LOG_FILE_PATH}`);
448
752
  console.log(`cdp: ${LOG_CDP_FILE_PATH}`);