browsertime 26.3.2 → 27.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 (105) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +1 -1
  3. package/browserscripts/timings/softNavigations.js +94 -0
  4. package/lib/chrome/har.js +36 -5
  5. package/lib/chrome/settings/chromeAndroidOptions.js +2 -1
  6. package/lib/chrome/webdriver/chromium.js +39 -0
  7. package/lib/chrome/webdriver/setupChromiumOptions.js +86 -31
  8. package/lib/core/engine/collector.js +340 -317
  9. package/lib/core/engine/command/addText.js +93 -12
  10. package/lib/core/engine/command/click.js +239 -139
  11. package/lib/core/engine/command/commandHelper.js +45 -0
  12. package/lib/core/engine/command/cookie.js +85 -0
  13. package/lib/core/engine/command/element.js +37 -2
  14. package/lib/core/engine/command/javaScript.js +14 -24
  15. package/lib/core/engine/command/measure/screenshots.js +114 -0
  16. package/lib/core/engine/command/measure/video.js +85 -0
  17. package/lib/core/engine/command/measure.js +104 -180
  18. package/lib/core/engine/command/mouse/clickAndHold.js +6 -3
  19. package/lib/core/engine/command/mouse/contextClick.js +56 -4
  20. package/lib/core/engine/command/mouse/doubleClick.js +49 -1
  21. package/lib/core/engine/command/mouse/mouseMove.js +50 -1
  22. package/lib/core/engine/command/mouse/singleClick.js +165 -129
  23. package/lib/core/engine/command/navigation.js +6 -6
  24. package/lib/core/engine/command/select.js +95 -3
  25. package/lib/core/engine/command/selectorParser.js +58 -0
  26. package/lib/core/engine/command/set.js +55 -1
  27. package/lib/core/engine/command/wait.js +41 -2
  28. package/lib/core/engine/commands.js +550 -42
  29. package/lib/core/engine/index.js +206 -150
  30. package/lib/core/engine/iteration.js +269 -137
  31. package/lib/firefox/networkManager.js +22 -30
  32. package/lib/firefox/webdriver/builder.js +14 -0
  33. package/lib/safari/har.js +131 -0
  34. package/lib/safari/native/ios-capture +0 -0
  35. package/lib/safari/native/ios-capture.m +340 -0
  36. package/lib/safari/safariInspectorClient.js +322 -0
  37. package/lib/safari/webdriver/safari.js +303 -13
  38. package/lib/safari/webkitToCdpAdapter.js +241 -0
  39. package/lib/support/cli.js +13 -0
  40. package/lib/support/engineUtils.js +16 -5
  41. package/lib/support/getViewPort.js +5 -0
  42. package/lib/support/har/index.js +41 -3
  43. package/lib/support/usbPower.js +2 -3
  44. package/lib/video/postprocessing/finetune/addTextToVideo.js +4 -0
  45. package/lib/video/postprocessing/finetune/getFont.js +3 -3
  46. package/lib/video/postprocessing/finetune/getTimingMetrics.js +7 -0
  47. package/lib/video/postprocessing/finetune/index.js +26 -9
  48. package/lib/video/screenRecording/ios/iosDeviceRecorder.js +109 -0
  49. package/lib/video/screenRecording/recorder.js +2 -2
  50. package/lib/video/screenRecording/setOrangeBackground.js +3 -0
  51. package/package.json +11 -11
  52. package/types/core/engine/command/addText.d.ts +35 -6
  53. package/types/core/engine/command/addText.d.ts.map +1 -1
  54. package/types/core/engine/command/click.d.ts +87 -16
  55. package/types/core/engine/command/click.d.ts.map +1 -1
  56. package/types/core/engine/command/commandHelper.d.ts +16 -0
  57. package/types/core/engine/command/commandHelper.d.ts.map +1 -0
  58. package/types/core/engine/command/cookie.d.ts +65 -0
  59. package/types/core/engine/command/cookie.d.ts.map +1 -0
  60. package/types/core/engine/command/element.d.ts +19 -1
  61. package/types/core/engine/command/element.d.ts.map +1 -1
  62. package/types/core/engine/command/javaScript.d.ts +4 -0
  63. package/types/core/engine/command/javaScript.d.ts.map +1 -1
  64. package/types/core/engine/command/measure/screenshots.d.ts +15 -0
  65. package/types/core/engine/command/measure/screenshots.d.ts.map +1 -0
  66. package/types/core/engine/command/measure/video.d.ts +26 -0
  67. package/types/core/engine/command/measure/video.d.ts.map +1 -0
  68. package/types/core/engine/command/measure.d.ts +59 -33
  69. package/types/core/engine/command/measure.d.ts.map +1 -1
  70. package/types/core/engine/command/mouse/clickAndHold.d.ts.map +1 -1
  71. package/types/core/engine/command/mouse/contextClick.d.ts +28 -4
  72. package/types/core/engine/command/mouse/contextClick.d.ts.map +1 -1
  73. package/types/core/engine/command/mouse/doubleClick.d.ts +24 -4
  74. package/types/core/engine/command/mouse/doubleClick.d.ts.map +1 -1
  75. package/types/core/engine/command/mouse/mouseMove.d.ts +30 -5
  76. package/types/core/engine/command/mouse/mouseMove.d.ts.map +1 -1
  77. package/types/core/engine/command/mouse/singleClick.d.ts +50 -13
  78. package/types/core/engine/command/mouse/singleClick.d.ts.map +1 -1
  79. package/types/core/engine/command/navigation.d.ts +3 -3
  80. package/types/core/engine/command/select.d.ts +45 -8
  81. package/types/core/engine/command/select.d.ts.map +1 -1
  82. package/types/core/engine/command/selectorParser.d.ts +20 -0
  83. package/types/core/engine/command/selectorParser.d.ts.map +1 -0
  84. package/types/core/engine/command/set.d.ts +34 -7
  85. package/types/core/engine/command/set.d.ts.map +1 -1
  86. package/types/core/engine/command/wait.d.ts +29 -6
  87. package/types/core/engine/command/wait.d.ts.map +1 -1
  88. package/types/core/engine/commands.d.ts +296 -28
  89. package/types/core/engine/commands.d.ts.map +1 -1
  90. package/types/support/engineUtils.d.ts.map +1 -1
  91. package/types/support/getViewPort.d.ts.map +1 -1
  92. package/types/support/usbPower.d.ts.map +1 -1
  93. package/types/video/postprocessing/finetune/addTextToVideo.d.ts.map +1 -1
  94. package/types/video/postprocessing/finetune/getTimingMetrics.d.ts.map +1 -1
  95. package/types/video/postprocessing/finetune/index.d.ts.map +1 -1
  96. package/types/video/screenRecording/ios/iosDeviceRecorder.d.ts +28 -0
  97. package/types/video/screenRecording/ios/iosDeviceRecorder.d.ts.map +1 -0
  98. package/types/video/screenRecording/recorder.d.ts +2 -2
  99. package/types/video/screenRecording/recorder.d.ts.map +1 -1
  100. package/types/video/screenRecording/setOrangeBackground.d.ts.map +1 -1
  101. package/types/video/video.d.ts +1 -1
  102. package/types/video/video.d.ts.map +1 -1
  103. package/visualmetrics/visualmetrics-portable.py +136 -136
  104. package/lib/video/screenRecording/ios/convertToMp4.js +0 -22
  105. package/lib/video/screenRecording/ios/iosRecorder.js +0 -68
package/CHANGELOG.md CHANGED
@@ -1,5 +1,70 @@
1
1
  # Browsertime changelog (we do [semantic versioning](https://semver.org))
2
2
 
3
+ ## 27.0.0 - 2026-05-01
4
+
5
+ ### Highlights
6
+
7
+ This release is a big push on the scripting API and on iOS real-device testing. All old scripting commands will work but we have new and improveed
8
+ syntax that you can use if you follow our documentation. This release also tries to support the upcoming Chrome soft navigations.
9
+
10
+ * **Unified selector syntax across commands.** All interaction commands now accept selectors directly (`commands.click('#btn')`, `commands.addText('#input', 'text')`, etc.) with optional `id:`/`xpath:`/`text:`/`link:`/`name:`/`class:` prefixes. The old `AndWait` family is deprecated in favour of `{ waitForNavigation: true }`.
11
+ * **A much bigger convenience-command surface.** New `type`, `find`, `getText`, `getValue`, `isVisible`, `exists`, `clear`, `fill`, `getAttribute`, `isEnabled`, `isChecked`, `hover`, `press`, `getTitle`, `getUrl`, `check`, `uncheck`, `scrollIntoView`, `select.byText`, `waitForUrl`, `clickAndMeasure`, `click.byText`, plus a full `cookie` command.
12
+ * **Chrome soft navigations.** SPA route changes (React, Next.js, Vue, Turbo) now produce a HAR page and the standard FCP/LCP/CLS/INP metrics, using Chrome's PerformanceObserver soft-navigation entry type.
13
+ * **Safari on iOS over USB.** HAR via `ios_webkit_debug_proxy`, plus video recording and visual metrics via a CoreMediaIO screen-capture helper.
14
+ * **TypeScript navigation scripts** are now supported on Node.js 22.18.0+ via native type stripping.
15
+
16
+ ### Breaking
17
+ * Changed default `--timeouts.elementWait` from 0 to 6000ms. All element commands (click, addText) now auto-wait up to 6 seconds for elements to appear. Set `--timeouts.elementWait 0` to restore the old behavior [#2389](https://github.com/sitespeedio/browsertime/pull/2389).
18
+ * Click commands now use the Selenium Actions API for real OS-level mouse events instead of JavaScript `.click()`. Elements must be visible and interactable. If you need to click a hidden element, use `commands.js.run()` instead [#2381](https://github.com/sitespeedio/browsertime/pull/2381).
19
+
20
+ ### Added
21
+ * Unified selector syntax for all interaction commands. You can now call `commands.click('#btn')`, `commands.addText('#input', 'text')`, `commands.wait('#el', { timeout: 5000 })`, `commands.select('#dropdown', 'value')`, and `commands.set('#field', 'value')` directly. Supports prefixes: `id:`, `xpath:`, `text:`, `link:`, `name:`, `class:` — CSS selector is the default [#2390](https://github.com/sitespeedio/browsertime/pull/2390) [#2408](https://github.com/sitespeedio/browsertime/pull/2408).
22
+ * The `AndWait` methods are deprecated in favour of `{ waitForNavigation: true }` [#2390](https://github.com/sitespeedio/browsertime/pull/2390) [#2396](https://github.com/sitespeedio/browsertime/pull/2396).
23
+ * Navigation methods (back, forward, refresh) now support `{ waitForNavigation: true }` for consistency [#2408](https://github.com/sitespeedio/browsertime/pull/2408).
24
+ * Added `commands.type(selector, text)` as a shorthand for `addText` with a more conventional parameter order [#2387](https://github.com/sitespeedio/browsertime/pull/2387).
25
+ * Added `commands.find(selector, options)` helper that combines element lookup with optional waiting and visibility checks [#2386](https://github.com/sitespeedio/browsertime/pull/2386).
26
+ * Added `commands.measure.clickAndMeasure(alias, selector)` shortcut for the common measure.start/click/measure.stop pattern [#2382](https://github.com/sitespeedio/browsertime/pull/2382).
27
+ * Added `commands.click.byText(text)` to click any element by visible text, not just links [#2384](https://github.com/sitespeedio/browsertime/pull/2384).
28
+ * Added `--timeouts.elementWait` option for auto-waiting on elements before interaction [#2383](https://github.com/sitespeedio/browsertime/pull/2383).
29
+ * Added support for TypeScript scripts (.ts, .mts, .cts) using Node.js native type stripping (requires Node.js 22.18.0+) [#2363](https://github.com/sitespeedio/browsertime/pull/2363).
30
+ * Added `commands.getText(selector)`, `commands.getValue(selector)`, and `commands.isVisible(selector)` convenience methods [#2411](https://github.com/sitespeedio/browsertime/pull/2411).
31
+ * Added `commands.exists(selector, { timeout })` for checking element presence without throwing [#2412](https://github.com/sitespeedio/browsertime/pull/2412).
32
+ * Added `commands.clear(selector)` to clear form element content [#2413](https://github.com/sitespeedio/browsertime/pull/2413).
33
+ * Added `commands.fill({ selector: text, ... })` for filling multiple form fields at once [#2414](https://github.com/sitespeedio/browsertime/pull/2414).
34
+ * Added unified selector syntax to mouse commands: `commands.mouse.singleClick(selector)`, `commands.mouse.doubleClick(selector)`, `commands.mouse.contextClick(selector)`, `commands.mouse.moveTo(selector)` [#2415](https://github.com/sitespeedio/browsertime/pull/2415).
35
+ * Added `commands.cookie` for managing browser cookies: `get`, `getAll`, `set`, `delete`, `deleteAll` [#2420](https://github.com/sitespeedio/browsertime/pull/2420).
36
+ * Added `commands.getAttribute(selector, name)`, `commands.isEnabled(selector)`, and `commands.isChecked(selector)` for element state queries [#2421](https://github.com/sitespeedio/browsertime/pull/2421).
37
+ * Added `commands.hover(selector)` as alias for mouse.moveTo, `commands.press(key)` for keyboard input, `commands.getTitle()` and `commands.getUrl()` for page info [#2422](https://github.com/sitespeedio/browsertime/pull/2422).
38
+ * Added `commands.check(selector)` and `commands.uncheck(selector)` for checkbox/radio interactions [#2423](https://github.com/sitespeedio/browsertime/pull/2423).
39
+ * Added `commands.scrollIntoView(selector)` to scroll elements into the viewport [#2425](https://github.com/sitespeedio/browsertime/pull/2425).
40
+ * Added `commands.select.byText(selector, text)` to select dropdown options by visible text [#2426](https://github.com/sitespeedio/browsertime/pull/2426).
41
+ * Added `commands.waitForUrl(pattern, { timeout })` to wait for URL changes after navigation [#2427](https://github.com/sitespeedio/browsertime/pull/2427).
42
+ * Added video recording and visual metrics for Safari on iOS over USB. A native helper captures the device's screen via CoreMediaIO and pipes frames to ffmpeg. The device is woken into screen-capture mode automatically — no manual QuickTime step required [#2432](https://github.com/sitespeedio/browsertime/pull/2432) [#2436](https://github.com/sitespeedio/browsertime/pull/2436).
43
+ * Added HAR capture for Safari on iOS via `ios_webkit_debug_proxy`. Browsertime starts and stops `iwdp` automatically and exits with a clear error if it isn't installed (`brew install ios-webkit-debug-proxy`). New `--safari.includeResponseBodies` CLI option. macOS Safari is unaffected [#2431](https://github.com/sitespeedio/browsertime/pull/2431).
44
+ * Added soft navigation support for Chrome. Single-page app route changes (React, Next.js, Vue, Turbo) now produce a HAR page and the standard FCP/LCP/CLS/INP metrics, using Chrome's PerformanceObserver soft-navigation entry type to confirm user interaction + URL change + visible paint [#2438](https://github.com/sitespeedio/browsertime/pull/2438).
45
+ * Updated dependencies to latest compatible versions: chrome-har 1.2.1, selenium-webdriver 4.43.0, execa 9.6.1, jimp 1.6.1 [#2437](https://github.com/sitespeedio/browsertime/pull/2437).
46
+ * Firefox 149 in the Docker container [#2375](https://github.com/sitespeedio/browsertime/pull/2375).
47
+ * Record the full Chrome/Edge configuration browsertime applied to the browser (args, preferences, mobile emulation, binary path, extensions count) under `info.browser` in the result JSON, not just what the user passed via `--chrome.args` [#2447](https://github.com/sitespeedio/browsertime/pull/2447).
48
+ * Record Firefox preferences and the resolved binary path under `info.browser` in the result JSON [#2448](https://github.com/sitespeedio/browsertime/pull/2448).
49
+ * Write long-task ranges and user-timing marks/measures into the HAR under `pages[].pageTimings` as `_longTasks` (array of `[startMs, endMs]` tuples) and `_userTimings` (object keyed by entry name → startTime), so downstream waterfall viewers can show what the browser was doing between requests [#2449](https://github.com/sitespeedio/browsertime/pull/2449).
50
+ * Added modern device profiles to the mobile emulation device list [#2446](https://github.com/sitespeedio/browsertime/pull/2446).
51
+
52
+ ### Fixed
53
+ * Pin the iOS Safari tab being measured so unrelated open tabs cannot clobber the active inspector target. The WebDriver tab is navigated to a sentinel URL and identified by it before iWDP messages are processed, otherwise an unrelated `Target.targetCreated` event could leave the HAR empty [#2440](https://github.com/sitespeedio/browsertime/pull/2440).
54
+ * Fixed `drawtext` failing on ffmpeg 8 by quoting font paths and forwarding ffmpeg stderr so font/filter errors are visible [#2435](https://github.com/sitespeedio/browsertime/pull/2435).
55
+ * Fixed Firefox network idle timeout on repeated iterations. The WebSocket message listener was never removed between iterations, causing the inflight counter to go negative [#2391](https://github.com/sitespeedio/browsertime/pull/2391).
56
+ * Fixed Firefox network idle timeout when no network events arrive. Timestamps were initialized as undefined causing the idle check to never trigger [#2394](https://github.com/sitespeedio/browsertime/pull/2394).
57
+ * Fixed intermittent crash when setting orange background before document.body exists on Edge/Windows [#2393](https://github.com/sitespeedio/browsertime/pull/2393).
58
+ * Element interaction error messages now include the current page URL for easier debugging [#2385](https://github.com/sitespeedio/browsertime/pull/2385).
59
+ * Added `actions.clear()` after `perform()` in all mouse commands to prevent pointer/key state leaking between actions [#2406](https://github.com/sitespeedio/browsertime/pull/2406).
60
+ * Standardized error handling in all command `run()` methods using `executeCommand` with URL context. Fixed typo in select.deselectById and removed dead code in wait [#2410](https://github.com/sitespeedio/browsertime/pull/2410).
61
+ * Added clear error message when loading TypeScript scripts on Node.js older than 22.18.0 [#2404](https://github.com/sitespeedio/browsertime/pull/2404).
62
+ * Disabled the "You can zoom in and out" popup on Android [#2378](https://github.com/sitespeedio/browsertime/pull/2378).
63
+ * Warn when `--viewPort` is set with a width below 500px on desktop Chrome. Chrome enforces a hard ~500px minimum window width and silently clamps narrower requests, but browsertime kept recording the filmstrip and video at the requested size, leaving captures cropped. The warning points users at mobile emulation, which lays out at the requested size while the OS window stays desktop-sized [#2445](https://github.com/sitespeedio/browsertime/pull/2445).
64
+ * Stop a perceptual SSIM failure from discarding every other visual metric for the iteration. `calculate_perceptual_speed_index` now returns `(None, None)` on internal errors instead of letting the exception propagate up through `calculate_visual_metrics`, so SpeedIndex, FCP, LCP and the rest still flow through when SSIM can't run [#2444](https://github.com/sitespeedio/browsertime/pull/2444).
65
+ * Drain ffmpeg/ffprobe stderr after the process exits in `visualmetrics-portable.py`. The previous Popen+poll loop dropped output buffered between the last read and exit — silently losing `keep pts:` lines and therefore filmstrip frames — and could deadlock when ffmpeg's debug output filled the OS pipe buffer [#2443](https://github.com/sitespeedio/browsertime/pull/2443).
66
+ * Converted `visualmetrics-portable.py` path handling to `pathlib` for consistent cross-platform behaviour [#2442](https://github.com/sitespeedio/browsertime/pull/2442).
67
+
3
68
  ## 26.3.2 - 2026-01-24
4
69
  ### Fixed
5
70
  * Hello the new and fixed bidi-har 0.0.22 that fixes the response body [#2361](https://github.com/sitespeedio/browsertime/pull/2361).
package/README.md CHANGED
@@ -157,7 +157,7 @@ The default video will include a timer and showing when the metrics happens, but
157
157
 
158
158
 
159
159
  ## Browser Support
160
- Browsertime supports Firefox, Chrome, and Edge (Chromium version) on desktop and Safari on Mac OS. On Android we support Chrome and Firefox. Safari on iOS has limited support: there's no HAR file and no visual metrics. You can also use the Safari simulator on Mac OS.
160
+ Browsertime supports Firefox, Chrome, and Edge (Chromium version) on desktop and Safari on Mac OS. On Android we support Chrome and Firefox. Safari on iOS supports HAR (via `ios_webkit_debug_proxy`) and video / visual metrics (via screen capture over USB). You can also use the Safari simulator on Mac OS.
161
161
 
162
162
  ## How does it work
163
163
  Browsertime uses Selenium NodeJS to drive the browser. It starts the browser, load a URL, executes configurable Javascripts to collect metrics, collect a HAR file.
@@ -0,0 +1,94 @@
1
+ (function () {
2
+ const supported = PerformanceObserver.supportedEntryTypes;
3
+ if (!supported || supported.indexOf('soft-navigation') === -1) {
4
+ return;
5
+ }
6
+ var startIndex = window.__bt_softNavCount || 0;
7
+ const allNew = performance.getEntriesByType('soft-navigation').slice(startIndex);
8
+ if (allNew.length === 0) {
9
+ return;
10
+ }
11
+ // Use the last entry — a single user interaction can trigger
12
+ // multiple intermediate soft navigations (e.g. React, Turbo)
13
+ const entry = allNew[allNew.length - 1];
14
+ var navId = entry.navigationId;
15
+
16
+ // CLS for this soft navigation
17
+ var cls = 0;
18
+ if (supported.indexOf('layout-shift') !== -1) {
19
+ const observer = new PerformanceObserver(list => {});
20
+ observer.observe({type: 'layout-shift', buffered: true});
21
+ const shifts = observer.takeRecords().filter(function (s) {
22
+ return s.navigationId === navId;
23
+ });
24
+ var max = 0;
25
+ var curr = 0;
26
+ var firstTs = Number.NEGATIVE_INFINITY;
27
+ var prevTs = Number.NEGATIVE_INFINITY;
28
+ for (let s of shifts) {
29
+ if (s.hadRecentInput) continue;
30
+ if (s.startTime - firstTs > 5000 || s.startTime - prevTs > 1000) {
31
+ firstTs = s.startTime;
32
+ curr = 0;
33
+ }
34
+ prevTs = s.startTime;
35
+ curr += s.value;
36
+ max = Math.max(max, curr);
37
+ }
38
+ cls = max;
39
+ }
40
+
41
+ // INP for this soft navigation
42
+ var inp = 0;
43
+ if (supported.indexOf('event') !== -1) {
44
+ const observer = new PerformanceObserver(list => {});
45
+ observer.observe({type: 'event', buffered: true});
46
+ const events = observer.takeRecords().filter(function (e) {
47
+ return e.navigationId === navId && e.interactionId;
48
+ });
49
+ const MAX_INTERACTIONS = 10;
50
+ const longestList = [];
51
+ const longestMap = {};
52
+ for (let e of events) {
53
+ var minLongest = longestList[longestList.length - 1];
54
+ var existing = longestMap[e.interactionId];
55
+ if (existing || longestList.length < MAX_INTERACTIONS || e.duration > minLongest.latency) {
56
+ if (existing) {
57
+ existing.latency = Math.max(existing.latency, e.duration);
58
+ } else {
59
+ var interaction = { id: e.interactionId, latency: e.duration };
60
+ longestMap[interaction.id] = interaction;
61
+ longestList.push(interaction);
62
+ }
63
+ longestList.sort(function (a, b) { return b.latency - a.latency; });
64
+ longestList.splice(MAX_INTERACTIONS).forEach(function (i) { delete longestMap[i.id]; });
65
+ }
66
+ }
67
+ var inpEntry = longestList[longestList.length - 1];
68
+ inp = inpEntry ? inpEntry.latency : 0;
69
+ }
70
+
71
+ // LCP value
72
+ var lcp = 0;
73
+ if (entry.largestInteractionContentfulPaint) {
74
+ lcp = typeof entry.largestInteractionContentfulPaint === 'number'
75
+ ? entry.largestInteractionContentfulPaint
76
+ : entry.largestInteractionContentfulPaint.renderTime ||
77
+ entry.largestInteractionContentfulPaint.loadTime ||
78
+ entry.largestInteractionContentfulPaint.startTime || 0;
79
+ }
80
+
81
+ return [{
82
+ name: entry.name,
83
+ navigationId: navId,
84
+ interactionId: entry.interactionId,
85
+ startTime: Number(entry.startTime.toFixed(0)),
86
+ duration: Number(entry.duration.toFixed(0)),
87
+ firstContentfulPaint: entry.presentationTime
88
+ ? Number(entry.presentationTime.toFixed(0))
89
+ : 0,
90
+ largestInteractionContentfulPaint: Number(lcp.toFixed(0)),
91
+ cumulativeLayoutShift: cls,
92
+ interactionToNextPaint: inp
93
+ }];
94
+ })();
package/lib/chrome/har.js CHANGED
@@ -23,6 +23,32 @@ export async function getHar(
23
23
  const logs = await runner.getLogs(Type.PERFORMANCE);
24
24
  const messages = logs.map(entry => JSON.parse(entry.message).message);
25
25
 
26
+ // Inject a SoftNavigation.detected event so chrome-har renames the
27
+ // current HAR page with the soft navigation URL. The PerformanceObserver
28
+ // API is the only reliable source that confirms all three soft navigation
29
+ // criteria (user interaction + URL change + visible paint).
30
+ try {
31
+ const softNavEntries = await runner.runScript(
32
+ `const supported = PerformanceObserver.supportedEntryTypes;
33
+ if (!supported || supported.indexOf('soft-navigation') === -1) return [];
34
+ const startIndex = window.__bt_softNavCount || 0;
35
+ const entries = performance.getEntriesByType('soft-navigation').slice(startIndex);
36
+ if (entries.length === 0) return [];
37
+ const last = entries[entries.length - 1];
38
+ return [{ url: last.name, startTime: last.startTime }];`,
39
+ 'GET_SOFT_NAVIGATIONS_FOR_HAR'
40
+ );
41
+ if (softNavEntries && softNavEntries.length > 0) {
42
+ const entry = softNavEntries[0];
43
+ messages.push({
44
+ method: 'SoftNavigation.detected',
45
+ params: { url: entry.url, startTime: entry.startTime }
46
+ });
47
+ }
48
+ } catch {
49
+ // Soft navigations not supported, ignore
50
+ }
51
+
26
52
  if (logPerfEntries) {
27
53
  if (!result.extraJson) {
28
54
  result.extraJson = {};
@@ -68,15 +94,20 @@ export async function getHar(
68
94
  }
69
95
 
70
96
  if (har.log.pages.length > 0) {
71
- har.log.pages[0].title = `${result.url} run ${index}`;
97
+ const page = har.log.pages[0];
98
+ // For soft navigations, use the soft navigation URL as the title
99
+ const pageUrl = page._softNavigation ? page.title : result.url;
100
+ page.title = `${pageUrl} run ${index}`;
72
101
  // Hack to add the URL from a SPA
73
- if (result.alias && !aliasAndUrl[result.alias]) {
102
+ if (page._softNavigation) {
103
+ page._url = pageUrl;
104
+ } else if (result.alias && !aliasAndUrl[result.alias]) {
74
105
  aliasAndUrl[result.alias] = result.url;
75
- har.log.pages[0]._url = result.url;
106
+ page._url = result.url;
76
107
  } else if (result.alias && aliasAndUrl[result.alias]) {
77
- har.log.pages[0]._url = aliasAndUrl[result.alias];
108
+ page._url = aliasAndUrl[result.alias];
78
109
  } else {
79
- har.log.pages[0]._url = result.url;
110
+ page._url = result.url;
80
111
  }
81
112
  }
82
113
  return har;
@@ -26,5 +26,6 @@ export const chromeAndroidOptions = [
26
26
  '--disable-breakpad',
27
27
  '--dont-require-litepage-redirect-infobar',
28
28
  '--override-https-image-compression-infobar',
29
- '--disable-fetching-hints-at-navigation-start'
29
+ '--disable-fetching-hints-at-navigation-start',
30
+ '--propagate-iph-for-testing'
30
31
  ];
@@ -420,6 +420,45 @@ export class Chromium {
420
420
  result.renderBlocking.requests = render.renderBlockingInfo;
421
421
  }
422
422
 
423
+ // If a soft navigation was detected, promote its metrics to the
424
+ // standard locations so they flow through googleWebVitals, logging,
425
+ // and statistics just like regular page load metrics.
426
+ if (
427
+ result.browserScripts.timings &&
428
+ result.browserScripts.timings.softNavigations &&
429
+ result.browserScripts.timings.softNavigations.length > 0
430
+ ) {
431
+ const softNav = result.browserScripts.timings.softNavigations.at(-1);
432
+ const navStart = softNav.startTime;
433
+
434
+ if (softNav.firstContentfulPaint) {
435
+ result.browserScripts.timings.paintTiming =
436
+ result.browserScripts.timings.paintTiming || {};
437
+ result.browserScripts.timings.paintTiming['first-contentful-paint'] =
438
+ softNav.firstContentfulPaint - navStart;
439
+ }
440
+
441
+ if (softNav.largestInteractionContentfulPaint > 0) {
442
+ result.browserScripts.timings.largestContentfulPaint = {
443
+ renderTime: Math.max(
444
+ 0,
445
+ softNav.largestInteractionContentfulPaint - navStart
446
+ )
447
+ };
448
+ }
449
+
450
+ if (softNav.cumulativeLayoutShift !== undefined) {
451
+ result.browserScripts.pageinfo = result.browserScripts.pageinfo || {};
452
+ result.browserScripts.pageinfo.cumulativeLayoutShift =
453
+ softNav.cumulativeLayoutShift;
454
+ }
455
+
456
+ if (softNav.interactionToNextPaint) {
457
+ result.browserScripts.timings.interactionToNextPaint =
458
+ softNav.interactionToNextPaint;
459
+ }
460
+ }
461
+
423
462
  // Google Web Vitals hacksery
424
463
  result.googleWebVitals = {};
425
464
  if (result.browserScripts.pageinfo) {
@@ -19,6 +19,8 @@ const CHROME_AMD_EDGE_INTERNAL_PHONE_HOME = [
19
19
  'MAP optimizationguide-pa.googleapis.com 127.0.0.1'
20
20
  ];
21
21
 
22
+ const CHROME_FEATURES_THAT_WE_ENABLE = ['SoftNavigationHeuristics'];
23
+
22
24
  const CHROME_FEATURES_THAT_WE_DISABLES = [
23
25
  'AutofillServerCommunication',
24
26
  'CalculateNativeWinOcclusion',
@@ -43,10 +45,36 @@ export function setupChromiumOptions(
43
45
  options,
44
46
  baseDir
45
47
  ) {
48
+ // Record every argument / preference / capability we apply to the browser
49
+ // so it can be surfaced in the result JSON for debugging — issue #1622.
50
+ // Stored on `options` because both Chrome and Edge call this and both end
51
+ // up needing it later in the engine for `result.info.browser`.
52
+ const recorded = {
53
+ args: [],
54
+ preferences: {},
55
+ mobileEmulation: undefined,
56
+ binaryPath: undefined,
57
+ extensions: 0
58
+ };
59
+ options.recordedBrowserSettings = recorded;
60
+
61
+ const addArgs = arg => {
62
+ seleniumOptions.addArguments(arg);
63
+ if (Array.isArray(arg)) {
64
+ recorded.args.push(...arg);
65
+ } else {
66
+ recorded.args.push(arg);
67
+ }
68
+ };
69
+ const setPrefs = prefs => {
70
+ seleniumOptions.setUserPreferences(prefs);
71
+ Object.assign(recorded.preferences, prefs);
72
+ };
73
+
46
74
  // Fixing save password popup, only on Desktop
47
75
 
48
76
  if (!isAndroidConfigured(options)) {
49
- seleniumOptions.setUserPreferences({
77
+ setPrefs({
50
78
  'profile.password_manager_enable': false,
51
79
  'profile.default_content_setting_values.notifications': 2,
52
80
  credentials_enable_service: false
@@ -54,28 +82,50 @@ export function setupChromiumOptions(
54
82
  }
55
83
 
56
84
  if (options.headless) {
57
- seleniumOptions.addArguments('--headless=new');
85
+ addArgs('--headless=new');
58
86
  }
59
87
 
60
88
  // If we run in Docker we need to always use no-sandbox
61
89
  if (options.docker) {
62
- seleniumOptions.addArguments('--no-sandbox');
63
- seleniumOptions.addArguments('--disable-setuid-sandbox');
90
+ addArgs('--no-sandbox');
91
+ addArgs('--disable-setuid-sandbox');
64
92
  }
65
93
 
66
94
  if (options.xvfb && (options.xvfb === true || options.xvfb === 'true')) {
67
- seleniumOptions.addArguments('--disable-gpu');
95
+ addArgs('--disable-gpu');
68
96
  }
69
97
 
70
- seleniumOptions.addArguments(
71
- '--disable-features=' + CHROME_FEATURES_THAT_WE_DISABLES.join(',')
72
- );
98
+ addArgs('--disable-features=' + CHROME_FEATURES_THAT_WE_DISABLES.join(','));
99
+
100
+ addArgs('--enable-features=' + CHROME_FEATURES_THAT_WE_ENABLE.join(','));
73
101
 
74
102
  const viewPort = getViewPort(options);
75
103
  // If viewport is defined (only on desktop) then set start args
76
104
  if (viewPort) {
77
- seleniumOptions.addArguments('--window-position=0,0');
78
- seleniumOptions.addArguments('--window-size=' + viewPort.replace('x', ','));
105
+ addArgs('--window-position=0,0');
106
+ addArgs('--window-size=' + viewPort.replace('x', ','));
107
+
108
+ // Desktop Chrome enforces a minimum window width (~500px) regardless of
109
+ // --window-size, so a request like --viewPort 360x640 silently becomes
110
+ // 500x640. The video and filmstrips are still cropped to the requested
111
+ // size, which is why they look chopped. Mobile emulation is the supported
112
+ // way to test smaller viewports — it tells the renderer to lay out at
113
+ // the requested size while the OS window can stay desktop-sized.
114
+ // See https://github.com/sitespeedio/browsertime/issues/1222
115
+ const usingEmulation =
116
+ browserOptions.mobileEmulation &&
117
+ (browserOptions.mobileEmulation.deviceName ||
118
+ (browserOptions.mobileEmulation.width &&
119
+ browserOptions.mobileEmulation.height));
120
+ if (!usingEmulation) {
121
+ const [w] = viewPort.split('x').map(Number);
122
+ if (w && w < 500) {
123
+ log.warn(
124
+ 'Requested viewport %s — desktop Chrome enforces a minimum window width of about 500px and will silently use a wider window. The video, filmstrip and screenshots will be cropped because they are sized to the requested viewport. To test smaller viewports, use Chrome mobile emulation: --chrome.mobileEmulation.deviceName "iPhone X" (or set --chrome.mobileEmulation.width and --chrome.mobileEmulation.height).',
125
+ viewPort
126
+ );
127
+ }
128
+ }
79
129
  }
80
130
 
81
131
  // If we are recording responses and we also block on domain
@@ -92,10 +142,10 @@ export function setupChromiumOptions(
92
142
  }
93
143
  excludes += ' EXCLUDE localhost';
94
144
 
95
- seleniumOptions.addArguments('--host-resolver-rules=' + excludes);
145
+ addArgs('--host-resolver-rules=' + excludes);
96
146
  } // If we are replaying with WebPageReplay
97
147
  else if (browserOptions.webPageReplayHostResolver) {
98
- seleniumOptions.addArguments(
148
+ addArgs(
99
149
  `--host-resolver-rules= "MAP *:80 127.0.0.1:${browserOptions.webPageReplayHTTPPort}, MAP *:443 127.0.0.1:${browserOptions.webPageReplayHTTPSPort}, EXCLUDE localhost"`
100
150
  );
101
151
  } // If we do not use WebPageReplay but wanna block on domain
@@ -105,7 +155,7 @@ export function setupChromiumOptions(
105
155
  for (let domain of excludesDomains) {
106
156
  excludes += 'MAP * 127.0.0.1, EXCLUDE ' + domain + ',';
107
157
  }
108
- seleniumOptions.addArguments('--host-resolver-rules=' + excludes);
158
+ addArgs('--host-resolver-rules=' + excludes);
109
159
  } else {
110
160
  // Make sure we only set this if we do not have any other host resolver rules
111
161
  const chromeCommandLineArguments = toArray(browserOptions.args);
@@ -113,7 +163,7 @@ export function setupChromiumOptions(
113
163
  argument => argument.includes('host-resolver-rules')
114
164
  );
115
165
  if (argumentsWithHostResolverRules.length === 0) {
116
- seleniumOptions.addArguments(
166
+ addArgs(
117
167
  `--host-resolver-rules="${CHROME_AMD_EDGE_INTERNAL_PHONE_HOME.join(
118
168
  ','
119
169
  )}"`
@@ -129,51 +179,48 @@ export function setupChromiumOptions(
129
179
  seleniumOptions.addExtensions(
130
180
  readFileSync(extension, { encoding: 'base64' })
131
181
  );
182
+ recorded.extensions += 1;
132
183
  }
133
184
  }
134
185
 
135
186
  if (options.debug) {
136
- seleniumOptions.addArguments('--auto-open-devtools-for-tabs');
187
+ addArgs('--auto-open-devtools-for-tabs');
137
188
  }
138
189
 
139
190
  const perfLogConfig = { enableNetwork: true, enablePage: true };
140
191
  seleniumOptions.setPerfLoggingPrefs(perfLogConfig);
141
192
 
142
193
  if (options.userAgent) {
143
- seleniumOptions.addArguments('--user-agent=' + options.userAgent);
194
+ addArgs('--user-agent=' + options.userAgent);
144
195
  }
145
196
 
146
197
  if (browserOptions.ignoreCertificateErrors) {
147
- seleniumOptions.addArguments('--ignore-certificate-errors');
198
+ addArgs('--ignore-certificate-errors');
148
199
  }
149
200
 
150
201
  if (browserOptions.collectNetLog) {
151
202
  const dir = isAndroidConfigured(browserOptions)
152
203
  ? '/data/local/tmp'
153
204
  : baseDir;
154
- seleniumOptions.addArguments(`--log-net-log=${dir}/chromeNetlog.json`);
205
+ addArgs(`--log-net-log=${dir}/chromeNetlog.json`);
155
206
  const level = browserOptions.netLogCaptureMode || 'IncludeSensitive';
156
- seleniumOptions.addArguments(`--net-log-capture-mode=${level}`);
207
+ addArgs(`--net-log-capture-mode=${level}`);
157
208
  }
158
209
 
159
210
  if (browserOptions.android) {
160
- seleniumOptions.addArguments(defaultAndroidChromeOptions);
161
- seleniumOptions.addArguments(
162
- '--remote-debugging-port=' + options.devToolsPort
163
- );
211
+ addArgs(defaultAndroidChromeOptions);
212
+ addArgs('--remote-debugging-port=' + options.devToolsPort);
164
213
  } else {
165
214
  if (browserOptions.noDefaultOptions) {
166
215
  log.info('Skip setting default options for Chrome');
167
216
  } else {
168
- seleniumOptions.addArguments(defaultChromeOptions);
169
- seleniumOptions.addArguments(
170
- '--remote-debugging-port=' + options.devToolsPort
171
- );
217
+ addArgs(defaultChromeOptions);
218
+ addArgs('--remote-debugging-port=' + options.devToolsPort);
172
219
  }
173
220
  }
174
221
 
175
222
  if (browserOptions.enableVideoAutoplay) {
176
- seleniumOptions.addArguments('--autoplay-policy=no-user-gesture-required');
223
+ addArgs('--autoplay-policy=no-user-gesture-required');
177
224
  }
178
225
 
179
226
  // It's a new splash screen introduced in Chrome 98
@@ -186,23 +233,31 @@ export function setupChromiumOptions(
186
233
  argument.includes('disable-features') &&
187
234
  !argument.includes('ChromeWhatsNewUI')
188
235
  ) {
189
- seleniumOptions.addArguments(`${argument},ChromeWhatsNewUI`);
236
+ addArgs(`${argument},ChromeWhatsNewUI`);
190
237
  log.debug('Set Chrome args %j', `${argument},ChromeWhatsNewUI`);
238
+ } else if (
239
+ argument.includes('enable-features') &&
240
+ !argument.includes('SoftNavigationHeuristics')
241
+ ) {
242
+ addArgs(`${argument},SoftNavigationHeuristics`);
243
+ log.debug('Set Chrome args %j', `${argument},SoftNavigationHeuristics`);
191
244
  } else {
192
- seleniumOptions.addArguments(argument);
245
+ addArgs(argument);
193
246
  log.debug('Set Chrome args %j', argument);
194
247
  }
195
248
  }
196
249
  } else {
197
- seleniumOptions.addArguments('--disable-features=ChromeWhatsNewUI');
250
+ addArgs('--disable-features=ChromeWhatsNewUI');
198
251
  }
199
252
 
200
253
  if (browserOptions.binaryPath) {
201
254
  seleniumOptions.setChromeBinaryPath(browserOptions.binaryPath);
255
+ recorded.binaryPath = browserOptions.binaryPath;
202
256
  }
203
257
 
204
258
  if (browserOptions.mobileEmulation) {
205
259
  seleniumOptions.setMobileEmulation(browserOptions.mobileEmulation);
260
+ recorded.mobileEmulation = browserOptions.mobileEmulation;
206
261
  }
207
262
 
208
263
  // See https://bugs.chromium.org/p/chromium/issues/detail?id=818483