@vpalmisano/webrtcperf 4.0.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 (53) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +296 -0
  3. package/app.min.js +2 -0
  4. package/build/src/app.d.ts +6 -0
  5. package/build/src/app.js +207 -0
  6. package/build/src/app.js.map +1 -0
  7. package/build/src/config.d.ts +104 -0
  8. package/build/src/config.js +880 -0
  9. package/build/src/config.js.map +1 -0
  10. package/build/src/generate-config-docs.d.ts +1 -0
  11. package/build/src/generate-config-docs.js +41 -0
  12. package/build/src/generate-config-docs.js.map +1 -0
  13. package/build/src/index.d.ts +9 -0
  14. package/build/src/index.js +26 -0
  15. package/build/src/index.js.map +1 -0
  16. package/build/src/media.d.ts +33 -0
  17. package/build/src/media.js +113 -0
  18. package/build/src/media.js.map +1 -0
  19. package/build/src/rtcstats.d.ts +302 -0
  20. package/build/src/rtcstats.js +418 -0
  21. package/build/src/rtcstats.js.map +1 -0
  22. package/build/src/server.d.ts +173 -0
  23. package/build/src/server.js +639 -0
  24. package/build/src/server.js.map +1 -0
  25. package/build/src/session.d.ts +277 -0
  26. package/build/src/session.js +1552 -0
  27. package/build/src/session.js.map +1 -0
  28. package/build/src/stats.d.ts +243 -0
  29. package/build/src/stats.js +1383 -0
  30. package/build/src/stats.js.map +1 -0
  31. package/build/src/utils.d.ts +249 -0
  32. package/build/src/utils.js +1220 -0
  33. package/build/src/utils.js.map +1 -0
  34. package/build/src/visqol.d.ts +6 -0
  35. package/build/src/visqol.js +61 -0
  36. package/build/src/visqol.js.map +1 -0
  37. package/build/src/vmaf.d.ts +83 -0
  38. package/build/src/vmaf.js +624 -0
  39. package/build/src/vmaf.js.map +1 -0
  40. package/build/tsconfig.tsbuildinfo +1 -0
  41. package/package.json +129 -0
  42. package/src/app.ts +241 -0
  43. package/src/config.ts +852 -0
  44. package/src/generate-config-docs.ts +47 -0
  45. package/src/index.ts +9 -0
  46. package/src/media.ts +151 -0
  47. package/src/rtcstats.ts +507 -0
  48. package/src/server.ts +645 -0
  49. package/src/session.ts +1908 -0
  50. package/src/stats.ts +1668 -0
  51. package/src/utils.ts +1295 -0
  52. package/src/visqol.ts +62 -0
  53. package/src/vmaf.ts +771 -0
package/src/session.ts ADDED
@@ -0,0 +1,1908 @@
1
+ import { getSessionThrottleValues, throttleLauncher } from '@vpalmisano/throttler'
2
+ import assert from 'assert'
3
+ import axios from 'axios'
4
+ import chalk from 'chalk'
5
+ import EventEmitter from 'events'
6
+ import fs from 'fs'
7
+ import JSON5 from 'json5'
8
+ import { LoremIpsum } from 'lorem-ipsum'
9
+ import NodeCache from 'node-cache'
10
+ import os from 'os'
11
+ import path from 'path'
12
+ import puppeteer, {
13
+ Browser,
14
+ BrowserContext,
15
+ CDPSession,
16
+ CookieParam,
17
+ ElementHandle,
18
+ KeyInput,
19
+ Metrics,
20
+ Page,
21
+ Permission,
22
+ } from 'puppeteer-core'
23
+ import {
24
+ type Interception,
25
+ RequestInterceptionManager,
26
+ getUrlPatternRegExp,
27
+ } from 'puppeteer-intercept-and-modify-requests'
28
+ import * as sdpTransform from 'sdp-transform'
29
+ import { gunzipSync } from 'zlib'
30
+
31
+ import { RtcStats, rtcStatKey, updateRtcStats } from './rtcstats'
32
+ import { FastStats } from './stats'
33
+ import {
34
+ PeerConnectionExternal,
35
+ PeerConnectionExternalMethod,
36
+ checkChromeExecutable,
37
+ downloadUrl,
38
+ enabledForSession,
39
+ getProcessStats,
40
+ getSystemStats,
41
+ hideAuth,
42
+ increaseKey,
43
+ logger,
44
+ portForwarder,
45
+ resolveIP,
46
+ resolvePackagePath,
47
+ sha256,
48
+ sleep,
49
+ waitStopProcess,
50
+ } from './utils'
51
+ import { MediaPath } from './media'
52
+
53
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
54
+ const NavigatorHardwareConcurrency = require('puppeteer-extra-plugin-stealth/evasions/navigator.hardwareConcurrency')
55
+
56
+ const log = logger('webrtcperf:session')
57
+
58
+ declare global {
59
+ let webrtcperf: {
60
+ collectPeerConnectionStats: () => Promise<{
61
+ stats: RtcStats[]
62
+ signalingHost?: string
63
+ participantName?: string
64
+ activePeerConnections: number
65
+ peerConnectionConnectionTime: number
66
+ peerConnectionDisconnectionTime: number
67
+ peerConnectionsCreated: number
68
+ peerConnectionsConnected: number
69
+ peerConnectionsDisconnected: number
70
+ peerConnectionsFailed: number
71
+ peerConnectionsClosed: number
72
+ peerConnectionsDelay: number
73
+ }>
74
+ collectAudioEndToEndStats: () => {
75
+ delay: number
76
+ startFrameDelay: number
77
+ }
78
+ collectVideoEndToEndStats: () => {
79
+ videoDelay: number
80
+ videoStartFrameDelay: number
81
+ screenDelay: number
82
+ screenStartFrameDelay: number
83
+ }
84
+ collectCpuPressure: () => number
85
+ collectCustomMetrics: () => Promise<Record<string, number | string>>
86
+ collectVideoStats: () => {
87
+ width: number
88
+ height: number
89
+ bufferedTime: number
90
+ playingTime: number
91
+ bufferingTime: number
92
+ bufferingEvents: number
93
+ }
94
+ getParticipantName: () => string
95
+ startFakeScreenshare: () => Promise<void>
96
+ stopFakeScreenshare: () => void
97
+ }
98
+ }
99
+
100
+ const PageLogColors = {
101
+ error: 'red',
102
+ warn: 'yellow',
103
+ info: 'cyan',
104
+ log: 'grey',
105
+ debug: 'white',
106
+ requestfailed: 'magenta',
107
+ }
108
+
109
+ type PageLogColorsKey = 'error' | 'warn' | 'info' | 'log' | 'debug' | 'requestfailed'
110
+
111
+ type SessionStats = Record<string, number | Record<string, number>>
112
+
113
+ export interface SessionParams {
114
+ /** The chromium running instance url. */
115
+ chromiumUrl: string
116
+ /** The chromium executable path. */
117
+ chromiumPath: string
118
+ /** Chromium additional field trials. */
119
+ chromiumFieldTrials: string
120
+ /** The browser width. */
121
+ windowWidth: number
122
+ /** The browser height. */
123
+ windowHeight: number
124
+ /** The browser device scale factor. */
125
+ deviceScaleFactor: number
126
+ /**
127
+ * If unset, the browser will run in headless mode.
128
+ * When running on Linux, set to a valid X display variable (e.g. `:0`).
129
+ */
130
+ display: string
131
+ /** Enables RED for OPUS codec (experimental). */
132
+ /* audioRedForOpus: boolean */
133
+ /** The page URL. */
134
+ url: string
135
+ /** The page URL query. */
136
+ urlQuery: string
137
+ /** Custom URL handler. */
138
+ customUrlHandler: string
139
+ customUrlHandlerFn?: CustomUrlHandlerFn
140
+ mediaPath?: MediaPath
141
+ videoWidth: number
142
+ videoHeight: number
143
+ videoFramerate: number
144
+ useFakeMedia: boolean
145
+ enableGpu: string
146
+ enableBrowserLogging: string
147
+ startTimestamp: number
148
+ sessions: number
149
+ tabsPerSession: number
150
+ spawnPeriod: number
151
+ statsInterval: number
152
+ disabledVideoCodecs: string
153
+ localStorage: string
154
+ sessionStorage: string
155
+ clearCookies: boolean
156
+ scriptPath: string
157
+ showPageLog: boolean
158
+ pageLogFilter: string
159
+ pageLogPath: string
160
+ userAgent: string
161
+ id: number
162
+ throttleIndex: number
163
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
164
+ evaluateAfter?: any[]
165
+ exposedFunctions?: string
166
+ scriptParams: string
167
+ blockedUrls: string
168
+ extraHeaders: string
169
+ responseModifiers: string
170
+ downloadResponses: string
171
+ extraCSS: string
172
+ cookies: string
173
+ overridePermissions: string
174
+ hardwareConcurrency: number
175
+ debuggingPort: number
176
+ debuggingAddress: string
177
+ randomAudioPeriod: number
178
+ maxVideoDecoders: number
179
+ maxVideoDecodersAt: number
180
+ incognito: boolean
181
+ serverPort: number
182
+ serverSecret: string
183
+ serverUseHttps: boolean
184
+ }
185
+
186
+ export type CustomUrlHandlerFn = (params: {
187
+ id: number
188
+ sessions: number
189
+ tabIndex: number
190
+ tabsPerSession: number
191
+ index: number
192
+ pid: number
193
+ env: Record<string, string>
194
+ params: Record<string, unknown>
195
+ }) => Promise<string>
196
+
197
+ /**
198
+ * Implements a test session instance running on a browser instance.
199
+ */
200
+ export class Session extends EventEmitter {
201
+ private readonly chromiumUrl: string
202
+ private readonly chromiumPath?: string
203
+ private readonly chromiumFieldTrials?: string
204
+ private readonly windowWidth: number
205
+ private readonly windowHeight: number
206
+ private readonly deviceScaleFactor: number
207
+ private readonly display: string
208
+ /* private readonly audioRedForOpus: boolean */
209
+ public readonly mediaPath?: MediaPath
210
+ private readonly videoWidth: number
211
+ private readonly videoHeight: number
212
+ private readonly videoFramerate: number
213
+ private readonly useFakeMedia: boolean
214
+ private readonly enableGpu: string
215
+ private readonly enableBrowserLogging: boolean
216
+ private readonly startTimestamp: number
217
+ private readonly sessions: number
218
+ private readonly tabsPerSession: number
219
+ private readonly spawnPeriod: number
220
+ private readonly statsInterval: number
221
+ private readonly disabledVideoCodecs: string[]
222
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
223
+ private readonly localStorage?: any
224
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
225
+ private readonly sessionStorage?: any
226
+ private readonly clearCookies: boolean
227
+ private readonly scriptPath: string
228
+ private readonly showPageLog: boolean
229
+ private readonly pageLogFilter: string
230
+ private readonly pageLogPath: string
231
+ private readonly userAgent: string
232
+ private readonly evaluateAfter: {
233
+ // eslint-disable-next-line
234
+ pageFunction: Function
235
+ // eslint-disable-next-line
236
+ args: any
237
+ }[]
238
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
239
+ private readonly exposedFunctions: any
240
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
241
+ private readonly scriptParams: any
242
+ private readonly blockedUrls: string[]
243
+ private readonly extraHeaders?: Record<string, Record<string, string>>
244
+ private readonly responseModifiers: Record<
245
+ string,
246
+ {
247
+ search?: RegExp
248
+ replace?: string
249
+ file?: string
250
+ headers?: Record<string, string>
251
+ }[]
252
+ > = {}
253
+ private readonly downloadResponses: { urlPattern: RegExp; output: string; append?: boolean }[] = []
254
+ private readonly extraCSS: string
255
+ private readonly cookies: CookieParam[] = []
256
+ private readonly overridePermissions: Permission[] = []
257
+ private readonly hardwareConcurrency: number
258
+ private readonly debuggingPort: number
259
+ private readonly debuggingAddress: string
260
+ private readonly randomAudioPeriod: number
261
+ private readonly maxVideoDecoders: number
262
+ private readonly maxVideoDecodersAt: number
263
+ private readonly incognito: boolean
264
+ private readonly serverPort: number
265
+ private readonly serverSecret: string
266
+ private readonly serverUseHttps: boolean
267
+
268
+ private running = false
269
+ private browser?: Browser
270
+ private context?: BrowserContext
271
+ private stopPortForwarder?: () => void
272
+
273
+ /** The numeric id assigned to the session. */
274
+ readonly id: number
275
+ /** The throttle configuration index assigned to the session. */
276
+ readonly throttleIndex: number
277
+ /** The test page url. */
278
+ readonly url: string
279
+ /** The url query. */
280
+ readonly urlQuery: string
281
+ /**
282
+ * The custom URL handler. This is the path to a JavaScript module (.mjs) exporting the function.
283
+ * The function itself takes an object as input with the following parameters:
284
+ *
285
+ * @typedef {Object} CustomUrlHandler
286
+ * @property {string} id - The identifier for the URL.
287
+ * @property {string} sessions - The number of sessions.
288
+ * @property {string} tabIndex - The index of the current tab.
289
+ * @property {string} tabsPerSession - The number of tabs per session.
290
+ * @property {string} index - The index for the URL.
291
+ * @property {string} pid - The process identifier for the URL.
292
+ *
293
+ * @type {string} path - The path to the JavaScript file containing the function:
294
+ * (params: CustomUrlHandler) => Promise<string>
295
+ */
296
+ readonly customUrlHandler: string
297
+ /**
298
+ * Imported custom URL handler function.
299
+ * @typedef {Object} CustomUrlHandler
300
+ * @property {number} id - The identifier for the URL.
301
+ * @property {number} sessions - The number of sessions.
302
+ * @property {number} tabIndex - The index of the current tab.
303
+ * @property {number} tabsPerSession - The number of tabs per session.
304
+ * @property {number} index - The index for the URL.
305
+ * @property {number} pid - The process identifier for the URL.
306
+ * @property {Record<string, string>} env - The process environment.
307
+ *
308
+ * @type {string} path - The path to the JavaScript file containing the function:
309
+ * (params: CustomUrlHandler) => Promise<string>
310
+ */
311
+ public customUrlHandlerFn?: CustomUrlHandlerFn
312
+ /** The latest stats extracted from page. */
313
+ stats: SessionStats = {}
314
+ /** The browser opened pages. */
315
+ readonly pages = new Map<number, Page>()
316
+ readonly httpResourcesStats = new Map<
317
+ number,
318
+ {
319
+ sentBytes: number
320
+ recvBytes: number
321
+ recvLatency: FastStats
322
+ wsSentBytes: number
323
+ wsRecvBytes: number
324
+ wsRecvLatency: FastStats
325
+ }
326
+ >()
327
+ /** The browser opened pages metrics. */
328
+ readonly pagesMetrics = new Map<number, Metrics>()
329
+ /** The page warnings count. */
330
+ pageWarnings = 0
331
+ /** The page errors count. */
332
+ pageErrors = 0
333
+ private screensharePage?: Page
334
+
335
+ private static readonly jsonFetchCache = new NodeCache({
336
+ stdTTL: 30,
337
+ checkperiod: 15,
338
+ })
339
+
340
+ constructor({
341
+ chromiumUrl,
342
+ chromiumPath,
343
+ chromiumFieldTrials,
344
+ windowWidth,
345
+ windowHeight,
346
+ deviceScaleFactor,
347
+ display,
348
+ /* audioRedForOpus, */
349
+ url,
350
+ urlQuery,
351
+ customUrlHandler,
352
+ customUrlHandlerFn,
353
+ mediaPath,
354
+ videoWidth,
355
+ videoHeight,
356
+ videoFramerate,
357
+ useFakeMedia,
358
+ enableGpu,
359
+ enableBrowserLogging,
360
+ startTimestamp,
361
+ sessions,
362
+ tabsPerSession,
363
+ spawnPeriod,
364
+ statsInterval,
365
+ disabledVideoCodecs,
366
+ localStorage,
367
+ sessionStorage,
368
+ clearCookies,
369
+ scriptPath,
370
+ showPageLog,
371
+ pageLogFilter,
372
+ pageLogPath,
373
+ userAgent,
374
+ id,
375
+ throttleIndex,
376
+ evaluateAfter,
377
+ exposedFunctions,
378
+ scriptParams,
379
+ blockedUrls,
380
+ extraHeaders,
381
+ responseModifiers,
382
+ downloadResponses,
383
+ extraCSS,
384
+ cookies,
385
+ overridePermissions,
386
+ hardwareConcurrency,
387
+ debuggingPort,
388
+ debuggingAddress,
389
+ randomAudioPeriod,
390
+ maxVideoDecoders,
391
+ maxVideoDecodersAt,
392
+ incognito,
393
+ serverPort,
394
+ serverSecret,
395
+ serverUseHttps,
396
+ }: SessionParams) {
397
+ super()
398
+ log.debug('constructor', { id })
399
+ this.id = id
400
+ this.chromiumUrl = chromiumUrl
401
+ this.chromiumPath = chromiumPath || undefined
402
+ this.chromiumFieldTrials = chromiumFieldTrials || undefined
403
+ this.windowWidth = windowWidth || 1920
404
+ this.windowHeight = windowHeight || 1080
405
+ this.deviceScaleFactor = deviceScaleFactor || 1
406
+ this.debuggingPort = debuggingPort || 0
407
+ this.debuggingAddress = debuggingAddress || ''
408
+ this.display = display
409
+ /* this.audioRedForOpus = !!audioRedForOpus */
410
+ this.url = url
411
+ this.urlQuery = urlQuery
412
+ if (!this.urlQuery && url.includes('?')) {
413
+ const parts = url.split('?', 2)
414
+ this.url = parts[0]
415
+ this.urlQuery = parts[1]
416
+ }
417
+ this.customUrlHandler = customUrlHandler
418
+ this.customUrlHandlerFn = customUrlHandlerFn
419
+ this.mediaPath = mediaPath
420
+ this.videoWidth = videoWidth
421
+ this.videoHeight = videoHeight
422
+ this.videoFramerate = videoFramerate
423
+ this.useFakeMedia = useFakeMedia
424
+ this.enableGpu = enableGpu
425
+ this.enableBrowserLogging = enabledForSession(this.id, enableBrowserLogging)
426
+ this.startTimestamp = startTimestamp || Date.now()
427
+ this.sessions = sessions || 1
428
+ this.tabsPerSession = tabsPerSession || 1
429
+ assert(this.tabsPerSession >= 1, 'tabsPerSession should be >= 1')
430
+ this.spawnPeriod = spawnPeriod || 1000
431
+ this.statsInterval = statsInterval || 10
432
+ if (disabledVideoCodecs) {
433
+ this.disabledVideoCodecs = disabledVideoCodecs
434
+ .split(',')
435
+ .map(s => s.trim())
436
+ .filter(s => s.length)
437
+ } else {
438
+ this.disabledVideoCodecs = []
439
+ }
440
+ if (localStorage) {
441
+ try {
442
+ this.localStorage = JSON5.parse(localStorage)
443
+ } catch (err: unknown) {
444
+ log.error(`error parsing localStorage: ${(err as Error).stack}`)
445
+ this.localStorage = null
446
+ }
447
+ }
448
+ if (sessionStorage) {
449
+ try {
450
+ this.sessionStorage = JSON5.parse(sessionStorage)
451
+ } catch (err: unknown) {
452
+ log.error(`error parsing sessionStorage: ${(err as Error).stack}`)
453
+ this.sessionStorage = null
454
+ }
455
+ }
456
+ this.clearCookies = clearCookies
457
+ this.scriptPath = scriptPath
458
+ this.showPageLog = showPageLog
459
+ this.pageLogFilter = pageLogFilter
460
+ this.pageLogPath = pageLogPath
461
+ this.userAgent = userAgent
462
+ this.randomAudioPeriod = randomAudioPeriod
463
+ this.maxVideoDecoders = maxVideoDecoders
464
+ this.maxVideoDecodersAt = maxVideoDecodersAt
465
+ this.incognito = incognito
466
+ this.serverPort = serverPort
467
+ this.serverSecret = serverSecret
468
+ this.serverUseHttps = serverUseHttps
469
+
470
+ this.throttleIndex = throttleIndex
471
+ this.evaluateAfter = evaluateAfter || []
472
+ this.exposedFunctions = exposedFunctions || {}
473
+ if (scriptParams) {
474
+ try {
475
+ this.scriptParams = JSON5.parse(scriptParams)
476
+ } catch (err) {
477
+ log.error(`error parsing scriptParams '${scriptParams}': ${(err as Error).stack}`)
478
+ throw err
479
+ }
480
+ } else {
481
+ this.scriptParams = {}
482
+ }
483
+ this.blockedUrls = (blockedUrls || '')
484
+ .split(',')
485
+ .map(s => s.trim())
486
+ .filter(s => s.length)
487
+ // Always block sentry.io.
488
+ this.blockedUrls.push('ingest.sentry.io')
489
+
490
+ if (extraHeaders) {
491
+ try {
492
+ this.extraHeaders = JSON5.parse(extraHeaders)
493
+ } catch (err) {
494
+ log.error(`error parsing extraHeaders: ${(err as Error).stack}`)
495
+ this.extraHeaders = undefined
496
+ }
497
+ } else {
498
+ this.extraHeaders = undefined
499
+ }
500
+
501
+ if (responseModifiers) {
502
+ try {
503
+ const parsed = JSON5.parse(responseModifiers)
504
+ Object.entries(parsed).forEach(([url, replacements]) => {
505
+ if (!Array.isArray(replacements)) {
506
+ throw new Error(
507
+ `responseModifiers replacements should be an array of { search, replace, body, headers } objects: ${replacements}`,
508
+ )
509
+ }
510
+ this.responseModifiers[url] = replacements.map(({ search, replace, file, headers }) => ({
511
+ search: search ? new RegExp(search, 'g') : undefined,
512
+ replace,
513
+ file,
514
+ headers,
515
+ }))
516
+ })
517
+ } catch (err) {
518
+ throw new Error(`error parsing responseModifiers "${responseModifiers}": ${(err as Error).stack}`)
519
+ }
520
+ }
521
+
522
+ if (downloadResponses) {
523
+ try {
524
+ const parsed = JSON5.parse(downloadResponses)
525
+ if (!Array.isArray(parsed)) throw new Error(`downloadResponses should be an array: ${downloadResponses}`)
526
+ parsed.forEach(({ urlPattern, output, append }) => {
527
+ this.downloadResponses.push({ urlPattern: getUrlPatternRegExp(urlPattern), output, append })
528
+ })
529
+ } catch (err) {
530
+ throw new Error(`error parsing downloadResponses "${downloadResponses}": ${(err as Error).stack}`)
531
+ }
532
+ }
533
+
534
+ this.extraCSS = extraCSS
535
+
536
+ if (cookies) {
537
+ try {
538
+ this.cookies = JSON5.parse(cookies)
539
+ } catch (err) {
540
+ log.error(`error parsing cookies: ${(err as Error).stack}`)
541
+ }
542
+ }
543
+
544
+ if (overridePermissions) {
545
+ this.overridePermissions = overridePermissions
546
+ .split(',')
547
+ .map(s => s.trim())
548
+ .filter(s => s.length) as Permission[]
549
+ }
550
+
551
+ this.hardwareConcurrency = hardwareConcurrency
552
+ }
553
+
554
+ /**
555
+ * Returns the chromium browser launch args
556
+ * @return the args list
557
+ */
558
+ getBrowserArgs(env: Record<string, string>): string[] {
559
+ // https://peter.sh/experiments/chromium-command-line-switches/
560
+ // https://source.chromium.org/chromium/chromium/src/+/main:testing/variations/fieldtrial_testing_config.json;l=8877?q=%20fieldtrial_testing_config.json&ss=chromium
561
+ const args = [
562
+ '--no-sandbox',
563
+ '--no-zygote',
564
+ '--ignore-certificate-errors',
565
+ '--no-user-gesture-required',
566
+ '--autoplay-policy=no-user-gesture-required',
567
+ '--disable-infobars',
568
+ '--allow-running-insecure-content',
569
+ `--unsafely-treat-insecure-origin-as-secure=http://${new URL(this.url || 'http://localhost').host}`,
570
+ '--disable-web-security',
571
+ '--disable-features=IsolateOrigins,Translate,CalculateNativeWinOcclusion',
572
+ '--disable-background-timer-throttling',
573
+ '--disable-backgrounding-occluded-windows',
574
+ '--disable-renderer-backgrounding',
575
+ '--disable-site-isolation-trials',
576
+ '--enable-usermedia-screen-capturing',
577
+ '--allow-http-screen-capture',
578
+ `--remote-debugging-port=${this.debuggingPort ? this.debuggingPort + this.id : 0}`,
579
+ '--enable-features=VaapiVideoDecoder,VaapiVideoEncoder,VaapiVideoDecodeLinuxGL,ElementCapture',
580
+ `--window-size=${this.windowWidth},${this.windowHeight}`,
581
+ ]
582
+
583
+ let fieldTrials = this.chromiumFieldTrials || ''
584
+
585
+ if (this.enableBrowserLogging && this.pageLogPath) {
586
+ const pageLogDir = path.dirname(this.pageLogPath)
587
+ const eventLogPath = path.resolve(pageLogDir, `webrtc-event-logging-${this.id}`)
588
+ fs.mkdirSync(eventLogPath, { recursive: true })
589
+ args.push('--enable-logging', '--vmodule=*/webrtc/*=5', '--v=0', `--webrtc-event-logging=${eventLogPath}`)
590
+ fieldTrials = 'WebRTC-RtcEventLogNewFormat/Disabled/' + fieldTrials
591
+ env.CHROME_LOG_FILE = path.resolve(pageLogDir, `chrome-${this.id}.log`)
592
+ }
593
+
594
+ if (this.maxVideoDecoders !== -1 && this.id >= this.maxVideoDecodersAt) {
595
+ fieldTrials = `WebRTC-MaxVideoDecoders/${this.maxVideoDecoders}/` + fieldTrials
596
+ }
597
+ if (fieldTrials.length) {
598
+ args.push(`--force-fieldtrials=${fieldTrials}`)
599
+ }
600
+
601
+ if (this.mediaPath) {
602
+ if (this.useFakeMedia) {
603
+ log.debug(`${this.id} using chromium as fake media source`)
604
+ args.push(
605
+ '--use-fake-ui-for-media-stream',
606
+ `--use-fake-device-for-media-stream=display-media-type=browser,fps=30`,
607
+ `--use-file-for-fake-video-capture=${this.mediaPath.video}`,
608
+ `--use-file-for-fake-audio-capture=${this.mediaPath.audio}`,
609
+ )
610
+ } else {
611
+ log.debug(`${this.id} using ${this.mediaPath} as fake media source`)
612
+ args.push(
613
+ '--auto-accept-camera-and-microphone-capture',
614
+ `--auto-select-tab-capture-source-by-title=webrtcperf-screenshare`,
615
+ '--mute-audio',
616
+ )
617
+ }
618
+ }
619
+
620
+ if (this.enableGpu) {
621
+ args.push(
622
+ '--ignore-gpu-blocklist',
623
+ '--enable-gpu-rasterization',
624
+ '--enable-zero-copy',
625
+ '--disable-gpu-sandbox',
626
+ '--enable-vulkan',
627
+ )
628
+ if (this.enableGpu === 'egl') {
629
+ args.push('--use-gl=egl')
630
+ } else {
631
+ args.push('--use-gl=angle', '--use-angle=vulkan')
632
+ }
633
+ } else {
634
+ args.push(
635
+ // Disables webgl support.
636
+ '--disable-3d-apis',
637
+ '--disable-site-isolation-trials',
638
+ // '--renderer-process-limit=2',
639
+ // '--single-process',
640
+ )
641
+ }
642
+
643
+ return args
644
+ }
645
+
646
+ /**
647
+ * Start
648
+ */
649
+ async start(): Promise<void> {
650
+ if (this.running) {
651
+ return
652
+ }
653
+ this.running = true
654
+ if (this.browser) {
655
+ log.warn(`${this.id} start: already running`)
656
+ return
657
+ }
658
+ log.debug(`${this.id} start`)
659
+
660
+ if (this.chromiumUrl) {
661
+ // connect to a remote chrome instance
662
+ try {
663
+ this.browser = await puppeteer.connect({
664
+ browserURL: this.chromiumUrl,
665
+ defaultViewport: {
666
+ width: this.windowWidth,
667
+ height: this.windowHeight,
668
+ deviceScaleFactor: this.deviceScaleFactor,
669
+ isMobile: false,
670
+ hasTouch: false,
671
+ isLandscape: false,
672
+ },
673
+ })
674
+ } catch (err) {
675
+ log.error(`${this.id} browser connect error: ${(err as Error).stack}`)
676
+ return this.stop()
677
+ }
678
+ } else {
679
+ // run a browser instance locally
680
+ let executablePath = this.chromiumPath
681
+ if (!executablePath || !fs.existsSync(executablePath)) {
682
+ executablePath = await checkChromeExecutable()
683
+ log.debug(`using executablePath=${executablePath}`)
684
+ }
685
+
686
+ // Create the process wrapper.
687
+ if (this.throttleIndex > -1 && os.platform() === 'linux') {
688
+ executablePath = await throttleLauncher(executablePath, this.throttleIndex)
689
+ }
690
+
691
+ const env = { ...process.env } as Record<string, string>
692
+ if (!this.display) {
693
+ delete env.DISPLAY
694
+ } else {
695
+ env.DISPLAY = this.display
696
+ }
697
+
698
+ const args = this.getBrowserArgs(env)
699
+ const ignoreDefaultArgs = [
700
+ '--disable-dev-shm-usage',
701
+ '--remote-debugging-port',
702
+ //'--hide-scrollbars',
703
+ '--enable-automation',
704
+ '--window-size',
705
+ ]
706
+
707
+ log.debug(`[session ${this.id}] Using args:\n ${args.join('\n ')}`)
708
+ log.debug(`[session ${this.id}] Default args:\n ${puppeteer.defaultArgs().join('\n ')}`)
709
+
710
+ try {
711
+ this.browser = await puppeteer.launch({
712
+ browser: 'chrome',
713
+ headless: this.display ? false : true,
714
+ executablePath,
715
+ handleSIGINT: false,
716
+ env,
717
+ // dumpio: this.enableBrowserLogging,
718
+ // devtools: true,
719
+ defaultViewport: {
720
+ width: this.windowWidth,
721
+ height: this.windowHeight,
722
+ deviceScaleFactor: this.deviceScaleFactor,
723
+ isMobile: false,
724
+ hasTouch: false,
725
+ isLandscape: false,
726
+ },
727
+ ignoreDefaultArgs,
728
+ args,
729
+ })
730
+ const version = await this.browser.version()
731
+ log.debug(`[session ${this.id}] Using chrome version: ${version}`)
732
+ } catch (err) {
733
+ log.error(`[session ${this.id}] Browser launch error: ${(err as Error).stack}`)
734
+ return this.stop()
735
+ }
736
+ }
737
+
738
+ assert(this.browser, 'BrowserNotCreated')
739
+
740
+ if (this.debuggingPort && this.debuggingAddress !== '127.0.0.1') {
741
+ this.stopPortForwarder = await portForwarder(this.debuggingPort + this.id, this.debuggingAddress)
742
+ }
743
+
744
+ this.browser.once('disconnected', () => {
745
+ log.debug('browser disconnected')
746
+ return this.stop()
747
+ })
748
+
749
+ // get GPU infos from chrome://gpu page
750
+ /* if (this.enableGpu) {
751
+ try {
752
+ const page = await this.browser.newPage()
753
+ await page.goto('chrome://gpu')
754
+ const data = await page.evaluate(() =>
755
+ [
756
+ // eslint-disable-next-line no-undef
757
+ ...document.querySelectorAll('ul.feature-status-list > li > span'),
758
+ ].map(
759
+ (e, i) =>
760
+ `${i % 2 === 0 ? '\n- ' : ''}${(e as HTMLSpanElement).innerText}`,
761
+ ),
762
+ )
763
+ await page.close()
764
+ console.log(`GPU infos:${data.join('')}`)
765
+ } catch (err) {
766
+ log.warn(`${this.id} error getting gpu info: %j`, err)
767
+ }
768
+ } */
769
+
770
+ // open pages
771
+ for (let i = 0; i < this.tabsPerSession; i++) {
772
+ this.openPage(i).catch(err => log.error(`openPage error: ${(err as Error).stack}`))
773
+ if (i < this.tabsPerSession - 1) {
774
+ await sleep(this.spawnPeriod)
775
+ }
776
+ }
777
+ }
778
+
779
+ private setupPageCmd(index: number, tabIndex: number, url: string) {
780
+ let cmd = `\
781
+ webrtcperf = {};
782
+ webrtcperf.config = {
783
+ START_TIMESTAMP: ${this.startTimestamp},
784
+ WEBRTC_PERF_URL: "${hideAuth(url)}",
785
+ WEBRTC_PERF_SESSION: ${this.id},
786
+ WEBRTC_PERF_TAB_INDEX: ${tabIndex},
787
+ WEBRTC_PERF_INDEX: ${index},
788
+ STATS_INTERVAL: ${this.statsInterval},
789
+ VIDEO_WIDTH: ${this.videoWidth},
790
+ VIDEO_HEIGHT: ${this.videoHeight},
791
+ VIDEO_FRAMERATE: ${this.videoFramerate},
792
+ RANDOM_AUDIO_PERIOD: ${this.randomAudioPeriod},
793
+ USE_FAKE_MEDIA: ${this.useFakeMedia},
794
+ };
795
+ try {
796
+ webrtcperf.params = JSON.parse('${JSON.stringify(this.scriptParams)}' || '{}');
797
+ } catch (err) {
798
+ console.error('[webrtcperf] Error parsing scriptParams:', err);
799
+ webrtcperf.params = {};
800
+ }
801
+ `
802
+
803
+ if (this.serverPort) {
804
+ cmd += `\
805
+ webrtcperf.config.SAVE_MEDIA_URL = "ws${this.serverUseHttps ? 's' : ''}://localhost:${this.serverPort}/?auth=${this.serverSecret}&action=write-stream";
806
+ `
807
+ if (this.mediaPath?.mp4 && !this.useFakeMedia) {
808
+ cmd += `\
809
+ webrtcperf.config.VIDEO_URL = "http${this.serverUseHttps ? 's' : ''}://localhost:${this.serverPort}/cache/${path.basename(this.mediaPath.mp4)}?auth=${this.serverSecret}";
810
+ webrtcperf.config.AUDIO_URL = "http${this.serverUseHttps ? 's' : ''}://localhost:${this.serverPort}/cache/${path.basename(this.mediaPath.m4a)}?auth=${this.serverSecret}";
811
+ `
812
+ }
813
+ }
814
+
815
+ if (this.disabledVideoCodecs.length) {
816
+ log.debug('Using disabledVideoCodecs:', this.disabledVideoCodecs)
817
+ cmd += `webrtcperf.config.GET_CAPABILITIES_DISABLED_VIDEO_CODECS = JSON.parse('${JSON.stringify(
818
+ this.disabledVideoCodecs,
819
+ )}');\n`
820
+ }
821
+
822
+ return cmd
823
+ }
824
+
825
+ /**
826
+ * openPage
827
+ * @param tabIndex
828
+ */
829
+ async openPage(tabIndex: number): Promise<void> {
830
+ if (!this.browser) {
831
+ return
832
+ }
833
+ const index = this.id + tabIndex
834
+ let saveFile: fs.promises.FileHandle | undefined = undefined
835
+ let url = this.url
836
+
837
+ if (!url) {
838
+ if (this.customUrlHandler && !this.customUrlHandlerFn) {
839
+ const customUrlHandlerPath = path.resolve(process.cwd(), this.customUrlHandler)
840
+ if (!fs.existsSync(customUrlHandlerPath)) {
841
+ throw new Error(`Custom url handler script not found: "${customUrlHandlerPath}"`)
842
+ }
843
+ this.customUrlHandlerFn = (await import(/* webpackIgnore: true */ customUrlHandlerPath)).default
844
+ }
845
+ if (!this.customUrlHandlerFn) {
846
+ throw new Error(`Custom url handler function not set`)
847
+ }
848
+ url = await this.customUrlHandlerFn({
849
+ id: this.id,
850
+ sessions: this.sessions,
851
+ tabIndex,
852
+ tabsPerSession: this.tabsPerSession,
853
+ index,
854
+ pid: process.pid,
855
+ env: { ...process.env } as Record<string, string>,
856
+ params: this.scriptParams,
857
+ })
858
+ log.debug(`customUrlHandlerFn: ${url}`)
859
+ }
860
+
861
+ if (!url) {
862
+ throw new Error(`Page URL not set`)
863
+ }
864
+
865
+ if (this.urlQuery) {
866
+ url += `?${this.urlQuery
867
+ .replace(/\$s/g, String(this.id))
868
+ .replace(/\$S/g, String(this.sessions))
869
+ .replace(/\$t/g, String(tabIndex))
870
+ .replace(/\$T/g, String(this.tabsPerSession))
871
+ .replace(/\$i/g, String(index))
872
+ .replace(/\$p/g, String(process.pid))}`
873
+ }
874
+
875
+ log.debug(`opening page ${index} (session: ${this.id} tab: ${tabIndex}): ${hideAuth(url)}`)
876
+
877
+ if (this.incognito) {
878
+ this.context = await this.browser.createBrowserContext()
879
+ } else {
880
+ this.context = this.browser.defaultBrowserContext()
881
+ }
882
+
883
+ if (this.overridePermissions.length) {
884
+ await this.context.overridePermissions(new URL(url).origin, this.overridePermissions)
885
+ }
886
+
887
+ const page = await this.getNewPage(tabIndex)
888
+
889
+ await page.setBypassCSP(true)
890
+
891
+ if (this.userAgent) {
892
+ await page.setUserAgent(this.userAgent)
893
+ }
894
+
895
+ await Promise.all(
896
+ Object.keys(this.exposedFunctions).map(
897
+ async (name: string) =>
898
+ await page.exposeFunction(name, (...args: unknown[]) => this.exposedFunctions[name](...args)),
899
+ ),
900
+ )
901
+
902
+ // Export config to page.
903
+ let cmd = this.setupPageCmd(index, tabIndex, url)
904
+ if (this.localStorage) {
905
+ log.debug('Using localStorage:', this.localStorage)
906
+ Object.entries(this.localStorage).map(([key, value]) => {
907
+ cmd += `localStorage.setItem('${key}', '${JSON.stringify(value)}');\n`
908
+ })
909
+ }
910
+ if (this.sessionStorage) {
911
+ log.debug('Using sessionStorage:', this.sessionStorage)
912
+ Object.entries(this.sessionStorage).map(([key, value]) => {
913
+ cmd += `sessionStorage.setItem('${key}', '${JSON.stringify(value)}');\n`
914
+ })
915
+ }
916
+ log.debug('init command:', cmd)
917
+ await page.evaluateOnNewDocument(cmd)
918
+
919
+ // Clear cookies.
920
+ if (this.clearCookies) {
921
+ try {
922
+ const client = await page.target().createCDPSession()
923
+ await client.send('Network.clearBrowserCookies')
924
+ } catch (err) {
925
+ log.error(`clearCookies error: ${(err as Error).stack}`)
926
+ }
927
+ }
928
+
929
+ // Load page script.
930
+ {
931
+ const filePath = resolvePackagePath('@vpalmisano/webrtcperf-js')
932
+ if (!fs.existsSync(filePath)) {
933
+ throw new Error(`@vpalmisano/webrtcperf-js script not found: ${filePath}`)
934
+ }
935
+ log.debug(`loading @vpalmisano/webrtcperf-js script from: ${filePath}`)
936
+ await page.evaluateOnNewDocument(fs.readFileSync(filePath, 'utf8'))
937
+ }
938
+
939
+ // Execute external script(s).
940
+ if (this.scriptPath) {
941
+ if (this.scriptPath.startsWith('base64:gzip:')) {
942
+ const data = Buffer.from(this.scriptPath.replace('base64:gzip:', ''), 'base64')
943
+ const code = gunzipSync(data).toString()
944
+ log.debug(`loading script from ${code.length} bytes`)
945
+ await page.evaluateOnNewDocument(code)
946
+ } else {
947
+ for (const filePath of this.scriptPath.split(',')) {
948
+ if (!filePath.trim()) {
949
+ continue
950
+ }
951
+ if (filePath.startsWith('http')) {
952
+ log.debug(`loading custom script from url: ${filePath}`)
953
+ const res = await downloadUrl(filePath)
954
+ if (!res?.data) {
955
+ throw new Error(`Failed to download script from: ${filePath}`)
956
+ }
957
+ await page.evaluateOnNewDocument(res.data)
958
+ } else {
959
+ if (!fs.existsSync(filePath)) {
960
+ log.warn(`custom script not found: ${filePath}`)
961
+ continue
962
+ }
963
+ log.debug(`loading custom script from file: ${filePath}`)
964
+ await page.evaluateOnNewDocument(fs.readFileSync(filePath, 'utf8'))
965
+ }
966
+ }
967
+ }
968
+ }
969
+
970
+ page.on('dialog', async dialog => {
971
+ log.debug(`page ${index + 1} dialog ${dialog.type()}: ${dialog.message()}`)
972
+ try {
973
+ await dialog.accept()
974
+ } catch (err) {
975
+ log.debug(`dialog accept error: ${(err as Error).message}`)
976
+ }
977
+ try {
978
+ await dialog.dismiss()
979
+ } catch (err) {
980
+ log.debug(`dialog dismiss error: ${(err as Error).message}`)
981
+ }
982
+ })
983
+
984
+ page.once('close', () => {
985
+ log.debug(`page ${index + 1} closed`)
986
+ this.pages.delete(index)
987
+ this.httpResourcesStats.delete(index)
988
+ this.pagesMetrics.delete(index)
989
+
990
+ if (saveFile) {
991
+ saveFile.close().catch(err => {
992
+ log.error(`saveFile close error: ${(err as Error).stack}`)
993
+ })
994
+ saveFile = undefined
995
+ }
996
+
997
+ if (this.browser && this.running) {
998
+ setTimeout(
999
+ () => this.openPage(index).catch(err => log.error(`openPage after close error: ${(err as Error).stack}`)),
1000
+ 1000,
1001
+ )
1002
+ }
1003
+ })
1004
+
1005
+ // Enable request interception.
1006
+ let setRequestInterceptionState = true
1007
+
1008
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1009
+ const pageCDPSession = (page as any)._client() as CDPSession
1010
+ await pageCDPSession.send('Network.setBypassServiceWorker', {
1011
+ bypass: true,
1012
+ })
1013
+
1014
+ const interceptManager = new RequestInterceptionManager(pageCDPSession, {
1015
+ onError: error => {
1016
+ log.error('Request interception error:', error)
1017
+ },
1018
+ })
1019
+
1020
+ const interceptions: Interception[] = []
1021
+
1022
+ // Blocked URLs.
1023
+ this.blockedUrls.forEach(blockedUrl => {
1024
+ interceptions.push({
1025
+ urlPattern: blockedUrl,
1026
+ modifyRequest: () => ({ errorReason: 'BlockedByClient' }),
1027
+ })
1028
+ })
1029
+
1030
+ // Add extra headers.
1031
+ if (this.extraHeaders) {
1032
+ Object.entries(this.extraHeaders).forEach(([url, obj]) => {
1033
+ const headers = Object.entries(obj).map(([name, value]) => ({
1034
+ name,
1035
+ value,
1036
+ }))
1037
+ interceptions.push({
1038
+ urlPattern: url,
1039
+ modifyRequest: ({ event }) => {
1040
+ log.debug(`adding extraHeaders in: ${event.request.url}`, headers)
1041
+ return { headers }
1042
+ },
1043
+ })
1044
+ })
1045
+ }
1046
+
1047
+ // Response modifiers.
1048
+ Object.entries(this.responseModifiers).forEach(([url, replacements]) => {
1049
+ interceptions.push({
1050
+ urlPattern: url,
1051
+ modifyResponse: async ({ event, body }) => {
1052
+ const responseHeaders = event.responseHeaders || []
1053
+ for (const { search, replace, file, headers } of replacements) {
1054
+ if (search && replace) {
1055
+ log.debug(`using responseModifiers in: ${event.request.url}: ${search.toString()} => ${replace}`)
1056
+ body = body?.replace(search, replace)
1057
+ } else if (file) {
1058
+ log.debug(`using responseModifiers in: ${event.request.url}: ${file}`)
1059
+ body = await fs.promises.readFile(file, 'utf8')
1060
+ }
1061
+ if (headers) {
1062
+ for (const [name, value] of Object.entries(headers)) {
1063
+ responseHeaders.push({
1064
+ name,
1065
+ value,
1066
+ })
1067
+ }
1068
+ }
1069
+ }
1070
+ return { body, responseHeaders }
1071
+ },
1072
+ })
1073
+ })
1074
+
1075
+ await interceptManager.intercept(...interceptions)
1076
+
1077
+ // Download responses.
1078
+ if (this.downloadResponses.length) {
1079
+ page.on('response', async response => {
1080
+ if (!response.ok()) return
1081
+ const url = response.url()
1082
+ for (const { urlPattern, output, append } of this.downloadResponses) {
1083
+ if (!urlPattern.test(url)) continue
1084
+ try {
1085
+ const data = await response.buffer()
1086
+ if (data.byteLength > 0) {
1087
+ if (append) {
1088
+ const savePath = output.replaceAll('${id}', this.id.toString())
1089
+ if (!fs.existsSync(path.dirname(savePath))) {
1090
+ await fs.promises.mkdir(path.dirname(output), { recursive: true })
1091
+ }
1092
+ log.debug(`appending response body ${data.byteLength} to: ${savePath}`)
1093
+ await fs.promises.appendFile(savePath, data)
1094
+ } else {
1095
+ if (!fs.existsSync(output)) {
1096
+ await fs.promises.mkdir(output, { recursive: true })
1097
+ }
1098
+ const savePath = path.join(output, `${path.basename(new URL(url).pathname)}`)
1099
+ log.debug(`saving response body ${data.byteLength} to: ${savePath}`)
1100
+ await fs.promises.writeFile(savePath, data)
1101
+ }
1102
+ }
1103
+ } catch (err) {
1104
+ log.error(`downloadResponses error: ${(err as Error).stack}`)
1105
+ }
1106
+ }
1107
+ })
1108
+ }
1109
+
1110
+ // Allow to change the setRequestInterception state from page.
1111
+ const setRequestInterceptionFunction = async (value: boolean) => {
1112
+ if (value === setRequestInterceptionState) {
1113
+ return
1114
+ }
1115
+ log.debug(`setRequestInterception to ${value}`)
1116
+ try {
1117
+ if (!value) {
1118
+ await interceptManager.disable()
1119
+ } else {
1120
+ await interceptManager.enable()
1121
+ }
1122
+ setRequestInterceptionState = value
1123
+ } catch (err) {
1124
+ log.error(`setRequestInterception error: ${(err as Error).stack}`)
1125
+ }
1126
+ }
1127
+
1128
+ await page.exposeFunction('setRequestInterception', setRequestInterceptionFunction)
1129
+
1130
+ await page.exposeFunction(
1131
+ 'jsonFetch',
1132
+ async (
1133
+ options: axios.AxiosRequestConfig & {
1134
+ validStatuses: number[]
1135
+ downloadPath: string
1136
+ },
1137
+ cacheKey = '',
1138
+ cacheTimeout = 0,
1139
+ ) => {
1140
+ if (cacheKey) {
1141
+ const ret = Session.jsonFetchCache.get(cacheKey)
1142
+ if (ret) {
1143
+ return ret
1144
+ }
1145
+ }
1146
+ try {
1147
+ if (options.validStatuses) {
1148
+ options.validateStatus = status => options.validStatuses.includes(status)
1149
+ }
1150
+ const { status, data, headers } = await axios(options)
1151
+ if (options.responseType === 'stream') {
1152
+ if (options.downloadPath && !fs.existsSync(options.downloadPath)) {
1153
+ log.debug(`jsonFetch saving file to: ${options.downloadPath}`, headers['content-disposition'])
1154
+ await fs.promises.mkdir(path.dirname(options.downloadPath), {
1155
+ recursive: true,
1156
+ })
1157
+ const writer = fs.createWriteStream(options.downloadPath)
1158
+ await new Promise<void>((resolve, reject) => {
1159
+ writer.on('error', err => reject(err))
1160
+ writer.on('close', () => resolve())
1161
+ data.pipe(writer)
1162
+ })
1163
+ }
1164
+ if (cacheKey) {
1165
+ Session.jsonFetchCache.set(cacheKey, { status }, cacheTimeout)
1166
+ }
1167
+ return { status, headers }
1168
+ } else {
1169
+ if (cacheKey) {
1170
+ Session.jsonFetchCache.set(cacheKey, { status, data }, cacheTimeout)
1171
+ }
1172
+ return { status, headers, data }
1173
+ }
1174
+ } catch (err) {
1175
+ const error = (err as Error).message
1176
+ log.warn(`jsonFetch error: ${error}`)
1177
+ return { status: 500, error }
1178
+ }
1179
+ },
1180
+ )
1181
+
1182
+ await page.exposeFunction('readLocalFile', (filePath: string, encoding?: BufferEncoding) => {
1183
+ filePath = path.resolve(process.cwd(), filePath)
1184
+ return fs.promises.readFile(filePath, encoding)
1185
+ })
1186
+
1187
+ // PeerConnectionExternal
1188
+ await page.exposeFunction(
1189
+ 'createPeerConnectionExternal',
1190
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1191
+ async (options: any) => {
1192
+ const pc = new PeerConnectionExternal(options)
1193
+ return { id: pc.id }
1194
+ },
1195
+ )
1196
+
1197
+ await page.exposeFunction(
1198
+ 'callPeerConnectionExternalMethod',
1199
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1200
+ async (id: number, name: PeerConnectionExternalMethod, arg: any) => {
1201
+ const pc = PeerConnectionExternal.get(id)
1202
+ if (pc) {
1203
+ return pc[name](arg)
1204
+ }
1205
+ },
1206
+ )
1207
+
1208
+ // Simulate keypress
1209
+ await page.exposeFunction('keypressText', async (selector: string, text: string, delay = 20) => {
1210
+ await page.type(selector, text, { delay })
1211
+ })
1212
+
1213
+ // Simulate mouse clicks
1214
+ await page.exposeFunction('mouseClick', async (selector: string, x = 0, y = 0) => {
1215
+ await page.click(selector, { offset: { x, y } })
1216
+ })
1217
+
1218
+ const lorem = new LoremIpsum({
1219
+ sentencesPerParagraph: {
1220
+ max: 4,
1221
+ min: 1,
1222
+ },
1223
+ wordsPerSentence: {
1224
+ max: 16,
1225
+ min: 2,
1226
+ },
1227
+ })
1228
+
1229
+ await page.exposeFunction('loremIpsum', (count = 1) => lorem.generateSentences(count))
1230
+
1231
+ await page.exposeFunction(
1232
+ 'keypressRandomText',
1233
+ async (selector: string, count = 1, prefix = '', suffix = '', delay = 0) => {
1234
+ const c = prefix + lorem.generateSentences(count) + suffix
1235
+ const frames = await page.frames()
1236
+ for (const frame of frames) {
1237
+ const el = await frame.$(selector)
1238
+ if (el) {
1239
+ await el.focus()
1240
+ await frame.type(selector, c, { delay })
1241
+ }
1242
+ }
1243
+ },
1244
+ )
1245
+
1246
+ await page.exposeFunction('uploadFileFromUrl', async (fileUrl: string, selector: string) => {
1247
+ const filename = sha256(fileUrl) + '.' + fileUrl.split('.').slice(-1)[0]
1248
+ const filePath = path.join(os.homedir(), '.webrtcperf/uploads', filename)
1249
+ if (!fs.existsSync(filePath)) {
1250
+ await downloadUrl(fileUrl, undefined, filePath)
1251
+ }
1252
+ log.debug(`uploadFileFromUrl: ${filePath}`)
1253
+ const frames = await page.frames()
1254
+ for (const frame of frames) {
1255
+ const el = await frame.$(selector)
1256
+ if (el) {
1257
+ await (el as ElementHandle<HTMLInputElement>).uploadFile(filePath)
1258
+ break
1259
+ }
1260
+ }
1261
+ })
1262
+
1263
+ // add extra styles
1264
+ if (this.extraCSS) {
1265
+ log.debug(`Add extraCSS: ${this.extraCSS}`)
1266
+ try {
1267
+ await page.evaluateOnNewDocument(
1268
+ (css: string) => {
1269
+ document.addEventListener('DOMContentLoaded', () => {
1270
+ const style = document.createElement('style')
1271
+ style.setAttribute('id', 'webrtcperf-extra-style')
1272
+ style.setAttribute('type', 'text/css')
1273
+ style.innerHTML = css
1274
+
1275
+ document.head.appendChild(style)
1276
+ })
1277
+ },
1278
+ this.extraCSS.replace(/important/g, '!important'),
1279
+ )
1280
+ } catch (err) {
1281
+ log.error(`Add extraCSS error: ${(err as Error).stack}`)
1282
+ }
1283
+ }
1284
+
1285
+ // add cookies
1286
+ if (this.cookies) {
1287
+ try {
1288
+ await page.setCookie(...this.cookies)
1289
+ } catch (err) {
1290
+ log.error(`Set cookies error: ${(err as Error).stack}`)
1291
+ }
1292
+ }
1293
+
1294
+ // Page logs and errors.
1295
+ if (this.pageLogPath) {
1296
+ try {
1297
+ await fs.promises.mkdir(path.dirname(this.pageLogPath), {
1298
+ recursive: true,
1299
+ })
1300
+ saveFile = await fs.promises.open(this.pageLogPath, 'a')
1301
+ } catch (err) {
1302
+ log.error(`error opening page log file: ${this.pageLogPath}: ${(err as Error).stack}`)
1303
+ }
1304
+ }
1305
+
1306
+ await page.exposeFunction('webrtcperf_serializedConsoleLog', async (type: PageLogColorsKey, text: string) => {
1307
+ if (this.showPageLog || saveFile) {
1308
+ try {
1309
+ await this.onPageMessage(index, type, text, saveFile)
1310
+ } catch (err) {
1311
+ log.error(`serializedConsoleLog error: ${(err as Error).stack}`)
1312
+ }
1313
+ }
1314
+ })
1315
+
1316
+ if (this.showPageLog || saveFile) {
1317
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1318
+ page.on('pageerror', async (error: any) => {
1319
+ const text = `pageerror: ${error.message?.message || error.message} - ${error.message?.stack || error.stack}`
1320
+ await this.onPageMessage(index, 'error', text, saveFile)
1321
+ })
1322
+
1323
+ page.on('requestfailed', async request => {
1324
+ const err = (request.failure()?.errorText || '').trim()
1325
+ if (err === 'net::ERR_ABORTED') {
1326
+ return
1327
+ }
1328
+ const text = `${request.method()} ${request.url()}: ${err}`
1329
+ await this.onPageMessage(index, 'requestfailed', text, saveFile)
1330
+ })
1331
+ }
1332
+
1333
+ await page.exposeFunction('webrtcperf_sdpParse', (sdpStr: string) => sdpTransform.parse(sdpStr))
1334
+
1335
+ await page.exposeFunction('webrtcperf_sdpWrite', (sdp: sdpTransform.SessionDescription) => sdpTransform.write(sdp))
1336
+
1337
+ await page.exposeFunction('webrtcperf_startFakeScreenshare', async () => {
1338
+ if (!this.browser) return
1339
+ let screensharePage = page
1340
+ if (!this.useFakeMedia) {
1341
+ if (!this.screensharePage) {
1342
+ screensharePage = this.screensharePage = await this.browser.newPage()
1343
+ await this.screensharePage.evaluateOnNewDocument(this.setupPageCmd(index, tabIndex, 'about:blank'))
1344
+ await this.screensharePage.evaluateOnNewDocument(
1345
+ fs.readFileSync(resolvePackagePath('@vpalmisano/webrtcperf-js'), 'utf8'),
1346
+ )
1347
+ await screensharePage.exposeFunction(
1348
+ 'webrtcperf_keypressText',
1349
+ async (selector: string, text: string, delay = 20) => {
1350
+ await screensharePage.type(selector, text, { delay })
1351
+ },
1352
+ )
1353
+ await screensharePage.exposeFunction('webrtcperf_keyPress', async (key: KeyInput) => {
1354
+ await screensharePage.keyboard.press(key)
1355
+ })
1356
+ await screensharePage.goto(
1357
+ `http${this.serverUseHttps ? 's' : ''}://localhost:${this.serverPort}/empty-page?auth=${this.serverSecret}&title=webrtcperf-screenshare`,
1358
+ )
1359
+ }
1360
+ }
1361
+ await screensharePage.evaluate(() => webrtcperf.startFakeScreenshare())
1362
+ })
1363
+
1364
+ await page.exposeFunction('webrtcperf_stopFakeScreenshare', async () => {
1365
+ if (!this.useFakeMedia && this.screensharePage) {
1366
+ await this.screensharePage.close()
1367
+ this.screensharePage = undefined
1368
+ } else {
1369
+ await page.evaluate(() => webrtcperf.stopFakeScreenshare())
1370
+ }
1371
+ })
1372
+
1373
+ // HTTP stats.
1374
+ const resourcesStats = {
1375
+ sentBytes: 0,
1376
+ recvBytes: 0,
1377
+ recvLatency: new FastStats({ store_data: false }),
1378
+ wsSentBytes: 0,
1379
+ wsRecvBytes: 0,
1380
+ wsRecvLatency: new FastStats({ store_data: false }),
1381
+ }
1382
+ this.httpResourcesStats.set(index, resourcesStats)
1383
+
1384
+ const pendingRequests = new Map<string, { url: string; timestamp: number }>()
1385
+ pageCDPSession.on('Network.requestWillBeSent', event => {
1386
+ if (event.request.url.startsWith('data:')) return
1387
+ const { requestId, request, timestamp } = event
1388
+ const sentBytes = request.postDataEntries?.reduce((acc, entry) => acc + (entry.bytes?.length || 0), 0)
1389
+ //log.log('Network.requestWillBeSent', event.type, request.url, sentBytes)
1390
+ if (sentBytes) resourcesStats.sentBytes += sentBytes
1391
+ pendingRequests.set(requestId, { url: request.url, timestamp })
1392
+ })
1393
+
1394
+ pageCDPSession.on('Network.responseReceived', event => {
1395
+ const request = pendingRequests.get(event.requestId)
1396
+ if (!request) return
1397
+ const { response } = event
1398
+ if (response.fromDiskCache) {
1399
+ pendingRequests.delete(event.requestId)
1400
+ return
1401
+ }
1402
+ resourcesStats.recvBytes += response.encodedDataLength
1403
+ })
1404
+
1405
+ pageCDPSession.on('Network.dataReceived', event => {
1406
+ const request = pendingRequests.get(event.requestId)
1407
+ if (!request) return
1408
+ resourcesStats.recvBytes += event.encodedDataLength
1409
+ })
1410
+
1411
+ pageCDPSession.on('Network.loadingFinished', event => {
1412
+ const request = pendingRequests.get(event.requestId)
1413
+ if (!request) return
1414
+ pendingRequests.delete(event.requestId)
1415
+ const { timestamp } = event
1416
+ resourcesStats.recvLatency.push(timestamp - request.timestamp)
1417
+ })
1418
+
1419
+ pageCDPSession.on('Network.webSocketCreated', event => {
1420
+ pendingRequests.set(event.requestId, { url: event.url, timestamp: Date.now() })
1421
+ })
1422
+
1423
+ pageCDPSession.on('Network.webSocketHandshakeResponseReceived', event => {
1424
+ const request = pendingRequests.get(event.requestId)
1425
+ if (!request) return
1426
+ pendingRequests.delete(event.requestId)
1427
+ resourcesStats.wsRecvLatency.push((Date.now() - request.timestamp) / 1000)
1428
+ })
1429
+
1430
+ pageCDPSession.on('Network.webSocketFrameSent', event => {
1431
+ resourcesStats.wsSentBytes += event.response.payloadData.length
1432
+ })
1433
+
1434
+ pageCDPSession.on('Network.webSocketFrameReceived', event => {
1435
+ resourcesStats.wsRecvBytes += event.response.payloadData.length
1436
+ })
1437
+
1438
+ // hardware concurrency
1439
+ if (this.hardwareConcurrency) {
1440
+ const plugin = NavigatorHardwareConcurrency({ hardwareConcurrency: this.hardwareConcurrency })
1441
+ await plugin.onPageCreated(page)
1442
+ }
1443
+
1444
+ log.debug(`Page ${index + 1} "${url}" loading`)
1445
+ const pageLoadTime = Date.now()
1446
+
1447
+ // open the page url
1448
+ try {
1449
+ await page.goto(url, {
1450
+ waitUntil: 'domcontentloaded',
1451
+ timeout: 60 * 1000,
1452
+ })
1453
+ } catch (error) {
1454
+ log.error(`Page ${index + 1} "${url}" load error: ${(error as Error).stack}`)
1455
+ await page.close()
1456
+ return
1457
+ }
1458
+
1459
+ // add to pages map
1460
+ this.pages.set(index, page)
1461
+
1462
+ log.debug(`Page ${index + 1} "${url}" loaded in ${(Date.now() - pageLoadTime) / 1000}s`)
1463
+
1464
+ for (let i = 0; i < this.evaluateAfter.length; i++) {
1465
+ await page.evaluate(
1466
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1467
+ this.evaluateAfter[i].pageFunction as any,
1468
+ ...this.evaluateAfter[i].args,
1469
+ )
1470
+ }
1471
+ }
1472
+
1473
+ private async getNewPage(tabIndex: number): Promise<Page> {
1474
+ log.debug(`getNewPage ${tabIndex}`)
1475
+ assert(this.context, 'NoBrowserContextCreated')
1476
+ return await this.context.newPage()
1477
+ }
1478
+
1479
+ private async onPageMessage(
1480
+ index: number,
1481
+ type: PageLogColorsKey,
1482
+ text: string,
1483
+ saveFile?: fs.promises.FileHandle,
1484
+ ): Promise<void> {
1485
+ if (text.endsWith('net::ERR_BLOCKED_BY_CLIENT.Inspector')) {
1486
+ return
1487
+ }
1488
+ const isBlocked = this.blockedUrls.some(
1489
+ blockedUrl => (type === 'requestfailed' || text.search('FetchError') !== -1) && text.search(blockedUrl) !== -1,
1490
+ )
1491
+ if (isBlocked) {
1492
+ return
1493
+ }
1494
+ const color = PageLogColors[type] || 'grey'
1495
+ const filter = this.pageLogFilter ? new RegExp(this.pageLogFilter, 'ig') : null
1496
+ if (!filter || text.match(filter)) {
1497
+ const errorOrWarning = ['error', 'warning'].includes(type)
1498
+ const isWebrtcPerf = text.startsWith('[webrtcperf')
1499
+ if (saveFile) {
1500
+ if (!errorOrWarning && !isWebrtcPerf && text.length > 1024) {
1501
+ text = text.slice(0, 1024) + `... +${text.length - 1024} bytes`
1502
+ }
1503
+ await saveFile.write(`${new Date().toISOString()} [page ${index}] (${type}) ${text}\n`)
1504
+ }
1505
+ if (this.showPageLog) {
1506
+ if (!errorOrWarning && !isWebrtcPerf && text.length > 256) {
1507
+ text = text.slice(0, 256) + `... +${text.length - 256} bytes`
1508
+ }
1509
+ console.log(chalk`{bold [page ${index}]} {${color} (${type}) ${text}}`)
1510
+ }
1511
+ if (type === 'error') {
1512
+ this.pageErrors += 1
1513
+ } else if (type === 'warn') {
1514
+ this.pageWarnings += 1
1515
+ }
1516
+ }
1517
+ }
1518
+
1519
+ /**
1520
+ * updateStats
1521
+ */
1522
+ async updateStats(): Promise<SessionStats> {
1523
+ if (!this.browser) {
1524
+ this.stats = {}
1525
+ return this.stats
1526
+ }
1527
+
1528
+ const collectedStats: SessionStats = {}
1529
+
1530
+ try {
1531
+ const processStats = await getProcessStats()
1532
+ Object.assign(collectedStats, {
1533
+ nodeCpu: processStats.cpu,
1534
+ nodeMemory: processStats.memory,
1535
+ })
1536
+ } catch (err) {
1537
+ log.error(`node getProcessStats error: ${(err as Error).stack}`)
1538
+ }
1539
+
1540
+ try {
1541
+ const systemStats = getSystemStats()
1542
+ if (systemStats) {
1543
+ collectedStats.usedCpu = systemStats.usedCpu
1544
+ collectedStats.usedMemory = systemStats.usedMemory
1545
+ collectedStats.usedGpu = systemStats.usedGpu
1546
+ if (collectedStats.usedCpu > 80) {
1547
+ log.warn(`High system CPU usage: ${collectedStats.usedCpu.toFixed(2)}%`)
1548
+ }
1549
+ if (collectedStats.usedMemory > 80) {
1550
+ log.warn(`High system memory usage: ${collectedStats.usedMemory.toFixed(2)}%`)
1551
+ }
1552
+ }
1553
+ } catch (err) {
1554
+ log.error(`node getSystemStats error: ${(err as Error).stack}`)
1555
+ }
1556
+
1557
+ const browserProcess = this.browser.process()
1558
+ if (browserProcess) {
1559
+ try {
1560
+ const processStats = await getProcessStats(browserProcess.pid, true)
1561
+ Object.assign(collectedStats, processStats)
1562
+ } catch (err) {
1563
+ log.error(`getProcessStats error: ${(err as Error).stack}`)
1564
+ }
1565
+ }
1566
+
1567
+ const pages: Record<string, number> = {}
1568
+ const peerConnections: Record<string, number> = {}
1569
+ const peerConnectionConnectionTime: Record<string, number> = {}
1570
+ const peerConnectionDisconnectionTime: Record<string, number> = {}
1571
+ const peerConnectionsCreated: Record<string, number> = {}
1572
+ const peerConnectionsClosed: Record<string, number> = {}
1573
+ const peerConnectionsConnected: Record<string, number> = {}
1574
+ const peerConnectionsDisconnected: Record<string, number> = {}
1575
+ const peerConnectionsFailed: Record<string, number> = {}
1576
+ const peerConnectionsDelay: Record<string, number> = {}
1577
+ const audioEndToEndDelayStats: Record<string, number> = {}
1578
+ const audioStartFrameDelayStats: Record<string, number> = {}
1579
+ const videoEndToEndDelayStats: Record<string, number> = {}
1580
+ const screenEndToEndDelayStats: Record<string, number> = {}
1581
+ const videoStartFrameDelayStats: Record<string, number> = {}
1582
+ const screenStartFrameDelayStats: Record<string, number> = {}
1583
+ const httpSentBytesStats: Record<string, number> = {}
1584
+ const httpRecvBytesStats: Record<string, number> = {}
1585
+ const httpRecvLatencyStats: Record<string, number> = {}
1586
+ const wsSentBytesStats: Record<string, number> = {}
1587
+ const wsRecvBytesStats: Record<string, number> = {}
1588
+ const wsRecvLatencyStats: Record<string, number> = {}
1589
+ const pageCpu: Record<string, number> = {}
1590
+ const pageMemory: Record<string, number> = {}
1591
+ const cpuPressureStats: Record<string, number> = {}
1592
+
1593
+ const videoWidth: Record<string, number> = {}
1594
+ const videoHeight: Record<string, number> = {}
1595
+ const videoBufferedTime: Record<string, number> = {}
1596
+ const videoPlayingTime: Record<string, number> = {}
1597
+ const videoBufferingTime: Record<string, number> = {}
1598
+ const videoBufferingEvents: Record<string, number> = {}
1599
+
1600
+ const throttleUpValuesRate: Record<string, number> = {}
1601
+ const throttleUpValuesDelay: Record<string, number> = {}
1602
+ const throttleUpValuesLoss: Record<string, number> = {}
1603
+ const throttleUpValuesQueue: Record<string, number> = {}
1604
+ const throttleDownValuesRate: Record<string, number> = {}
1605
+ const throttleDownValuesDelay: Record<string, number> = {}
1606
+ const throttleDownValuesLoss: Record<string, number> = {}
1607
+ const throttleDownValuesQueue: Record<string, number> = {}
1608
+
1609
+ const customStats: Record<string, Record<string, number | string>> = {}
1610
+
1611
+ await Promise.allSettled(
1612
+ [...this.pages.entries()].map(async ([pageIndex, page]) => {
1613
+ try {
1614
+ // Collect stats from the page.
1615
+ const {
1616
+ peerConnectionStats,
1617
+ audioEndToEndDelay,
1618
+ videoEndToEndDelay,
1619
+ cpuPressure,
1620
+ videoStats,
1621
+ customMetrics,
1622
+ } = await page.evaluate(async () => ({
1623
+ peerConnectionStats: await webrtcperf.collectPeerConnectionStats(),
1624
+ audioEndToEndDelay: webrtcperf.collectAudioEndToEndStats(),
1625
+ videoEndToEndDelay: webrtcperf.collectVideoEndToEndStats(),
1626
+ cpuPressure: webrtcperf.collectCpuPressure(),
1627
+ videoStats: webrtcperf.collectVideoStats(),
1628
+ customMetrics: 'collectCustomMetrics' in window ? webrtcperf.collectCustomMetrics() : null,
1629
+ }))
1630
+ const { participantName } = peerConnectionStats
1631
+
1632
+ const httpResourcesStats = this.httpResourcesStats.get(pageIndex)
1633
+
1634
+ // Get host from the first collected remote address.
1635
+ if (!peerConnectionStats.signalingHost && peerConnectionStats.stats.length) {
1636
+ const values = Object.values(peerConnectionStats.stats[0])
1637
+ if (values.length) {
1638
+ peerConnectionStats.signalingHost = await resolveIP(values[0].remoteAddress as string)
1639
+ }
1640
+ }
1641
+ const { stats, activePeerConnections, signalingHost } = peerConnectionStats
1642
+
1643
+ // Calculate stats keys.
1644
+ const hostKey = rtcStatKey({
1645
+ hostName: signalingHost,
1646
+ participantName,
1647
+ })
1648
+ const pageKey = rtcStatKey({
1649
+ pageIndex,
1650
+ hostName: signalingHost,
1651
+ participantName,
1652
+ })
1653
+
1654
+ // Set pages counter.
1655
+ increaseKey(pages, hostKey, 1)
1656
+
1657
+ // Set peerConnections counters.
1658
+ increaseKey(peerConnections, pageKey, activePeerConnections)
1659
+ increaseKey(peerConnectionConnectionTime, pageKey, peerConnectionStats.peerConnectionConnectionTime)
1660
+ increaseKey(peerConnectionDisconnectionTime, pageKey, peerConnectionStats.peerConnectionDisconnectionTime)
1661
+ increaseKey(peerConnectionsCreated, pageKey, peerConnectionStats.peerConnectionsCreated)
1662
+ increaseKey(peerConnectionsClosed, pageKey, peerConnectionStats.peerConnectionsClosed)
1663
+ increaseKey(peerConnectionsConnected, pageKey, peerConnectionStats.peerConnectionsConnected)
1664
+ increaseKey(peerConnectionsDisconnected, pageKey, peerConnectionStats.peerConnectionsDisconnected)
1665
+ increaseKey(peerConnectionsFailed, pageKey, peerConnectionStats.peerConnectionsFailed)
1666
+ increaseKey(peerConnectionsDelay, pageKey, peerConnectionStats.peerConnectionsDelay)
1667
+
1668
+ // E2E stats.
1669
+ if (audioEndToEndDelay) {
1670
+ audioEndToEndDelayStats[pageKey] = audioEndToEndDelay.delay
1671
+ audioStartFrameDelayStats[pageKey] = audioEndToEndDelay.startFrameDelay
1672
+ }
1673
+ if (videoEndToEndDelay) {
1674
+ videoEndToEndDelayStats[pageKey] = videoEndToEndDelay.videoDelay
1675
+ videoStartFrameDelayStats[pageKey] = videoEndToEndDelay.videoStartFrameDelay
1676
+ screenEndToEndDelayStats[pageKey] = videoEndToEndDelay.screenDelay
1677
+ screenStartFrameDelayStats[pageKey] = videoEndToEndDelay.screenStartFrameDelay
1678
+ }
1679
+
1680
+ // HTTP stats.
1681
+ if (httpResourcesStats) {
1682
+ if (httpResourcesStats.sentBytes > 0) httpSentBytesStats[pageKey] = httpResourcesStats.sentBytes
1683
+ if (httpResourcesStats.recvBytes > 0) httpRecvBytesStats[pageKey] = httpResourcesStats.recvBytes
1684
+ if (httpResourcesStats.recvLatency.length)
1685
+ httpRecvLatencyStats[pageKey] = httpResourcesStats.recvLatency.amean()
1686
+ if (httpResourcesStats.wsSentBytes > 0) wsSentBytesStats[pageKey] = httpResourcesStats.wsSentBytes
1687
+ if (httpResourcesStats.wsRecvBytes > 0) wsRecvBytesStats[pageKey] = httpResourcesStats.wsRecvBytes
1688
+ if (httpResourcesStats.wsRecvLatency.length)
1689
+ wsRecvLatencyStats[pageKey] = httpResourcesStats.wsRecvLatency.amean()
1690
+ }
1691
+
1692
+ if (cpuPressure !== undefined) cpuPressureStats[pageKey] = cpuPressure
1693
+ if (videoStats) {
1694
+ videoWidth[pageKey] = videoStats.width
1695
+ videoHeight[pageKey] = videoStats.height
1696
+ videoBufferedTime[pageKey] = videoStats.bufferedTime
1697
+ videoPlayingTime[pageKey] = videoStats.playingTime
1698
+ videoBufferingTime[pageKey] = videoStats.bufferingTime
1699
+ videoBufferingEvents[pageKey] = videoStats.bufferingEvents
1700
+ }
1701
+
1702
+ // Collect RTC stats.
1703
+ for (const s of stats) {
1704
+ for (const [trackId, value] of Object.entries(s)) {
1705
+ try {
1706
+ updateRtcStats(collectedStats as RtcStats, pageIndex, trackId, value, signalingHost, participantName)
1707
+ } catch (err) {
1708
+ log.error(`updateRtcStats error for ${trackId}: ${(err as Error).stack}`, err)
1709
+ }
1710
+ }
1711
+ }
1712
+
1713
+ // Collect custom metrics.
1714
+ if (customMetrics) {
1715
+ for (const [name, value] of Object.entries(customMetrics)) {
1716
+ if (!customStats[name]) {
1717
+ customStats[name] = {}
1718
+ }
1719
+ customStats[name][pageKey] = value
1720
+ }
1721
+ }
1722
+
1723
+ // Collect page metrics
1724
+ /* const metrics = await page.metrics()
1725
+ if (metrics.Timestamp) {
1726
+ const lastMetrics = this.pagesMetrics.get(pageIndex)
1727
+ if (lastMetrics?.Timestamp) {
1728
+ const elapsedTime = metrics.Timestamp - lastMetrics.Timestamp
1729
+ if (elapsedTime > 10) {
1730
+ const durationDiff =
1731
+ metricsTotalDuration(metrics) -
1732
+ metricsTotalDuration(lastMetrics)
1733
+ const usage = (100 * durationDiff) / elapsedTime
1734
+ pageCpu[pageKey] = usage
1735
+ pageMemory[pageKey] = (metrics.JSHeapUsedSize || 0) / 1e6
1736
+ this.pagesMetrics.set(pageIndex, metrics)
1737
+ }
1738
+ } else {
1739
+ this.pagesMetrics.set(pageIndex, metrics)
1740
+ }
1741
+ } */
1742
+ pageCpu[pageKey] = (collectedStats.cpu as number) / this.tabsPerSession
1743
+ pageMemory[pageKey] = (collectedStats.memory as number) / this.tabsPerSession
1744
+
1745
+ // Collect throttle metrics
1746
+ const throttleUpValues = getSessionThrottleValues(this.throttleIndex, 'up')
1747
+ throttleUpValuesRate[pageKey] = throttleUpValues.rate || 0
1748
+ throttleUpValuesDelay[pageKey] = throttleUpValues.delay || 0
1749
+ throttleUpValuesLoss[pageKey] = throttleUpValues.loss || 0
1750
+ throttleUpValuesQueue[pageKey] = throttleUpValues.queue || 0
1751
+
1752
+ const throttleDownValues = getSessionThrottleValues(this.throttleIndex, 'down')
1753
+ throttleDownValuesRate[pageKey] = throttleDownValues.rate || 0
1754
+ throttleDownValuesDelay[pageKey] = throttleDownValues.delay || 0
1755
+ throttleDownValuesLoss[pageKey] = throttleDownValues.loss || 0
1756
+ throttleDownValuesQueue[pageKey] = throttleDownValues.queue || 0
1757
+ } catch (err) {
1758
+ const error = err as Error
1759
+ if (error.message.includes('Execution context was destroyed, most likely because of a navigation.')) {
1760
+ log.warn(`collectPeerConnectionStats for page ${pageIndex} error: ${error.message}`)
1761
+ } else {
1762
+ log.error(`collectPeerConnectionStats for page ${pageIndex} error: ${error.stack}`)
1763
+ }
1764
+ }
1765
+ }),
1766
+ )
1767
+
1768
+ Object.assign(collectedStats, {
1769
+ pages,
1770
+ errors: this.pageErrors,
1771
+ warnings: this.pageWarnings,
1772
+ peerConnections,
1773
+ peerConnectionConnectionTime,
1774
+ peerConnectionDisconnectionTime,
1775
+ peerConnectionsConnected,
1776
+ peerConnectionsCreated,
1777
+ peerConnectionsClosed,
1778
+ peerConnectionsDisconnected,
1779
+ peerConnectionsFailed,
1780
+ peerConnectionsDelay,
1781
+ audioEndToEndDelay: audioEndToEndDelayStats,
1782
+ audioStartFrameDelay: audioStartFrameDelayStats,
1783
+ videoEndToEndDelay: videoEndToEndDelayStats,
1784
+ videoStartFrameDelay: videoStartFrameDelayStats,
1785
+ screenEndToEndDelay: screenEndToEndDelayStats,
1786
+ screenStartFrameDelay: screenStartFrameDelayStats,
1787
+ httpSentBytes: httpSentBytesStats,
1788
+ httpRecvBytes: httpRecvBytesStats,
1789
+ httpRecvLatency: httpRecvLatencyStats,
1790
+ wsSentBytes: wsSentBytesStats,
1791
+ wsRecvBytes: wsRecvBytesStats,
1792
+ wsRecvLatency: wsRecvLatencyStats,
1793
+ cpuPressure: cpuPressureStats,
1794
+ videoWidth,
1795
+ videoHeight,
1796
+ videoBufferedTime,
1797
+ videoPlayingTime,
1798
+ videoBufferingTime,
1799
+ videoBufferingEvents,
1800
+ pageCpu,
1801
+ pageMemory,
1802
+ throttleUpRate: throttleUpValuesRate,
1803
+ throttleUpDelay: throttleUpValuesDelay,
1804
+ throttleUpLoss: throttleUpValuesLoss,
1805
+ throttleUpQueue: throttleUpValuesQueue,
1806
+ throttleDownRate: throttleDownValuesRate,
1807
+ throttleDownDelay: throttleDownValuesDelay,
1808
+ throttleDownLoss: throttleDownValuesLoss,
1809
+ throttleDownQueue: throttleDownValuesQueue,
1810
+ ...customStats,
1811
+ })
1812
+
1813
+ if (pages.size < this.pages.size) {
1814
+ log.warn(`updateStats collected pages ${pages.size} < ${this.pages.size}`)
1815
+ }
1816
+
1817
+ this.stats = collectedStats
1818
+ return this.stats
1819
+ }
1820
+
1821
+ /**
1822
+ * stop
1823
+ */
1824
+ async stop(): Promise<void> {
1825
+ if (!this.running) {
1826
+ return
1827
+ }
1828
+ this.running = false
1829
+ log.debug(`${this.id} stop`)
1830
+
1831
+ if (this.stopPortForwarder) {
1832
+ this.stopPortForwarder()
1833
+ }
1834
+
1835
+ if (this.browser) {
1836
+ // close the opened tabs
1837
+ log.debug(`${this.id} closing ${this.pages.size} pages`)
1838
+ await Promise.allSettled(
1839
+ [...this.pages.values()].map(page => {
1840
+ return page.close({ runBeforeUnload: true })
1841
+ }),
1842
+ )
1843
+ if (this.pages.size > 0) {
1844
+ const now = Date.now()
1845
+ const maxWaitTime = 1000 * this.pages.size
1846
+ while (this.pages.size > 0 && Date.now() - now < maxWaitTime) {
1847
+ log.debug(`${this.id} waiting for ${this.pages.size} pages to close`)
1848
+ await sleep(200)
1849
+ }
1850
+ if (this.pages.size > 0) {
1851
+ log.warn(`${this.id} timeout closing ${this.pages.size} pages`)
1852
+ }
1853
+ }
1854
+
1855
+ if (this.screensharePage) {
1856
+ await this.screensharePage.close()
1857
+ this.screensharePage = undefined
1858
+ }
1859
+
1860
+ this.browser.removeAllListeners()
1861
+ if (this.chromiumUrl) {
1862
+ log.debug(`${this.id} disconnect from browser`)
1863
+ try {
1864
+ await this.browser.disconnect()
1865
+ } catch (err) {
1866
+ log.warn(`${this.id} browser disconnect error: ${(err as Error).message}`)
1867
+ }
1868
+ } else {
1869
+ const pid = this.browser.process()?.pid
1870
+ if (pid) {
1871
+ log.debug(`${this.id} closing browser (pid: ${pid})`)
1872
+ try {
1873
+ await this.browser.close()
1874
+ } catch (err) {
1875
+ log.error(`${this.id} browser close error: ${(err as Error).stack}`)
1876
+ }
1877
+ await waitStopProcess(pid, 5000)
1878
+ }
1879
+ }
1880
+ this.pages.clear()
1881
+ this.pagesMetrics.clear()
1882
+ this.browser = undefined
1883
+ }
1884
+
1885
+ this.emit('stop', this.id)
1886
+ }
1887
+
1888
+ /**
1889
+ * pageScreenshot
1890
+ * @param {number} pageIndex
1891
+ * @param {String} format The image format (png|jpeg|webp).
1892
+ * @return {String}
1893
+ */
1894
+ async pageScreenshot(pageIndex = 0, format = 'webp'): Promise<string> {
1895
+ log.debug(`pageScreenshot ${this.id}-${pageIndex}`)
1896
+ const index = this.id + pageIndex
1897
+ const page = this.pages.get(index)
1898
+ if (!page) {
1899
+ throw new Error(`Page ${index} not found`)
1900
+ }
1901
+ const filePath = `/tmp/screenshot-${index}.${format}`
1902
+ await page.screenshot({
1903
+ path: filePath,
1904
+ fullPage: true,
1905
+ })
1906
+ return filePath
1907
+ }
1908
+ }