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/relay-core.test.ts
CHANGED
|
@@ -730,6 +730,7 @@ describe('Relay Core Tests', () => {
|
|
|
730
730
|
console.error('Test error 67890');
|
|
731
731
|
console.warn('Test warning 11111');
|
|
732
732
|
console.log('Test log 2 with', { data: 'object' });
|
|
733
|
+
setTimeout(() => { throw new Error('Test pageerror 22222'); }, 0);
|
|
733
734
|
});
|
|
734
735
|
// Wait for logs to be captured
|
|
735
736
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
@@ -752,6 +753,7 @@ describe('Relay Core Tests', () => {
|
|
|
752
753
|
expect(output).toContain('[log] Test log 12345')
|
|
753
754
|
expect(output).toContain('[error] Test error 67890')
|
|
754
755
|
expect(output).toContain('[warning] Test warning 11111')
|
|
756
|
+
expect(output).toContain('[pageerror] Test pageerror 22222')
|
|
755
757
|
|
|
756
758
|
// Test filtering by search string
|
|
757
759
|
const errorLogsResult = await client.callTool({
|
|
@@ -769,7 +771,7 @@ describe('Relay Core Tests', () => {
|
|
|
769
771
|
// With context lines (5 above/below), nearby logs are also included
|
|
770
772
|
expect(errorOutput).toContain('[log] Test log 12345')
|
|
771
773
|
|
|
772
|
-
// Test that logs
|
|
774
|
+
// Test that logs persist across page reload
|
|
773
775
|
await client.callTool({
|
|
774
776
|
name: 'execute',
|
|
775
777
|
arguments: {
|
|
@@ -812,7 +814,9 @@ describe('Relay Core Tests', () => {
|
|
|
812
814
|
},
|
|
813
815
|
})
|
|
814
816
|
|
|
815
|
-
// Check logs after reload - old logs
|
|
817
|
+
// Check logs after reload - old logs persist (no longer cleared on navigation)
|
|
818
|
+
// so both pre- and post-reload logs are present. Use sinceLastCall to get
|
|
819
|
+
// only new logs if needed.
|
|
816
820
|
const afterReloadResult = await client.callTool({
|
|
817
821
|
name: 'execute',
|
|
818
822
|
arguments: {
|
|
@@ -826,7 +830,7 @@ describe('Relay Core Tests', () => {
|
|
|
826
830
|
|
|
827
831
|
const afterReloadOutput = (afterReloadResult as any).content[0].text
|
|
828
832
|
expect(afterReloadOutput).toContain('[log] After reload 88888')
|
|
829
|
-
expect(afterReloadOutput).
|
|
833
|
+
expect(afterReloadOutput).toContain('[log] Before reload 99999')
|
|
830
834
|
|
|
831
835
|
// Clean up
|
|
832
836
|
await client.callTool({
|
|
@@ -943,7 +947,7 @@ describe('Relay Core Tests', () => {
|
|
|
943
947
|
expect(allOutput).toContain('[log] PageA log 11111')
|
|
944
948
|
expect(allOutput).toContain('[log] PageB log 33333')
|
|
945
949
|
|
|
946
|
-
// Test that reloading page A
|
|
950
|
+
// Test that reloading page A preserves logs (no longer cleared on navigation)
|
|
947
951
|
await client.callTool({
|
|
948
952
|
name: 'execute',
|
|
949
953
|
arguments: {
|
|
@@ -957,7 +961,7 @@ describe('Relay Core Tests', () => {
|
|
|
957
961
|
},
|
|
958
962
|
})
|
|
959
963
|
|
|
960
|
-
// Check page A logs -
|
|
964
|
+
// Check page A logs - logs persist across navigation, so both old and new are present
|
|
961
965
|
const pageAAfterReloadResult = await client.callTool({
|
|
962
966
|
name: 'execute',
|
|
963
967
|
arguments: {
|
|
@@ -971,7 +975,7 @@ describe('Relay Core Tests', () => {
|
|
|
971
975
|
|
|
972
976
|
const pageAAfterReloadOutput = (pageAAfterReloadResult as any).content[0].text
|
|
973
977
|
expect(pageAAfterReloadOutput).toContain('[log] PageA after reload 55555')
|
|
974
|
-
expect(pageAAfterReloadOutput).
|
|
978
|
+
expect(pageAAfterReloadOutput).toContain('[log] PageA log 11111')
|
|
975
979
|
|
|
976
980
|
// Check page B logs - should still have original logs
|
|
977
981
|
const pageBAfterAReloadResult = await client.callTool({
|
|
@@ -921,12 +921,24 @@ describe('CDP Session Tests', () => {
|
|
|
921
921
|
<body>
|
|
922
922
|
<div id="root"></div>
|
|
923
923
|
<script>
|
|
924
|
-
function
|
|
925
|
-
return React.createElement('button', { id: 'react-btn' },
|
|
924
|
+
function SaveButton(props) {
|
|
925
|
+
return React.createElement('button', { id: 'react-btn' }, props.label);
|
|
926
|
+
}
|
|
927
|
+
function Panel() {
|
|
928
|
+
return React.createElement('section', null, React.createElement(SaveButton, {
|
|
929
|
+
label: 'Click me',
|
|
930
|
+
count: 3,
|
|
931
|
+
config: { variant: 'primary' },
|
|
932
|
+
onClick: () => {}
|
|
933
|
+
}));
|
|
934
|
+
}
|
|
935
|
+
function App() {
|
|
936
|
+
return React.createElement(Panel);
|
|
926
937
|
}
|
|
927
938
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
|
928
|
-
root.render(React.createElement(
|
|
939
|
+
root.render(React.createElement(App));
|
|
929
940
|
</script>
|
|
941
|
+
<button id="plain-btn">Plain</button>
|
|
930
942
|
</body>
|
|
931
943
|
</html>
|
|
932
944
|
`)
|
|
@@ -946,40 +958,54 @@ describe('CDP Session Tests', () => {
|
|
|
946
958
|
const btnCount = await btn.count()
|
|
947
959
|
expect(btnCount).toBe(1)
|
|
948
960
|
|
|
949
|
-
const hasBippyBefore = await cdpPage!.evaluate(() => !!
|
|
961
|
+
const hasBippyBefore = await cdpPage!.evaluate(() => !!globalThis.__bippy)
|
|
950
962
|
expect(hasBippyBefore).toBe(false)
|
|
951
963
|
|
|
952
964
|
const wsUrl = getCdpUrl({ port: TEST_PORT })
|
|
953
965
|
const cdpSession = await getCDPSessionForPage({ page: cdpPage! })
|
|
954
966
|
|
|
955
|
-
const { getReactSource } = await import('./react-source.js')
|
|
967
|
+
const { getReactSource, getReactComponentInfo } = await import('./react-source.js')
|
|
956
968
|
const source = await getReactSource({ locator: btn, cdp: cdpSession })
|
|
969
|
+
const info = await getReactComponentInfo({ locator: btn, cdp: cdpSession })
|
|
970
|
+
const plainInfo = await getReactComponentInfo({ locator: cdpPage!.locator('#plain-btn'), cdp: cdpSession })
|
|
957
971
|
|
|
958
|
-
const hasBippyAfter = await cdpPage!.evaluate(() => !!
|
|
972
|
+
const hasBippyAfter = await cdpPage!.evaluate(() => !!globalThis.__bippy)
|
|
959
973
|
expect(hasBippyAfter).toBe(true)
|
|
960
974
|
|
|
961
975
|
const hasFiber = await btn.evaluate((el) => {
|
|
962
|
-
const bippy =
|
|
976
|
+
const bippy = globalThis.__bippy
|
|
977
|
+
if (!bippy) return false
|
|
963
978
|
const fiber = bippy.getFiberFromHostInstance(el)
|
|
964
979
|
return !!fiber
|
|
965
980
|
})
|
|
966
981
|
expect(hasFiber).toBe(true)
|
|
967
982
|
|
|
968
983
|
const componentName = await btn.evaluate((el) => {
|
|
969
|
-
const bippy =
|
|
984
|
+
const bippy = globalThis.__bippy
|
|
985
|
+
if (!bippy) return null
|
|
970
986
|
const fiber = bippy.getFiberFromHostInstance(el)
|
|
971
987
|
let current = fiber
|
|
972
988
|
while (current) {
|
|
973
989
|
if (bippy.isCompositeFiber(current)) {
|
|
974
990
|
return bippy.getDisplayName(current.type)
|
|
975
991
|
}
|
|
976
|
-
current = current.return
|
|
992
|
+
current = current.return ?? null
|
|
977
993
|
}
|
|
978
994
|
return null
|
|
979
995
|
})
|
|
980
|
-
expect(componentName).toBe('
|
|
996
|
+
expect(componentName).toBe('SaveButton')
|
|
997
|
+
expect(plainInfo).toBe(null)
|
|
998
|
+
expect(info?.componentName).toBe('SaveButton')
|
|
999
|
+
expect(info?.hierarchy.map((item) => item.componentName)).toEqual(['SaveButton', 'Panel', 'App'])
|
|
1000
|
+
expect(info?.props).toEqual({
|
|
1001
|
+
label: 'Click me',
|
|
1002
|
+
count: 3,
|
|
1003
|
+
config: { variant: 'primary' },
|
|
1004
|
+
onClick: '[function]',
|
|
1005
|
+
})
|
|
981
1006
|
|
|
982
1007
|
console.log('Component name from fiber:', componentName)
|
|
1008
|
+
console.log('React component info:', info)
|
|
983
1009
|
console.log('Source location (null for UMD React, works on local dev servers with JSX transform):', source)
|
|
984
1010
|
|
|
985
1011
|
await browser.close()
|
|
@@ -1251,7 +1277,15 @@ describe('Auto-enable Tests', () => {
|
|
|
1251
1277
|
})
|
|
1252
1278
|
expect(tabCountBefore).toBe(0)
|
|
1253
1279
|
|
|
1254
|
-
const
|
|
1280
|
+
const previousAutoEnable = process.env.PLAYWRITER_AUTO_ENABLE
|
|
1281
|
+
delete process.env.PLAYWRITER_AUTO_ENABLE
|
|
1282
|
+
const browser = await chromium.connectOverCDP(getCdpUrl({ port: TEST_PORT, autoEnable: true })).finally(() => {
|
|
1283
|
+
if (previousAutoEnable === undefined) {
|
|
1284
|
+
delete process.env.PLAYWRITER_AUTO_ENABLE
|
|
1285
|
+
return
|
|
1286
|
+
}
|
|
1287
|
+
process.env.PLAYWRITER_AUTO_ENABLE = previousAutoEnable
|
|
1288
|
+
})
|
|
1255
1289
|
|
|
1256
1290
|
const pages = browser.contexts()[0].pages()
|
|
1257
1291
|
expect(pages.length).toBeGreaterThan(0)
|
package/src/relay-state.test.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Unit tests for relay state transitions.
|
|
3
3
|
* Data-in / data-out transitions for the unified extension map.
|
|
4
4
|
*/
|
|
5
|
-
import { describe, test, expect } from 'vitest'
|
|
5
|
+
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
|
|
6
6
|
import type { WSContext } from 'hono/ws'
|
|
7
7
|
import type { Protocol } from './cdp-types.js'
|
|
8
8
|
import * as relayState from './relay-state.js'
|
|
@@ -568,3 +568,69 @@ describe('store.setState with transitions', () => {
|
|
|
568
568
|
expect(state.playwrightClients.size).toBe(1)
|
|
569
569
|
})
|
|
570
570
|
})
|
|
571
|
+
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
// DNS rebinding protection — Host header validation
|
|
574
|
+
// Node.js fetch/undici treats Host as a forbidden header, so we use the raw
|
|
575
|
+
// http module to send requests with arbitrary Host values.
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
import http from 'node:http'
|
|
579
|
+
|
|
580
|
+
function httpGet({ port, path, host }: { port: number; path: string; host: string }): Promise<{ status: number; body: string }> {
|
|
581
|
+
return new Promise((resolve, reject) => {
|
|
582
|
+
const req = http.request({ hostname: '127.0.0.1', port, path, method: 'GET', headers: { Host: host } }, (res) => {
|
|
583
|
+
let body = ''
|
|
584
|
+
res.on('data', (chunk: Buffer) => {
|
|
585
|
+
body += chunk.toString()
|
|
586
|
+
})
|
|
587
|
+
res.on('end', () => {
|
|
588
|
+
resolve({ status: res.statusCode!, body })
|
|
589
|
+
})
|
|
590
|
+
})
|
|
591
|
+
req.on('error', reject)
|
|
592
|
+
req.end()
|
|
593
|
+
})
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
describe('Host header validation (DNS rebinding protection)', () => {
|
|
597
|
+
let server: { close(): void } | null = null
|
|
598
|
+
const TEST_PORT = 19996
|
|
599
|
+
|
|
600
|
+
beforeAll(async () => {
|
|
601
|
+
const { startPlayWriterCDPRelayServer } = await import('./cdp-relay.js')
|
|
602
|
+
server = await startPlayWriterCDPRelayServer({ port: TEST_PORT })
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
afterAll(async () => {
|
|
606
|
+
server?.close()
|
|
607
|
+
server = null
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
test('rejects requests with non-localhost Host header', async () => {
|
|
611
|
+
// DNS rebinding: evil.com resolves to 127.0.0.1
|
|
612
|
+
const res = await httpGet({ port: TEST_PORT, path: '/', host: 'evil.com' })
|
|
613
|
+
expect(res.status).toBe(403)
|
|
614
|
+
expect(res.body).toContain('Invalid Host header')
|
|
615
|
+
|
|
616
|
+
// With port suffix
|
|
617
|
+
const res2 = await httpGet({ port: TEST_PORT, path: '/version', host: 'evil.com:19996' })
|
|
618
|
+
expect(res2.status).toBe(403)
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
test('allows requests with localhost Host headers', async () => {
|
|
622
|
+
const localhostVariants = [
|
|
623
|
+
`localhost:${TEST_PORT}`,
|
|
624
|
+
`127.0.0.1:${TEST_PORT}`,
|
|
625
|
+
`[::1]:${TEST_PORT}`,
|
|
626
|
+
'localhost',
|
|
627
|
+
'127.0.0.1',
|
|
628
|
+
'LOCALHOST',
|
|
629
|
+
]
|
|
630
|
+
|
|
631
|
+
for (const hostValue of localhostVariants) {
|
|
632
|
+
const res = await httpGet({ port: TEST_PORT, path: '/', host: hostValue })
|
|
633
|
+
expect(res.status, `Expected 200 for Host: ${hostValue}`).toBe(200)
|
|
634
|
+
}
|
|
635
|
+
})
|
|
636
|
+
})
|
package/src/screen-recording.ts
CHANGED
|
@@ -18,6 +18,21 @@ import type {
|
|
|
18
18
|
} from './protocol.js'
|
|
19
19
|
import { GhostCursorController } from './ghost-cursor-controller.js'
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Build headers for the relay's privileged /recording/* HTTP endpoints.
|
|
23
|
+
* Reads PLAYWRITER_TOKEN from env so in-process callers (executor running
|
|
24
|
+
* inside `playwriter serve --token …`) authenticate against their own relay.
|
|
25
|
+
* The `serve` command sets the env var at startup.
|
|
26
|
+
*/
|
|
27
|
+
function recordingHeaders(): Record<string, string> {
|
|
28
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
29
|
+
const token = process.env.PLAYWRITER_TOKEN
|
|
30
|
+
if (token) {
|
|
31
|
+
headers['Authorization'] = `Bearer ${token}`
|
|
32
|
+
}
|
|
33
|
+
return headers
|
|
34
|
+
}
|
|
35
|
+
|
|
21
36
|
/**
|
|
22
37
|
* Generate a CLI command that starts a managed Playwriter browser with the
|
|
23
38
|
* bundled extension preloaded. This enables screen recording without a manual
|
|
@@ -293,7 +308,7 @@ export async function startRecording(options: StartRecordingOptions): Promise<Re
|
|
|
293
308
|
|
|
294
309
|
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/start`, {
|
|
295
310
|
method: 'POST',
|
|
296
|
-
headers:
|
|
311
|
+
headers: recordingHeaders(),
|
|
297
312
|
body: JSON.stringify({
|
|
298
313
|
sessionId,
|
|
299
314
|
frameRate,
|
|
@@ -341,7 +356,7 @@ export async function stopRecording(
|
|
|
341
356
|
|
|
342
357
|
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/stop`, {
|
|
343
358
|
method: 'POST',
|
|
344
|
-
headers:
|
|
359
|
+
headers: recordingHeaders(),
|
|
345
360
|
body: JSON.stringify({ sessionId }),
|
|
346
361
|
})
|
|
347
362
|
|
|
@@ -368,7 +383,8 @@ export async function isRecording(options: {
|
|
|
368
383
|
if (sessionId) {
|
|
369
384
|
url.searchParams.set('sessionId', sessionId)
|
|
370
385
|
}
|
|
371
|
-
|
|
386
|
+
// GET request — only the Authorization header matters here
|
|
387
|
+
const response = await fetch(url.toString(), { headers: recordingHeaders() })
|
|
372
388
|
const result = (await response.json()) as IsRecordingResult
|
|
373
389
|
|
|
374
390
|
return { isRecording: result.isRecording, startedAt: result.startedAt, tabId: result.tabId }
|
|
@@ -386,7 +402,7 @@ export async function cancelRecording(options: {
|
|
|
386
402
|
|
|
387
403
|
const response = await fetch(`http://127.0.0.1:${relayPort}/recording/cancel`, {
|
|
388
404
|
method: 'POST',
|
|
389
|
-
headers:
|
|
405
|
+
headers: recordingHeaders(),
|
|
390
406
|
body: JSON.stringify({ sessionId }),
|
|
391
407
|
})
|
|
392
408
|
|
package/src/skill.md
CHANGED
|
@@ -93,7 +93,7 @@ playwriter -s 1 -e 'await state.page.click("button")'
|
|
|
93
93
|
playwriter -s 1 -e 'await state.page.title()'
|
|
94
94
|
|
|
95
95
|
# Take a screenshot
|
|
96
|
-
playwriter -s 1 -e 'await state.page.screenshot({ path: "screenshot.png", scale: "css" })'
|
|
96
|
+
playwriter -s 1 -e 'await state.page.screenshot({ path: "/absolute/path/to/screenshot.png", scale: "css" })'
|
|
97
97
|
|
|
98
98
|
# Get accessibility snapshot
|
|
99
99
|
playwriter -s 1 -e 'await snapshot({ page: state.page })'
|
|
@@ -129,6 +129,16 @@ console.log({ title, url });
|
|
|
129
129
|
- **Heredoc** (`<<'EOF'`): best for multiline code. The quoted `'EOF'` delimiter disables all bash expansion. Any character works inside, including `$`, backticks, and single quotes.
|
|
130
130
|
- **`$'...'`**: allows `\'` escaping but `\n`, `\t`, `\\` become special — conflicts with JS regex patterns.
|
|
131
131
|
|
|
132
|
+
### Execute from file
|
|
133
|
+
|
|
134
|
+
For longer scripts, use `-f` instead of `-e` to execute JavaScript from a file:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
playwriter -s 1 -f script.js
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The file is read from disk and executed in the same sandbox as `-e`. All context variables (`state`, `page`, `context`, etc.) are available. `-e` and `-f` cannot be used together.
|
|
141
|
+
|
|
132
142
|
### Debugging playwriter issues
|
|
133
143
|
|
|
134
144
|
If some internal critical error happens you can read the relay server logs to understand the issue. The log file is located in the user home directory:
|
|
@@ -193,10 +203,19 @@ You can collaborate with the user - they can help with captchas, difficult eleme
|
|
|
193
203
|
- `page` - a default page (may be shared with other agents). Prefer creating your own page and storing it in `state` (see "working with pages")
|
|
194
204
|
- `context` - browser context, access all pages via `context.pages()`
|
|
195
205
|
- `require` - load Node.js modules (e.g., `const fs = require('node:fs')`). ESM `import` is not available in the sandbox
|
|
196
|
-
- Node.js globals: `setTimeout`, `setInterval`, `fetch`, `URL`, `Buffer`, `crypto`, etc.
|
|
206
|
+
- Node.js globals: `setTimeout`, `setInterval`, `fetch`, `URL`, `Buffer`, `crypto`, `process`, etc.
|
|
207
|
+
|
|
208
|
+
**Not available in the sandbox:** `__dirname`, `__filename`, `import`.
|
|
197
209
|
|
|
198
210
|
**Important:** `state` is **session-isolated** but pages are **shared** across all sessions. See "working with pages" for how to avoid interference.
|
|
199
211
|
|
|
212
|
+
**Sandboxed `fs` write restrictions:** `require('node:fs')` is scoped. Writes (writeFileSync, mkdirSync, etc.) only succeed in:
|
|
213
|
+
- The **directory where `playwriter` CLI was invoked** (the session's cwd)
|
|
214
|
+
- `/tmp`
|
|
215
|
+
- The OS temp directory (`os.tmpdir()`, e.g. `/var/folders/.../T/` on macOS)
|
|
216
|
+
|
|
217
|
+
Writing to any other path (e.g. `~/Downloads`, `~/Desktop`) throws `EPERM: operation not permitted, access outside allowed directories`. To save files elsewhere, write to a temp path first, then move the file using a shell command outside the sandbox.
|
|
218
|
+
|
|
200
219
|
## rules
|
|
201
220
|
|
|
202
221
|
- **Initialize state.page first**: see "working with pages" — at the start of a task, assign `state.page` (reuse `about:blank` or create one) and use `state.page` for all automation steps.
|
|
@@ -205,10 +224,12 @@ You can collaborate with the user - they can help with captchas, difficult eleme
|
|
|
205
224
|
- **No bringToFront**: never call unless user asks - it's disruptive and unnecessary, you can interact with background pages
|
|
206
225
|
- **Check state after actions**: always verify page state after clicking/submitting (see next section)
|
|
207
226
|
- **Clean up listeners**: call `state.page.removeAllListeners()` at end of message to prevent leaks
|
|
227
|
+
- **Always print page logs after every action**: call `getLatestLogs({ page: state.page, sinceLastCall: true })` after every goto, click, or submit to catch console errors and warnings. Do not manually collect `page.on('console')` events; manual listeners miss logs emitted before the listener is attached. The first `sinceLastCall` call returns all buffered logs including startup and hydration errors.
|
|
208
228
|
- **CDP sessions**: use `getCDPSession({ page: state.page })` not `state.page.context().newCDPSession()` - NEVER use `newCDPSession()` method, it doesn't work through playwriter relay
|
|
209
229
|
- **Wait for load**: use `state.page.waitForLoadState('domcontentloaded')` not `state.page.waitForEvent('load')` - waitForEvent times out if already loaded
|
|
210
230
|
- **Minimize timeouts**: prefer proper waits (`waitForSelector`, `waitForPageLoad`) over `state.page.waitForTimeout()`. Short timeouts (1-2s) are acceptable for non-deterministic events like animations, tab opens, or async UI updates where no specific selector is available
|
|
211
231
|
- **Snapshot before screenshot**: always use `snapshot()` first to understand page state (text-based, fast, cheap). Only use `screenshot` when you specifically need visual/spatial information. Never take a screenshot just to check if a page loaded or to read text content — snapshot gives you that instantly without burning image tokens
|
|
232
|
+
- **Always use absolute file paths for Playwright artifact APIs**: for `page.screenshot({ path })`, `locator.screenshot({ path })`, `elementHandle.screenshot({ path })`, `page.pdf({ path })`, `download.saveAs(path)`, and `video.saveAs(path)`, always pass an absolute path. Relative paths are resolved by Playwright client internals, not the sandboxed `fs`, so they may use the relay server cwd instead of your session cwd.
|
|
212
233
|
- **Snapshot replaces page.evaluate() for inspection**: do NOT write `page.evaluate()` calls to manually query class names, bounding boxes, child counts, or visibility flags. `snapshot()` already shows every interactive element with its text, role, and a ready-to-use locator. If you catch yourself writing `document.querySelector` or `getBoundingClientRect` inside evaluate — stop and use `snapshot()` instead. Reserve `page.evaluate()` for actions that modify page state (e.g., `localStorage.clear()`, scroll manipulation) or extract non-DOM data (e.g., `window.__CONFIG__`)
|
|
213
234
|
|
|
214
235
|
## interaction feedback loop
|
|
@@ -216,18 +237,21 @@ You can collaborate with the user - they can help with captchas, difficult eleme
|
|
|
216
237
|
Every browser interaction must follow **observe → act → observe**. Never chain multiple actions blindly.
|
|
217
238
|
|
|
218
239
|
1. **Open page** — get or create your page, navigate to URL
|
|
219
|
-
2. **Observe** — print `state.page.url()` + `snapshot()`. Always print URL — pages can redirect unexpectedly.
|
|
240
|
+
2. **Observe** — print `state.page.url()` + `snapshot()` + `getLatestLogs({ sinceLastCall: true })`. Always print URL — pages can redirect unexpectedly.
|
|
220
241
|
3. **Check** — if page isn't ready (loading, wrong URL, content missing), wait and observe again
|
|
221
242
|
4. **Act** — perform one action (click, type, submit)
|
|
222
|
-
5. **Observe again** — print URL + snapshot to verify the action's effect
|
|
243
|
+
5. **Observe again** — print URL + snapshot + page logs to verify the action's effect
|
|
223
244
|
6. **Repeat** from step 3 until task is complete
|
|
224
245
|
|
|
246
|
+
**Always print page logs after every action** using `getLatestLogs({ sinceLastCall: true })`. This returns only new console messages and errors since the last call, so you catch hydration errors, failed network requests, and runtime exceptions without duplicates. The first call returns all buffered logs from the page, including logs emitted before your script started.
|
|
247
|
+
|
|
225
248
|
```js
|
|
226
249
|
// Each step should be a separate execute call:
|
|
227
250
|
// Step 1: navigate + observe
|
|
228
251
|
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
|
229
252
|
await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
|
|
230
253
|
console.log('URL:', state.page.url())
|
|
254
|
+
console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
|
|
231
255
|
await snapshot({ page: state.page }).then(console.log)
|
|
232
256
|
```
|
|
233
257
|
|
|
@@ -235,25 +259,26 @@ await snapshot({ page: state.page }).then(console.log)
|
|
|
235
259
|
// Step 2: act + observe
|
|
236
260
|
await state.page.locator('button:has-text("Submit")').click()
|
|
237
261
|
console.log('URL:', state.page.url())
|
|
262
|
+
console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
|
|
238
263
|
await snapshot({ page: state.page }).then(console.log)
|
|
239
264
|
```
|
|
240
265
|
|
|
241
266
|
If nothing changed after an action, try `waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element.
|
|
242
267
|
|
|
243
|
-
**Deeper observation** — when snapshots aren't enough to understand what happened, combine
|
|
268
|
+
**Deeper observation** — when snapshots aren't enough to understand what happened, combine snapshot with filtered logs:
|
|
244
269
|
|
|
245
270
|
```js
|
|
246
|
-
//
|
|
271
|
+
// Search for specific errors in all logs (not just since last call)
|
|
247
272
|
const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 })
|
|
248
273
|
|
|
249
|
-
// Combine snapshot + logs for full picture
|
|
274
|
+
// Combine snapshot + filtered logs for full picture
|
|
250
275
|
const snap = await snapshot({ page: state.page, search: /dialog|error|message/ })
|
|
251
276
|
const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 })
|
|
252
277
|
console.log('UI:', snap)
|
|
253
278
|
console.log('Logs:', logs)
|
|
254
279
|
```
|
|
255
280
|
|
|
256
|
-
Use `getLatestLogs()` for
|
|
281
|
+
Use `getLatestLogs({ sinceLastCall: true })` after every action, `getLatestLogs({ search })` for targeted debugging, `state.page.url()` for navigation, screenshots only for visual layout issues.
|
|
257
282
|
|
|
258
283
|
## common mistakes to avoid
|
|
259
284
|
|
|
@@ -601,7 +626,7 @@ Instead, use simpler alternatives (single download via `a.click()`, store data i
|
|
|
601
626
|
|
|
602
627
|
```js
|
|
603
628
|
const [download] = await Promise.all([state.page.waitForEvent('download'), state.page.click('button.download')])
|
|
604
|
-
await download.saveAs(`/
|
|
629
|
+
await download.saveAs(`/absolute/path/${download.suggestedFilename()}`)
|
|
605
630
|
```
|
|
606
631
|
|
|
607
632
|
**iFrames** - two approaches depending on what you need:
|
|
@@ -669,17 +694,22 @@ For carousels or lazy-loaded galleries, you may need to click navigation arrows
|
|
|
669
694
|
|
|
670
695
|
## utility functions
|
|
671
696
|
|
|
672
|
-
**getLatestLogs** - retrieve captured browser console logs (up to 5000 per page
|
|
697
|
+
**getLatestLogs** - retrieve captured browser console logs and page errors (up to 5000 per page):
|
|
698
|
+
|
|
699
|
+
Always use this helper when inspecting browser logs. Do not attach new `page.on('console')` listeners for debugging because they only see future events and can miss logs emitted during page startup or hydration.
|
|
700
|
+
|
|
701
|
+
Use `sinceLastCall: true` after every action to get only new logs since the previous call. The first call returns all buffered logs including pre-existing ones. Logs persist across navigations so you never miss errors from page transitions.
|
|
673
702
|
|
|
674
703
|
```js
|
|
675
|
-
await getLatestLogs({ page?, count?, search? })
|
|
676
|
-
//
|
|
704
|
+
await getLatestLogs({ page?, count?, search?, sinceLastCall? })
|
|
705
|
+
// After every action: get only new logs
|
|
706
|
+
const newLogs = await getLatestLogs({ page: state.page, sinceLastCall: true })
|
|
707
|
+
// Search all logs (ignores cursor):
|
|
677
708
|
const errors = await getLatestLogs({ search: /error/i, count: 50 })
|
|
678
|
-
const pageLogs = await getLatestLogs({ page: state.page })
|
|
709
|
+
const pageLogs = await getLatestLogs({ page: state.page, count: 100 })
|
|
710
|
+
const hydrationErrors = await getLatestLogs({ page: state.page, search: /hydration|pageerror|React/i })
|
|
679
711
|
```
|
|
680
712
|
|
|
681
|
-
For custom log collection across runs, store in state: `state.logs = []; state.page.on('console', m => state.logs.push(m.text()))`
|
|
682
|
-
|
|
683
713
|
**getCleanHTML** - get cleaned HTML from a locator or page, with search and diffing:
|
|
684
714
|
|
|
685
715
|
```js
|
|
@@ -754,6 +784,19 @@ const source = await getReactSource({ locator: state.page.locator('[data-testid=
|
|
|
754
784
|
// => { fileName, lineNumber, columnNumber, componentName }
|
|
755
785
|
```
|
|
756
786
|
|
|
787
|
+
**getReactComponentInfo** - get best-effort React component info for an element. Returns `null` for non-React elements and never throws just because an element was not rendered by React. Source locations are usually only available in React dev builds. Props are sanitized and truncated so functions, DOM nodes, circular refs, and huge objects do not flood the output.
|
|
788
|
+
|
|
789
|
+
```js
|
|
790
|
+
const info = await getReactComponentInfo({ locator: state.page.locator('[data-testid="submit-btn"]') })
|
|
791
|
+
// => { componentName, source, hierarchy, props } | null
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
**inspectPinnedElement** - inspect a Playwriter pinned element and print the element `outerHTML` plus React component info when available. Used by the in-page toolbar and right-click copy flow.
|
|
795
|
+
|
|
796
|
+
```js
|
|
797
|
+
await inspectPinnedElement('https://example.com', 'globalThis.playwriterPinnedElem1')
|
|
798
|
+
```
|
|
799
|
+
|
|
757
800
|
**getStylesForLocator** - inspect CSS styles applied to an element, like browser DevTools "Styles" panel. Useful for debugging styling issues, finding where a CSS property is defined (file:line), and checking inherited styles. Returns selector, source location, and declarations for each matching rule. ALWAYS fetch `https://playwriter.dev/resources/styles-api.md` first with curl or webfetch tool.
|
|
758
801
|
|
|
759
802
|
```js
|
|
@@ -806,7 +849,7 @@ await screenshotWithAccessibilityLabels({ page: state.page })
|
|
|
806
849
|
|
|
807
850
|
Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs.
|
|
808
851
|
|
|
809
|
-
**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: '
|
|
852
|
+
**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: '/absolute/path/to/screenshot.png' })`. Also accepts `width`, `height`, `maxDimension`, `quality`, `format` (default: `'png'`), `output`. Alias: `resizeImage`.
|
|
810
853
|
|
|
811
854
|
**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`).
|
|
812
855
|
|
|
@@ -815,7 +858,7 @@ For demos, use interaction methods (`locator.click()`, `page.mouse.move()`) inst
|
|
|
815
858
|
```js
|
|
816
859
|
await recording.start({
|
|
817
860
|
page: state.page,
|
|
818
|
-
outputPath: '
|
|
861
|
+
outputPath: '/absolute/path/to/recording.mp4',
|
|
819
862
|
frameRate: 30, // default
|
|
820
863
|
audio: false, // default (tab audio)
|
|
821
864
|
videoBitsPerSecond: 2500000,
|
|
@@ -869,7 +912,7 @@ await el.click()
|
|
|
869
912
|
Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays:
|
|
870
913
|
|
|
871
914
|
```js
|
|
872
|
-
await state.page.screenshot({ path: 'shot.png', scale: 'css' })
|
|
915
|
+
await state.page.screenshot({ path: '/absolute/path/to/shot.png', scale: 'css' })
|
|
873
916
|
```
|
|
874
917
|
|
|
875
918
|
If you want to read back the image file into context, resize it first so it consumes fewer tokens:
|
|
@@ -1048,7 +1091,7 @@ await state.page.setViewportSize({ width: 1280, height: 720 })
|
|
|
1048
1091
|
### region screenshot (zoom equivalent)
|
|
1049
1092
|
|
|
1050
1093
|
```js
|
|
1051
|
-
await state.page.screenshot({ path: 'region.png', scale: 'css', clip: { x: 100, y: 200, width: 400, height: 300 } })
|
|
1094
|
+
await state.page.screenshot({ path: '/absolute/path/to/region.png', scale: 'css', clip: { x: 100, y: 200, width: 400, height: 300 } })
|
|
1052
1095
|
```
|
|
1053
1096
|
|
|
1054
1097
|
Prefer locator-based actions over coordinates — locators are stable across scroll/resize, auto-wait for elements, and don't require screenshot round-trips that burn ~800 image tokens per cycle.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { startPlayWriterCDPRelayServer } from './cdp-relay.js'
|
|
2
2
|
import { createFileLogger } from './create-logger.js'
|
|
3
|
+
import { waitForRelayVersion } from './relay-client.js'
|
|
3
4
|
import { LOG_CDP_FILE_PATH } from './utils.js'
|
|
4
5
|
|
|
5
6
|
process.title = 'playwriter-ws-server'
|
|
@@ -25,7 +26,27 @@ export async function startServer({
|
|
|
25
26
|
host = '127.0.0.1',
|
|
26
27
|
token,
|
|
27
28
|
}: { port?: number; host?: string; token?: string } = {}) {
|
|
28
|
-
|
|
29
|
+
let server
|
|
30
|
+
try {
|
|
31
|
+
server = await startPlayWriterCDPRelayServer({ port, host, token, logger })
|
|
32
|
+
} catch (err: unknown) {
|
|
33
|
+
// When two relay processes race to start (issue #75), the loser gets
|
|
34
|
+
// EADDRINUSE. Check if the winner is a valid relay and exit cleanly
|
|
35
|
+
// instead of crashing with a scary error in the logs.
|
|
36
|
+
const errWithCode = err as NodeJS.ErrnoException
|
|
37
|
+
if (errWithCode?.code === 'EADDRINUSE') {
|
|
38
|
+
// The winner may have bound the port but not be ready to answer /version
|
|
39
|
+
// yet, so poll for up to 2 seconds before giving up.
|
|
40
|
+
const version = await waitForRelayVersion({ port })
|
|
41
|
+
if (version) {
|
|
42
|
+
await logger.log(`Another relay (v${version}) already bound to port ${port}, exiting gracefully`)
|
|
43
|
+
process.exit(0)
|
|
44
|
+
}
|
|
45
|
+
await logger.error(`Port ${port} is in use by a non-relay process`)
|
|
46
|
+
process.exit(1)
|
|
47
|
+
}
|
|
48
|
+
throw err
|
|
49
|
+
}
|
|
29
50
|
|
|
30
51
|
console.log('CDP Relay Server running. Press Ctrl+C to stop.')
|
|
31
52
|
console.log('Logs are being written to:', logger.logFilePath)
|
package/src/utils.ts
CHANGED
|
@@ -36,11 +36,13 @@ export function getCdpUrl({
|
|
|
36
36
|
host = '127.0.0.1',
|
|
37
37
|
token,
|
|
38
38
|
extensionId,
|
|
39
|
+
autoEnable,
|
|
39
40
|
}: {
|
|
40
41
|
port?: number
|
|
41
42
|
host?: string
|
|
42
43
|
token?: string
|
|
43
44
|
extensionId?: string | null
|
|
45
|
+
autoEnable?: boolean
|
|
44
46
|
} = {}) {
|
|
45
47
|
const id = `${Math.random().toString(36).substring(2, 15)}_${Date.now()}`
|
|
46
48
|
const params = new URLSearchParams()
|
|
@@ -50,6 +52,9 @@ export function getCdpUrl({
|
|
|
50
52
|
if (extensionId) {
|
|
51
53
|
params.set('extensionId', extensionId)
|
|
52
54
|
}
|
|
55
|
+
if (autoEnable) {
|
|
56
|
+
params.set('autoEnable', '1')
|
|
57
|
+
}
|
|
53
58
|
const queryString = params.toString()
|
|
54
59
|
const suffix = queryString ? `?${queryString}` : ''
|
|
55
60
|
const { wsBaseUrl } = parseRelayHost(host, port)
|