playwriter 0.1.0 → 0.2.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-relay.d.ts.map +1 -1
- package/dist/cdp-relay.js +17 -5
- 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 +61 -20
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts.map +1 -1
- package/dist/executor.js +38 -1
- package/dist/executor.js.map +1 -1
- package/dist/extension/background.js +322 -52
- 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 +19 -5
- 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-session.test.js +34 -6
- package/dist/relay-session.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/package.json +3 -3
- package/src/cdp-relay.ts +17 -5
- package/src/cli-help.test.ts +22 -0
- package/src/cli.ts +66 -19
- package/src/executor.ts +47 -4
- package/src/mcp.ts +6 -1
- package/src/performance-examples.ts +186 -0
- package/src/react-source.ts +310 -24
- package/src/relay-session.test.ts +36 -10
- package/src/screen-recording.ts +20 -4
- package/src/skill.md +30 -6
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,6 +155,20 @@ async function getServerUrl(host?: string): Promise<string> {
|
|
|
134
155
|
return httpBaseUrl
|
|
135
156
|
}
|
|
136
157
|
|
|
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
|
+
|
|
137
172
|
async function fetchExtensionsStatus(host?: string): Promise<ExtensionStatus[]> {
|
|
138
173
|
try {
|
|
139
174
|
const serverUrl = await getServerUrl(host)
|
|
@@ -225,12 +260,7 @@ async function executeCode(options: {
|
|
|
225
260
|
try {
|
|
226
261
|
const response = await fetch(executeUrl, {
|
|
227
262
|
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
|
-
},
|
|
263
|
+
headers: buildAuthHeaders({ token, json: true }),
|
|
234
264
|
body: JSON.stringify({ sessionId, code, timeout, cwd }),
|
|
235
265
|
})
|
|
236
266
|
|
|
@@ -321,6 +351,7 @@ interface BrowserOption {
|
|
|
321
351
|
cli
|
|
322
352
|
.command('session new', 'Create a new session and print the session ID')
|
|
323
353
|
.option('--host <host>', 'Remote relay server host')
|
|
354
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
324
355
|
.option('--browser <key>', 'Browser key when multiple browsers are available')
|
|
325
356
|
.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
357
|
.action(async (options) => {
|
|
@@ -342,7 +373,7 @@ cli
|
|
|
342
373
|
}
|
|
343
374
|
await ensureRelayForSessionCreation(isLocal)
|
|
344
375
|
const serverUrl = await getServerUrl(options.host)
|
|
345
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint })
|
|
376
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint, token: options.token })
|
|
346
377
|
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
347
378
|
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
348
379
|
return
|
|
@@ -372,7 +403,7 @@ cli
|
|
|
372
403
|
if (instances.length === 1 && !options.browser) {
|
|
373
404
|
const instance = instances[0]
|
|
374
405
|
const serverUrl = await getServerUrl(options.host)
|
|
375
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles })
|
|
406
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: instance.wsUrl, browser: instance.browser, profiles: instance.profiles, token: options.token })
|
|
376
407
|
const profileLabel = formatInstanceProfiles(instance)
|
|
377
408
|
console.log(
|
|
378
409
|
`Session ${result.id} created (direct CDP, ${instance.browser}${profileLabel}). Use with: playwriter -s ${result.id} -e "..."`,
|
|
@@ -396,7 +427,7 @@ cli
|
|
|
396
427
|
process.exit(1)
|
|
397
428
|
}
|
|
398
429
|
const serverUrl = await getServerUrl(options.host)
|
|
399
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
|
|
430
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
|
|
400
431
|
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
401
432
|
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
402
433
|
return
|
|
@@ -457,7 +488,7 @@ cli
|
|
|
457
488
|
const cwd = process.cwd()
|
|
458
489
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
459
490
|
method: 'POST',
|
|
460
|
-
headers: {
|
|
491
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
461
492
|
body: JSON.stringify({ extensionId, cwd }),
|
|
462
493
|
})
|
|
463
494
|
if (!response.ok) {
|
|
@@ -509,14 +540,14 @@ cli
|
|
|
509
540
|
try {
|
|
510
541
|
const serverUrl = await getServerUrl(options.host)
|
|
511
542
|
if (selected.type === 'direct') {
|
|
512
|
-
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles })
|
|
543
|
+
const result = await createDirectSession({ serverUrl, cdpEndpoint: selected.wsUrl!, browser: selected.browser, profiles: selected.profiles, token: options.token })
|
|
513
544
|
console.log(`Session ${result.id} created (direct CDP). Use with: playwriter -s ${result.id} -e "..."`)
|
|
514
545
|
console.log(pc.dim('NOTE: Recording unavailable in direct CDP mode.'))
|
|
515
546
|
} else {
|
|
516
547
|
const cwd = process.cwd()
|
|
517
548
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
518
549
|
method: 'POST',
|
|
519
|
-
headers: {
|
|
550
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
520
551
|
body: JSON.stringify({ extensionId: selected.extensionId, cwd }),
|
|
521
552
|
})
|
|
522
553
|
if (!response.ok) {
|
|
@@ -552,16 +583,18 @@ async function createDirectSession({
|
|
|
552
583
|
cdpEndpoint,
|
|
553
584
|
browser,
|
|
554
585
|
profiles,
|
|
586
|
+
token,
|
|
555
587
|
}: {
|
|
556
588
|
serverUrl: string
|
|
557
589
|
cdpEndpoint: string
|
|
558
590
|
browser?: string
|
|
559
591
|
profiles?: Array<{ name: string; email: string }>
|
|
592
|
+
token?: string
|
|
560
593
|
}): Promise<{ id: string }> {
|
|
561
594
|
const cwd = process.cwd()
|
|
562
595
|
const response = await fetch(`${serverUrl}/cli/session/new`, {
|
|
563
596
|
method: 'POST',
|
|
564
|
-
headers: {
|
|
597
|
+
headers: buildAuthHeaders({ token, json: true }),
|
|
565
598
|
body: JSON.stringify({ cdpEndpoint, cwd, browser, profiles }),
|
|
566
599
|
})
|
|
567
600
|
if (!response.ok) {
|
|
@@ -622,6 +655,7 @@ function printBrowserTable(options: BrowserOption[]): void {
|
|
|
622
655
|
cli
|
|
623
656
|
.command('session list', 'List all active sessions')
|
|
624
657
|
.option('--host <host>', 'Remote relay server host')
|
|
658
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
625
659
|
.action(async (options) => {
|
|
626
660
|
if (!options.host && !process.env.PLAYWRITER_HOST) {
|
|
627
661
|
await ensureRelayServer({ logger: console, env: cliRelayEnv })
|
|
@@ -639,6 +673,7 @@ cli
|
|
|
639
673
|
|
|
640
674
|
try {
|
|
641
675
|
const response = await fetch(`${serverUrl}/cli/sessions`, {
|
|
676
|
+
headers: buildAuthHeaders({ token: options.token }),
|
|
642
677
|
signal: AbortSignal.timeout(2000),
|
|
643
678
|
})
|
|
644
679
|
if (!response.ok) {
|
|
@@ -711,6 +746,7 @@ cli
|
|
|
711
746
|
cli
|
|
712
747
|
.command('session delete <sessionId>', 'Delete a session and clear its state')
|
|
713
748
|
.option('--host <host>', 'Remote relay server host')
|
|
749
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
714
750
|
.action(async (sessionId, options) => {
|
|
715
751
|
const serverUrl = await getServerUrl(options.host)
|
|
716
752
|
|
|
@@ -721,7 +757,7 @@ cli
|
|
|
721
757
|
try {
|
|
722
758
|
const response = await fetch(`${serverUrl}/cli/session/delete`, {
|
|
723
759
|
method: 'POST',
|
|
724
|
-
headers: {
|
|
760
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
725
761
|
body: JSON.stringify({ sessionId }),
|
|
726
762
|
})
|
|
727
763
|
|
|
@@ -741,6 +777,7 @@ cli
|
|
|
741
777
|
cli
|
|
742
778
|
.command('session reset <sessionId>', 'Reset the browser connection for a session')
|
|
743
779
|
.option('--host <host>', 'Remote relay server host')
|
|
780
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
744
781
|
.action(async (sessionId, options) => {
|
|
745
782
|
const cwd = process.cwd()
|
|
746
783
|
const serverUrl = await getServerUrl(options.host)
|
|
@@ -752,7 +789,7 @@ cli
|
|
|
752
789
|
try {
|
|
753
790
|
const response = await fetch(`${serverUrl}/cli/reset`, {
|
|
754
791
|
method: 'POST',
|
|
755
|
-
headers: {
|
|
792
|
+
headers: buildAuthHeaders({ token: options.token, json: true }),
|
|
756
793
|
body: JSON.stringify({ sessionId, cwd }),
|
|
757
794
|
})
|
|
758
795
|
|
|
@@ -789,6 +826,14 @@ cli
|
|
|
789
826
|
process.exit(1)
|
|
790
827
|
}
|
|
791
828
|
|
|
829
|
+
// Expose the token to in-process callers (screen-recording.ts, etc.) so
|
|
830
|
+
// they can attach Authorization: Bearer ... when calling the relay's own
|
|
831
|
+
// privileged endpoints. Required because we no longer bypass auth for
|
|
832
|
+
// loopback — see commit history for the tunnel-agent threat model.
|
|
833
|
+
if (token) {
|
|
834
|
+
process.env.PLAYWRITER_TOKEN = token
|
|
835
|
+
}
|
|
836
|
+
|
|
792
837
|
// Check if server is already running on the port
|
|
793
838
|
const net = await import('node:net')
|
|
794
839
|
const isPortInUse = await new Promise<boolean>((resolve) => {
|
|
@@ -872,6 +917,7 @@ cli
|
|
|
872
917
|
cli
|
|
873
918
|
.command('browser list', 'List all available browsers: extension-connected and direct CDP on port 9222')
|
|
874
919
|
.option('--host <host>', z.string().describe('Remote relay server host'))
|
|
920
|
+
.option('--token <token>', 'Authentication token (or use PLAYWRITER_TOKEN env var)')
|
|
875
921
|
.action(async (options) => {
|
|
876
922
|
const isLocal = !options.host && !process.env.PLAYWRITER_HOST
|
|
877
923
|
|
|
@@ -933,6 +979,7 @@ cli.command('skill', 'Print the full playwriter usage instructions').action(() =
|
|
|
933
979
|
})
|
|
934
980
|
|
|
935
981
|
cli.help()
|
|
982
|
+
cli.completions()
|
|
936
983
|
cli.version(VERSION)
|
|
937
984
|
|
|
938
|
-
cli.parse()
|
|
985
|
+
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,
|
|
@@ -104,14 +104,14 @@ export function getAutoReturnExpression(code: string): string | null {
|
|
|
104
104
|
if (
|
|
105
105
|
expr.type === 'AssignmentExpression' ||
|
|
106
106
|
expr.type === 'UpdateExpression' ||
|
|
107
|
-
(expr.type === 'UnaryExpression' &&
|
|
107
|
+
(expr.type === 'UnaryExpression' && expr.operator === 'delete')
|
|
108
108
|
) {
|
|
109
109
|
return null
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
// Don't auto-return sequence expressions that contain assignments
|
|
113
113
|
if (expr.type === 'SequenceExpression') {
|
|
114
|
-
const hasAssignment = expr.expressions.some((e
|
|
114
|
+
const hasAssignment = expr.expressions.some((e) => e.type === 'AssignmentExpression')
|
|
115
115
|
if (hasAssignment) {
|
|
116
116
|
return null
|
|
117
117
|
}
|
|
@@ -1043,6 +1043,47 @@ export class PlaywrightExecutor {
|
|
|
1043
1043
|
return getReactSource({ locator: options.locator, cdp })
|
|
1044
1044
|
}
|
|
1045
1045
|
|
|
1046
|
+
const getReactComponentInfoFn = async (options: { locator: Locator | ElementHandle }) => {
|
|
1047
|
+
const targetPage = await (async (): Promise<Page | null> => {
|
|
1048
|
+
if ('page' in options.locator) {
|
|
1049
|
+
return options.locator.page()
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
return (await options.locator.ownerFrame())?.page() ?? null
|
|
1053
|
+
})()
|
|
1054
|
+
if (!targetPage) {
|
|
1055
|
+
throw new Error('Could not get page from locator')
|
|
1056
|
+
}
|
|
1057
|
+
const cdp = await getCDPSession({ page: targetPage })
|
|
1058
|
+
return getReactComponentInfo({ locator: options.locator, cdp })
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
const inspectPinnedElement = async (pageUrl: string, elementExpression: string) => {
|
|
1062
|
+
const targetPage = context.pages().find((candidate) => candidate.url() === pageUrl) || context.pages()[0]
|
|
1063
|
+
if (!targetPage) {
|
|
1064
|
+
throw new Error('No Playwright pages are available')
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
this.userState.page = targetPage
|
|
1068
|
+
const handle = (await targetPage.evaluateHandle((expression) => {
|
|
1069
|
+
return Function(`return (${expression})`)()
|
|
1070
|
+
}, elementExpression)).asElement()
|
|
1071
|
+
|
|
1072
|
+
const result = await (async () => {
|
|
1073
|
+
if (!handle) {
|
|
1074
|
+
return { url: targetPage.url(), outerHTML: null, react: null }
|
|
1075
|
+
}
|
|
1076
|
+
return {
|
|
1077
|
+
url: targetPage.url(),
|
|
1078
|
+
outerHTML: await handle.evaluate((el) => el.outerHTML),
|
|
1079
|
+
react: await getReactComponentInfoFn({ locator: handle }),
|
|
1080
|
+
}
|
|
1081
|
+
})()
|
|
1082
|
+
|
|
1083
|
+
console.log(result)
|
|
1084
|
+
return result
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1046
1087
|
const screenshotCollector: ScreenshotResult[] = []
|
|
1047
1088
|
// Separate collector for images produced by resizeImageForAgent() calls.
|
|
1048
1089
|
// These get merged into result.images so the CLI can emit them via Kitty Graphics.
|
|
@@ -1146,6 +1187,8 @@ export class PlaywrightExecutor {
|
|
|
1146
1187
|
getStylesForLocator: getStylesForLocatorFn,
|
|
1147
1188
|
formatStylesAsText,
|
|
1148
1189
|
getReactSource: getReactSourceFn,
|
|
1190
|
+
getReactComponentInfo: getReactComponentInfoFn,
|
|
1191
|
+
inspectPinnedElement,
|
|
1149
1192
|
screenshotWithAccessibilityLabels: screenshotWithAccessibilityLabelsFn,
|
|
1150
1193
|
resizeImageForAgent: resizeImageForAgentFn,
|
|
1151
1194
|
// Backward-compatible alias for resizeImageForAgent
|
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
|
})
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Example snippets for profiling website performance with Playwriter and CDP.
|
|
2
|
+
|
|
3
|
+
import { console, getCDPSession, page } from './debugger-examples-types.js'
|
|
4
|
+
|
|
5
|
+
type PerfMetrics = {
|
|
6
|
+
paints: Record<string, number>
|
|
7
|
+
lcp: number
|
|
8
|
+
cls: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type ObservedPerfEntry = {
|
|
12
|
+
name: string
|
|
13
|
+
startTime: number
|
|
14
|
+
duration: number
|
|
15
|
+
hadRecentInput?: boolean
|
|
16
|
+
value?: number
|
|
17
|
+
interactionId?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type NavigationTimingEntry = {
|
|
21
|
+
responseStart: number
|
|
22
|
+
domContentLoadedEventEnd: number
|
|
23
|
+
loadEventEnd: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type LongTaskEntry = {
|
|
27
|
+
startTime: number
|
|
28
|
+
duration: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type EventTimingEntry = {
|
|
32
|
+
name: string
|
|
33
|
+
duration: number
|
|
34
|
+
interactionId: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Example: Collect navigation timing and basic web vitals from the current page
|
|
38
|
+
async function collectWebVitals() {
|
|
39
|
+
await page.evaluate(() => {
|
|
40
|
+
const metrics: PerfMetrics = {
|
|
41
|
+
paints: {},
|
|
42
|
+
lcp: 0,
|
|
43
|
+
cls: 0,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const perfGlobal = globalThis as typeof globalThis & {
|
|
47
|
+
__pwPerfMetrics?: PerfMetrics
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
perfGlobal.__pwPerfMetrics = metrics
|
|
51
|
+
|
|
52
|
+
new PerformanceObserver((list) => {
|
|
53
|
+
for (const entry of list.getEntries() as ObservedPerfEntry[]) {
|
|
54
|
+
metrics.paints[entry.name] = entry.startTime
|
|
55
|
+
}
|
|
56
|
+
}).observe({ type: 'paint', buffered: true } as never)
|
|
57
|
+
|
|
58
|
+
new PerformanceObserver((list) => {
|
|
59
|
+
const entries = list.getEntries() as ObservedPerfEntry[]
|
|
60
|
+
const lastEntry = entries[entries.length - 1]
|
|
61
|
+
if (!lastEntry) {
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
metrics.lcp = lastEntry.startTime
|
|
65
|
+
}).observe({ type: 'largest-contentful-paint', buffered: true } as never)
|
|
66
|
+
|
|
67
|
+
new PerformanceObserver((list) => {
|
|
68
|
+
for (const entry of list.getEntries() as ObservedPerfEntry[]) {
|
|
69
|
+
if (entry.hadRecentInput) {
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
metrics.cls += entry.value || 0
|
|
73
|
+
}
|
|
74
|
+
}).observe({ type: 'layout-shift', buffered: true } as never)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await page.reload({ waitUntil: 'domcontentloaded' })
|
|
78
|
+
|
|
79
|
+
const report = await page.evaluate(() => {
|
|
80
|
+
const perfGlobal = globalThis as typeof globalThis & {
|
|
81
|
+
__pwPerfMetrics?: PerfMetrics
|
|
82
|
+
}
|
|
83
|
+
const nav = performance.getEntriesByType('navigation' as never)[0] as unknown as
|
|
84
|
+
| NavigationTimingEntry
|
|
85
|
+
| undefined
|
|
86
|
+
const metrics = perfGlobal.__pwPerfMetrics
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
ttfb: nav?.responseStart || 0,
|
|
90
|
+
domContentLoaded: nav?.domContentLoadedEventEnd || 0,
|
|
91
|
+
load: nav?.loadEventEnd || 0,
|
|
92
|
+
fcp: metrics?.paints['first-contentful-paint'] || 0,
|
|
93
|
+
lcp: metrics?.lcp || 0,
|
|
94
|
+
cls: metrics?.cls || 0,
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
console.log(report)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Example: Measure the biggest transferred requests with raw CDP network events
|
|
102
|
+
async function collectHeaviestRequests() {
|
|
103
|
+
const cdp = await getCDPSession({ page })
|
|
104
|
+
await cdp.send('Network.enable')
|
|
105
|
+
await cdp.send('Network.setCacheDisabled', { cacheDisabled: true })
|
|
106
|
+
|
|
107
|
+
const responses = new Map<string, { url: string; mimeType: string }>()
|
|
108
|
+
const finished = new Map<string, number>()
|
|
109
|
+
|
|
110
|
+
cdp.on('Network.responseReceived', (event) => {
|
|
111
|
+
responses.set(event.requestId, {
|
|
112
|
+
url: event.response.url,
|
|
113
|
+
mimeType: event.response.mimeType,
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
cdp.on('Network.loadingFinished', (event) => {
|
|
118
|
+
finished.set(event.requestId, event.encodedDataLength)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
await page.reload({ waitUntil: 'domcontentloaded' })
|
|
122
|
+
|
|
123
|
+
const largest = [...responses.entries()]
|
|
124
|
+
.map(([requestId, response]) => {
|
|
125
|
+
return {
|
|
126
|
+
url: response.url,
|
|
127
|
+
mimeType: response.mimeType,
|
|
128
|
+
bytes: finished.get(requestId) || 0,
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
.sort((a, b) => b.bytes - a.bytes)
|
|
132
|
+
.slice(0, 10)
|
|
133
|
+
|
|
134
|
+
console.log(largest)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Example: Check whether interactivity is blocked by long tasks or slow events
|
|
138
|
+
async function measureInteractivity() {
|
|
139
|
+
await page.evaluate(() => {
|
|
140
|
+
const perfGlobal = globalThis as typeof globalThis & {
|
|
141
|
+
__pwLongTasks?: LongTaskEntry[]
|
|
142
|
+
__pwEventTimings?: EventTimingEntry[]
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
perfGlobal.__pwLongTasks = []
|
|
146
|
+
perfGlobal.__pwEventTimings = []
|
|
147
|
+
|
|
148
|
+
new PerformanceObserver((list) => {
|
|
149
|
+
perfGlobal.__pwLongTasks?.push(
|
|
150
|
+
...(list.getEntries() as ObservedPerfEntry[]).map((entry) => ({
|
|
151
|
+
startTime: entry.startTime,
|
|
152
|
+
duration: entry.duration,
|
|
153
|
+
})),
|
|
154
|
+
)
|
|
155
|
+
}).observe({ type: 'longtask', buffered: true } as never)
|
|
156
|
+
|
|
157
|
+
new PerformanceObserver((list) => {
|
|
158
|
+
perfGlobal.__pwEventTimings?.push(
|
|
159
|
+
...(list.getEntries() as ObservedPerfEntry[]).map((entry) => ({
|
|
160
|
+
name: entry.name,
|
|
161
|
+
duration: entry.duration,
|
|
162
|
+
interactionId: entry.interactionId || 0,
|
|
163
|
+
})),
|
|
164
|
+
)
|
|
165
|
+
}).observe({ type: 'event', buffered: true, durationThreshold: 16 } as never)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const button = page.getByRole('button').first()
|
|
169
|
+
await button.click()
|
|
170
|
+
|
|
171
|
+
const report = await page.evaluate(() => {
|
|
172
|
+
const perfGlobal = globalThis as typeof globalThis & {
|
|
173
|
+
__pwLongTasks?: LongTaskEntry[]
|
|
174
|
+
__pwEventTimings?: EventTimingEntry[]
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
longTasks: (perfGlobal.__pwLongTasks || []).filter((entry) => entry.duration >= 50),
|
|
179
|
+
events: (perfGlobal.__pwEventTimings || []).filter((entry) => entry.interactionId !== 0),
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
console.log(report)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export { collectWebVitals, collectHeaviestRequests, measureInteractivity }
|