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
package/src/mcp.ts CHANGED
@@ -17,6 +17,8 @@ import dedent from 'string-dedent'
17
17
  import { LOG_FILE_PATH, VERSION, parseRelayHost } from './utils.js'
18
18
  import { ensureRelayServer, RELAY_PORT } from './relay-client.js'
19
19
  import { PlaywrightExecutor, CodeExecutionTimeoutError } from './executor.js'
20
+ import { discoverChromeInstances, resolveDirectInput, appendSessionToWsUrl } from './chrome-discovery.js'
21
+ import crypto from 'node:crypto'
20
22
 
21
23
  const __filename = fileURLToPath(import.meta.url)
22
24
  const __dirname = path.dirname(__filename)
@@ -87,11 +89,57 @@ async function ensureRelayServerForMcp(): Promise<void> {
87
89
  await ensureRelayServer({ logger: mcpLogger })
88
90
  }
89
91
 
92
+ /**
93
+ * Resolve direct CDP config from PLAYWRITER_DIRECT env var.
94
+ * - "auto" / "1" / "true": auto-discover Chrome on default port 9222
95
+ * - "ws://..." / "wss://...": use explicit WebSocket endpoint
96
+ * - "host:port": resolve to ws:// URL via HTTP probe + DevToolsActivePort fallback
97
+ */
98
+ async function getDirectCdpConfig(): Promise<{ directCdpUrl: string } | null> {
99
+ const directEnv = process.env.PLAYWRITER_DIRECT
100
+ if (!directEnv) {
101
+ return null
102
+ }
103
+
104
+ // Auto-discover: check default port 9222
105
+ if (directEnv === '1') {
106
+ const instances = await discoverChromeInstances()
107
+ if (instances.length === 0) {
108
+ throw new Error(
109
+ 'PLAYWRITER_DIRECT is set but no Chrome found on port 9222. ' +
110
+ 'Enable debugging at chrome://inspect/#remote-debugging or launch with --remote-debugging-port=9222.',
111
+ )
112
+ }
113
+ const sessionId = crypto.randomUUID()
114
+ const wsUrl = appendSessionToWsUrl(instances[0].wsUrl, sessionId)
115
+ mcpLog(`Direct CDP: using ${instances[0].browser} on port ${instances[0].port}`)
116
+ return { directCdpUrl: wsUrl }
117
+ }
118
+
119
+ // ws://, wss://, or host:port — resolveDirectInput handles all three
120
+ const resolved = await resolveDirectInput(directEnv)
121
+ const sessionId = crypto.randomUUID()
122
+ const directCdpUrl = appendSessionToWsUrl(resolved, sessionId)
123
+ mcpLog(`Direct CDP: resolved ${directEnv} → ${directCdpUrl}`)
124
+ return { directCdpUrl }
125
+ }
126
+
90
127
  async function getOrCreateExecutor(): Promise<PlaywrightExecutor> {
91
128
  if (executor) {
92
129
  return executor
93
130
  }
94
131
 
132
+ // Direct CDP mode takes priority over relay/remote
133
+ const directConfig = await getDirectCdpConfig()
134
+ if (directConfig) {
135
+ executor = new PlaywrightExecutor({
136
+ cdpConfig: directConfig,
137
+ logger: mcpLogger,
138
+ cwd: process.cwd(),
139
+ })
140
+ return executor
141
+ }
142
+
95
143
  const remote = getRemoteConfig()
96
144
  if (!remote) {
97
145
  await ensureRelayServerForMcp()
@@ -194,17 +242,31 @@ server.tool(
194
242
  async ({ code, timeout }) => {
195
243
  try {
196
244
  // Check relay server on every execute to auto-recover from crashes
197
- const remote = getRemoteConfig()
198
- if (!remote) {
199
- await ensureRelayServerForMcp()
245
+ // (skip in direct CDP mode — no relay involved)
246
+ if (!process.env.PLAYWRITER_DIRECT) {
247
+ const remote = getRemoteConfig()
248
+ if (!remote) {
249
+ await ensureRelayServerForMcp()
250
+ }
200
251
  }
201
252
 
202
253
  const exec = await getOrCreateExecutor()
203
254
  const result = await exec.execute(code, timeout)
204
255
 
205
256
  // Transform executor result to MCP format
257
+ // Append screenshot metadata to text for MCP (image is included inline as content)
258
+ const MAX_TEXT = 10000
259
+ let text = result.text
260
+ for (const s of result.screenshots) {
261
+ text += `\nScreenshot saved to: ${s.path} (image included below, ${s.labelCount} labels)\n`
262
+ text += `Accessibility snapshot:\n${s.snapshot}\n`
263
+ }
264
+ if (text.length > MAX_TEXT) {
265
+ text = text.slice(0, MAX_TEXT) + '\n\n[Truncated]'
266
+ }
267
+
206
268
  const content: Array<{ type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }> = [
207
- { type: 'text', text: result.text },
269
+ { type: 'text', text },
208
270
  ]
209
271
 
210
272
  for (const image of result.images) {
@@ -255,9 +317,12 @@ server.tool(
255
317
  async () => {
256
318
  try {
257
319
  // Check relay server to auto-recover from crashes
258
- const remote = getRemoteConfig()
259
- if (!remote) {
260
- await ensureRelayServerForMcp()
320
+ // (skip in direct CDP mode — no relay involved)
321
+ if (!process.env.PLAYWRITER_DIRECT) {
322
+ const remote = getRemoteConfig()
323
+ if (!remote) {
324
+ await ensureRelayServerForMcp()
325
+ }
261
326
  }
262
327
 
263
328
  const exec = await getOrCreateExecutor()
@@ -288,12 +353,17 @@ export async function startMcp(options: { host?: string; token?: string } = {})
288
353
  process.env.PLAYWRITER_TOKEN = options.token
289
354
  }
290
355
 
291
- const remote = getRemoteConfig()
292
- if (!remote) {
293
- await ensureRelayServerForMcp()
356
+ // In direct CDP mode (PLAYWRITER_DIRECT env var), no relay server needed
357
+ if (process.env.PLAYWRITER_DIRECT) {
358
+ mcpLog(`Using direct CDP connection: ${process.env.PLAYWRITER_DIRECT}`)
294
359
  } else {
295
- mcpLog(`Using remote CDP relay server: ${remote.host}`)
296
- await checkRemoteServer(remote)
360
+ const remote = getRemoteConfig()
361
+ if (!remote) {
362
+ await ensureRelayServerForMcp()
363
+ } else {
364
+ mcpLog(`Using remote CDP relay server: ${remote.host}`)
365
+ await checkRemoteServer(remote)
366
+ }
297
367
  }
298
368
 
299
369
  const transport = new StdioServerTransport()
@@ -0,0 +1,36 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { createRequire } from 'node:module'
5
+
6
+ const require = createRequire(import.meta.url)
7
+
8
+ export function getInstalledPlaywriterPackageDir(): string {
9
+ const localPackageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
10
+ const localPackageJsonPath = path.join(localPackageDir, 'package.json')
11
+ if (fs.existsSync(localPackageJsonPath)) {
12
+ return localPackageDir
13
+ }
14
+
15
+ const packageJsonPath = require.resolve('playwriter/package.json')
16
+ return path.dirname(packageJsonPath)
17
+ }
18
+
19
+ export function getBundledExtensionPath(): string {
20
+ const packageDir = getInstalledPlaywriterPackageDir()
21
+ const candidates = [
22
+ path.join(packageDir, 'dist', 'extension'),
23
+ path.join(packageDir, '..', 'extension', 'dist'),
24
+ ]
25
+
26
+ for (const extensionPath of candidates) {
27
+ const manifestPath = path.join(extensionPath, 'manifest.json')
28
+ if (fs.existsSync(manifestPath)) {
29
+ return extensionPath
30
+ }
31
+ }
32
+
33
+ throw new Error(
34
+ `Bundled Playwriter extension not found under ${packageDir}. Rebuild or reinstall the playwriter package.`,
35
+ )
36
+ }
@@ -264,11 +264,11 @@ describe('Relay Core Tests', () => {
264
264
 
265
265
  expect(summary).toMatchInlineSnapshot(`
266
266
  {
267
- "hasBrowserDownloadProgress": false,
268
- "hasBrowserDownloadWillBegin": false,
267
+ "hasBrowserDownloadProgress": true,
268
+ "hasBrowserDownloadWillBegin": true,
269
269
  "hasBrowserSetDownloadBehavior": true,
270
- "hasPageDownloadProgress": false,
271
- "hasPageDownloadWillBegin": false,
270
+ "hasPageDownloadProgress": true,
271
+ "hasPageDownloadWillBegin": true,
272
272
  "hasPageSetDownloadBehavior": true,
273
273
  }
274
274
  `)
@@ -475,11 +475,18 @@ describe('Relay Core Tests', () => {
475
475
  name: 'hacker-news',
476
476
  url: 'https://news.ycombinator.com/item?id=1',
477
477
  expectedContent: ['role=link', 'Hacker News'],
478
+ waitForCode: js`
479
+ await state.page.locator('a[href="news"]').first().waitFor({ timeout: 10000 });
480
+ await state.page.locator('a.hnuser').first().waitFor({ timeout: 10000 });
481
+ `,
478
482
  },
479
483
  {
480
484
  name: 'shadcn-ui',
481
485
  url: 'https://ui.shadcn.com/',
482
486
  expectedContent: ['shadcn'],
487
+ waitForCode: js`
488
+ await state.page.locator('text=shadcn/ui').first().waitFor({ timeout: 10000 });
489
+ `,
483
490
  },
484
491
  ]
485
492
 
@@ -502,7 +509,10 @@ describe('Relay Core Tests', () => {
502
509
  name: 'execute',
503
510
  arguments: {
504
511
  code: js`
512
+ // External pages can expose a partial AX tree right after domcontentloaded,
513
+ // so wait for stable page-specific content before snapshotting.
505
514
  await state.page.goto('${testCase.url}', { waitUntil: 'domcontentloaded' });
515
+ ${testCase.waitForCode}
506
516
  const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false, interactiveOnly: true });
507
517
  return snap;
508
518
  `,
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, beforeAll, afterAll } from 'vitest'
2
2
  import { chromium, type Page } from '@xmorse/playwright-core'
3
3
  import WebSocket from 'ws'
4
+ import path from 'node:path'
4
5
  import { getCdpUrl } from './utils.js'
5
6
  import {
6
7
  setupTestContext,
@@ -13,12 +14,18 @@ import {
13
14
  import './test-declarations.js'
14
15
 
15
16
  const TEST_PORT = 19992
17
+ const FIXTURE_EXTENSION_PATH = path.resolve('../extension/test-fixtures/fixture-extension')
16
18
 
17
19
  describe('Relay Navigation Tests', () => {
18
20
  let testCtx: TestContext | null = null
19
21
 
20
22
  beforeAll(async () => {
21
- testCtx = await setupTestContext({ port: TEST_PORT, tempDirPrefix: 'pw-nav-test-', toggleExtension: true })
23
+ testCtx = await setupTestContext({
24
+ port: TEST_PORT,
25
+ tempDirPrefix: 'pw-nav-test-',
26
+ toggleExtension: true,
27
+ additionalExtensions: [FIXTURE_EXTENSION_PATH],
28
+ })
22
29
  }, 600000)
23
30
 
24
31
  afterAll(async () => {
@@ -757,4 +764,102 @@ describe('Relay Navigation Tests', () => {
757
764
  await server.close()
758
765
  }
759
766
  }, 30000)
767
+
768
+ it('should not crash when page has chrome-extension:// iframe from another extension', async () => {
769
+ // Reproduces https://github.com/remorses/playwriter/issues/18
770
+ // Extensions like LastPass, SurfingKeys inject chrome-extension:// iframes into every page.
771
+ // When playwriter attaches the debugger and Target.setAutoAttach is active, Chrome
772
+ // auto-attaches to these restricted iframe targets. Without filtering, the relay tries
773
+ // to send Runtime.runIfWaitingForDebugger to the restricted child session, which Chrome
774
+ // blocks with "Cannot access a chrome-extension:// URL of a different extension",
775
+ // causing the entire debugger to detach.
776
+
777
+ const browserContext = getBrowserContext()
778
+ const serviceWorker = await getExtensionServiceWorker(browserContext)
779
+
780
+ // Discover the fixture extension's ID from its service worker
781
+ const playwriterExtId = serviceWorker.url().match(/chrome-extension:\/\/([^/]+)/)?.[1]
782
+ expect(playwriterExtId).toBeTruthy()
783
+
784
+ let fixtureExtId: string | undefined
785
+ for (let i = 0; i < 50; i++) {
786
+ const allSws = browserContext.serviceWorkers()
787
+ const fixtureSw = allSws.find((sw) => {
788
+ const id = sw.url().match(/chrome-extension:\/\/([^/]+)/)?.[1]
789
+ return id && id !== playwriterExtId
790
+ })
791
+ if (fixtureSw) {
792
+ fixtureExtId = fixtureSw.url().match(/chrome-extension:\/\/([^/]+)/)?.[1]
793
+ break
794
+ }
795
+ await new Promise((r) => {
796
+ setTimeout(r, 100)
797
+ })
798
+ }
799
+ expect(fixtureExtId).toBeTruthy()
800
+ console.log('Fixture extension ID:', fixtureExtId)
801
+
802
+ // Create a page that embeds the fixture extension's page as an iframe,
803
+ // reproducing what extensions like SurfingKeys/LastPass do.
804
+ const server = await createSimpleServer({
805
+ routes: {
806
+ '/': `<!doctype html><html><body>
807
+ <h1>Main page</h1>
808
+ <iframe src="chrome-extension://${fixtureExtId}/page.html" id="ext-iframe"></iframe>
809
+ </body></html>`,
810
+ },
811
+ })
812
+
813
+ const page = await browserContext.newPage()
814
+ try {
815
+ await page.goto(server.baseUrl, { waitUntil: 'domcontentloaded' })
816
+ await page.bringToFront()
817
+
818
+ // Enable playwriter on this page — this must NOT crash the debugger
819
+ await withTimeout({
820
+ promise: serviceWorker.evaluate(async () => {
821
+ await globalThis.toggleExtensionForActiveTab()
822
+ }),
823
+ timeoutMs: 5000,
824
+ errorMessage: 'Timed out toggling extension on page with chrome-extension:// iframe',
825
+ })
826
+
827
+ // Give time for any async errors (Target.attachedToTarget for the iframe) to surface
828
+ await new Promise((r) => {
829
+ setTimeout(r, 1500)
830
+ })
831
+
832
+ // Verify the extension is still connected by connecting over CDP and interacting
833
+ const browser = await withTimeout({
834
+ promise: chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT })),
835
+ timeoutMs: 5000,
836
+ errorMessage: 'Timed out connecting over CDP — extension likely crashed',
837
+ })
838
+ const context = browser.contexts()[0]
839
+ const cdpPage = context.pages().find((p) => p.url().startsWith(server.baseUrl))
840
+ expect(cdpPage).toBeDefined()
841
+
842
+ // Verify we can execute JS on the page (proves the debugger session is alive)
843
+ const title = await cdpPage!.evaluate(() => document.querySelector('h1')?.textContent)
844
+ expect(title).toBe('Main page')
845
+
846
+ // Verify the chrome-extension:// iframe did NOT get exposed as a frame
847
+ // (it should be filtered out as a restricted target)
848
+ const frames = cdpPage!.frames()
849
+ const extFrame = frames.find((f) => f.url().startsWith('chrome-extension://'))
850
+ expect(extFrame).toBeUndefined()
851
+
852
+ await browser.close()
853
+ } finally {
854
+ // Toggle off to clean up
855
+ await page.bringToFront()
856
+ await serviceWorker
857
+ .evaluate(async () => {
858
+ await globalThis.toggleExtensionForActiveTab()
859
+ })
860
+ .catch(() => {})
861
+ await page.close()
862
+ await server.close()
863
+ }
864
+ }, 30000)
760
865
  })
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Regression tests for session-scoped filesystem resolution and session cwd reporting.
3
+ */
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import { describe, expect, test } from 'vitest'
8
+ import { ExecutorManager, type SessionMetadata } from './executor.js'
9
+ import { createScopedFS } from './scoped-fs.js'
10
+
11
+ function createTempDir(prefix: string): string {
12
+ const tempRoot = path.join(process.cwd(), 'tmp')
13
+ fs.mkdirSync(tempRoot, { recursive: true })
14
+ return fs.mkdtempSync(path.join(tempRoot, prefix))
15
+ }
16
+
17
+ describe('ScopedFS', () => {
18
+ test('resolves relative paths from the session cwd instead of the relay cwd', () => {
19
+ const sessionDir = createTempDir('scoped-fs-session-')
20
+ const relayDir = createTempDir('scoped-fs-relay-')
21
+ const originalCwd = process.cwd()
22
+
23
+ try {
24
+ process.chdir(relayDir)
25
+
26
+ const scopedFs = createScopedFS([sessionDir], sessionDir)
27
+ scopedFs.writeFileSync('note.txt', 'hello from session')
28
+
29
+ expect(fs.readFileSync(path.join(sessionDir, 'note.txt'), 'utf-8')).toBe('hello from session')
30
+ expect(fs.existsSync(path.join(relayDir, 'note.txt'))).toBe(false)
31
+ } finally {
32
+ process.chdir(originalCwd)
33
+ fs.rmSync(sessionDir, { recursive: true, force: true })
34
+ fs.rmSync(relayDir, { recursive: true, force: true })
35
+ }
36
+ })
37
+ })
38
+
39
+ describe('ExecutorManager.listSessions', () => {
40
+ test('includes the resolved cwd for each session', () => {
41
+ const sessionDir = createTempDir('executor-session-')
42
+ const sessionMetadata: SessionMetadata = {
43
+ extensionId: 'profile:test',
44
+ browser: 'Chrome',
45
+ profile: { email: 'test@example.com', id: 'profile-1' },
46
+ }
47
+
48
+ try {
49
+ const manager = new ExecutorManager({
50
+ cdpConfig: { port: 19988 },
51
+ logger: {
52
+ log: () => {},
53
+ error: () => {},
54
+ },
55
+ })
56
+
57
+ manager.getExecutor({
58
+ sessionId: '7',
59
+ cwd: sessionDir,
60
+ sessionMetadata,
61
+ })
62
+
63
+ expect(manager.listSessions()).toEqual([
64
+ {
65
+ id: '7',
66
+ stateKeys: [],
67
+ extensionId: 'profile:test',
68
+ browser: 'Chrome',
69
+ profile: { email: 'test@example.com', id: 'profile-1' },
70
+ cwd: sessionDir,
71
+ },
72
+ ])
73
+ } finally {
74
+ fs.rmSync(sessionDir, { recursive: true, force: true })
75
+ }
76
+ })
77
+ })
package/src/scoped-fs.ts CHANGED
@@ -7,7 +7,7 @@ import os from 'node:os'
7
7
  * Any attempt to access files outside the allowed directories will throw an EPERM error.
8
8
  *
9
9
  * By default, allows access to:
10
- * - Current working directory (process.cwd())
10
+ * - The base directory captured when the sandbox is created
11
11
  * - /tmp
12
12
  * - os.tmpdir()
13
13
  *
@@ -15,10 +15,13 @@ import os from 'node:os'
15
15
  */
16
16
  export class ScopedFS {
17
17
  private allowedDirs: string[]
18
+ private baseDir: string
18
19
 
19
- constructor(allowedDirs?: string[]) {
20
- // Default allowed directories: cwd, /tmp, os.tmpdir()
21
- const defaultDirs = [process.cwd(), '/tmp', os.tmpdir()]
20
+ constructor(allowedDirs?: string[], baseDir?: string) {
21
+ this.baseDir = path.resolve(baseDir || process.cwd())
22
+
23
+ // Default allowed directories: session cwd, /tmp, os.tmpdir()
24
+ const defaultDirs = [this.baseDir, '/tmp', os.tmpdir()]
22
25
 
23
26
  // Use provided dirs or defaults, resolve all to absolute paths
24
27
  const dirs = allowedDirs ?? defaultDirs
@@ -40,8 +43,8 @@ export class ScopedFS {
40
43
  */
41
44
  private resolvePath(filePath: string): string {
42
45
  // If it's an absolute path, use it directly
43
- // If it's relative, resolve from cwd
44
- const resolved = path.resolve(filePath)
46
+ // If it's relative, resolve from the session cwd captured when the sandbox was created.
47
+ const resolved = path.isAbsolute(filePath) ? path.resolve(filePath) : path.resolve(this.baseDir, filePath)
45
48
 
46
49
  if (!this.isPathAllowed(resolved)) {
47
50
  const error = new Error(
@@ -410,8 +413,8 @@ export class ScopedFS {
410
413
 
411
414
  /**
412
415
  * Create a scoped fs instance with allowed directories.
413
- * Defaults to cwd, /tmp, and os.tmpdir() if no directories specified.
416
+ * Defaults to the captured base directory, /tmp, and os.tmpdir() if no directories specified.
414
417
  */
415
- export function createScopedFS(allowedDirs?: string[]): ScopedFS {
416
- return new ScopedFS(allowedDirs)
418
+ export function createScopedFS(allowedDirs?: string[], baseDir?: string): ScopedFS {
419
+ return new ScopedFS(allowedDirs, baseDir)
417
420
  }
@@ -9,37 +9,23 @@
9
9
  import os from 'node:os'
10
10
  import path from 'node:path'
11
11
  import type { BrowserContext, Page } from '@xmorse/playwright-core'
12
+ import { shouldUseHeadlessByDefault } from './browser-config.js'
12
13
  import type {
13
14
  StartRecordingResult,
14
15
  StopRecordingResult,
15
16
  IsRecordingResult,
16
17
  CancelRecordingResult,
17
18
  } from './protocol.js'
18
- import { EXTENSION_IDS } from './utils.js'
19
19
  import { RecordingGhostCursorController } from './recording-ghost-cursor.js'
20
20
 
21
21
  /**
22
- * Generate a shell command to quit and restart Chrome with flags that allow automatic tab capture.
23
- * This enables screen recording without user interaction (clicking extension icon).
24
- *
25
- * Required flags:
26
- * - --allowlisted-extension-id=<id> - grants the extension special privileges (one per extension)
27
- * - --auto-accept-this-tab-capture - auto-accepts tab capture permission requests
22
+ * Generate a CLI command that starts a managed Playwriter browser with the
23
+ * bundled extension preloaded. This enables screen recording without a manual
24
+ * extension click on fresh automation sessions.
28
25
  */
29
26
  export function getChromeRestartCommand(): string {
30
- const platform = os.platform()
31
- const extensionFlags = EXTENSION_IDS.map((id) => `--allowlisted-extension-id=${id}`).join(' ')
32
- // --profile-directory=Default skips the profile picker on startup, preventing Chrome from hanging
33
- const flags = `${extensionFlags} --auto-accept-this-tab-capture --profile-directory=Default`
34
-
35
- if (platform === 'darwin') {
36
- return `osascript -e 'quit app "Google Chrome"' && sleep 1 && open -a "Google Chrome" --args ${flags}`
37
- }
38
- if (platform === 'win32') {
39
- return `taskkill /IM chrome.exe /F & timeout /t 1 & start chrome.exe ${flags}`
40
- }
41
- // Linux
42
- return `pkill chrome; sleep 1; google-chrome ${flags}`
27
+ const headlessFlag = shouldUseHeadlessByDefault({ platform: os.platform() }) ? ' --headless' : ''
28
+ return `playwriter browser start${headlessFlag}`
43
29
  }
44
30
 
45
31
  const DEFAULT_ASPECT_RATIO = { width: 16, height: 9 }
@@ -331,8 +317,7 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
331
317
  const restartCmd = getChromeRestartCommand()
332
318
  throw new Error(
333
319
  `Failed to start recording: ${errorMsg}\n\n` +
334
- `For automated recording, Chrome must be restarted with special flags.\n` +
335
- `WARNING: This will close all Chrome windows. Save your work first!\n\n` +
320
+ `For automated recording, start a managed Playwriter browser with the bundled extension loaded:\n\n` +
336
321
  ` ${restartCmd}\n\n` +
337
322
  `Or click the Playwriter extension icon on the tab once to grant permission.`,
338
323
  )
package/src/skill.md CHANGED
@@ -44,6 +44,34 @@ Reset a session if the browser connection is stale or broken:
44
44
  playwriter session reset <sessionId>
45
45
  ```
46
46
 
47
+ ### Direct CDP connection (--direct)
48
+
49
+ Only use `--direct` when the user explicitly asks for it. This mode requires the user to accept a debugging approval dialog in Chrome, so it cannot be used autonomously.
50
+
51
+ `--direct` connects to Chrome's DevTools Protocol without the extension. Unlike extension mode, it gives access to **all existing pages** in the browser — no need to enable per tab. Works with any Chromium browser (Chrome, Brave, Arc, Edge, etc.).
52
+
53
+ The user must first enable debugging in Chrome:
54
+ - Open `chrome://inspect/#remote-debugging` in Chrome, or
55
+ - Launch Chrome with `chrome --remote-debugging-port=9222`
56
+
57
+ Then create a session:
58
+
59
+ ```bash
60
+ playwriter session new --direct
61
+ ```
62
+
63
+ By default, `context` is bound to the first Chrome profile. If the user has multiple profiles open, use `browser.contexts()` to access other profiles' pages and cookies:
64
+
65
+ ```js
66
+ const contexts = browser.contexts()
67
+ // contexts[0] = first profile, contexts[1] = second profile, etc.
68
+ const otherProfilePage = contexts[1].pages()[0]
69
+ ```
70
+
71
+ `browser.contexts()` only makes sense when using `--direct`. in extension mode there is just one context for each session.
72
+
73
+ **Limitations:** screen recording (`recording.start/stop`) is unavailable in direct mode.
74
+
47
75
  ### Execute code
48
76
 
49
77
  ```bash
@@ -758,6 +786,8 @@ await editor.edit({ url: matches[0].url, oldString: 'DEBUG = false', newString:
758
786
 
759
787
  **screenshotWithAccessibilityLabels** - take a screenshot with Vimium-style visual labels overlaid on interactive elements. Shows labels, captures screenshot, then removes labels. The image and accessibility snapshot are automatically included in the response. Can be called multiple times to capture multiple screenshots. Use a timeout of **20 seconds** for complex pages.
760
788
 
789
+ This is only for **finding interactive elements** on the page. To share a screenshot with the user or save an image, use `page.screenshot()` + `resizeImageForAgent()` instead (see "taking screenshots" section below).
790
+
761
791
  Prefer this for pages with grids, image galleries, maps, or complex visual layouts where spatial position matters. For simple text-heavy pages, `snapshot` with search is faster and uses fewer tokens.
762
792
 
763
793
  ```js
@@ -775,7 +805,7 @@ await screenshotWithAccessibilityLabels({ page: state.page })
775
805
 
776
806
  Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs.
777
807
 
778
- **resizeImage** - shrink an image in-place so it consumes fewer tokens when read back into context. `await resizeImage({ input: './screenshot.png' })`. Also accepts `width`, `height`, `maxDimension`, `quality`, `output`.
808
+ **resizeImageForAgent** - shrink an image so it consumes fewer tokens when read back into context. The resized image is automatically included in the response (visible to the LLM). `await resizeImageForAgent({ input: './screenshot.png' })`. Also accepts `width`, `height`, `maxDimension`, `quality`, `format` (default: `'png'`), `output`. Alias: `resizeImage`.
779
809
 
780
810
  **recording.start / recording.stop** - record the page as a video at native FPS (30-60fps). Uses `chrome.tabCapture` so **recording survives page navigation**. Auto-overlays a ghost cursor that follows mouse actions. Requires user to have clicked the Playwriter extension icon on the tab. Auto-resizes viewport to 16:9 (override with `aspectRatio: null`). Auto-stops after 15 min (override with `maxDurationMs`).
781
811
 
@@ -844,7 +874,7 @@ await state.page.screenshot({ path: 'shot.png', scale: 'css' })
844
874
  If you want to read back the image file into context, resize it first so it consumes fewer tokens:
845
875
 
846
876
  ```js
847
- await resizeImage({ input: './shot.png' })
877
+ await resizeImageForAgent({ input: './shot.png' })
848
878
  ```
849
879
 
850
880
  ## page.evaluate
@@ -986,14 +986,13 @@ describe('Snapshot & Screenshot Tests', () => {
986
986
  const textContent = content.find((c) => c.type === 'text')
987
987
  expect(textContent).toBeDefined()
988
988
  expect(textContent.text).toContain('Screenshot saved to:')
989
- expect(textContent.text).toContain('.jpg')
990
- expect(textContent.text).toContain('Labels shown:')
989
+ expect(textContent.text).toContain('image included below')
991
990
  expect(textContent.text).toContain('Accessibility snapshot:')
992
991
  expect(textContent.text).toContain('Submit Form')
993
992
 
994
993
  const imageContent = content.find((c) => c.type === 'image')
995
994
  expect(imageContent).toBeDefined()
996
- expect(imageContent.mimeType).toBe('image/jpeg')
995
+ expect(imageContent.mimeType).toBe('image/png')
997
996
  expect(imageContent.data).toBeDefined()
998
997
  expect(imageContent.data.length).toBeGreaterThan(100)
999
998
 
@@ -1009,7 +1008,7 @@ describe('Snapshot & Screenshot Tests', () => {
1009
1008
  console.log('Screenshot dimensions:', dimensions.width, 'x', dimensions.height)
1010
1009
  console.log('Window viewport:', viewport)
1011
1010
 
1012
- expect(dimensions.type).toBe('jpg')
1011
+ expect(dimensions.type).toBe('png')
1013
1012
  expect(dimensions.width).toBeGreaterThan(0)
1014
1013
  expect(dimensions.height).toBeGreaterThan(0)
1015
1014