@wdio/lighthouse-service 9.32.0 → 10.0.0-alpha.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/index.js CHANGED
@@ -1,16 +1,8 @@
1
1
  // src/commands.ts
2
2
  import logger3 from "@wdio/logger";
3
+ import { desktopConfig, startFlow } from "lighthouse";
3
4
 
4
5
  // src/constants.ts
5
- import InstallableManifest from "lighthouse/lighthouse-core/audits/installable-manifest.js";
6
- import ServiceWorker from "lighthouse/lighthouse-core/audits/service-worker.js";
7
- import SplashScreen from "lighthouse/lighthouse-core/audits/splash-screen.js";
8
- import ThemedOmnibox from "lighthouse/lighthouse-core/audits/themed-omnibox.js";
9
- import ContentWidth from "lighthouse/lighthouse-core/audits/content-width.js";
10
- import Viewport from "lighthouse/lighthouse-core/audits/viewport.js";
11
- import AppleTouchIcon from "lighthouse/lighthouse-core/audits/apple-touch-icon.js";
12
- import MaskableIcon from "lighthouse/lighthouse-core/audits/maskable-icon.js";
13
- import { throttling } from "lighthouse/lighthouse-core/config/constants.js";
14
6
  var DEFAULT_TRACING_CATEGORIES = [
15
7
  // Exclude default categories. We'll be selective to minimize trace size
16
8
  "-*",
@@ -35,13 +27,10 @@ var DEFAULT_TRACING_CATEGORIES = [
35
27
  "disabled-by-default-devtools.screenshot",
36
28
  // This doesn't add its own events, but adds a `stackTrace` property to devtools.timeline events
37
29
  "disabled-by-default-devtools.timeline.stack",
38
- // Additional categories used by devtools. Not used by Lighthouse, but included to facilitate
30
+ // Additional categories used by DevTools. Not used by Lighthouse, but included to facilitate
39
31
  // loading traces from Lighthouse into the Performance panel.
40
32
  "disabled-by-default-devtools.timeline.frame",
41
33
  "latencyInfo"
42
- // CPU sampling profiler data only enabled for debugging purposes
43
- // 'disabled-by-default-v8.cpu_profiler',
44
- // 'disabled-by-default-v8.cpu_profiler.hires',
45
34
  ];
46
35
  var IGNORED_URLS = [
47
36
  "data:,",
@@ -52,8 +41,6 @@ var IGNORED_URLS = [
52
41
  // all chrome extensions
53
42
  ];
54
43
  var FRAME_LOAD_START_TIMEOUT = 2e3;
55
- var TRACING_TIMEOUT = 15e3;
56
- var MAX_TRACE_WAIT_TIME = 45e3;
57
44
  var DEFAULT_NETWORK_THROTTLING_STATE = "online";
58
45
  var DEFAULT_FORM_FACTOR = "desktop";
59
46
  var UNSUPPORTED_ERROR_MESSAGE = "Can't connect to Chrome DevTools! The @wdio/lighthouse-service currently only supports Chrome and Chromium!\n\nGiven that cloud vendors don't expose access to the Chrome DevTools Protocol this service also usually only works when running tests locally or through a Selenium Grid (https://www.selenium.dev/documentation/grid/) v4 or higher.";
@@ -84,17 +71,15 @@ var NETWORK_STATES = {
84
71
  },
85
72
  "Regular 3G": {
86
73
  offline: false,
87
- latency: throttling.mobileRegular3G.requestLatencyMs,
88
- // DevTools expects throughput in bytes per second rather than kbps
89
- downloadThroughput: Math.floor(throttling.mobileRegular3G.downloadThroughputKbps * 1024 / 8),
90
- uploadThroughput: Math.floor(throttling.mobileRegular3G.uploadThroughputKbps * 1024 / 8)
74
+ latency: 300 * 3.75,
75
+ downloadThroughput: Math.floor(700 * 1024 / 8),
76
+ uploadThroughput: Math.floor(700 * 1024 / 8)
91
77
  },
92
78
  "Good 3G": {
93
79
  offline: false,
94
- latency: throttling.mobileSlow4G.requestLatencyMs,
95
- // DevTools expects throughput in bytes per second rather than kbps
96
- downloadThroughput: Math.floor(throttling.mobileSlow4G.downloadThroughputKbps * 1024 / 8),
97
- uploadThroughput: Math.floor(throttling.mobileSlow4G.uploadThroughputKbps * 1024 / 8)
80
+ latency: 150 * 3.75,
81
+ downloadThroughput: Math.floor(1.6 * 1024 * 1024 / 8),
82
+ uploadThroughput: Math.floor(750 * 1024 / 8)
98
83
  },
99
84
  "Regular 4G": {
100
85
  offline: false,
@@ -128,25 +113,17 @@ var DEFAULT_THROTTLE_STATE = {
128
113
  cacheEnabled: false,
129
114
  formFactor: DEFAULT_FORM_FACTOR
130
115
  };
131
- var NETWORK_RECORDER_EVENTS = [
132
- "Network.requestWillBeSent",
133
- "Network.requestServedFromCache",
134
- "Network.responseReceived",
135
- "Network.dataReceived",
136
- "Network.loadingFinished",
137
- "Network.loadingFailed",
138
- "Network.resourceChangedPriority"
116
+ var PWA_AUDIT_NAMES = [
117
+ "isInstallable",
118
+ "serviceWorker",
119
+ "splashScreen",
120
+ "themedOmnibox",
121
+ "contentWith",
122
+ "viewport",
123
+ "appleTouchIcon",
124
+ "maskableIcon"
139
125
  ];
140
- var PWA_AUDITS = {
141
- isInstallable: InstallableManifest,
142
- serviceWorker: ServiceWorker,
143
- splashScreen: SplashScreen,
144
- themedOmnibox: ThemedOmnibox,
145
- contentWith: ContentWidth,
146
- viewport: Viewport,
147
- appleTouchIcon: AppleTouchIcon,
148
- maskableIcon: MaskableIcon
149
- };
126
+ var TRACE_COMMANDS = ["click", "navigateTo", "url"];
150
127
 
151
128
  // src/handler/network.ts
152
129
  var NetworkHandler = class {
@@ -256,35 +233,6 @@ var NetworkHandler = class {
256
233
  }
257
234
  };
258
235
 
259
- // src/utils.ts
260
- import Driver from "lighthouse/lighthouse-core/gather/driver.js";
261
-
262
- // src/lighthouse/cri.ts
263
- import CriConnection from "lighthouse/lighthouse-core/gather/connections/cri.js";
264
- var DEFAULT_HOSTNAME = "localhost";
265
- var DEFAULT_PORT = "9222";
266
- var ChromeProtocolPatched = class extends CriConnection {
267
- _sessionId;
268
- /**
269
- * Add constructor for typing safety
270
- * @param {number=} port Optional port number. Defaults to 9222;
271
- * @param {string=} hostname Optional hostname. Defaults to localhost.
272
- * @constructor
273
- */
274
- constructor(port = DEFAULT_PORT, hostname = DEFAULT_HOSTNAME) {
275
- super(port, hostname);
276
- }
277
- setSessionId(sessionId) {
278
- this._sessionId = sessionId;
279
- }
280
- /**
281
- * force every command to be send with the given session id
282
- */
283
- sendCommand(method, sessionId, ...paramArgs) {
284
- return super.sendCommand(method, sessionId || this._sessionId, ...paramArgs);
285
- }
286
- };
287
-
288
236
  // src/utils.ts
289
237
  var CUSTOM_COMMANDS = [
290
238
  "getMetrics",
@@ -294,7 +242,11 @@ var CUSTOM_COMMANDS = [
294
242
  "enablePerformanceAudits",
295
243
  "disablePerformanceAudits",
296
244
  "getMainThreadWorkBreakdown",
297
- "checkPWA"
245
+ "checkPWA",
246
+ "getPerformanceScore",
247
+ "getTraceLogs",
248
+ "getPageWeight",
249
+ "endTracing"
298
250
  ];
299
251
  function setUnsupportedCommand(browser) {
300
252
  for (const command of CUSTOM_COMMANDS) {
@@ -314,104 +266,100 @@ function sumByKey(list, key) {
314
266
  function isSupportedUrl(url) {
315
267
  return IGNORED_URLS.filter((ignoredUrl) => url.startsWith(ignoredUrl)).length === 0;
316
268
  }
317
- async function getLighthouseDriver(session, target) {
318
- const connection = session.connection();
319
- if (!connection) {
320
- throw new Error("Couldn't find a CDP connection");
269
+ function parseTraceBuffer(buffer) {
270
+ const raw = typeof buffer === "string" ? buffer : Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength).toString("utf8");
271
+ const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
272
+ if (Array.isArray(parsed)) {
273
+ return { traceEvents: parsed };
321
274
  }
322
- const cUrl = new URL(connection.url());
323
- const cdpConnection = new ChromeProtocolPatched(cUrl.port, cUrl.hostname);
324
- if (!cUrl.pathname.startsWith("/devtools/browser")) {
325
- await cdpConnection._connectToSocket({
326
- webSocketDebuggerUrl: connection.url(),
327
- id: (await target.asPage()).mainFrame()._id
328
- });
329
- const { sessionId } = await cdpConnection.sendCommand(
330
- "Target.attachToTarget",
331
- void 0,
332
- { targetId: (await target.asPage()).mainFrame()._id, flatten: true }
333
- );
334
- cdpConnection.setSessionId(sessionId);
335
- return new Driver(cdpConnection);
275
+ if (parsed && typeof parsed === "object") {
276
+ return parsed;
336
277
  }
337
- const list = await cdpConnection._runJsonCommand("list");
338
- await cdpConnection._connectToSocket(list[0]);
339
- return new Driver(cdpConnection);
278
+ throw new Error("Trace buffer did not contain JSON trace data");
340
279
  }
341
-
342
- // src/gatherer/devtools.ts
343
- var DevtoolsGatherer = class {
344
- _logs = [];
345
- onMessage(msgObj) {
346
- this._logs.push(msgObj);
280
+ function isInternalBrowserPage(url) {
281
+ return url.includes("BiDi-CDP Mapper") || url.startsWith("devtools://") || url.startsWith("chrome-extension://");
282
+ }
283
+ function pageMatchesUrl(pageUrl, currentUrl) {
284
+ return pageUrl === currentUrl || pageUrl.includes(currentUrl) || currentUrl.includes(pageUrl);
285
+ }
286
+ function resolveAuditablePage(pages, currentUrl) {
287
+ const candidates = pages.filter((page) => !isInternalBrowserPage(page.url()));
288
+ if (currentUrl && currentUrl !== "data:,") {
289
+ const match = candidates.find((page) => pageMatchesUrl(page.url(), currentUrl));
290
+ if (match) {
291
+ return match;
292
+ }
347
293
  }
348
- /**
349
- * retrieve logs and clean cache
350
- */
351
- getLogs() {
352
- return this._logs.splice(0, this._logs.length);
294
+ return candidates.find((page) => isSupportedUrl(page.url())) ?? candidates.at(-1);
295
+ }
296
+ async function getAuditablePuppeteerPage(puppeteer, currentUrl) {
297
+ const pages = await puppeteer.pages();
298
+ const fromPages = resolveAuditablePage(pages, currentUrl);
299
+ if (fromPages) {
300
+ return fromPages;
353
301
  }
354
- };
302
+ if (!puppeteer.waitForTarget) {
303
+ return void 0;
304
+ }
305
+ const target = currentUrl && currentUrl !== "data:," ? await puppeteer.waitForTarget(async (t) => pageMatchesUrl(t.url(), currentUrl) && !isInternalBrowserPage(t.url()) && Boolean(await t.page())) : await puppeteer.waitForTarget(async (t) => (t.type?.() === "page" || Boolean(await t.page())) && !isInternalBrowserPage(t.url()));
306
+ return await target?.page() ?? void 0;
307
+ }
308
+ var CORE_METRIC_KEYS = [
309
+ "firstContentfulPaint",
310
+ "largestContentfulPaint",
311
+ "speedIndex"
312
+ ];
313
+ function metricValue(lhr, auditId, metricKey) {
314
+ const audits = lhr.audits ?? {};
315
+ const metrics = audits.metrics?.details?.items?.[0] ?? {};
316
+ const value = metrics[metricKey] ?? audits[auditId]?.numericValue;
317
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
318
+ }
319
+ function hasCorePerformanceMetrics(lhr) {
320
+ if (!lhr) {
321
+ return false;
322
+ }
323
+ return CORE_METRIC_KEYS.every((key) => {
324
+ const auditId = key === "firstContentfulPaint" ? "first-contentful-paint" : key === "largestContentfulPaint" ? "largest-contentful-paint" : "speed-index";
325
+ return metricValue(lhr, auditId, key) !== void 0;
326
+ });
327
+ }
328
+ function selectLighthouseResult(result) {
329
+ const steps = result?.steps ?? [];
330
+ for (let i = steps.length - 1; i >= 0; i--) {
331
+ if (hasCorePerformanceMetrics(steps[i]?.lhr)) {
332
+ return steps[i].lhr;
333
+ }
334
+ }
335
+ return steps.at(-1)?.lhr;
336
+ }
337
+ function describeLighthouseResult(lhr) {
338
+ const url = lhr?.finalDisplayedUrl || lhr?.finalUrl || "unknown";
339
+ const runtimeError = lhr?.runtimeError ? `${lhr.runtimeError.code ?? "unknown"}: ${lhr.runtimeError.message ?? ""}`.trim() : "none";
340
+ return `url=${url} runtimeError=${runtimeError}`;
341
+ }
355
342
 
356
343
  // src/auditor.ts
357
- import Diagnostics from "lighthouse/lighthouse-core/audits/diagnostics.js";
358
- import MainThreadWorkBreakdown from "lighthouse/lighthouse-core/audits/mainthread-work-breakdown.js";
359
- import Metrics from "lighthouse/lighthouse-core/audits/metrics.js";
360
- import ServerResponseTime from "lighthouse/lighthouse-core/audits/server-response-time.js";
361
- import CumulativeLayoutShift from "lighthouse/lighthouse-core/audits/metrics/cumulative-layout-shift.js";
362
- import FirstContentfulPaint from "lighthouse/lighthouse-core/audits/metrics/first-contentful-paint.js";
363
- import LargestContentfulPaint from "lighthouse/lighthouse-core/audits/metrics/largest-contentful-paint.js";
364
- import SpeedIndex from "lighthouse/lighthouse-core/audits/metrics/speed-index.js";
365
- import InteractiveMetric from "lighthouse/lighthouse-core/audits/metrics/interactive.js";
366
- import TotalBlockingTime from "lighthouse/lighthouse-core/audits/metrics/total-blocking-time.js";
367
- import ReportScoring from "lighthouse/lighthouse-core/scoring.js";
368
- import defaultConfig from "lighthouse/lighthouse-core/config/default-config.js";
369
344
  import logger from "@wdio/logger";
370
345
  var log = logger("@wdio/lighthouse-service:Auditor");
346
+ function asNumber(value) {
347
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
348
+ }
349
+ function roundMetric(value) {
350
+ const numeric = asNumber(value);
351
+ return numeric === void 0 ? void 0 : Math.round(numeric);
352
+ }
371
353
  var Auditor = class {
372
- constructor(_traceLogs, _devtoolsLogs, _formFactor) {
373
- this._traceLogs = _traceLogs;
374
- this._devtoolsLogs = _devtoolsLogs;
354
+ constructor(_lhr, _formFactor) {
355
+ this._lhr = _lhr;
375
356
  this._formFactor = _formFactor;
376
- if (_traceLogs) {
377
- this._url = _traceLogs.pageUrl;
378
- }
379
357
  }
380
- _traceLogs;
381
- _devtoolsLogs;
358
+ _lhr;
382
359
  _formFactor;
383
- _url;
384
- _audit(AUDIT, params = {}) {
385
- const auditContext = {
386
- options: {
387
- ...AUDIT.defaultOptions
388
- },
389
- settings: {
390
- throttlingMethod: "devtools",
391
- formFactor: this._formFactor || DEFAULT_FORM_FACTOR
392
- },
393
- LighthouseRunWarnings: false,
394
- computedCache: /* @__PURE__ */ new Map()
395
- };
396
- try {
397
- return AUDIT.audit({
398
- traces: { defaultPass: this._traceLogs },
399
- devtoolsLogs: { defaultPass: this._devtoolsLogs },
400
- TestedAsMobileDevice: true,
401
- GatherContext: { gatherMode: "navigation" },
402
- ...params
403
- }, auditContext);
404
- } catch (error) {
405
- log.error(error);
406
- return {
407
- score: 0,
408
- error
409
- };
410
- }
411
- }
412
360
  /**
413
- * an Auditor instance is created for every trace so provide an updateCommands
414
- * function to receive the latest performance metrics with the browser instance
361
+ * an Auditor instance is created for every Lighthouse run so provide an
362
+ * updateCommands function to receive the latest performance metrics
415
363
  */
416
364
  updateCommands(browser, customFn) {
417
365
  const commands = Object.getOwnPropertyNames(Object.getPrototypeOf(this)).filter(
@@ -420,428 +368,230 @@ var Auditor = class {
420
368
  commands.forEach((fnName) => browser.addCommand(fnName, customFn || this[fnName].bind(this)));
421
369
  }
422
370
  /**
423
- * Returns a list with a breakdown of all main thread task and their total duration
371
+ * Returns a list with a breakdown of all main thread tasks and their total duration
424
372
  */
425
373
  async getMainThreadWorkBreakdown() {
426
- const result = await this._audit(MainThreadWorkBreakdown);
427
- return result.details.items.map(
428
- ({ group, duration }) => ({ group, duration })
429
- );
374
+ const items = this._lhr?.audits?.["mainthread-work-breakdown"]?.details?.items;
375
+ if (!Array.isArray(items)) {
376
+ return [];
377
+ }
378
+ return items.map((item) => ({
379
+ group: String(item.group ?? ""),
380
+ duration: asNumber(item.duration) ?? 0
381
+ }));
430
382
  }
431
383
  /**
432
384
  * Get some useful diagnostics about the page load
433
385
  */
434
386
  async getDiagnostics() {
435
- const result = await this._audit(Diagnostics);
436
- if (!Object.prototype.hasOwnProperty.call(result, "details")) {
387
+ const details = this._lhr?.audits?.diagnostics?.details;
388
+ if (!details || !Array.isArray(details.items) || !details.items[0]) {
437
389
  return null;
438
390
  }
439
- return result.details.items[0];
391
+ return details.items[0];
440
392
  }
441
393
  /**
442
- * Get most common used performance metrics
394
+ * Get most commonly used performance metrics
443
395
  */
444
396
  async getMetrics() {
445
- const serverResponseTime = await this._audit(ServerResponseTime, { URL: this._url });
446
- const cumulativeLayoutShift = await this._audit(CumulativeLayoutShift);
447
- const result = await this._audit(Metrics);
448
- const metrics = result.details.items[0] || {};
397
+ const audits = this._lhr?.audits ?? {};
398
+ const metrics = audits.metrics?.details?.items?.[0] ?? {};
449
399
  return {
450
- timeToFirstByte: Math.round(serverResponseTime.numericValue),
451
- serverResponseTime: Math.round(serverResponseTime.numericValue),
452
- domContentLoaded: metrics.observedDomContentLoaded,
453
- firstVisualChange: metrics.observedFirstVisualChange,
454
- firstPaint: metrics.observedFirstPaint,
455
- firstContentfulPaint: metrics.firstContentfulPaint,
456
- firstMeaningfulPaint: metrics.firstMeaningfulPaint,
457
- largestContentfulPaint: metrics.largestContentfulPaint,
458
- lastVisualChange: metrics.observedLastVisualChange,
459
- interactive: metrics.interactive,
460
- load: metrics.observedLoad,
461
- speedIndex: metrics.speedIndex,
462
- totalBlockingTime: metrics.totalBlockingTime,
463
- maxPotentialFID: metrics.maxPotentialFID,
464
- cumulativeLayoutShift: cumulativeLayoutShift.numericValue
400
+ timeToFirstByte: roundMetric(audits["server-response-time"]?.numericValue),
401
+ serverResponseTime: roundMetric(audits["server-response-time"]?.numericValue),
402
+ domContentLoaded: asNumber(metrics.observedDomContentLoaded),
403
+ firstVisualChange: asNumber(metrics.observedFirstVisualChange),
404
+ firstPaint: asNumber(metrics.observedFirstPaint),
405
+ firstContentfulPaint: asNumber(metrics.firstContentfulPaint ?? audits["first-contentful-paint"]?.numericValue),
406
+ firstMeaningfulPaint: asNumber(metrics.firstMeaningfulPaint),
407
+ largestContentfulPaint: asNumber(metrics.largestContentfulPaint ?? audits["largest-contentful-paint"]?.numericValue),
408
+ lastVisualChange: asNumber(metrics.observedLastVisualChange),
409
+ interactive: asNumber(metrics.interactive ?? audits.interactive?.numericValue),
410
+ load: asNumber(metrics.observedLoad),
411
+ speedIndex: asNumber(metrics.speedIndex ?? audits["speed-index"]?.numericValue),
412
+ totalBlockingTime: asNumber(metrics.totalBlockingTime ?? audits["total-blocking-time"]?.numericValue),
413
+ maxPotentialFID: asNumber(metrics.maxPotentialFID ?? audits["max-potential-fid"]?.numericValue),
414
+ cumulativeLayoutShift: asNumber(
415
+ audits["cumulative-layout-shift"]?.numericValue ?? metrics.cumulativeLayoutShift
416
+ ),
417
+ interactionToNextPaint: asNumber(audits["interaction-to-next-paint"]?.numericValue)
465
418
  };
466
419
  }
467
420
  /**
468
- * Returns the Lighthouse Performance Score which is a weighted mean of the following metrics: firstMeaningfulPaint, interactive, speedIndex
421
+ * Returns the Lighthouse Performance Score, a weighted mean of FCP, SI, LCP, TBT and CLS.
469
422
  */
470
423
  async getPerformanceScore() {
471
- const auditResults = {
472
- "speed-index": await this._audit(SpeedIndex),
473
- "first-contentful-paint": await this._audit(FirstContentfulPaint),
474
- "largest-contentful-paint": await this._audit(LargestContentfulPaint),
475
- "cumulative-layout-shift": await this._audit(CumulativeLayoutShift),
476
- "total-blocking-time": await this._audit(TotalBlockingTime),
477
- interactive: await this._audit(InteractiveMetric)
478
- };
479
- if (!auditResults.interactive || !auditResults["cumulative-layout-shift"] || !auditResults["first-contentful-paint"] || !auditResults["largest-contentful-paint"] || !auditResults["speed-index"] || !auditResults["total-blocking-time"]) {
480
- log.info("One or multiple required metrics couldn't be found, setting performance score to: null");
424
+ const score = this._lhr?.categories?.performance?.score;
425
+ if (typeof score !== "number" || Number.isNaN(score)) {
426
+ log.info("Performance score couldn't be found, setting performance score to: null");
481
427
  return null;
482
428
  }
483
- const scores = defaultConfig.categories.performance.auditRefs.filter((auditRef) => auditRef.weight).map((auditRef) => ({
484
- score: auditResults[auditRef.id].score,
485
- weight: auditRef.weight
486
- }));
487
- return ReportScoring.arithmeticMean(scores);
488
- }
489
- async _auditPWA(params, auditsToBeRun = Object.keys(PWA_AUDITS)) {
490
- const audits = await Promise.all(
491
- Object.entries(PWA_AUDITS).filter(([name]) => auditsToBeRun.includes(name)).map(
492
- async ([name, Audit]) => [name, await this._audit(Audit, params)]
493
- )
494
- );
495
- return {
496
- passed: !audits.find(([, result]) => result.score < 1),
497
- details: audits.reduce((details, [name, result]) => {
498
- details[name] = result;
499
- return details;
500
- }, {})
501
- };
429
+ return score;
502
430
  }
503
431
  };
504
432
 
505
- // src/gatherer/pwa.ts
506
- import FRGatherer from "lighthouse/lighthouse-core/fraggle-rock/gather/session.js";
507
- import pageFunctions from "lighthouse/lighthouse-core/lib/page-functions.js";
508
- import NetworkRecorder from "lighthouse/lighthouse-core/lib/network-recorder.js";
509
- import InstallabilityErrors from "lighthouse/lighthouse-core/gather/gatherers/installability-errors.js";
510
- import WebAppManifest from "lighthouse/lighthouse-core/gather/gatherers/web-app-manifest.js";
511
- import LinkElements from "lighthouse/lighthouse-core/gather/gatherers/link-elements.js";
512
- import ViewportDimensions from "lighthouse/lighthouse-core/gather/gatherers/viewport-dimensions.js";
513
- import serviceWorkers from "lighthouse/lighthouse-core/gather/driver/service-workers.js";
514
-
515
- // src/scripts/collectMetaElements.ts
516
- function collectMetaElements() {
517
- const selector = "head meta";
518
- const realMatchesFn = window.Element.prototype.matches;
519
- const metas = [];
520
- const _findAllElements = (nodes) => {
521
- for (let i = 0, el; el = nodes[i]; ++i) {
522
- if (!selector || realMatchesFn.call(el, selector)) {
523
- metas.push(el);
524
- }
525
- if (el.shadowRoot) {
526
- _findAllElements(el.shadowRoot.querySelectorAll("*"));
527
- }
433
+ // src/pwa.ts
434
+ import logger2 from "@wdio/logger";
435
+ var log2 = logger2("@wdio/lighthouse-service:PWA");
436
+ function passed(displayValue, explanation) {
437
+ return { score: 1, displayValue, explanation };
438
+ }
439
+ function failed(displayValue, explanation) {
440
+ return { score: 0, displayValue, explanation };
441
+ }
442
+ function parseIconSize(sizes) {
443
+ if (!sizes || sizes === "any") {
444
+ return sizes === "any" ? Number.POSITIVE_INFINITY : 0;
445
+ }
446
+ return sizes.split(/\s+/).reduce((max, entry) => {
447
+ const match = entry.match(/^(\d+)x(\d+)$/i);
448
+ if (!match) {
449
+ return max;
528
450
  }
529
- };
530
- _findAllElements(document.querySelectorAll("*"));
531
- return metas.map((meta) => {
532
- const getAttribute = (name) => {
533
- const attr = meta.attributes.getNamedItem(name);
534
- if (!attr) {
535
- return;
451
+ return Math.max(max, Math.min(Number(match[1]), Number(match[2])));
452
+ }, 0);
453
+ }
454
+ function hasIconOfSize(icons = [], minSize) {
455
+ return icons.some((icon) => parseIconSize(icon.sizes) >= minSize);
456
+ }
457
+ async function collectPageState(page) {
458
+ return page.evaluate(async () => {
459
+ const manifestLink = document.querySelector('link[rel~="manifest"]');
460
+ const viewport = document.querySelector('meta[name="viewport"]');
461
+ const appleTouch = document.querySelector('link[rel~="apple-touch-icon"]');
462
+ const themeColor = document.querySelector('meta[name="theme-color"]');
463
+ let manifest = null;
464
+ let manifestParseError;
465
+ const manifestHref = manifestLink?.href;
466
+ if (manifestHref) {
467
+ try {
468
+ const response = await fetch(manifestHref, { credentials: "same-origin" });
469
+ if (!response.ok) {
470
+ manifestParseError = `Failed to fetch manifest: HTTP ${response.status}`;
471
+ } else {
472
+ manifest = await response.json();
473
+ }
474
+ } catch (error) {
475
+ manifestParseError = error instanceof Error ? error.message : String(error);
536
476
  }
537
- return attr.value;
538
- };
477
+ }
478
+ let hasServiceWorker = false;
479
+ try {
480
+ const registration = await navigator.serviceWorker?.getRegistration();
481
+ hasServiceWorker = Boolean(registration);
482
+ } catch {
483
+ hasServiceWorker = false;
484
+ }
539
485
  return {
540
- // @ts-ignore
541
- name: meta.name.toLowerCase(),
542
- // @ts-ignore
543
- content: meta.content,
544
- property: getAttribute("property"),
545
- // @ts-ignore
546
- httpEquiv: meta.httpEquiv ? meta.httpEquiv.toLowerCase() : void 0,
547
- charset: getAttribute("charset")
486
+ url: location.href,
487
+ manifestHref,
488
+ manifest,
489
+ manifestParseError,
490
+ viewportContent: viewport?.content,
491
+ appleTouchIconHref: appleTouch?.href,
492
+ themeColor: themeColor?.content,
493
+ innerWidth: window.innerWidth,
494
+ documentWidth: document.documentElement.clientWidth,
495
+ hasServiceWorker
548
496
  };
549
497
  });
550
498
  }
551
-
552
- // src/gatherer/pwa.ts
553
- var PWAGatherer = class {
554
- constructor(_session, _page, _driver) {
555
- this._session = _session;
556
- this._page = _page;
557
- this._driver = _driver;
558
- this._frGatherer = new FRGatherer(this._session);
559
- this._networkRecorder = new NetworkRecorder();
560
- NETWORK_RECORDER_EVENTS.forEach((method) => {
561
- this._session.on(method, (params) => this._networkRecorder.dispatch({ method, params }));
562
- });
563
- this._page.on("load", () => {
564
- this._networkRecords = this._networkRecorder.getRawRecords();
565
- delete this._networkRecorder;
566
- this._networkRecorder = new NetworkRecorder();
567
- });
568
- }
569
- _session;
570
- _page;
571
- _driver;
572
- _frGatherer;
573
- _networkRecorder;
574
- _networkRecords = [];
575
- async gatherData() {
576
- const pageUrl = await this._page?.url();
577
- const passContext = {
578
- url: pageUrl,
579
- driver: this._driver
580
- };
581
- const loadData = {
582
- networkRecords: this._networkRecords
583
- };
584
- const linkElements = new LinkElements();
585
- const viewportDimensions = new ViewportDimensions();
586
- const { registrations } = await serviceWorkers.getServiceWorkerRegistrations(this._frGatherer);
587
- const { versions } = await serviceWorkers.getServiceWorkerVersions(this._frGatherer);
588
- return {
589
- URL: { requestedUrl: pageUrl, finalUrl: pageUrl },
590
- WebAppManifest: await WebAppManifest.getWebAppManifest(this._frGatherer, pageUrl),
591
- InstallabilityErrors: await InstallabilityErrors.getInstallabilityErrors(this._frGatherer),
592
- MetaElements: await this._driver.evaluate(collectMetaElements, {
593
- args: [],
594
- useIsolation: true,
595
- deps: [pageFunctions.getElementsInDocument]
596
- }),
597
- ViewportDimensions: await viewportDimensions.afterPass(passContext),
598
- ServiceWorker: { versions, registrations },
599
- LinkElements: await linkElements.afterPass(passContext, loadData)
600
- };
499
+ async function getInstallabilityErrors(session) {
500
+ try {
501
+ const result = await session.send("Page.getInstallabilityErrors");
502
+ return result.installabilityErrors ?? result.errors ?? [];
503
+ } catch (error) {
504
+ log2.debug(`Page.getInstallabilityErrors is unavailable: ${error.message}`);
505
+ return [];
601
506
  }
602
- };
603
-
604
- // src/gatherer/trace.ts
605
- import { EventEmitter } from "node:events";
606
- import NetworkRecorder2 from "lighthouse/lighthouse-core/lib/network-recorder.js";
607
- import NetworkMonitor from "lighthouse/lighthouse-core/gather/driver/network-monitor.js";
608
- import ProtocolSession from "lighthouse/lighthouse-core/fraggle-rock/gather/session.js";
609
- import { waitForFullyLoaded } from "lighthouse/lighthouse-core/gather/driver/wait-for-condition.js";
610
- import logger2 from "@wdio/logger";
611
-
612
- // src/scripts/registerPerformanceObserverInPage.ts
613
- function registerPerformanceObserverInPage() {
614
- window.____lastLongTask = window.performance.now();
615
- const observer = new window.PerformanceObserver((entryList) => {
616
- const entries = entryList.getEntries();
617
- for (const entry of entries) {
618
- if (entry.entryType === "longtask") {
619
- const taskEnd = entry.startTime + entry.duration;
620
- window.____lastLongTask = Math.max(window.____lastLongTask, taskEnd);
621
- }
622
- }
623
- });
624
- observer.observe({ entryTypes: ["longtask"] });
625
- window.____lhPerformanceObserver = observer;
626
507
  }
627
-
628
- // src/gatherer/trace.ts
629
- var log2 = logger2("@wdio/lighthouse-service:TraceGatherer");
630
- var TraceGatherer = class extends EventEmitter {
631
- constructor(_session, _page, _driver) {
632
- super();
508
+ function evaluatePWAChecks(state, installabilityErrors) {
509
+ const manifest = state.manifest;
510
+ const icons = manifest?.icons ?? [];
511
+ const display = manifest?.display;
512
+ const hasName = Boolean(manifest?.name || manifest?.short_name);
513
+ const hasStartUrl = Boolean(manifest?.start_url);
514
+ const hasValidDisplay = ["fullscreen", "standalone", "minimal-ui"].includes(String(display));
515
+ const preferRelatedApps = manifest?.prefer_related_applications === true;
516
+ const installableByManifest = Boolean(
517
+ state.manifestHref && manifest && hasName && hasStartUrl && hasValidDisplay && !preferRelatedApps && hasIconOfSize(icons, 192)
518
+ );
519
+ const blockingInstallErrors = installabilityErrors.filter((error) => error.errorId && error.errorId !== "already-installed");
520
+ const isInstallable = blockingInstallErrors.length === 0 && installableByManifest ? passed("installable-manifest", "Web app manifest meets installability requirements") : failed(
521
+ "installable-manifest",
522
+ blockingInstallErrors.length ? `Installability errors: ${blockingInstallErrors.map((error) => error.errorId).join(", ")}` : state.manifestParseError || "Web app manifest does not meet installability requirements"
523
+ );
524
+ const serviceWorker = state.hasServiceWorker ? passed("service-worker", "A service worker controls this page") : failed("service-worker", "No service worker registration was found for this page");
525
+ const splashScreen = manifest && hasName && manifest.background_color && hasIconOfSize(icons, 512) ? passed("splash-screen", "Manifest provides a custom splash screen") : failed("splash-screen", "Manifest is missing name, background_color, or a 512px icon");
526
+ const themedOmnibox = state.themeColor || manifest?.theme_color ? passed("themed-omnibox", "A theme color is provided via manifest or meta tag") : failed("themed-omnibox", "No theme-color meta tag or manifest.theme_color was found");
527
+ const hasViewport = Boolean(state.viewportContent && /width=|initial-scale=/i.test(state.viewportContent));
528
+ const viewport = hasViewport ? passed("viewport", "A viewport meta tag is configured") : failed("viewport", "No usable viewport meta tag was found");
529
+ const contentFits = !hasViewport || Math.abs(state.innerWidth - state.documentWidth) <= 1;
530
+ const contentWith = contentFits ? passed("content-width", "Content size matches the viewport") : failed("content-width", `Content width (${state.documentWidth}) does not match viewport width (${state.innerWidth})`);
531
+ const appleTouchIcon = state.appleTouchIconHref ? passed("apple-touch-icon", "An Apple touch icon is present") : failed("apple-touch-icon", "No apple-touch-icon link was found");
532
+ const maskableIcon = icons.some((icon) => String(icon.purpose || "").split(/\s+/).includes("maskable")) ? passed("maskable-icon", "Manifest includes a maskable icon") : failed("maskable-icon", "Manifest does not include a maskable icon");
533
+ return {
534
+ isInstallable,
535
+ serviceWorker,
536
+ splashScreen,
537
+ themedOmnibox,
538
+ contentWith,
539
+ viewport,
540
+ appleTouchIcon,
541
+ maskableIcon
542
+ };
543
+ }
544
+ var PWAAuditor = class {
545
+ constructor(_session, _page) {
633
546
  this._session = _session;
634
547
  this._page = _page;
635
- this._driver = _driver;
636
- NETWORK_RECORDER_EVENTS.forEach((method) => {
637
- this._networkListeners[method] = (params) => this._networkStatusMonitor.dispatch({ method, params });
638
- });
639
- this._protocolSession = new ProtocolSession(_session);
640
- this._networkMonitor = new NetworkMonitor(_session);
641
548
  }
642
549
  _session;
643
550
  _page;
644
- _driver;
645
- _failingFrameLoadIds = [];
646
- _pageLoadDetected = false;
647
- _networkListeners = {};
648
- _frameId;
649
- _loaderId;
650
- _pageUrl;
651
- _networkStatusMonitor;
652
- _networkMonitor;
653
- _protocolSession;
654
- _trace;
655
- _traceStart;
656
- _clickTraceTimeout;
657
- _waitConditionPromises = [];
658
- async startTracing(url) {
659
- delete this._trace;
660
- this._networkStatusMonitor = new NetworkRecorder2();
661
- NETWORK_RECORDER_EVENTS.forEach((method) => {
662
- this._session.on(method, this._networkListeners[method]);
663
- });
664
- this._traceStart = Date.now();
665
- log2.info(`Start tracing frame with url ${url}`);
666
- await this._driver.beginTrace();
667
- if (url === CLICK_TRANSITION) {
668
- log2.info("Start checking for page load for click");
669
- this._clickTraceTimeout = setTimeout(async () => {
670
- log2.info("No page load detected, canceling trace");
671
- return this.finishTracing();
672
- }, FRAME_LOAD_START_TIMEOUT);
673
- }
674
- await this._page.evaluateOnNewDocument(registerPerformanceObserverInPage);
675
- this._waitConditionPromises.push(
676
- waitForFullyLoaded(this._protocolSession, this._networkMonitor, { timedOut: 1 })
677
- );
678
- }
679
- /**
680
- * store frame id of frames that are being traced
681
- */
682
- async onFrameNavigated(msgObj) {
683
- if (!this.isTracing) {
684
- return;
685
- }
686
- if (this._failingFrameLoadIds.includes(msgObj.frame.url)) {
687
- this._waitConditionPromises = [];
688
- this._frameId = '"unsuccessful loaded frame"';
689
- this.finishTracing();
690
- this.emit("tracingError", new Error(`Page with url "${msgObj.frame.url}" failed to load`));
691
- if (this._clickTraceTimeout) {
692
- clearTimeout(this._clickTraceTimeout);
693
- }
694
- }
695
- if (
696
- // we already detected a frameId before
697
- this._frameId || // the event was thrown for a sub frame (e.g. iframe)
698
- msgObj.frame.parentId || // we don't support the url of given frame
699
- !isSupportedUrl(msgObj.frame.url)
700
- ) {
701
- log2.info(`Ignore navigated frame with url ${msgObj.frame.url}`);
702
- return;
703
- }
704
- this._frameId = msgObj.frame.id;
705
- this._loaderId = msgObj.frame.loaderId;
706
- this._pageUrl = msgObj.frame.url;
707
- log2.info(`Page load detected: ${this._pageUrl}, set frameId ${this._frameId}, set loaderId ${this._loaderId}`);
708
- if (this._clickTraceTimeout && !this._pageLoadDetected) {
709
- log2.info("Page load detected for click, clearing click trace timeout}");
710
- this._pageLoadDetected = true;
711
- clearTimeout(this._clickTraceTimeout);
712
- }
713
- this.emit("tracingStarted", msgObj.frame.id);
714
- }
715
- /**
716
- * once the page load event has fired, we can grab some performance
717
- * metrics and timing
718
- */
719
- async onLoadEventFired() {
720
- if (!this.isTracing) {
721
- return;
722
- }
723
- const loadPromise = Promise.all(this._waitConditionPromises).then(() => async () => {
724
- const minTraceTime = TRACING_TIMEOUT - (Date.now() - (this._traceStart || 0));
725
- if (minTraceTime > 0) {
726
- log2.info(`page load happen to quick, waiting ${minTraceTime}ms more`);
727
- await new Promise((resolve) => setTimeout(resolve, minTraceTime));
728
- }
729
- return this.completeTracing();
730
- });
731
- const cleanupFn = await Promise.race([
732
- loadPromise,
733
- this.waitForMaxTimeout()
551
+ async audit(auditsToBeRun = [...PWA_AUDIT_NAMES]) {
552
+ const [state, installabilityErrors] = await Promise.all([
553
+ collectPageState(this._page),
554
+ getInstallabilityErrors(this._session)
734
555
  ]);
735
- this._waitConditionPromises = [];
736
- return cleanupFn();
737
- }
738
- onFrameLoadFail(request) {
739
- const frame = request.frame();
740
- if (frame) {
741
- this._failingFrameLoadIds.push(frame.url());
742
- }
743
- }
744
- get isTracing() {
745
- return typeof this._traceStart === "number";
746
- }
747
- /**
748
- * once tracing has finished capture trace logs into memory
749
- */
750
- async completeTracing() {
751
- const traceDuration = Date.now() - (this._traceStart || 0);
752
- log2.info(`Tracing completed after ${traceDuration}ms, capturing performance data for frame ${this._frameId}`);
753
- try {
754
- const traceEvents = await this._driver.endTrace();
755
- const startedInBrowserEvt = traceEvents.traceEvents.find((e) => e.name === "TracingStartedInBrowser");
756
- const mainFrame = startedInBrowserEvt && startedInBrowserEvt.args && startedInBrowserEvt.args.data.frames?.find((frame) => !frame.parent);
757
- if (mainFrame && mainFrame.processId) {
758
- const threadNameEvt = traceEvents.traceEvents.find((e) => e.ph === "R" && e.cat === "blink.user_timing" && e.name === "navigationStart" && e.args.data.isLoadingMainFrame);
759
- if (threadNameEvt) {
760
- log2.info(`Replace mainFrame process id ${mainFrame.processId} with actual thread process id ${threadNameEvt.pid}`);
761
- mainFrame.processId = threadNameEvt.pid;
762
- } else {
763
- log2.info(`Couldn't replace mainFrame process id ${mainFrame.processId} with actual thread process id`);
764
- }
765
- }
766
- this._trace = {
767
- ...traceEvents,
768
- frameId: this._frameId,
769
- loaderId: this._loaderId,
770
- pageUrl: this._pageUrl,
771
- traceStart: this._traceStart,
772
- traceEnd: Date.now()
773
- };
774
- this.emit("tracingComplete", this._trace);
775
- this.finishTracing();
776
- } catch (err) {
777
- log2.error(`Error capturing tracing logs: ${err.stack}`);
778
- this.emit("tracingError", err);
779
- return this.finishTracing();
780
- }
781
- }
782
- /**
783
- * clear tracing states and emit tracingFinished
784
- */
785
- finishTracing() {
786
- log2.info(`Tracing for ${this._frameId} completed`);
787
- this._pageLoadDetected = false;
788
- NETWORK_RECORDER_EVENTS.forEach(
789
- (method) => this._session.off(method, this._networkListeners[method])
556
+ const allDetails = evaluatePWAChecks(state, installabilityErrors);
557
+ const details = Object.fromEntries(
558
+ Object.entries(allDetails).filter(([name]) => auditsToBeRun.includes(name))
790
559
  );
791
- delete this._networkStatusMonitor;
792
- delete this._traceStart;
793
- delete this._frameId;
794
- delete this._loaderId;
795
- delete this._pageUrl;
796
- this._failingFrameLoadIds = [];
797
- this._waitConditionPromises = [];
798
- this.emit("tracingFinished");
799
- }
800
- waitForMaxTimeout(maxWaitForLoadedMs = MAX_TRACE_WAIT_TIME) {
801
- return new Promise(
802
- (resolve) => setTimeout(resolve, maxWaitForLoadedMs)
803
- ).then(() => async () => {
804
- log2.error("Neither network nor CPU idle time could be detected within timeout, wrapping up tracing");
805
- return this.completeTracing();
806
- });
560
+ return {
561
+ passed: !Object.values(details).some((result) => result.score < 1),
562
+ details
563
+ };
807
564
  }
808
565
  };
809
566
 
810
567
  // src/commands.ts
811
568
  var log3 = logger3("@wdio/lighthouse-service:CommandHandler");
812
- var TRACE_COMMANDS = ["click", "navigateTo", "url"];
813
569
  function isCDPSessionOnMessageObject(data) {
814
570
  return data !== null && typeof data === "object" && Object.prototype.hasOwnProperty.call(data, "params") && Object.prototype.hasOwnProperty.call(data, "method");
815
571
  }
572
+ function isTraceCommand(commandName) {
573
+ return TRACE_COMMANDS.includes(commandName);
574
+ }
816
575
  var CommandHandler = class {
817
- constructor(_session, _page, _driver, _options, _browser) {
576
+ constructor(_session, _page, _options, _browser) {
818
577
  this._session = _session;
819
578
  this._page = _page;
820
- this._driver = _driver;
821
579
  this._options = _options;
822
580
  this._browser = _browser;
823
581
  this._networkHandler = new NetworkHandler(_session);
824
- this._traceGatherer = new TraceGatherer(_session, _page, _driver);
825
- this._pwaGatherer = new PWAGatherer(_session, _page, _driver);
826
- _session.on("Page.loadEventFired", this._traceGatherer.onLoadEventFired.bind(this._traceGatherer));
827
- _session.on("Page.frameNavigated", this._traceGatherer.onFrameNavigated.bind(this._traceGatherer));
828
- _page.on("requestfailed", this._traceGatherer.onFrameLoadFail.bind(this._traceGatherer));
829
- this._pwaGatherer = new PWAGatherer(_session, _page, _driver);
582
+ this._pwaAuditor = new PWAAuditor(_session, _page);
583
+ _session.on("Page.frameNavigated", this._onFrameNavigated);
830
584
  const commands = Object.getOwnPropertyNames(Object.getPrototypeOf(this)).filter(
831
585
  (fnName) => fnName !== "constructor" && !fnName.startsWith("_")
832
586
  );
833
587
  commands.forEach((fnName) => _browser.addCommand(
834
588
  fnName,
835
- this[fnName].bind(this),
836
- {}
837
- // Use the non-deprecated overload with options
589
+ this[fnName].bind(this)
838
590
  ));
839
- this._devtoolsGatherer = new DevtoolsGatherer();
840
591
  _session.on("*", this._propagateWSEvents.bind(this));
841
592
  }
842
593
  _session;
843
594
  _page;
844
- _driver;
845
595
  _options;
846
596
  _browser;
847
597
  _isTracing = false;
@@ -852,9 +602,12 @@ var CommandHandler = class {
852
602
  _cpuThrottling;
853
603
  _networkThrottling;
854
604
  _formFactor;
855
- _traceGatherer;
856
- _devtoolsGatherer;
857
- _pwaGatherer;
605
+ _pwaAuditor;
606
+ _flow;
607
+ _flowInProgress = false;
608
+ _pageLoadDetected = false;
609
+ _clickTraceTimeout;
610
+ _onFrameNavigated = this._handleFrameNavigated.bind(this);
858
611
  /**
859
612
  * Start tracing the browser. You can optionally pass in custom tracing categories and the
860
613
  * sampling frequency.
@@ -883,9 +636,11 @@ var CommandHandler = class {
883
636
  if (!traceBuffer) {
884
637
  throw new Error("No tracebuffer captured");
885
638
  }
886
- this._traceEvents = JSON.parse(traceBuffer.toString());
639
+ const parsed = parseTraceBuffer(traceBuffer);
640
+ this._traceEvents = parsed.traceEvents ?? parsed;
887
641
  this._isTracing = false;
888
642
  } catch (err) {
643
+ this._isTracing = false;
889
644
  throw new Error(`Couldn't parse trace events: ${err.message}`);
890
645
  }
891
646
  return this._traceEvents;
@@ -942,15 +697,12 @@ var CommandHandler = class {
942
697
  await this._session.send("Network.emulateNetworkConditions", NETWORK_STATES[networkThrottling]);
943
698
  }
944
699
  async checkPWA(auditsToBeRun) {
945
- const auditor = new Auditor();
946
- const artifacts = await this._pwaGatherer.gatherData();
947
- return auditor._auditPWA(artifacts, auditsToBeRun);
700
+ return this._pwaAuditor.audit(auditsToBeRun);
948
701
  }
949
702
  _propagateWSEvents(data) {
950
703
  if (!isCDPSessionOnMessageObject(data)) {
951
704
  return;
952
705
  }
953
- this._devtoolsGatherer?.onMessage(data);
954
706
  const method = data.method || "event";
955
707
  try {
956
708
  log3.debug(`cdp event: ${method} with params ${JSON.stringify(data.params)}`);
@@ -967,24 +719,57 @@ var CommandHandler = class {
967
719
  ])
968
720
  ));
969
721
  }
970
- _beforeCmd(commandName, params) {
971
- const isCommandNavigation = ["url", "navigateTo"].some((cmdName) => cmdName === commandName);
972
- if (!this._shouldRunPerformanceAudits || !this._traceGatherer || this._traceGatherer.isTracing || !TRACE_COMMANDS.includes(commandName)) {
722
+ async _beforeCmd(commandName, params) {
723
+ const isCommandNavigation = ["url", "navigateTo"].includes(commandName);
724
+ if (!this._shouldRunPerformanceAudits || this._flowInProgress || !isTraceCommand(commandName)) {
973
725
  return;
974
726
  }
975
- this.setThrottlingProfile(this._networkThrottling, this._cpuThrottling, this._cacheEnabled);
727
+ await this.setThrottlingProfile(this._networkThrottling, this._cpuThrottling, this._cacheEnabled);
976
728
  const url = isCommandNavigation ? params[0] : CLICK_TRANSITION;
977
- return this._traceGatherer.startTracing(url);
729
+ this._pageLoadDetected = false;
730
+ this._flowInProgress = true;
731
+ if (url === CLICK_TRANSITION) {
732
+ this._clickTraceTimeout = setTimeout(() => {
733
+ if (!this._pageLoadDetected) {
734
+ log3.info("No page load detected, canceling Lighthouse navigation");
735
+ }
736
+ }, FRAME_LOAD_START_TIMEOUT);
737
+ }
738
+ try {
739
+ await this._refreshAuditablePage();
740
+ this._flow = await startFlow(this._page, this._getFlowOptions());
741
+ await this._flow.startNavigation({ name: `WebdriverIO ${commandName}` });
742
+ } catch (error) {
743
+ this._resetFlowState();
744
+ throw error;
745
+ }
978
746
  }
979
- _afterCmd(commandName) {
980
- if (!this._traceGatherer || !this._traceGatherer.isTracing || !TRACE_COMMANDS.includes(commandName)) {
747
+ async _afterCmd(commandName) {
748
+ if (!this._flow || !this._flowInProgress || !isTraceCommand(commandName)) {
981
749
  return;
982
750
  }
983
- this._traceGatherer.once("tracingComplete", (traceEvents) => {
984
- const auditor = new Auditor(traceEvents, this._devtoolsGatherer?.getLogs(), this._formFactor);
751
+ const skipClickWithoutNavigation = commandName === "click" && !this._pageLoadDetected;
752
+ try {
753
+ if (skipClickWithoutNavigation) {
754
+ log3.info("Click did not trigger a page load, skipping Lighthouse navigation");
755
+ await this._cancelNavigation("click did not trigger a page load");
756
+ return;
757
+ }
758
+ await this._flow.endNavigation();
759
+ let lhr = selectLighthouseResult(await this._flow.createFlowResult());
760
+ if (!hasCorePerformanceMetrics(lhr)) {
761
+ log3.warn(`Wrapped navigation produced no performance metrics (${describeLighthouseResult(lhr)})`);
762
+ lhr = await this._navigateWithLighthouse() ?? lhr;
763
+ }
764
+ if (!hasCorePerformanceMetrics(lhr)) {
765
+ throw new Error(`Lighthouse did not capture core performance metrics (${describeLighthouseResult(lhr)})`);
766
+ }
767
+ log3.info(`Captured Lighthouse performance metrics (${describeLighthouseResult(lhr)})`);
768
+ const auditor = new Auditor(lhr, this._formFactor);
985
769
  auditor.updateCommands(this._browser);
986
- });
987
- this._traceGatherer.once("tracingError", (err) => {
770
+ } catch (err) {
771
+ log3.error(`Couldn't capture performance due to: ${err.message}`);
772
+ this._disposeFlow();
988
773
  const auditor = new Auditor();
989
774
  auditor.updateCommands(
990
775
  this._browser,
@@ -993,16 +778,107 @@ var CommandHandler = class {
993
778
  throw new Error(`Couldn't capture performance due to: ${err.message}`);
994
779
  }
995
780
  );
996
- });
997
- return new Promise((resolve) => {
998
- log3.info(`Wait until tracing for command ${commandName} finishes`);
999
- this._traceGatherer?.once("tracingFinished", async () => {
1000
- log3.info("Disable throttling");
1001
- await this.setThrottlingProfile("online", 0, true);
1002
- log3.info("continuing with next WebDriver command");
1003
- resolve();
1004
- });
1005
- });
781
+ } finally {
782
+ this._resetFlowState();
783
+ log3.info("Disable throttling");
784
+ await this.setThrottlingProfile("online", 0, true);
785
+ }
786
+ }
787
+ _getFlowOptions() {
788
+ const requestedFormFactor = this._formFactor;
789
+ const formFactor = requestedFormFactor === "mobile" ? "mobile" : "desktop";
790
+ return {
791
+ name: "WebdriverIO performance flow",
792
+ config: formFactor === "desktop" ? desktopConfig : void 0,
793
+ flags: {
794
+ formFactor,
795
+ /**
796
+ * Let Lighthouse apply its own desktop/mobile viewport. The Puppeteer
797
+ * connection uses `defaultViewport: null`, and headless Chrome on some
798
+ * CI hosts otherwise paints into a 0×0 window (no FCP / empty LHR).
799
+ */
800
+ ...requestedFormFactor === "none" ? { screenEmulation: { disabled: true } } : {},
801
+ throttlingMethod: "provided",
802
+ disableStorageReset: true,
803
+ skipAboutBlank: true,
804
+ onlyCategories: ["performance"],
805
+ logLevel: "error"
806
+ }
807
+ };
808
+ }
809
+ _handleFrameNavigated(event) {
810
+ if (!this._flowInProgress || event.frame?.parentId || !event.frame?.url || !isSupportedUrl(event.frame.url)) {
811
+ return;
812
+ }
813
+ this._pageLoadDetected = true;
814
+ if (this._clickTraceTimeout) {
815
+ clearTimeout(this._clickTraceTimeout);
816
+ this._clickTraceTimeout = void 0;
817
+ }
818
+ }
819
+ async _refreshAuditablePage() {
820
+ try {
821
+ const puppeteer = this._page.browser();
822
+ const currentUrl = await this._browser.getUrl();
823
+ const page = await getAuditablePuppeteerPage(puppeteer, currentUrl);
824
+ if (page) {
825
+ this._page = page;
826
+ }
827
+ } catch (error) {
828
+ log3.debug(`Could not refresh Puppeteer page: ${error.message}`);
829
+ }
830
+ }
831
+ async _navigateWithLighthouse() {
832
+ const currentUrl = await this._browser.getUrl();
833
+ if (!isSupportedUrl(currentUrl)) {
834
+ log3.warn(`Cannot fall back to Lighthouse navigate for unsupported url: ${currentUrl}`);
835
+ return void 0;
836
+ }
837
+ log3.info(`Falling back to Lighthouse-controlled navigation (${currentUrl})`);
838
+ await this._refreshAuditablePage();
839
+ if (!this._flow?.navigate) {
840
+ this._disposeFlow();
841
+ this._flow = await startFlow(this._page, this._getFlowOptions());
842
+ }
843
+ await this._flow.navigate(currentUrl);
844
+ return selectLighthouseResult(await this._flow.createFlowResult());
845
+ }
846
+ async _cancelNavigation(reason) {
847
+ this._disposeFlow();
848
+ try {
849
+ await Promise.race([
850
+ this._flow?.endNavigation() ?? Promise.resolve(),
851
+ new Promise((_, reject) => {
852
+ setTimeout(() => reject(new Error(reason)), FRAME_LOAD_START_TIMEOUT);
853
+ })
854
+ ]);
855
+ } catch (error) {
856
+ log3.debug(`Lighthouse navigation cancelled: ${error.message}`);
857
+ }
858
+ const auditor = new Auditor();
859
+ auditor.updateCommands(
860
+ this._browser,
861
+ /* istanbul ignore next */
862
+ () => {
863
+ throw new Error(`Couldn't capture performance due to: ${reason}`);
864
+ }
865
+ );
866
+ }
867
+ _disposeFlow() {
868
+ try {
869
+ this._flow?.dispose?.();
870
+ } catch (error) {
871
+ log3.debug(`Failed to dispose Lighthouse flow: ${error.message}`);
872
+ }
873
+ }
874
+ _resetFlowState() {
875
+ if (this._clickTraceTimeout) {
876
+ clearTimeout(this._clickTraceTimeout);
877
+ this._clickTraceTimeout = void 0;
878
+ }
879
+ this._flow = void 0;
880
+ this._flowInProgress = false;
881
+ this._pageLoadDetected = false;
1006
882
  }
1007
883
  };
1008
884
 
@@ -1043,33 +919,21 @@ var DevToolsService = class {
1043
919
  if (typeof cpuThrottling !== "number") {
1044
920
  throw new Error(`CPU throttling rate needs to be typeof number but was "${typeof cpuThrottling}"`);
1045
921
  }
1046
- if (this._command.length === 1) {
1047
- this._command[0].enablePerformanceAudits({ networkThrottling, cpuThrottling, cacheEnabled, formFactor });
1048
- } else {
1049
- for (const c of this._command) {
1050
- c.enablePerformanceAudits({ networkThrottling, cpuThrottling, cacheEnabled, formFactor });
1051
- }
922
+ for (const c of this._command) {
923
+ c.enablePerformanceAudits({ networkThrottling, cpuThrottling, cacheEnabled, formFactor });
1052
924
  }
1053
925
  }
1054
926
  /**
1055
927
  * custom command to disable performance audits
1056
928
  */
1057
929
  _disablePerformanceAudits() {
1058
- if (this._command.length === 1) {
1059
- this._command[0].disablePerformanceAudits();
1060
- } else {
1061
- for (const c of this._command) {
1062
- c.disablePerformanceAudits();
1063
- }
930
+ for (const c of this._command) {
931
+ c.disablePerformanceAudits();
1064
932
  }
1065
933
  }
1066
934
  async _setThrottlingProfile(networkThrottling = DEFAULT_THROTTLE_STATE.networkThrottling, cpuThrottling = DEFAULT_THROTTLE_STATE.cpuThrottling, cacheEnabled = DEFAULT_THROTTLE_STATE.cacheEnabled) {
1067
- if (this._command.length === 1) {
1068
- this._command[0].setThrottlingProfile(networkThrottling, cpuThrottling, cacheEnabled);
1069
- } else {
1070
- for (const c of this._command) {
1071
- c.setThrottlingProfile(networkThrottling, cpuThrottling, cacheEnabled);
1072
- }
935
+ for (const c of this._command) {
936
+ c.setThrottlingProfile(networkThrottling, cpuThrottling, cacheEnabled);
1073
937
  }
1074
938
  }
1075
939
  async _checkPWA(auditsToBeRun) {
@@ -1090,22 +954,12 @@ var DevToolsService = class {
1090
954
  return setUnsupportedCommand(browser2);
1091
955
  }
1092
956
  const url = await browser2.getUrl();
1093
- const target = url !== "data:," ? await puppeteer.waitForTarget(
1094
- async (t) => t.url().includes(url) && !t.url().includes("BiDi-CDP Mapper") && Boolean(await t.page())
1095
- ) : await puppeteer.waitForTarget(
1096
- async (t) => t.type() === "page" || // @ts-expect-error
1097
- Boolean(t._getTargetInfo().browserContextId) && !!await t.page()
1098
- );
1099
- if (!target) {
1100
- throw new Error("No page target found");
1101
- }
1102
- const page = await target.page();
957
+ const page = await getAuditablePuppeteerPage(puppeteer, url);
1103
958
  if (!page) {
1104
959
  throw new Error("No page found");
1105
960
  }
1106
- const session = await target.createCDPSession();
1107
- const driver = await getLighthouseDriver(session, target);
1108
- const cmd = new CommandHandler(session, page, driver, this._options, browser2);
961
+ const session = await page.target().createCDPSession();
962
+ const cmd = new CommandHandler(session, page, this._options, browser2);
1109
963
  await cmd._initCommand();
1110
964
  this._command.push(cmd);
1111
965
  }