playwriter 0.2.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 +1 -1
- 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 +103 -6
- package/dist/cdp-relay.js.map +1 -1
- package/dist/cli.js +8 -5
- 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 +102 -32
- package/dist/executor.js.map +1 -1
- package/dist/extension/background.js +23 -12
- package/dist/extension/manifest.json +1 -1
- package/dist/prompt.md +32 -13
- 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 +9 -1
- 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/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 +1 -1
- package/src/cdp-log.test.ts +131 -0
- package/src/cdp-log.ts +44 -2
- package/src/cdp-relay.ts +110 -5
- package/src/cli.ts +8 -5
- package/src/executor.ts +119 -35
- package/src/relay-client.ts +62 -5
- package/src/relay-core.test.ts +10 -6
- package/src/relay-session.test.ts +9 -1
- package/src/relay-state.test.ts +67 -1
- package/src/skill.md +32 -13
- package/src/start-relay-server.ts +22 -1
- package/src/utils.ts +5 -0
package/src/executor.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -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
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
if (!this.browserLogs.has(targetId)) {
|
|
541
|
-
this.browserLogs.set(targetId, [])
|
|
541
|
+
if (!this.browserLogs.has(page)) {
|
|
542
|
+
this.browserLogs.set(page, [])
|
|
542
543
|
}
|
|
543
544
|
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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 }) => {
|
|
@@ -1221,6 +1293,18 @@ export class PlaywrightExecutor {
|
|
|
1221
1293
|
// Ghost Browser API - only works in Ghost Browser, mirrors chrome.ghostPublicAPI etc
|
|
1222
1294
|
chrome: chromeGhostBrowser,
|
|
1223
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
|
+
}),
|
|
1224
1308
|
}
|
|
1225
1309
|
|
|
1226
1310
|
const vmContext = vm.createContext(vmContextObj)
|
package/src/relay-client.ts
CHANGED
|
@@ -39,6 +39,31 @@ export async function getRelayServerVersion(port: number = RELAY_PORT): Promise<
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Poll /version until a relay responds or timeout expires.
|
|
44
|
+
* Used during startup races where a relay may have bound the port
|
|
45
|
+
* but isn't serving HTTP yet (issue #75).
|
|
46
|
+
*/
|
|
47
|
+
export async function waitForRelayVersion({
|
|
48
|
+
port = RELAY_PORT,
|
|
49
|
+
timeoutMs = 2000,
|
|
50
|
+
intervalMs = 200,
|
|
51
|
+
}: {
|
|
52
|
+
port?: number
|
|
53
|
+
timeoutMs?: number
|
|
54
|
+
intervalMs?: number
|
|
55
|
+
} = {}): Promise<string | null> {
|
|
56
|
+
const end = Date.now() + timeoutMs
|
|
57
|
+
while (Date.now() < end) {
|
|
58
|
+
const version = await getRelayServerVersion(port)
|
|
59
|
+
if (version) {
|
|
60
|
+
return version
|
|
61
|
+
}
|
|
62
|
+
await sleep(intervalMs)
|
|
63
|
+
}
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
42
67
|
export async function getExtensionStatus(
|
|
43
68
|
port: number = RELAY_PORT,
|
|
44
69
|
): Promise<{ connected: boolean; activeTargets: number; playwriterVersion: string | null } | null> {
|
|
@@ -196,11 +221,26 @@ export interface EnsureRelayServerOptions {
|
|
|
196
221
|
env?: Record<string, string>
|
|
197
222
|
}
|
|
198
223
|
|
|
224
|
+
// Module-level dedup: if ensureRelayServer is called concurrently within the
|
|
225
|
+
// same process (e.g. two MCP tool handlers at once), only one spawn runs.
|
|
226
|
+
let pendingEnsure: Promise<true | undefined> | null = null
|
|
227
|
+
|
|
199
228
|
/**
|
|
200
229
|
* Ensures the relay server is running. Starts it if not running.
|
|
201
230
|
* Optionally restarts on version mismatch.
|
|
231
|
+
* Concurrent calls within the same process are deduplicated.
|
|
202
232
|
*/
|
|
203
233
|
export async function ensureRelayServer(options: EnsureRelayServerOptions = {}): Promise<true | undefined> {
|
|
234
|
+
if (pendingEnsure) {
|
|
235
|
+
return pendingEnsure
|
|
236
|
+
}
|
|
237
|
+
pendingEnsure = ensureRelayServerImpl(options).finally(() => {
|
|
238
|
+
pendingEnsure = null
|
|
239
|
+
})
|
|
240
|
+
return pendingEnsure
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function ensureRelayServerImpl(options: EnsureRelayServerOptions = {}): Promise<true | undefined> {
|
|
204
244
|
const { logger, restartOnVersionMismatch = true, env: additionalEnv } = options
|
|
205
245
|
const serverVersion = await getRelayServerVersion(RELAY_PORT)
|
|
206
246
|
|
|
@@ -227,11 +267,28 @@ export async function ensureRelayServer(options: EnsureRelayServerOptions = {}):
|
|
|
227
267
|
} else {
|
|
228
268
|
const listeningPids = await getListeningPidsForPort({ port: RELAY_PORT }).catch(() => [])
|
|
229
269
|
if (listeningPids.length > 0) {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
)
|
|
270
|
+
// Something is on the port but /version didn't respond. It might be a
|
|
271
|
+
// relay that's still starting (race with another CLI/MCP instance).
|
|
272
|
+
// Poll /version briefly before deciding to kill it (issue #75).
|
|
273
|
+
const foundVersion = await waitForRelayVersion({ port: RELAY_PORT })
|
|
274
|
+
if (foundVersion) {
|
|
275
|
+
// A relay came up while we waited; use it
|
|
276
|
+
if (foundVersion === VERSION || compareVersions(foundVersion, VERSION) > 0) {
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
if (!restartOnVersionMismatch) {
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
logger?.log(
|
|
283
|
+
pc.yellow(`CDP relay server version mismatch (server: ${foundVersion}, client: ${VERSION}), restarting...`),
|
|
284
|
+
)
|
|
285
|
+
} else {
|
|
286
|
+
logger?.log(
|
|
287
|
+
pc.yellow(
|
|
288
|
+
`Port ${RELAY_PORT} is already in use (pid(s): ${listeningPids.join(', ')}). Attempting to stop the existing process...`,
|
|
289
|
+
),
|
|
290
|
+
)
|
|
291
|
+
}
|
|
235
292
|
await killRelayServer({ port: RELAY_PORT })
|
|
236
293
|
}
|
|
237
294
|
|
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({
|
|
@@ -1277,7 +1277,15 @@ describe('Auto-enable Tests', () => {
|
|
|
1277
1277
|
})
|
|
1278
1278
|
expect(tabCountBefore).toBe(0)
|
|
1279
1279
|
|
|
1280
|
-
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
|
+
})
|
|
1281
1289
|
|
|
1282
1290
|
const pages = browser.contexts()[0].pages()
|
|
1283
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/skill.md
CHANGED
|
@@ -203,10 +203,19 @@ You can collaborate with the user - they can help with captchas, difficult eleme
|
|
|
203
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")
|
|
204
204
|
- `context` - browser context, access all pages via `context.pages()`
|
|
205
205
|
- `require` - load Node.js modules (e.g., `const fs = require('node:fs')`). ESM `import` is not available in the sandbox
|
|
206
|
-
- 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`.
|
|
207
209
|
|
|
208
210
|
**Important:** `state` is **session-isolated** but pages are **shared** across all sessions. See "working with pages" for how to avoid interference.
|
|
209
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
|
+
|
|
210
219
|
## rules
|
|
211
220
|
|
|
212
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.
|
|
@@ -215,6 +224,7 @@ You can collaborate with the user - they can help with captchas, difficult eleme
|
|
|
215
224
|
- **No bringToFront**: never call unless user asks - it's disruptive and unnecessary, you can interact with background pages
|
|
216
225
|
- **Check state after actions**: always verify page state after clicking/submitting (see next section)
|
|
217
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.
|
|
218
228
|
- **CDP sessions**: use `getCDPSession({ page: state.page })` not `state.page.context().newCDPSession()` - NEVER use `newCDPSession()` method, it doesn't work through playwriter relay
|
|
219
229
|
- **Wait for load**: use `state.page.waitForLoadState('domcontentloaded')` not `state.page.waitForEvent('load')` - waitForEvent times out if already loaded
|
|
220
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
|
|
@@ -227,18 +237,21 @@ You can collaborate with the user - they can help with captchas, difficult eleme
|
|
|
227
237
|
Every browser interaction must follow **observe → act → observe**. Never chain multiple actions blindly.
|
|
228
238
|
|
|
229
239
|
1. **Open page** — get or create your page, navigate to URL
|
|
230
|
-
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.
|
|
231
241
|
3. **Check** — if page isn't ready (loading, wrong URL, content missing), wait and observe again
|
|
232
242
|
4. **Act** — perform one action (click, type, submit)
|
|
233
|
-
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
|
|
234
244
|
6. **Repeat** from step 3 until task is complete
|
|
235
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
|
+
|
|
236
248
|
```js
|
|
237
249
|
// Each step should be a separate execute call:
|
|
238
250
|
// Step 1: navigate + observe
|
|
239
251
|
state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage())
|
|
240
252
|
await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' })
|
|
241
253
|
console.log('URL:', state.page.url())
|
|
254
|
+
console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
|
|
242
255
|
await snapshot({ page: state.page }).then(console.log)
|
|
243
256
|
```
|
|
244
257
|
|
|
@@ -246,25 +259,26 @@ await snapshot({ page: state.page }).then(console.log)
|
|
|
246
259
|
// Step 2: act + observe
|
|
247
260
|
await state.page.locator('button:has-text("Submit")').click()
|
|
248
261
|
console.log('URL:', state.page.url())
|
|
262
|
+
console.log('Page logs:', await getLatestLogs({ page: state.page, sinceLastCall: true }))
|
|
249
263
|
await snapshot({ page: state.page }).then(console.log)
|
|
250
264
|
```
|
|
251
265
|
|
|
252
266
|
If nothing changed after an action, try `waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element.
|
|
253
267
|
|
|
254
|
-
**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:
|
|
255
269
|
|
|
256
270
|
```js
|
|
257
|
-
//
|
|
271
|
+
// Search for specific errors in all logs (not just since last call)
|
|
258
272
|
const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 })
|
|
259
273
|
|
|
260
|
-
// Combine snapshot + logs for full picture
|
|
274
|
+
// Combine snapshot + filtered logs for full picture
|
|
261
275
|
const snap = await snapshot({ page: state.page, search: /dialog|error|message/ })
|
|
262
276
|
const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 })
|
|
263
277
|
console.log('UI:', snap)
|
|
264
278
|
console.log('Logs:', logs)
|
|
265
279
|
```
|
|
266
280
|
|
|
267
|
-
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.
|
|
268
282
|
|
|
269
283
|
## common mistakes to avoid
|
|
270
284
|
|
|
@@ -680,17 +694,22 @@ For carousels or lazy-loaded galleries, you may need to click navigation arrows
|
|
|
680
694
|
|
|
681
695
|
## utility functions
|
|
682
696
|
|
|
683
|
-
**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.
|
|
684
702
|
|
|
685
703
|
```js
|
|
686
|
-
await getLatestLogs({ page?, count?, search? })
|
|
687
|
-
//
|
|
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):
|
|
688
708
|
const errors = await getLatestLogs({ search: /error/i, count: 50 })
|
|
689
|
-
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 })
|
|
690
711
|
```
|
|
691
712
|
|
|
692
|
-
For custom log collection across runs, store in state: `state.logs = []; state.page.on('console', m => state.logs.push(m.text()))`
|
|
693
|
-
|
|
694
713
|
**getCleanHTML** - get cleaned HTML from a locator or page, with search and diffing:
|
|
695
714
|
|
|
696
715
|
```js
|
|
@@ -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)
|