playwriter 0.0.103 → 0.1.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 (74) hide show
  1. package/dist/bippy.js +1 -1
  2. package/dist/cdp-relay.d.ts.map +1 -1
  3. package/dist/cdp-relay.js +5 -0
  4. package/dist/cdp-relay.js.map +1 -1
  5. package/dist/cli-help.test.d.ts +2 -0
  6. package/dist/cli-help.test.d.ts.map +1 -0
  7. package/dist/cli-help.test.js +31 -0
  8. package/dist/cli-help.test.js.map +1 -0
  9. package/dist/cli.js +13 -5
  10. package/dist/cli.js.map +1 -1
  11. package/dist/executor.d.ts +2 -1
  12. package/dist/executor.d.ts.map +1 -1
  13. package/dist/executor.js +32 -22
  14. package/dist/executor.js.map +1 -1
  15. package/dist/extension/background.js +516 -22
  16. package/dist/extension/manifest.json +4 -2
  17. package/dist/extension-connection.test.d.ts.map +1 -1
  18. package/dist/extension-connection.test.js +79 -0
  19. package/dist/extension-connection.test.js.map +1 -1
  20. package/dist/ghost-cursor-client.js +170 -83
  21. package/dist/{recording-ghost-cursor.d.ts → ghost-cursor-controller.d.ts} +15 -10
  22. package/dist/ghost-cursor-controller.d.ts.map +1 -0
  23. package/dist/ghost-cursor-controller.js +98 -0
  24. package/dist/ghost-cursor-controller.js.map +1 -0
  25. package/dist/ghost-cursor.d.ts.map +1 -1
  26. package/dist/ghost-cursor.js +42 -26
  27. package/dist/ghost-cursor.js.map +1 -1
  28. package/dist/on-mouse-action.test.js +25 -0
  29. package/dist/on-mouse-action.test.js.map +1 -1
  30. package/dist/popup-relocation.test.d.ts +7 -0
  31. package/dist/popup-relocation.test.d.ts.map +1 -0
  32. package/dist/popup-relocation.test.js +139 -0
  33. package/dist/popup-relocation.test.js.map +1 -0
  34. package/dist/prompt.md +13 -12
  35. package/dist/readability.js +1 -1
  36. package/dist/relay-core.test.d.ts.map +1 -1
  37. package/dist/relay-core.test.js +101 -1
  38. package/dist/relay-core.test.js.map +1 -1
  39. package/dist/relay-state.d.ts +1 -0
  40. package/dist/relay-state.d.ts.map +1 -1
  41. package/dist/relay-state.js.map +1 -1
  42. package/dist/screen-recording.d.ts +2 -2
  43. package/dist/screen-recording.d.ts.map +1 -1
  44. package/dist/screen-recording.js +0 -3
  45. package/dist/screen-recording.js.map +1 -1
  46. package/dist/selector-generator.js +1 -1
  47. package/package.json +6 -6
  48. package/src/aria-snapshots/github-interactive.txt +5 -3
  49. package/src/aria-snapshots/github-raw.txt +8 -5
  50. package/src/aria-snapshots/hackernews-interactive.txt +241 -242
  51. package/src/aria-snapshots/hackernews-raw.txt +267 -268
  52. package/src/aria-snapshots/prosemirror-interactive.txt +3 -1
  53. package/src/aria-snapshots/prosemirror-raw.txt +4 -1
  54. package/src/assets/aria-labels-hacker-news.png +0 -0
  55. package/src/assets/aria-labels-old-reddit.png +0 -0
  56. package/src/cdp-relay.ts +5 -0
  57. package/src/cli-help.test.ts +41 -0
  58. package/src/cli.ts +14 -9
  59. package/src/executor.ts +33 -22
  60. package/src/extension-connection.test.ts +88 -0
  61. package/src/ghost-cursor-client.ts +221 -96
  62. package/src/{recording-ghost-cursor.ts → ghost-cursor-controller.ts} +50 -34
  63. package/src/ghost-cursor.ts +54 -41
  64. package/src/on-mouse-action.test.ts +30 -0
  65. package/src/popup-relocation.test.ts +163 -0
  66. package/src/relay-core.test.ts +117 -0
  67. package/src/relay-state.ts +1 -1
  68. package/src/screen-recording.ts +3 -6
  69. package/src/skill.md +13 -12
  70. package/src/snapshots/shadcn-ui-accessibility-full.md +174 -181
  71. package/src/snapshots/shadcn-ui-accessibility-interactive.md +6 -14
  72. package/dist/recording-ghost-cursor.d.ts.map +0 -1
  73. package/dist/recording-ghost-cursor.js +0 -79
  74. package/dist/recording-ghost-cursor.js.map +0 -1
@@ -60,4 +60,6 @@
60
60
  - role=link[name="Backers"]
61
61
  - role=link[name="Code of Conduct"]
62
62
  - role=link[name="Discuss"] >> nth=1
63
- - role=link[name="Report an Issue"]
63
+ - role=link[name="Report an Issue"]
64
+ - role=button[name="Pin element — click any element to copy its Playwriter reference"]
65
+ - role=button[name="Close Playwriter toolbar"]
@@ -138,4 +138,7 @@
138
138
  - role=link[name="Backers"]
139
139
  - role=link[name="Code of Conduct"]
140
140
  - role=link[name="Discuss"] >> nth=1
141
- - role=link[name="Report an Issue"]
141
+ - role=link[name="Report an Issue"]
142
+ - toolbar "Playwriter tools":
143
+ - role=button[name="Pin element — click any element to copy its Playwriter reference"]
144
+ - role=button[name="Close Playwriter toolbar"]
package/src/cdp-relay.ts CHANGED
@@ -163,6 +163,9 @@ export async function startPlayWriterCDPRelayServer({
163
163
  if (info.email) {
164
164
  return `email:${info.email}`
165
165
  }
166
+ if (info.installId) {
167
+ return `install:${info.browser || 'unknown'}:${info.installId}`
168
+ }
166
169
  if (info.browser) {
167
170
  return `browser:${info.browser}`
168
171
  }
@@ -1264,11 +1267,13 @@ export async function startPlayWriterCDPRelayServer({
1264
1267
  const browser = c.req.query('browser')
1265
1268
  const email = c.req.query('email')
1266
1269
  const id = c.req.query('id')
1270
+ const installId = c.req.query('installId')
1267
1271
  const version = c.req.query('v')
1268
1272
  return {
1269
1273
  browser: browser || undefined,
1270
1274
  email: email || undefined,
1271
1275
  id: id || undefined,
1276
+ installId: installId || undefined,
1272
1277
  version: version || undefined,
1273
1278
  }
1274
1279
  }
@@ -0,0 +1,41 @@
1
+ // Verifies CLI help stays runnable without loading browser-start-only dependencies.
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { execFile } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+ import { describe, expect, test } from 'vitest'
7
+
8
+ const execFileAsync = promisify(execFile)
9
+ const currentDir = path.dirname(fileURLToPath(import.meta.url))
10
+ const playwriterDir = path.resolve(currentDir, '..')
11
+ const viteNodeBinary = path.join(
12
+ playwriterDir,
13
+ 'node_modules',
14
+ '.bin',
15
+ process.platform === 'win32' ? 'vite-node.cmd' : 'vite-node',
16
+ )
17
+
18
+ async function runCli(args: string[]): Promise<{ stdout: string; stderr: string }> {
19
+ return execFileAsync(viteNodeBinary, ['src/cli.ts', ...args], {
20
+ cwd: playwriterDir,
21
+ env: process.env,
22
+ })
23
+ }
24
+
25
+ describe('playwriter cli help', () => {
26
+ test('renders root help without crashing', async () => {
27
+ const { stdout, stderr } = await runCli(['--help'])
28
+
29
+ expect(stdout).toContain('playwriter')
30
+ expect(stdout).toContain('serve')
31
+ expect(stderr).toBe('')
32
+ }, 30000)
33
+
34
+ test('renders serve help without crashing', async () => {
35
+ const { stdout, stderr } = await runCli(['serve', '--help'])
36
+
37
+ expect(stdout).toContain('Start the relay server on this machine')
38
+ expect(stdout).toContain('--replace')
39
+ expect(stderr).toBe('')
40
+ }, 30000)
41
+ })
package/src/cli.ts CHANGED
@@ -7,13 +7,6 @@ import { fileURLToPath } from 'node:url'
7
7
  import { goke } from 'goke'
8
8
  import { z } from 'zod'
9
9
  import pc from 'picocolors'
10
- import {
11
- getBrowserLaunchArgs,
12
- getDefaultBrowserUserDataDir,
13
- startBrowserProcess,
14
- } from './browser-launch.js'
15
- import { resolveBrowserExecutablePath, shouldUseHeadlessByDefault } from './browser-config.js'
16
- import { getBundledExtensionPath } from './package-paths.js'
17
10
 
18
11
  // Prevent Buffers from dumping hex bytes in util.inspect output.
19
12
  Buffer.prototype[util.inspect.custom] = function () {
@@ -52,6 +45,14 @@ cli
52
45
  }
53
46
 
54
47
  try {
48
+ // Avoid loading playwright-core during generic CLI startup/help. This command
49
+ // is the only path that needs browser discovery and bundled extension launch.
50
+ const [{ getBrowserLaunchArgs, getDefaultBrowserUserDataDir, startBrowserProcess }, { resolveBrowserExecutablePath, shouldUseHeadlessByDefault }, { getBundledExtensionPath }] = await Promise.all([
51
+ import('./browser-launch.js'),
52
+ import('./browser-config.js'),
53
+ import('./package-paths.js'),
54
+ ])
55
+
55
56
  await ensureRelayServer({ logger: console, env: cliRelayEnv })
56
57
 
57
58
  const browserPath = resolveBrowserExecutablePath({ browserPath: binaryPath })
@@ -324,7 +325,11 @@ cli
324
325
  .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')
325
326
  .action(async (options) => {
326
327
  const isLocal = !options.host && !process.env.PLAYWRITER_HOST
327
- const directEndpoint = typeof options.direct === 'string' ? options.direct : null
328
+ // goke 6.6: optional-value flags are string | undefined
329
+ // `--direct ws://...` → 'ws://...' (explicit endpoint)
330
+ // `--direct` → '' (bare flag, auto-discover)
331
+ // (omitted) → undefined (don't use direct CDP)
332
+ const directEndpoint = options.direct || null
328
333
 
329
334
  // If --direct with explicit endpoint, resolve it (handles host:port → ws://) then skip discovery
330
335
  if (directEndpoint) {
@@ -344,7 +349,7 @@ cli
344
349
  }
345
350
 
346
351
  // If --direct with no endpoint, discover Chrome instances
347
- if (options.direct === true) {
352
+ if (options.direct === '') {
348
353
  if (!isLocal) {
349
354
  console.error('Error: --direct auto-discovery only works locally.')
350
355
  console.error('For remote relay, pass an explicit endpoint reachable from the relay host:')
package/src/executor.ts CHANGED
@@ -37,7 +37,7 @@ import { getPageMarkdown, type GetPageMarkdownOptions } from './page-markdown.js
37
37
  import { createRecordingApi } from './screen-recording.js'
38
38
  import { createDemoVideo } from './ffmpeg.js'
39
39
  import { type GhostCursorClientOptions } from './ghost-cursor.js'
40
- import { RecordingGhostCursorController } from './recording-ghost-cursor.js'
40
+ import { GhostCursorController } from './ghost-cursor-controller.js'
41
41
 
42
42
  const __filename = fileURLToPath(import.meta.url)
43
43
  const __dirname = path.dirname(__filename)
@@ -312,6 +312,8 @@ export class PlaywrightExecutor {
312
312
  private sessionCwd: string | null
313
313
  private hasWarnedExtensionOutdated = false
314
314
 
315
+ private ghostCursorController: GhostCursorController
316
+
315
317
  constructor(options: ExecutorOptions) {
316
318
  this.cdpConfig = options.cdpConfig
317
319
  this.logger = options.logger || { log: console.log, error: console.error }
@@ -323,6 +325,13 @@ export class PlaywrightExecutor {
323
325
  this.sessionCwd || undefined,
324
326
  )
325
327
  this.sandboxedRequire = this.createSandboxedRequire(require)
328
+ this.ghostCursorController = new GhostCursorController({
329
+ logger: {
330
+ error: (...args: unknown[]) => {
331
+ this.logger.error(...args)
332
+ },
333
+ },
334
+ })
326
335
  }
327
336
 
328
337
  private createSandboxedRequire(originalRequire: NodeRequire): NodeRequire {
@@ -422,6 +431,9 @@ export class PlaywrightExecutor {
422
431
  const warning = getExtensionOutdatedWarning(playwriterVersion)
423
432
  if (warning) {
424
433
  this.logger.log(warning)
434
+ // Enqueue so MCP agents see version-skew messages in their next execute
435
+ // response — logger.log alone only reaches stdout, not the LLM.
436
+ this.enqueueWarning(warning)
425
437
  this.hasWarnedExtensionOutdated = true
426
438
  }
427
439
  }
@@ -433,7 +445,11 @@ export class PlaywrightExecutor {
433
445
  this.pagesWithListeners.add(page)
434
446
  this.setupPageCloseDetection(page)
435
447
  this.setupPageConsoleListener(page)
436
- this.setupPopupDetection(page)
448
+ this.setupNewPageLogging(page)
449
+ this.ghostCursorController.attachToPage({ page })
450
+ page.on('close', () => {
451
+ this.ghostCursorController.detachFromPage({ page })
452
+ })
437
453
  }
438
454
 
439
455
  private setupPageCloseDetection(page: Page) {
@@ -495,20 +511,21 @@ export class PlaywrightExecutor {
495
511
  })
496
512
  }
497
513
 
498
- private setupPopupDetection(page: Page) {
499
- // Listen for popup events (window.open, target=_blank) on each page.
500
- // This is more reliable than checking page.opener() on context 'page' event,
501
- // which also fires for context.newPage() and CDP reconnection scenarios.
514
+ private setupNewPageLogging(page: Page) {
515
+ // page.on('popup') fires for window.open, target=_blank, and cmd+click
516
+ // (but not context.newPage() or CDP reconnection). The extension
517
+ // auto-relocates popups to tabs, so these pages are controllable via
518
+ // context.pages(). Enqueue synchronously so the warning lands in the
519
+ // enclosing execute() call's scope. initialUrl may be 'about:blank'
520
+ // for blank-then-scripted popups.
502
521
  page.on('popup', (popup) => {
503
- const context = page.context()
504
- const pages = context.pages()
522
+ const pages = popup.context().pages()
505
523
  const rawIndex = pages.indexOf(popup)
506
524
  const pageIndex = rawIndex >= 0 ? String(rawIndex) : 'unknown'
507
- const url = popup.url()
525
+ const initialUrl = popup.url() || 'about:blank'
508
526
  this.enqueueWarning(
509
- `Popup window detected (page index ${pageIndex}, url: ${url}). ` +
510
- `Popup windows cannot be controlled by playwriter. ` +
511
- `Repeat the interaction in a way that does not open a popup, or navigate to the URL directly in a new tab.`,
527
+ `New page opened from current page (index ${pageIndex}, initial url: ${initialUrl}). ` +
528
+ `Access it via context.pages()[${pageIndex}] to interact with it.`,
512
529
  )
513
530
  })
514
531
  }
@@ -1057,13 +1074,7 @@ export class PlaywrightExecutor {
1057
1074
  // This permission is granted when the user clicks the Playwriter extension icon on a tab.
1058
1075
  const relayPort = this.cdpConfig.port || 19988
1059
1076
  const self = this
1060
- const recordingGhostCursor = new RecordingGhostCursorController({
1061
- logger: {
1062
- error: (...args: unknown[]) => {
1063
- self.logger.error(...args)
1064
- },
1065
- },
1066
- })
1077
+ const ghostCursorController = this.ghostCursorController
1067
1078
 
1068
1079
  const showGhostCursor = async (options?: ({ page?: Page } & GhostCursorClientOptions)) => {
1069
1080
  const targetPage = options?.page || page
@@ -1076,19 +1087,19 @@ export class PlaywrightExecutor {
1076
1087
  return rest
1077
1088
  })()
1078
1089
 
1079
- await recordingGhostCursor.show({ page: targetPage, cursorOptions })
1090
+ await ghostCursorController.show({ page: targetPage, cursorOptions })
1080
1091
  }
1081
1092
 
1082
1093
  const hideGhostCursor = async (options?: { page?: Page }) => {
1083
1094
  const targetPage = options?.page || page
1084
- await recordingGhostCursor.hide({ page: targetPage })
1095
+ await ghostCursorController.hide({ page: targetPage })
1085
1096
  }
1086
1097
 
1087
1098
  const recordingApi = createRecordingApi({
1088
1099
  context,
1089
1100
  defaultPage: page,
1090
1101
  relayPort,
1091
- ghostCursorController: recordingGhostCursor,
1102
+ ghostCursorController,
1092
1103
  onStart: () => {
1093
1104
  self.recordingStartedAt = Date.now()
1094
1105
  self.executionTimestamps = []
@@ -1,8 +1,12 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
1
4
  import { createMCPClient } from './mcp-client.js'
2
5
  import { describe, it, expect, beforeAll, afterAll } from 'vitest'
3
6
  import { chromium } from '@xmorse/playwright-core'
4
7
  import { getCdpUrl } from './utils.js'
5
8
  import { setupTestContext, cleanupTestContext, getExtensionServiceWorker, type TestContext, js } from './test-utils.js'
9
+ import { getExtensionsStatus } from './relay-client.js'
6
10
  import './test-declarations.js'
7
11
 
8
12
  const TEST_PORT = 19990
@@ -660,6 +664,90 @@ describe('Extension Connection Tests', () => {
660
664
  await page.goto('about:blank')
661
665
  })
662
666
 
667
+ it('should keep an active browser connected when another Chromium context starts', async () => {
668
+ const browserContext = getBrowserContext()
669
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
670
+
671
+ await serviceWorker.evaluate(async () => {
672
+ await globalThis.disconnectEverything()
673
+ })
674
+
675
+ const page = await browserContext.newPage()
676
+ const targetUrl = 'https://example.com/multi-context-stability'
677
+ await page.goto(targetUrl)
678
+ await page.waitForLoadState('domcontentloaded')
679
+ await page.bringToFront()
680
+
681
+ const enableResult = await serviceWorker.evaluate(async () => {
682
+ return await globalThis.toggleExtensionForActiveTab()
683
+ })
684
+ expect(enableResult.isConnected).toBe(true)
685
+
686
+ const secondUserDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-conn-second-'))
687
+ const extensionPath = path.resolve(process.cwd(), '../extension', `dist-${TEST_PORT}`)
688
+ const secondContext = await chromium.launchPersistentContext(secondUserDataDir, {
689
+ channel: 'chromium',
690
+ headless: !process.env.HEADFUL,
691
+ colorScheme: 'dark',
692
+ args: [`--disable-extensions-except=${extensionPath}`, `--load-extension=${extensionPath}`],
693
+ })
694
+
695
+ try {
696
+ await getExtensionServiceWorker(secondContext)
697
+
698
+ const statusSnapshots: Array<{ keys: string[]; activeTargets: number[] }> = []
699
+ for (let i = 0; i < 4; i++) {
700
+ await new Promise((resolve) => {
701
+ setTimeout(resolve, 1000)
702
+ })
703
+ const statuses = await getExtensionsStatus(TEST_PORT)
704
+ statusSnapshots.push({
705
+ keys: statuses.map((status) => {
706
+ return status.stableKey || status.extensionId
707
+ }),
708
+ activeTargets: statuses.map((status) => {
709
+ return status.activeTargets
710
+ }),
711
+ })
712
+ }
713
+
714
+ expect(statusSnapshots.every((snapshot) => {
715
+ return snapshot.keys.length >= 2
716
+ })).toBe(true)
717
+ expect(statusSnapshots.every((snapshot) => {
718
+ return new Set(snapshot.keys).size === snapshot.keys.length
719
+ })).toBe(true)
720
+ expect(statusSnapshots.every((snapshot) => {
721
+ return snapshot.activeTargets.some((count) => count > 0)
722
+ })).toBe(true)
723
+
724
+ const executeResult = await client.callTool({
725
+ name: 'execute',
726
+ arguments: {
727
+ code: js`
728
+ const pages = context.pages();
729
+ const testPage = pages.find((p) => p.url().includes('multi-context-stability'));
730
+ return { pagesCount: pages.length, found: !!testPage, url: testPage?.url() };
731
+ `,
732
+ },
733
+ })
734
+
735
+ const executeOutput = (executeResult as any).content[0].text
736
+ expect(executeOutput).toContain('found: true')
737
+ expect(executeOutput).toContain(targetUrl)
738
+ expect((executeResult as any).isError).not.toBe(true)
739
+ } finally {
740
+ await secondContext.close()
741
+ fs.rmSync(secondUserDataDir, { recursive: true, force: true })
742
+ if (!page.isClosed()) {
743
+ await page.close()
744
+ }
745
+ await serviceWorker.evaluate(async () => {
746
+ await globalThis.disconnectEverything()
747
+ })
748
+ }
749
+ }, 120000)
750
+
663
751
  it('should maintain correct page.url() with service worker pages', async () => {
664
752
  const browserContext = getBrowserContext()
665
753
  const serviceWorker = await getExtensionServiceWorker(browserContext)