From 5f59694f3fc26ed1fd5747c55cfe076841603891 Mon Sep 17 00:00:00 2001 From: Peter Hedenskog Date: Sun, 26 Jul 2026 21:54:56 +0200 Subject: [PATCH] Add SPA user journey tests for soft navigations Browsertime recently learned to measure SPA soft navigations, including ones that never touch the network, but the CI coverage was two single-step scripts. Real SPA sessions are multi-step journeys that mix routes with requests, routes rendered entirely from memory, and the occasional full page load, and regressions in the HAR page pairing or the per-step soft navigation slicing only show up in those flows. This turns the SPA test page into a small multi-route app and adds three user journeys: one covering fetch, no-request and image routes, one mixing soft and hard navigations, and one with only zero-request navigations. The journeys wait with the page complete check instead of fixed sleeps, so they also exercise the SPA inactivity logic they are meant to protect. Firefox runs the multi-route journey too, asserting that journeys complete in browsers without the soft navigation API. Co-authored-by: Claude Fable 5 noreply@anthropic.com Change-Id: I81333ebc8478f6930d8dc906d839822e69e75c38 --- .github/workflows/linux-chrome.yml | 45 ++++++++++ .github/workflows/linux-firefox.yml | 11 +++ test/data/commandscripts/spaJourney.cjs | 18 ++++ test/data/commandscripts/spaMixedJourney.cjs | 16 ++++ .../commandscripts/spaNoRequestJourney.cjs | 12 +++ test/data/html/spa/index.html | 86 ++++++++++++++++++- test/data/html/spa/products.json | 5 ++ 7 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 test/data/commandscripts/spaJourney.cjs create mode 100644 test/data/commandscripts/spaMixedJourney.cjs create mode 100644 test/data/commandscripts/spaNoRequestJourney.cjs create mode 100644 test/data/html/spa/products.json diff --git a/.github/workflows/linux-chrome.yml b/.github/workflows/linux-chrome.yml index 95ed4092c..3f174b205 100644 --- a/.github/workflows/linux-chrome.yml +++ b/.github/workflows/linux-chrome.yml @@ -198,6 +198,51 @@ jobs: jq -e '.[0].browserScripts[0].timings.softNavigations[0].name | endswith("/spa/page-3")' /tmp/soft-nav-no-request/browsertime.json jq -e '.log.pages[0]._softNavigation == true' /tmp/soft-nav-no-request/browsertime.har jq -e '.log.pages[0]._url | endswith("/spa/page-3")' /tmp/soft-nav-no-request/browsertime.har + - name: Test Chrome SPA user journey over multiple routes + run: | + ./bin/browsertime.js -b chrome -n 1 --xvfb --spa --resultDir /tmp/spa-journey test/data/commandscripts/spaJourney.cjs + echo "--- measured pages ---" + jq '[.[] | {url: .info.url, alias: .info.alias}]' /tmp/spa-journey/browsertime.json + echo "--- HAR pages ---" + jq '.log.pages | map({id, _url, _softNavigation})' /tmp/spa-journey/browsertime.har + jq -e '. | length == 3' /tmp/spa-journey/browsertime.json + jq -e '.[0].browserScripts[0].timings.softNavigations[0].name | endswith("/spa/products")' /tmp/spa-journey/browsertime.json + jq -e '.[1].browserScripts[0].timings.softNavigations[0].name | endswith("/spa/about")' /tmp/spa-journey/browsertime.json + jq -e '.[2].browserScripts[0].timings.softNavigations[0].name | endswith("/spa/gallery")' /tmp/spa-journey/browsertime.json + jq -e '.log.pages | length == 3' /tmp/spa-journey/browsertime.har + jq -e '[.log.pages[] | select(._softNavigation == true)] | length == 3' /tmp/spa-journey/browsertime.har + jq -e '(.log.pages[0].id) as $p | [.log.entries[] | select(.pageref == $p and (.request.url | contains("products.json")))] | length == 1' /tmp/spa-journey/browsertime.har + jq -e '(.log.pages[2].id) as $p | [.log.entries[] | select(.pageref == $p and (.request.url | contains("pirate.png")))] | length == 1' /tmp/spa-journey/browsertime.har + - name: Test Chrome SPA user journey mixing soft and hard navigations + run: | + ./bin/browsertime.js -b chrome -n 1 --xvfb --spa --resultDir /tmp/spa-mixed test/data/commandscripts/spaMixedJourney.cjs + echo "--- measured pages ---" + jq '[.[] | {url: .info.url, alias: .info.alias}]' /tmp/spa-mixed/browsertime.json + echo "--- HAR pages ---" + jq '.log.pages | map({id, _url, _softNavigation})' /tmp/spa-mixed/browsertime.har + jq -e '. | length == 3' /tmp/spa-mixed/browsertime.json + jq -e '.[0].browserScripts[0].timings.softNavigations[0].name | endswith("/spa/about")' /tmp/spa-mixed/browsertime.json + jq -e '.[1].info.url | endswith("/simple/")' /tmp/spa-mixed/browsertime.json + jq -e '.[1].browserScripts[0].timings.softNavigations == null' /tmp/spa-mixed/browsertime.json + jq -e '.[2].browserScripts[0].timings.softNavigations[0].name | endswith("/spa/products")' /tmp/spa-mixed/browsertime.json + jq -e '.log.pages | length == 3' /tmp/spa-mixed/browsertime.har + jq -e '.log.pages[0]._softNavigation == true' /tmp/spa-mixed/browsertime.har + jq -e '.log.pages[1]._softNavigation == null' /tmp/spa-mixed/browsertime.har + jq -e '(.log.pages[1].id) as $p | [.log.entries[] | select(.pageref == $p)] | length > 0' /tmp/spa-mixed/browsertime.har + - name: Test Chrome SPA user journey without any requests + run: | + ./bin/browsertime.js -b chrome -n 1 --xvfb --spa --resultDir /tmp/spa-no-request test/data/commandscripts/spaNoRequestJourney.cjs + echo "--- measured pages ---" + jq '[.[] | {url: .info.url, alias: .info.alias}]' /tmp/spa-no-request/browsertime.json + echo "--- HAR pages ---" + jq '.log.pages | map({id, _url, _softNavigation})' /tmp/spa-no-request/browsertime.har + jq -e '. | length == 2' /tmp/spa-no-request/browsertime.json + jq -e '.[0].browserScripts[0].timings.softNavigations | length == 1' /tmp/spa-no-request/browsertime.json + jq -e '.[1].browserScripts[0].timings.softNavigations | length == 1' /tmp/spa-no-request/browsertime.json + jq -e '.[0].browserScripts[0].timings.softNavigations[0].name | endswith("/spa/about")' /tmp/spa-no-request/browsertime.json + jq -e '.[1].browserScripts[0].timings.softNavigations[0].name | endswith("/spa/")' /tmp/spa-no-request/browsertime.json + jq -e '[.log.pages[] | select(._softNavigation == true)] | length == 2' /tmp/spa-no-request/browsertime.har + jq -e '.log.entries | length == 0' /tmp/spa-no-request/browsertime.har video: runs-on: ubuntu-22.04 steps: diff --git a/.github/workflows/linux-firefox.yml b/.github/workflows/linux-firefox.yml index 1e0dd7502..00faa5398 100644 --- a/.github/workflows/linux-firefox.yml +++ b/.github/workflows/linux-firefox.yml @@ -79,3 +79,14 @@ jobs: run: ./bin/browsertime.js -b firefox http://127.0.0.1:3000/simple/ -n 1 --viewPort 1000x600 --xvfb --enableProfileRun - name: Test Firefox BiDi and profiler commands run: ./bin/browsertime.js -b firefox test/data/commandscripts/firefox.cjs -n 1 --xvfb + - name: Test Firefox SPA user journey over multiple routes + run: | + ./bin/browsertime.js -b firefox -n 1 --xvfb --spa --resultDir /tmp/spa-journey test/data/commandscripts/spaJourney.cjs + echo "--- measured pages ---" + jq '[.[] | {url: .info.url, alias: .info.alias}]' /tmp/spa-journey/browsertime.json + jq -e '. | length == 3' /tmp/spa-journey/browsertime.json + jq -e '.[0].info.url | endswith("/spa/products")' /tmp/spa-journey/browsertime.json + jq -e '.[1].info.url | endswith("/spa/about")' /tmp/spa-journey/browsertime.json + jq -e '.[2].info.url | endswith("/spa/gallery")' /tmp/spa-journey/browsertime.json + # Firefox has no soft navigation API, so no per-route entries are expected + jq -e '.[0].browserScripts[0].timings.softNavigations == null' /tmp/spa-journey/browsertime.json diff --git a/test/data/commandscripts/spaJourney.cjs b/test/data/commandscripts/spaJourney.cjs new file mode 100644 index 000000000..9436a1d1d --- /dev/null +++ b/test/data/commandscripts/spaJourney.cjs @@ -0,0 +1,18 @@ +module.exports = async function (context, commands) { + await commands.navigate('http://127.0.0.1:3000/spa/'); + // A route that fetches data before it can render + await commands.measure.start('products'); + await commands.mouse.singleClick.bySelector('#nav-products'); + await commands.wait.byPageToComplete(); + await commands.measure.stop(); + // A route rendered entirely from memory, no requests + await commands.measure.start('about'); + await commands.mouse.singleClick.bySelector('#nav-about'); + await commands.wait.byPageToComplete(); + await commands.measure.stop(); + // A route that loads an image + await commands.measure.start('gallery'); + await commands.mouse.singleClick.bySelector('#nav-gallery'); + await commands.wait.byPageToComplete(); + return commands.measure.stop(); +}; diff --git a/test/data/commandscripts/spaMixedJourney.cjs b/test/data/commandscripts/spaMixedJourney.cjs new file mode 100644 index 000000000..9b3753ac8 --- /dev/null +++ b/test/data/commandscripts/spaMixedJourney.cjs @@ -0,0 +1,16 @@ +module.exports = async function (context, commands) { + await commands.navigate('http://127.0.0.1:3000/spa/'); + // A soft navigation without any request + await commands.measure.start('spa-about'); + await commands.mouse.singleClick.bySelector('#nav-about'); + await commands.wait.byPageToComplete(); + await commands.measure.stop(); + // A classic hard navigation in the middle of the journey + await commands.measure.start('http://127.0.0.1:3000/simple/'); + // Back into the SPA for a soft navigation with a request + await commands.navigate('http://127.0.0.1:3000/spa/'); + await commands.measure.start('spa-products'); + await commands.mouse.singleClick.bySelector('#nav-products'); + await commands.wait.byPageToComplete(); + return commands.measure.stop(); +}; diff --git a/test/data/commandscripts/spaNoRequestJourney.cjs b/test/data/commandscripts/spaNoRequestJourney.cjs new file mode 100644 index 000000000..3eb5b295f --- /dev/null +++ b/test/data/commandscripts/spaNoRequestJourney.cjs @@ -0,0 +1,12 @@ +module.exports = async function (context, commands) { + await commands.navigate('http://127.0.0.1:3000/spa/'); + // Two consecutive soft navigations that never touch the network + await commands.measure.start('about'); + await commands.mouse.singleClick.bySelector('#nav-about'); + await commands.wait.byPageToComplete(); + await commands.measure.stop(); + await commands.measure.start('home'); + await commands.mouse.singleClick.bySelector('#nav-home'); + await commands.wait.byPageToComplete(); + return commands.measure.stop(); +}; diff --git a/test/data/html/spa/index.html b/test/data/html/spa/index.html index 08ffa7153..89c746d16 100644 --- a/test/data/html/spa/index.html +++ b/test/data/html/spa/index.html @@ -3,15 +3,95 @@ SPA home +

Home

-

This page changes its URL via history.pushState when you click the - button. Combined with the user click and the new content painted into - the page, Chrome treats the transition as a soft navigation.

+ +
+

This page changes its URL via history.pushState when you click a + link or button. Combined with the user click and the new content painted + into the page, Chrome treats the transition as a soft navigation.

+