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.
Files changed (44) hide show
  1. package/dist/bippy.js +5 -5
  2. package/dist/cdp-relay.d.ts.map +1 -1
  3. package/dist/cdp-relay.js +17 -5
  4. package/dist/cdp-relay.js.map +1 -1
  5. package/dist/cli-help.test.js +22 -0
  6. package/dist/cli-help.test.js.map +1 -1
  7. package/dist/cli.js +61 -20
  8. package/dist/cli.js.map +1 -1
  9. package/dist/executor.d.ts.map +1 -1
  10. package/dist/executor.js +38 -1
  11. package/dist/executor.js.map +1 -1
  12. package/dist/extension/background.js +322 -52
  13. package/dist/extension/manifest.json +1 -1
  14. package/dist/mcp.d.ts.map +1 -1
  15. package/dist/mcp.js +6 -1
  16. package/dist/mcp.js.map +1 -1
  17. package/dist/performance-examples.d.ts +5 -0
  18. package/dist/performance-examples.d.ts.map +1 -0
  19. package/dist/performance-examples.js +112 -0
  20. package/dist/performance-examples.js.map +1 -0
  21. package/dist/performance-profiling.md +417 -0
  22. package/dist/prompt.md +19 -5
  23. package/dist/react-source.d.ts +44 -0
  24. package/dist/react-source.d.ts.map +1 -1
  25. package/dist/react-source.js +207 -20
  26. package/dist/react-source.js.map +1 -1
  27. package/dist/readability.js +1 -1
  28. package/dist/relay-session.test.js +34 -6
  29. package/dist/relay-session.test.js.map +1 -1
  30. package/dist/screen-recording.d.ts.map +1 -1
  31. package/dist/screen-recording.js +19 -4
  32. package/dist/screen-recording.js.map +1 -1
  33. package/dist/selector-generator.js +1 -1
  34. package/package.json +3 -3
  35. package/src/cdp-relay.ts +17 -5
  36. package/src/cli-help.test.ts +22 -0
  37. package/src/cli.ts +66 -19
  38. package/src/executor.ts +47 -4
  39. package/src/mcp.ts +6 -1
  40. package/src/performance-examples.ts +186 -0
  41. package/src/react-source.ts +310 -24
  42. package/src/relay-session.test.ts +36 -10
  43. package/src/screen-recording.ts +20 -4
  44. 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
- // If -e flag is provided, execute code via relay server
111
- if (options.eval) {
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: options.eval,
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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' && (expr as acorn.UnaryExpression).operator === 'delete')
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: acorn.Expression) => e.type === 'AssignmentExpression')
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: { 'Content-Type': 'application/json' },
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 }