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.
- package/dist/aria-snapshot.d.ts +7 -3
- package/dist/aria-snapshot.d.ts.map +1 -1
- package/dist/aria-snapshot.js +76 -18
- package/dist/aria-snapshot.js.map +1 -1
- package/dist/aria-snapshot.test.js +94 -0
- package/dist/aria-snapshot.test.js.map +1 -1
- package/dist/bippy.js +1 -1
- package/dist/browser-config.d.ts +12 -1
- package/dist/browser-config.d.ts.map +1 -1
- package/dist/browser-config.js +101 -38
- package/dist/browser-config.js.map +1 -1
- package/dist/browser-launch.d.ts +17 -0
- package/dist/browser-launch.d.ts.map +1 -0
- package/dist/browser-launch.js +44 -0
- package/dist/browser-launch.js.map +1 -0
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +34 -10
- package/dist/cdp-relay.js.map +1 -1
- package/dist/channel-owner-inspect.test.d.ts +2 -0
- package/dist/channel-owner-inspect.test.d.ts.map +1 -0
- package/dist/channel-owner-inspect.test.js +75 -0
- package/dist/channel-owner-inspect.test.js.map +1 -0
- package/dist/chrome-discovery.d.ts +65 -0
- package/dist/chrome-discovery.d.ts.map +1 -0
- package/dist/chrome-discovery.js +165 -0
- package/dist/chrome-discovery.js.map +1 -0
- package/dist/chrome-discovery.test.d.ts +2 -0
- package/dist/chrome-discovery.test.d.ts.map +1 -0
- package/dist/chrome-discovery.test.js +41 -0
- package/dist/chrome-discovery.test.js.map +1 -0
- package/dist/cli.js +357 -53
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts +44 -11
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +121 -55
- package/dist/executor.js.map +1 -1
- package/dist/executor.unit.test.js +29 -1
- package/dist/executor.unit.test.js.map +1 -1
- package/dist/extension/background.js +1704 -0
- package/dist/extension/icons/SolarCursorSquareBold.png +0 -0
- package/dist/extension/icons/icon-black-128.png +0 -0
- package/dist/extension/icons/icon-black-16.png +0 -0
- package/dist/extension/icons/icon-black-32.png +0 -0
- package/dist/extension/icons/icon-black-48.png +0 -0
- package/dist/extension/icons/icon-gray-128.png +0 -0
- package/dist/extension/icons/icon-gray-16.png +0 -0
- package/dist/extension/icons/icon-gray-32.png +0 -0
- package/dist/extension/icons/icon-gray-48.png +0 -0
- package/dist/extension/icons/icon-green-128.png +0 -0
- package/dist/extension/icons/icon-green-16.png +0 -0
- package/dist/extension/icons/icon-green-32.png +0 -0
- package/dist/extension/icons/icon-green-48.png +0 -0
- package/dist/extension/icons/playwriter-icon-black.png +0 -0
- package/dist/extension/icons/playwriter-icon-gray-disabled.png +0 -0
- package/dist/extension/icons/playwriter-icon-gray-disabled.svg +1 -0
- package/dist/extension/manifest.json +40 -0
- package/dist/extension/offscreen.js +214 -0
- package/dist/extension/src/offscreen.html +9 -0
- package/dist/extension/src/prism-bash.min.js +1 -0
- package/dist/extension/src/prism.min.js +1 -0
- package/dist/extension/src/welcome.html +399 -0
- package/dist/kitty-graphics.d.ts +22 -0
- package/dist/kitty-graphics.d.ts.map +1 -0
- package/dist/kitty-graphics.js +71 -0
- package/dist/kitty-graphics.js.map +1 -0
- package/dist/kitty-graphics.test.d.ts +2 -0
- package/dist/kitty-graphics.test.d.ts.map +1 -0
- package/dist/kitty-graphics.test.js +125 -0
- package/dist/kitty-graphics.test.js.map +1 -0
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +76 -12
- package/dist/mcp.js.map +1 -1
- package/dist/package-paths.d.ts +3 -0
- package/dist/package-paths.d.ts.map +1 -0
- package/dist/package-paths.js +29 -0
- package/dist/package-paths.js.map +1 -0
- package/dist/prompt.md +4 -2
- package/dist/readability.js +1 -1
- package/dist/relay-core.test.js +14 -4
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-navigation.test.d.ts.map +1 -1
- package/dist/relay-navigation.test.js +95 -1
- package/dist/relay-navigation.test.js.map +1 -1
- package/dist/scoped-fs.d.ts +5 -4
- package/dist/scoped-fs.d.ts.map +1 -1
- package/dist/scoped-fs.js +11 -9
- package/dist/scoped-fs.js.map +1 -1
- package/dist/scoped-fs.test.d.ts +5 -0
- package/dist/scoped-fs.test.d.ts.map +1 -0
- package/dist/scoped-fs.test.js +70 -0
- package/dist/scoped-fs.test.js.map +1 -0
- package/dist/screen-recording.d.ts +3 -6
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +7 -21
- package/dist/screen-recording.js.map +1 -1
- package/dist/selector-generator.js +1 -1
- package/dist/snapshot-tools.test.js +3 -4
- package/dist/snapshot-tools.test.js.map +1 -1
- package/dist/test-utils.d.ts +3 -1
- package/dist/test-utils.d.ts.map +1 -1
- package/dist/test-utils.js +24 -12
- package/dist/test-utils.js.map +1 -1
- package/package.json +6 -5
- package/src/aria-snapshot.test.ts +110 -0
- package/src/aria-snapshot.ts +86 -19
- package/src/aria-snapshots/contenteditable-interactive.txt +7 -0
- package/src/aria-snapshots/contenteditable-raw.txt +14 -0
- package/src/aria-snapshots/github-interactive.txt +0 -2
- package/src/aria-snapshots/github-raw.txt +0 -4
- package/src/aria-snapshots/hackernews-interactive.txt +240 -237
- package/src/aria-snapshots/hackernews-raw.txt +262 -258
- package/src/aria-snapshots/prosemirror-interactive.txt +63 -0
- package/src/aria-snapshots/prosemirror-raw.txt +141 -0
- package/src/assets/aria-labels-hacker-news.png +0 -0
- package/src/browser-config.ts +143 -37
- package/src/browser-launch.ts +75 -0
- package/src/cdp-relay.ts +46 -11
- package/src/channel-owner-inspect.test.ts +94 -0
- package/src/chrome-discovery.test.ts +50 -0
- package/src/chrome-discovery.ts +211 -0
- package/src/cli.ts +432 -59
- package/src/executor.ts +171 -72
- package/src/executor.unit.test.ts +32 -1
- package/src/kitty-graphics.test.ts +139 -0
- package/src/kitty-graphics.ts +79 -0
- package/src/mcp.ts +82 -12
- package/src/package-paths.ts +36 -0
- package/src/relay-core.test.ts +14 -4
- package/src/relay-navigation.test.ts +106 -1
- package/src/scoped-fs.test.ts +77 -0
- package/src/scoped-fs.ts +12 -9
- package/src/screen-recording.ts +7 -22
- package/src/skill.md +32 -2
- package/src/snapshot-tools.test.ts +3 -4
- package/src/snapshots/shadcn-ui-accessibility-full.md +181 -183
- package/src/snapshots/shadcn-ui-accessibility-interactive.md +119 -121
- 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
|
-
|
|
198
|
-
if (!
|
|
199
|
-
|
|
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
|
|
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
|
-
|
|
259
|
-
if (!
|
|
260
|
-
|
|
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
|
-
|
|
292
|
-
if (
|
|
293
|
-
|
|
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
|
-
|
|
296
|
-
|
|
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
|
+
}
|
package/src/relay-core.test.ts
CHANGED
|
@@ -264,11 +264,11 @@ describe('Relay Core Tests', () => {
|
|
|
264
264
|
|
|
265
265
|
expect(summary).toMatchInlineSnapshot(`
|
|
266
266
|
{
|
|
267
|
-
"hasBrowserDownloadProgress":
|
|
268
|
-
"hasBrowserDownloadWillBegin":
|
|
267
|
+
"hasBrowserDownloadProgress": true,
|
|
268
|
+
"hasBrowserDownloadWillBegin": true,
|
|
269
269
|
"hasBrowserSetDownloadBehavior": true,
|
|
270
|
-
"hasPageDownloadProgress":
|
|
271
|
-
"hasPageDownloadWillBegin":
|
|
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({
|
|
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
|
-
* -
|
|
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
|
-
|
|
21
|
-
|
|
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
|
|
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
|
}
|
package/src/screen-recording.ts
CHANGED
|
@@ -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
|
|
23
|
-
* This enables screen recording without
|
|
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
|
|
31
|
-
|
|
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,
|
|
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
|
-
**
|
|
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
|
|
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('
|
|
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/
|
|
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('
|
|
1011
|
+
expect(dimensions.type).toBe('png')
|
|
1013
1012
|
expect(dimensions.width).toBeGreaterThan(0)
|
|
1014
1013
|
expect(dimensions.height).toBeGreaterThan(0)
|
|
1015
1014
|
|