playwriter 0.1.0 → 0.3.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.
- package/dist/bippy.js +5 -5
- package/dist/cdp-log.d.ts +4 -1
- package/dist/cdp-log.d.ts.map +1 -1
- package/dist/cdp-log.js +39 -2
- package/dist/cdp-log.js.map +1 -1
- package/dist/cdp-log.test.d.ts +2 -0
- package/dist/cdp-log.test.d.ts.map +1 -0
- package/dist/cdp-log.test.js +109 -0
- package/dist/cdp-log.test.js.map +1 -0
- package/dist/cdp-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +120 -11
- package/dist/cdp-relay.js.map +1 -1
- package/dist/cli-help.test.js +22 -0
- package/dist/cli-help.test.js.map +1 -1
- package/dist/cli.js +69 -25
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts +4 -0
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +140 -33
- package/dist/executor.js.map +1 -1
- package/dist/extension/background.js +343 -62
- package/dist/extension/manifest.json +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +6 -1
- package/dist/mcp.js.map +1 -1
- package/dist/performance-examples.d.ts +5 -0
- package/dist/performance-examples.d.ts.map +1 -0
- package/dist/performance-examples.js +112 -0
- package/dist/performance-examples.js.map +1 -0
- package/dist/performance-profiling.md +417 -0
- package/dist/prompt.md +51 -18
- package/dist/react-source.d.ts +44 -0
- package/dist/react-source.d.ts.map +1 -1
- package/dist/react-source.js +207 -20
- package/dist/react-source.js.map +1 -1
- package/dist/readability.js +1 -1
- package/dist/relay-client.d.ts +11 -0
- package/dist/relay-client.d.ts.map +1 -1
- package/dist/relay-client.js +46 -1
- package/dist/relay-client.js.map +1 -1
- package/dist/relay-core.test.js +10 -6
- package/dist/relay-core.test.js.map +1 -1
- package/dist/relay-session.test.js +43 -7
- package/dist/relay-session.test.js.map +1 -1
- package/dist/relay-state.test.js +57 -1
- package/dist/relay-state.test.js.map +1 -1
- package/dist/screen-recording.d.ts.map +1 -1
- package/dist/screen-recording.js +19 -4
- package/dist/screen-recording.js.map +1 -1
- package/dist/selector-generator.js +1 -1
- package/dist/start-relay-server.d.ts +1 -1
- package/dist/start-relay-server.d.ts.map +1 -1
- package/dist/start-relay-server.js +23 -1
- package/dist/start-relay-server.js.map +1 -1
- package/dist/utils.d.ts +2 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +4 -1
- package/dist/utils.js.map +1 -1
- package/package.json +3 -3
- package/src/cdp-log.test.ts +131 -0
- package/src/cdp-log.ts +44 -2
- package/src/cdp-relay.ts +127 -10
- package/src/cli-help.test.ts +22 -0
- package/src/cli.ts +74 -24
- package/src/executor.ts +166 -39
- package/src/mcp.ts +6 -1
- package/src/performance-examples.ts +186 -0
- package/src/react-source.ts +310 -24
- package/src/relay-client.ts +62 -5
- package/src/relay-core.test.ts +10 -6
- package/src/relay-session.test.ts +45 -11
- package/src/relay-state.test.ts +67 -1
- package/src/screen-recording.ts +20 -4
- package/src/skill.md +62 -19
- package/src/start-relay-server.ts +22 -1
- package/src/utils.ts +5 -0
package/src/cli.ts
CHANGED
|
@@ -105,12 +105,33 @@ cli
|
|
|
105
105
|
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
106
106
|
.option('-s, --session <name>', 'Session ID (required for -e, get one with `playwriter session new`)')
|
|
107
107
|
.option('-e, --eval <code>', 'Execute JavaScript code and exit, read https://playwriter.dev/SKILL.md for usage')
|
|
108
|
+
.option('-f, --file <path>', 'Execute JavaScript from a file and exit')
|
|
108
109
|
.option('--timeout [ms]', z.number().default(10000).describe('Execution timeout in milliseconds'))
|
|
109
110
|
.action(async (options) => {
|
|
110
|
-
|
|
111
|
-
|
|
111
|
+
if (options.eval && options.file) {
|
|
112
|
+
console.error('Error: -e and -f cannot be used together.')
|
|
113
|
+
process.exit(1)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If -e or -f flag is provided, execute code via relay server
|
|
117
|
+
const code = (() => {
|
|
118
|
+
if (options.eval) {
|
|
119
|
+
return options.eval
|
|
120
|
+
}
|
|
121
|
+
if (options.file) {
|
|
122
|
+
const filePath = path.resolve(options.file)
|
|
123
|
+
if (!fs.existsSync(filePath)) {
|
|
124
|
+
console.error(`Error: File not found: ${filePath}`)
|
|
125
|
+
process.exit(1)
|
|
126
|
+
}
|
|
127
|
+
return fs.readFileSync(filePath, 'utf-8')
|
|
128
|
+
}
|
|
129
|
+
return null
|
|
130
|
+
})()
|
|
131
|
+
|
|
132
|
+
if (code) {
|
|
112
133
|
await executeCode({
|
|
113
|
-
code
|
|
134
|
+
code,
|
|
114
135
|
timeout: options.timeout || 10000,
|
|
115
136
|
sessionId: options.session,
|
|
116
137
|
host: options.host,
|
|
@@ -134,15 +155,32 @@ async function getServerUrl(host?: string): Promise<string> {
|
|
|
134
155
|
return httpBaseUrl
|
|
135
156
|
}
|
|
136
157
|
|
|
137
|
-
|
|
158
|
+
// Centralized header builder so every CLI subcommand sends the token consistently.
|
|
159
|
+
// Falls back to PLAYWRITER_TOKEN env var when --token is not provided.
|
|
160
|
+
function buildAuthHeaders({ token, json }: { token?: string; json?: boolean }): Record<string, string> {
|
|
161
|
+
const headers: Record<string, string> = {}
|
|
162
|
+
if (json) {
|
|
163
|
+
headers['Content-Type'] = 'application/json'
|
|
164
|
+
}
|
|
165
|
+
const effectiveToken = token || process.env.PLAYWRITER_TOKEN
|
|
166
|
+
if (effectiveToken) {
|
|
167
|
+
headers['Authorization'] = `Bearer ${effectiveToken}`
|
|
168
|
+
}
|
|
169
|
+
return headers
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function fetchExtensionsStatus({ host, token }: { host?: string; token?: string } = {}): Promise<ExtensionStatus[]> {
|
|
138
173
|
try {
|
|
139
174
|
const serverUrl = await getServerUrl(host)
|
|
175
|
+
const headers = buildAuthHeaders({ token })
|
|
140
176
|
const response = await fetch(`${serverUrl}/extensions/status`, {
|
|
141
177
|
signal: AbortSignal.timeout(2000),
|
|
178
|
+
headers,
|
|
142
179
|
})
|
|
143
180
|
if (!response.ok) {
|
|
144
181
|
const fallback = await fetch(`${serverUrl}/extension/status`, {
|
|
145
182
|
signal: AbortSignal.timeout(2000),
|
|
183
|
+
headers,
|
|
146
184
|
})
|
|
147
185
|
if (!fallback.ok) {
|
|
148
186
|
return []
|
|
@@ -225,12 +263,7 @@ async function executeCode(options: {
|
|
|
225
263
|
try {
|
|
226
264
|
const response = await fetch(executeUrl, {
|
|
227
265
|
method: 'POST',
|
|
228
|
-
headers: {
|
|
229
|
-
'Content-Type': 'application/json',
|
|
230
|
-
...(token || process.env.PLAYWRITER_TOKEN
|
|
231
|
-
? { Authorization: `Bearer ${token || process.env.PLAYWRITER_TOKEN}` }
|
|
232
|
-
: {}),
|
|
233
|
-
},
|
|
266
|
+
headers: buildAuthHeaders({ token, json: true }),
|
|
234
267
|
body: JSON.stringify({ sessionId, code, timeout, cwd }),
|
|
235
268
|
})
|
|
236
269
|
|
|
@@ -321,6 +354,7 @@ interface BrowserOption {
|
|
|
321
354
|
cli
|
|
322
355
|
.command('session new', 'Create a new session and print the session ID')
|
|
323
356
|
.option('--host <host>', 'Remote relay server host')
|
|
357
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
324
358
|
.option('--browser <key>', 'Browser key when multiple browsers are available')
|
|
325
359
|
.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')
|
|
326
360
|
.action(async (options) => {
|
|
@@ -342,7 +376,7 @@ cli
|
|
|
342
376
|
}
|
|
343
377
|
await ensureRelayForSessionCreation(isLocal)
|
|
344
378
|
const serverUrl = await getServerUrl(options.host)
|
|
345
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint })
|
|
379
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint, token: options.token })
|
|
346
380
|
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
347
381
|
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
348
382
|
return
|
|
@@ -372,7 +406,7 @@ cli
|
|
|
372
406
|
if (instances.length === 1 && !options.browser) {
|
|
373
407
|
const instance = instances[0]
|
|
374
408
|
const serverUrl = await getServerUrl(options.host)
|
|
375
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles })
|
|
409
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles, token: options.token })
|
|
376
410
|
const profileLabel = formatInstanceProfiles(instance)
|
|
377
411
|
console.log(
|
|
378
412
|
`Session ${result.id} created (direct CDP, ${instance.browser}${profileLabel}). Use with: playwriter -s ${result.id} -e "..."`,
|
|
@@ -396,7 +430,7 @@ cli
|
|
|
396
430
|
process.exit(1)
|
|
397
431
|
}
|
|
398
432
|
const serverUrl = await getServerUrl(options.host)
|
|
399
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
|
|
433
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
|
|
400
434
|
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
401
435
|
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
402
436
|
return
|
|
@@ -427,7 +461,7 @@ cli
|
|
|
427
461
|
})
|
|
428
462
|
}
|
|
429
463
|
} else {
|
|
430
|
-
extensions = await fetchExtensionsStatus(options.host)
|
|
464
|
+
extensions = await fetchExtensionsStatus({ host: options.host, token: options.token })
|
|
431
465
|
}
|
|
432
466
|
|
|
433
467
|
if (extensions.length === 0) {
|
|
@@ -457,8 +491,8 @@ cli
|
|
|
457
491
|
const cwd = process.cwd()
|
|
458
492
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
459
493
|
method: 'POST',
|
|
460
|
-
headers: {
|
|
461
|
-
body: JSON.stringify({ extensionId, cwd }),
|
|
494
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
495
|
+
body: JSON.stringify({ extensionId, cwd, autoEnable: true }),
|
|
462
496
|
})
|
|
463
497
|
if (!response.ok) {
|
|
464
498
|
const text = await response.text()
|
|
@@ -509,15 +543,15 @@ cli
|
|
|
509
543
|
try {
|
|
510
544
|
const serverUrl = await getServerUrl(options.host)
|
|
511
545
|
if (selected.type === 'direct') {
|
|
512
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
|
|
546
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
|
|
513
547
|
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
514
548
|
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
515
549
|
} else {
|
|
516
550
|
const cwd = process.cwd()
|
|
517
551
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
518
552
|
method: 'POST',
|
|
519
|
-
headers: {
|
|
520
|
-
body: JSON.stringify({ extensionId: selected.extensionId, cwd }),
|
|
553
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
554
|
+
body: JSON.stringify({ extensionId: selected.extensionId, cwd, autoEnable: true }),
|
|
521
555
|
})
|
|
522
556
|
if (!response.ok) {
|
|
523
557
|
const text = await response.text()
|
|
@@ -552,16 +586,18 @@ async function createDirectSession({
|
|
|
552
586
|
cdpEndpoint,
|
|
553
587
|
browser,
|
|
554
588
|
profiles,
|
|
589
|
+
token,
|
|
555
590
|
}: {
|
|
556
591
|
serverUrl: string
|
|
557
592
|
cdpEndpoint: string
|
|
558
593
|
browser?: string
|
|
559
594
|
profiles?: Array<{ name: string; email: string }>
|
|
595
|
+
token?: string
|
|
560
596
|
}): Promise<{ id: string }> {
|
|
561
597
|
const cwd = process.cwd()
|
|
562
598
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
563
599
|
method: 'POST',
|
|
564
|
-
headers: {
|
|
600
|
+
headers: buildAuthHeaders({ token, json: true }),
|
|
565
601
|
body: JSON.stringify({ cdpEndpoint, cwd, browser, profiles }),
|
|
566
602
|
})
|
|
567
603
|
if (!response.ok) {
|
|
@@ -622,6 +658,7 @@ function printBrowserTable(options: BrowserOption[]): void {
|
|
|
622
658
|
cli
|
|
623
659
|
.command('session list', 'List all active sessions')
|
|
624
660
|
.option('--host <host>', 'Remote relay server host')
|
|
661
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
625
662
|
.action(async (options) => {
|
|
626
663
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
|
627
664
|
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
|
@@ -639,6 +676,7 @@ cli
|
|
|
639
676
|
|
|
640
677
|
try {
|
|
641
678
|
const response = await fetch(`${serverUrl}/cli/sessions`, {
|
|
679
|
+
headers: buildAuthHeaders({ token: options.token }),
|
|
642
680
|
signal: AbortSignal.timeout(2000),
|
|
643
681
|
})
|
|
644
682
|
if (!response.ok) {
|
|
@@ -711,6 +749,7 @@ cli
|
|
|
711
749
|
cli
|
|
712
750
|
.command('session delete <sessionId>', 'Delete a session and clear its state')
|
|
713
751
|
.option('--host <host>', 'Remote relay server host')
|
|
752
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
714
753
|
.action(async (sessionId, options) => {
|
|
715
754
|
const serverUrl = await getServerUrl(options.host)
|
|
716
755
|
|
|
@@ -721,7 +760,7 @@ cli
|
|
|
721
760
|
try {
|
|
722
761
|
const response = await fetch(`${serverUrl}/cli/session/delete`, {
|
|
723
762
|
method: 'POST',
|
|
724
|
-
headers: {
|
|
763
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
725
764
|
body: JSON.stringify({ sessionId }),
|
|
726
765
|
})
|
|
727
766
|
|
|
@@ -741,6 +780,7 @@ cli
|
|
|
741
780
|
cli
|
|
742
781
|
.command('session reset <sessionId>', 'Reset the browser connection for a session')
|
|
743
782
|
.option('--host <host>', 'Remote relay server host')
|
|
783
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
744
784
|
.action(async (sessionId, options) => {
|
|
745
785
|
const cwd = process.cwd()
|
|
746
786
|
const serverUrl = await getServerUrl(options.host)
|
|
@@ -752,7 +792,7 @@ cli
|
|
|
752
792
|
try {
|
|
753
793
|
const response = await fetch(`${serverUrl}/cli/reset`, {
|
|
754
794
|
method: 'POST',
|
|
755
|
-
headers: {
|
|
795
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
756
796
|
body: JSON.stringify({ sessionId, cwd }),
|
|
757
797
|
})
|
|
758
798
|
|
|
@@ -789,6 +829,14 @@ cli
|
|
|
789
829
|
process.exit(1)
|
|
790
830
|
}
|
|
791
831
|
|
|
832
|
+
// Expose the token to in-process callers (screen-recording.ts, etc.) so
|
|
833
|
+
// they can attach Authorization: Bearer ... when calling the relay's own
|
|
834
|
+
// privileged endpoints. Required because we no longer bypass auth for
|
|
835
|
+
// loopback — see commit history for the tunnel-agent threat model.
|
|
836
|
+
if (token) {
|
|
837
|
+
process.env.PLAYWRITER_TOKEN = token
|
|
838
|
+
}
|
|
839
|
+
|
|
792
840
|
// Check if server is already running on the port
|
|
793
841
|
const net = await import('node:net')
|
|
794
842
|
const isPortInUse = await new Promise<boolean>((resolve) => {
|
|
@@ -872,6 +920,7 @@ cli
|
|
|
872
920
|
cli
|
|
873
921
|
.command('browser list', 'List all available browsers: extension-connected and direct CDP on port 9222')
|
|
874
922
|
.option('--host <host>', z.string().describe('Remote relay server host'))
|
|
923
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
875
924
|
.action(async (options) => {
|
|
876
925
|
const isLocal = !options.host && !process.env.PLAYWRITER_HOST
|
|
877
926
|
|
|
@@ -883,7 +932,7 @@ cli
|
|
|
883
932
|
const [extensions, directInstances] = await Promise.all([
|
|
884
933
|
isLocal
|
|
885
934
|
? waitForConnectedExtensions({ timeoutMs: 2000, pollIntervalMs: 200, logger: console })
|
|
886
|
-
: fetchExtensionsStatus(options.host),
|
|
935
|
+
: fetchExtensionsStatus({ host: options.host, token: options.token }),
|
|
887
936
|
isLocal ? discoverChromeInstances() : Promise.resolve([] as DiscoveredInstance[]),
|
|
888
937
|
])
|
|
889
938
|
|
|
@@ -933,6 +982,7 @@ cli.command('skill', 'Print the full playwriter usage instructions').action(() =
|
|
|
933
982
|
})
|
|
934
983
|
|
|
935
984
|
cli.help()
|
|
985
|
+
cli.completions()
|
|
936
986
|
cli.version(VERSION)
|
|
937
987
|
|
|
938
|
-
cli.parse()
|
|
988
|
+
await cli.parse()
|
package/src/executor.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Used by both MCP and CLI to execute Playwright code with persistent state.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { Page, Frame, Browser, BrowserContext, chromium, Locator, FrameLocator } from '@xmorse/playwright-core'
|
|
6
|
+
import { Page, Frame, Browser, BrowserContext, chromium, Locator, FrameLocator, ElementHandle } from '@xmorse/playwright-core'
|
|
7
7
|
import crypto from 'node:crypto'
|
|
8
8
|
import fs from 'node:fs'
|
|
9
9
|
import path from 'node:path'
|
|
@@ -21,7 +21,7 @@ import { ICDPSession, getCDPSessionForPage } from './cdp-session.js'
|
|
|
21
21
|
import { Debugger } from './debugger.js'
|
|
22
22
|
import { Editor } from './editor.js'
|
|
23
23
|
import { getStylesForLocator, formatStylesAsText, type StylesResult } from './styles.js'
|
|
24
|
-
import { getReactSource, type ReactSourceLocation } from './react-source.js'
|
|
24
|
+
import { getReactSource, getReactComponentInfo, type ReactSourceLocation } from './react-source.js'
|
|
25
25
|
import { ScopedFS } from './scoped-fs.js'
|
|
26
26
|
import {
|
|
27
27
|
screenshotWithAccessibilityLabels,
|
|
@@ -66,6 +66,7 @@ const usefulGlobals = {
|
|
|
66
66
|
AbortController,
|
|
67
67
|
AbortSignal,
|
|
68
68
|
structuredClone,
|
|
69
|
+
process,
|
|
69
70
|
} as const
|
|
70
71
|
|
|
71
72
|
/**
|
|
@@ -104,14 +105,14 @@ export function getAutoReturnExpression(code: string): string | null {
|
|
|
104
105
|
if (
|
|
105
106
|
expr.type === 'AssignmentExpression' ||
|
|
106
107
|
expr.type === 'UpdateExpression' ||
|
|
107
|
-
(expr.type === 'UnaryExpression' &&
|
|
108
|
+
(expr.type === 'UnaryExpression' && expr.operator === 'delete')
|
|
108
109
|
) {
|
|
109
110
|
return null
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
// Don't auto-return sequence expressions that contain assignments
|
|
113
114
|
if (expr.type === 'SequenceExpression') {
|
|
114
|
-
const hasAssignment = expr.expressions.some((e
|
|
115
|
+
const hasAssignment = expr.expressions.some((e) => e.type === 'AssignmentExpression')
|
|
115
116
|
if (hasAssignment) {
|
|
116
117
|
return null
|
|
117
118
|
}
|
|
@@ -226,6 +227,7 @@ export interface CdpConfig {
|
|
|
226
227
|
port?: number
|
|
227
228
|
token?: string
|
|
228
229
|
extensionId?: string | null
|
|
230
|
+
autoEnable?: boolean
|
|
229
231
|
/** Direct CDP WebSocket URL — bypasses relay + extension, connects straight to Chrome */
|
|
230
232
|
directCdpUrl?: string
|
|
231
233
|
}
|
|
@@ -287,7 +289,12 @@ export class PlaywrightExecutor {
|
|
|
287
289
|
private context: BrowserContext | null = null
|
|
288
290
|
|
|
289
291
|
private userState: Record<string, any> = {}
|
|
290
|
-
private browserLogs: Map<
|
|
292
|
+
private browserLogs: Map<Page, string[]> = new Map()
|
|
293
|
+
// Tracks the index up to which getLatestLogs({ sinceLastCall: true }) has
|
|
294
|
+
// returned logs. 0 means "return everything" (first call gets full buffer).
|
|
295
|
+
// When addBrowserLog shifts old entries (cap at MAX_LOGS_PER_PAGE), cursors
|
|
296
|
+
// are decremented so they stay in sync with the array.
|
|
297
|
+
private pageLogCursor: Map<Page, number> = new Map()
|
|
291
298
|
private lastSnapshots: WeakMap<Page, Map<string, string>> = new WeakMap()
|
|
292
299
|
private lastRefToLocator: WeakMap<Page, Map<string, string>> = new WeakMap()
|
|
293
300
|
private warningEvents: WarningEvent[] = []
|
|
@@ -531,41 +538,68 @@ export class PlaywrightExecutor {
|
|
|
531
538
|
}
|
|
532
539
|
|
|
533
540
|
private setupPageConsoleListener(page: Page) {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (!targetId) {
|
|
537
|
-
return
|
|
541
|
+
if (!this.browserLogs.has(page)) {
|
|
542
|
+
this.browserLogs.set(page, [])
|
|
538
543
|
}
|
|
539
544
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
page.on('framenavigated', (frame) => {
|
|
545
|
-
if (frame === page.mainFrame()) {
|
|
546
|
-
this.browserLogs.set(targetId, [])
|
|
547
|
-
}
|
|
548
|
-
})
|
|
545
|
+
// Logs are NOT cleared on navigation so that getLatestLogs({ sinceLastCall: true })
|
|
546
|
+
// can return errors from the previous page load. The MAX_LOGS_PER_PAGE cap (5000)
|
|
547
|
+
// prevents unbounded growth; old entries are shifted out in addBrowserLog.
|
|
549
548
|
|
|
550
549
|
page.on('close', () => {
|
|
551
|
-
this.browserLogs.delete(
|
|
550
|
+
this.browserLogs.delete(page)
|
|
551
|
+
this.pageLogCursor.delete(page)
|
|
552
552
|
})
|
|
553
553
|
|
|
554
554
|
page.on('console', (msg) => {
|
|
555
555
|
try {
|
|
556
556
|
const logEntry = `[${msg.type()}] ${msg.text()}`
|
|
557
|
-
|
|
558
|
-
this.browserLogs.set(targetId, [])
|
|
559
|
-
}
|
|
560
|
-
const pageLogs = this.browserLogs.get(targetId)!
|
|
561
|
-
pageLogs.push(logEntry)
|
|
562
|
-
if (pageLogs.length > MAX_LOGS_PER_PAGE) {
|
|
563
|
-
pageLogs.shift()
|
|
564
|
-
}
|
|
557
|
+
this.addBrowserLog({ page, logEntry })
|
|
565
558
|
} catch (e) {
|
|
566
559
|
this.logger.error('[Executor] Failed to get console message text:', e)
|
|
567
560
|
}
|
|
568
561
|
})
|
|
562
|
+
|
|
563
|
+
page.on('pageerror', (error) => {
|
|
564
|
+
this.addBrowserLog({ page, logEntry: `[pageerror] ${error.message}` })
|
|
565
|
+
})
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private addBrowserLog(options: { page: Page; logEntry: string }) {
|
|
569
|
+
if (!this.browserLogs.has(options.page)) {
|
|
570
|
+
this.browserLogs.set(options.page, [])
|
|
571
|
+
}
|
|
572
|
+
const pageLogs = this.browserLogs.get(options.page)!
|
|
573
|
+
pageLogs.push(options.logEntry)
|
|
574
|
+
if (pageLogs.length > MAX_LOGS_PER_PAGE) {
|
|
575
|
+
pageLogs.shift()
|
|
576
|
+
// Decrement cursor so it stays in sync with the shifted array.
|
|
577
|
+
// Clamp to 0 so the cursor never goes negative.
|
|
578
|
+
const cursor = this.pageLogCursor.get(options.page)
|
|
579
|
+
if (cursor !== undefined && cursor > 0) {
|
|
580
|
+
this.pageLogCursor.set(options.page, cursor - 1)
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
private pagesRelatedToPage(page: Page): Page[] {
|
|
586
|
+
const frameUrls = new Set(
|
|
587
|
+
page
|
|
588
|
+
.frames()
|
|
589
|
+
.map((frame) => {
|
|
590
|
+
return frame.url()
|
|
591
|
+
})
|
|
592
|
+
.filter((url) => {
|
|
593
|
+
return url && url !== 'about:blank'
|
|
594
|
+
}),
|
|
595
|
+
)
|
|
596
|
+
|
|
597
|
+
return page
|
|
598
|
+
.context()
|
|
599
|
+
.pages()
|
|
600
|
+
.filter((candidate) => {
|
|
601
|
+
return candidate === page || frameUrls.has(candidate.url())
|
|
602
|
+
})
|
|
569
603
|
}
|
|
570
604
|
|
|
571
605
|
private async checkExtensionStatus(): Promise<{
|
|
@@ -573,17 +607,24 @@ export class PlaywrightExecutor {
|
|
|
573
607
|
activeTargets: number
|
|
574
608
|
playwriterVersion: string | null
|
|
575
609
|
}> {
|
|
576
|
-
const { host = '127.0.0.1', port = 19988, extensionId } = this.cdpConfig
|
|
610
|
+
const { host = '127.0.0.1', port = 19988, extensionId, token } = this.cdpConfig
|
|
577
611
|
const { httpBaseUrl } = parseRelayHost(host, port)
|
|
578
612
|
const notConnected = { connected: false, activeTargets: 0, playwriterVersion: null }
|
|
613
|
+
const headers: Record<string, string> = {}
|
|
614
|
+
const effectiveToken = token || process.env.PLAYWRITER_TOKEN
|
|
615
|
+
if (effectiveToken) {
|
|
616
|
+
headers['Authorization'] = `Bearer ${effectiveToken}`
|
|
617
|
+
}
|
|
579
618
|
try {
|
|
580
619
|
if (extensionId) {
|
|
581
620
|
const response = await fetch(`${httpBaseUrl}/extensions/status`, {
|
|
582
621
|
signal: AbortSignal.timeout(2000),
|
|
622
|
+
headers,
|
|
583
623
|
})
|
|
584
624
|
if (!response.ok) {
|
|
585
625
|
const fallback = await fetch(`${httpBaseUrl}/extension/status`, {
|
|
586
626
|
signal: AbortSignal.timeout(2000),
|
|
627
|
+
headers,
|
|
587
628
|
})
|
|
588
629
|
if (!fallback.ok) {
|
|
589
630
|
return notConnected
|
|
@@ -617,6 +658,7 @@ export class PlaywrightExecutor {
|
|
|
617
658
|
|
|
618
659
|
const response = await fetch(`${httpBaseUrl}/extension/status`, {
|
|
619
660
|
signal: AbortSignal.timeout(2000),
|
|
661
|
+
headers,
|
|
620
662
|
})
|
|
621
663
|
if (!response.ok) {
|
|
622
664
|
return notConnected
|
|
@@ -968,21 +1010,50 @@ export class PlaywrightExecutor {
|
|
|
968
1010
|
})
|
|
969
1011
|
}
|
|
970
1012
|
|
|
971
|
-
const getLatestLogs = async (options?: {
|
|
972
|
-
|
|
1013
|
+
const getLatestLogs = async (options?: {
|
|
1014
|
+
page?: Page
|
|
1015
|
+
count?: number
|
|
1016
|
+
search?: string | RegExp
|
|
1017
|
+
// When true, only return logs added since the last getLatestLogs call
|
|
1018
|
+
// with sinceLastCall: true. First call returns all buffered logs.
|
|
1019
|
+
// Cursors are tracked per page so navigations and new logs are
|
|
1020
|
+
// never missed. Useful for checking page errors after each action.
|
|
1021
|
+
sinceLastCall?: boolean
|
|
1022
|
+
}) => {
|
|
1023
|
+
const { page: filterPage, count, search, sinceLastCall = false } = options || {}
|
|
973
1024
|
let allLogs: string[] = []
|
|
974
1025
|
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
const
|
|
978
|
-
if (!
|
|
979
|
-
|
|
1026
|
+
// Collect logs, optionally slicing from cursor when sinceLastCall is set
|
|
1027
|
+
const collectLogs = (targetPage: Page): string[] => {
|
|
1028
|
+
const logs = this.browserLogs.get(targetPage) || []
|
|
1029
|
+
if (!sinceLastCall) {
|
|
1030
|
+
return logs
|
|
980
1031
|
}
|
|
981
|
-
const
|
|
982
|
-
|
|
1032
|
+
const cursor = this.pageLogCursor.get(targetPage) || 0
|
|
1033
|
+
return logs.slice(cursor)
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (filterPage) {
|
|
1037
|
+
const relatedPages = this.pagesRelatedToPage(filterPage)
|
|
1038
|
+
allLogs = relatedPages.flatMap((relatedPage) => {
|
|
1039
|
+
return collectLogs(relatedPage)
|
|
1040
|
+
})
|
|
983
1041
|
} else {
|
|
984
|
-
for (const
|
|
985
|
-
allLogs.push(...
|
|
1042
|
+
for (const [p] of this.browserLogs) {
|
|
1043
|
+
allLogs.push(...collectLogs(p))
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// Advance cursors after collecting so next sinceLastCall call starts fresh
|
|
1048
|
+
if (sinceLastCall) {
|
|
1049
|
+
const pagesToAdvance = filterPage
|
|
1050
|
+
? this.pagesRelatedToPage(filterPage)
|
|
1051
|
+
: [...this.browserLogs.keys()]
|
|
1052
|
+
for (const p of pagesToAdvance) {
|
|
1053
|
+
const logs = this.browserLogs.get(p)
|
|
1054
|
+
if (logs) {
|
|
1055
|
+
this.pageLogCursor.set(p, logs.length)
|
|
1056
|
+
}
|
|
986
1057
|
}
|
|
987
1058
|
}
|
|
988
1059
|
|
|
@@ -1021,6 +1092,7 @@ export class PlaywrightExecutor {
|
|
|
1021
1092
|
|
|
1022
1093
|
const clearAllLogs = () => {
|
|
1023
1094
|
this.browserLogs.clear()
|
|
1095
|
+
this.pageLogCursor.clear()
|
|
1024
1096
|
}
|
|
1025
1097
|
|
|
1026
1098
|
const getCDPSession = async (options: { page: Page }) => {
|
|
@@ -1043,6 +1115,47 @@ export class PlaywrightExecutor {
|
|
|
1043
1115
|
return getReactSource({ locator: options.locator, cdp })
|
|
1044
1116
|
}
|
|
1045
1117
|
|
|
1118
|
+
const getReactComponentInfoFn = async (options: { locator: Locator | ElementHandle }) => {
|
|
1119
|
+
const targetPage = await (async (): Promise<Page | null> => {
|
|
1120
|
+
if ('page' in options.locator) {
|
|
1121
|
+
return options.locator.page()
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
return (await options.locator.ownerFrame())?.page() ?? null
|
|
1125
|
+
})()
|
|
1126
|
+
if (!targetPage) {
|
|
1127
|
+
throw new Error('Could not get page from locator')
|
|
1128
|
+
}
|
|
1129
|
+
const cdp = await getCDPSession({ page: targetPage })
|
|
1130
|
+
return getReactComponentInfo({ locator: options.locator, cdp })
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const inspectPinnedElement = async (pageUrl: string, elementExpression: string) => {
|
|
1134
|
+
const targetPage = context.pages().find((candidate) => candidate.url() === pageUrl) || context.pages()[0]
|
|
1135
|
+
if (!targetPage) {
|
|
1136
|
+
throw new Error('No Playwright pages are available')
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
this.userState.page = targetPage
|
|
1140
|
+
const handle = (await targetPage.evaluateHandle((expression) => {
|
|
1141
|
+
return Function(`return (${expression})`)()
|
|
1142
|
+
}, elementExpression)).asElement()
|
|
1143
|
+
|
|
1144
|
+
const result = await (async () => {
|
|
1145
|
+
if (!handle) {
|
|
1146
|
+
return { url: targetPage.url(), outerHTML: null, react: null }
|
|
1147
|
+
}
|
|
1148
|
+
return {
|
|
1149
|
+
url: targetPage.url(),
|
|
1150
|
+
outerHTML: await handle.evaluate((el) => el.outerHTML),
|
|
1151
|
+
react: await getReactComponentInfoFn({ locator: handle }),
|
|
1152
|
+
}
|
|
1153
|
+
})()
|
|
1154
|
+
|
|
1155
|
+
console.log(result)
|
|
1156
|
+
return result
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1046
1159
|
const screenshotCollector: ScreenshotResult[] = []
|
|
1047
1160
|
// Separate collector for images produced by resizeImageForAgent() calls.
|
|
1048
1161
|
// These get merged into result.images so the CLI can emit them via Kitty Graphics.
|
|
@@ -1146,6 +1259,8 @@ export class PlaywrightExecutor {
|
|
|
1146
1259
|
getStylesForLocator: getStylesForLocatorFn,
|
|
1147
1260
|
formatStylesAsText,
|
|
1148
1261
|
getReactSource: getReactSourceFn,
|
|
1262
|
+
getReactComponentInfo: getReactComponentInfoFn,
|
|
1263
|
+
inspectPinnedElement,
|
|
1149
1264
|
screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
|
|
1150
1265
|
resizeImageForAgent: resizeImageForAgentFn,
|
|
1151
1266
|
// Backward-compatible alias for resizeImageForAgent
|
|
@@ -1178,6 +1293,18 @@ export class PlaywrightExecutor {
|
|
|
1178
1293
|
// Ghost Browser API - only works in Ghost Browser, mirrors chrome.ghostPublicAPI etc
|
|
1179
1294
|
chrome: chromeGhostBrowser,
|
|
1180
1295
|
...usefulGlobals,
|
|
1296
|
+
// Expose process with safety overrides:
|
|
1297
|
+
// - cwd() returns the session's cwd instead of the relay server's cwd
|
|
1298
|
+
// - exit() is blocked to prevent killing the relay server
|
|
1299
|
+
// - chdir() is blocked to prevent affecting other sessions
|
|
1300
|
+
process: new Proxy(process, {
|
|
1301
|
+
get(target, prop, receiver) {
|
|
1302
|
+
if (prop === 'cwd') return () => self.sessionCwd || target.cwd()
|
|
1303
|
+
if (prop === 'exit') return () => { throw new Error('process.exit() is not allowed in the sandbox') }
|
|
1304
|
+
if (prop === 'chdir') return () => { throw new Error('process.chdir() is not allowed in the sandbox, use a new session with a different cwd instead') }
|
|
1305
|
+
return Reflect.get(target, prop, receiver)
|
|
1306
|
+
},
|
|
1307
|
+
}),
|
|
1181
1308
|
}
|
|
1182
1309
|
|
|
1183
1310
|
const vmContext = vm.createContext(vmContextObj)
|
package/src/mcp.ts
CHANGED
|
@@ -56,9 +56,14 @@ function getLogServerUrl(): string {
|
|
|
56
56
|
|
|
57
57
|
async function sendLogToRelayServer(level: string, ...args: any[]) {
|
|
58
58
|
try {
|
|
59
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
60
|
+
const token = process.env.PLAYWRITER_TOKEN
|
|
61
|
+
if (token) {
|
|
62
|
+
headers['Authorization'] = `Bearer ${token}`
|
|
63
|
+
}
|
|
59
64
|
await fetch(getLogServerUrl(), {
|
|
60
65
|
method: 'POST',
|
|
61
|
-
headers
|
|
66
|
+
headers,
|
|
62
67
|
body: JSON.stringify({ level, args }),
|
|
63
68
|
signal: AbortSignal.timeout(1000),
|
|
64
69
|
})
|