var counter = 0; var interacted; var timestamps = [] const MAX_CLICKS = 50; // Entries for one hard navigation + 50 soft navigations. const MAX_PAINT_ENTRIES = 51; const URL = "foobar.html"; const readValue = (value, defaultValue) => { return value !== undefined ? value : defaultValue; } const testSoftNavigation = options => { const addContent = options.addContent; const link = options.link; const pushState = readValue(options.pushState, url=>{history.pushState({}, '', url)}); const clicks = readValue(options.clicks, 1); const extraValidations = readValue(options.extraValidations, () => {}); const testName = options.testName; const pushUrl = readValue(options.pushUrl, true); const eventType = readValue(options.eventType, "click"); const interactionFunc = options.interactionFunc; const eventPrepWork = options.eventPrepWork; promise_test(async t => { await waitInitialLCP(); const preClickLcp = await getLcpEntries(); setEvent(t, link, pushState, addContent, pushUrl, eventType, eventPrepWork); let first_navigation_id; for (let i = 0; i < clicks; ++i) { const firstClick = (i === 0); let paint_entries_promise = waitOnPaintEntriesPromise(firstClick); interacted = false; interact(link, interactionFunc); const navigation_id = await waitOnSoftNav(); if (!first_navigation_id) { first_navigation_id = navigation_id; } // Ensure paint timing entries are fired before moving on to the next // click. await paint_entries_promise; } assert_equals( document.softNavigations, clicks, 'Soft Navigations detected are the same as the number of clicks'); await validateSoftNavigationEntry( clicks, extraValidations, pushUrl); await runEntryValidations(preClickLcp, first_navigation_id, clicks + 1, options.validate); }, testName); }; const testNavigationApi = (testName, navigateEventHandler, link) => { promise_test(async t => { navigation.addEventListener('navigate', navigateEventHandler); const navigated = new Promise(resolve => { navigation.addEventListener('navigatesuccess', resolve); navigation.addEventListener('navigateerror', resolve); }); await waitInitialLCP(); const preClickLcp = await getLcpEntries(); let paint_entries_promise = waitOnPaintEntriesPromise(); interact(link); const first_navigation_id = await waitOnSoftNav(); await navigated; await paint_entries_promise; assert_equals(document.softNavigations, 1, 'Soft Navigation detected'); await validateSoftNavigationEntry(1, () => {}, 'foobar.html'); await runEntryValidations(preClickLcp, first_navigation_id); }, testName); }; const testSoftNavigationNotDetected = options => { promise_test(async t => { const preClickLcp = await getLcpEntries(); options.eventTarget.addEventListener(options.eventName, options.eventHandler); interact(options.link); await new Promise((resolve, reject) => { (new PerformanceObserver(() => reject("Soft navigation should not be triggered"))).observe({ type: 'soft-navigation', buffered: true }); t.step_timeout(resolve, 1000); }); if (document.softNavigations) { assert_equals( document.softNavigations, 0, 'Soft Navigation not detected'); } const postClickLcp = await getLcpEntries(); assert_equals( preClickLcp.length, postClickLcp.length, 'No LCP entries accumulated'); }, options.testName); }; const runEntryValidations = async (preClickLcp, first_navigation_id, entries_expected_number = 2, validate = null) => { await validatePaintEntries('first-contentful-paint', entries_expected_number, first_navigation_id); await validatePaintEntries('first-paint', entries_expected_number, first_navigation_id); const postClickLcp = await getLcpEntries(); const postClickLcpWithoutSoftNavs = await getLcpEntriesWithoutSoftNavs(); assert_greater_than( postClickLcp.length, preClickLcp.length, 'Soft navigation should have triggered at least an LCP entry'); if (validate) { await validate(); } assert_equals( postClickLcpWithoutSoftNavs.length, preClickLcp.length, 'Soft navigation should not have triggered an LCP entry when the ' + 'observer did not opt in'); assert_not_equals( postClickLcp[postClickLcp.length - 1].size, preClickLcp[preClickLcp.length - 1].size, 'Soft navigation LCP element should not have identical size to the hard ' + 'navigation LCP element'); assert_equals( postClickLcp[preClickLcp.length].navigationId, first_navigation_id, 'Soft navigation LCP should have the same navigation ' + 'ID as the last soft nav entry') }; const interact = (link, interactionFunc = undefined) => { if (test_driver) { if (interactionFunc) { interactionFunc(); } else { test_driver.click(link); } timestamps[counter] = {"syncPostInteraction": performance.now()}; } } const setEvent = (t, button, pushState, addContent, pushUrl, eventType, prepWork) => { const eventObject = (eventType == 'click' || eventType.startsWith("key")) ? button : window; eventObject.addEventListener(eventType, async e => { let prepWorkFailed = false; if (prepWork &&!prepWork(t)) { prepWorkFailed = true; } // This is the end of the event's sync processing. if (!timestamps[counter]["eventEnd"]) { timestamps[counter]["eventEnd"] = performance.now(); } if (prepWorkFailed) { return; } // Jump through a task, to ensure task tracking is working properly. await new Promise(r => t.step_timeout(r, 0)); const url = URL + "?" + counter; if (pushState) { // Change the URL if (pushUrl) { pushState(url); } else { pushState(); } } // Wait 10 ms to make sure the timestamps are correct. await new Promise(r => t.step_timeout(r, 10)); await addContent(url); interacted = true; ++counter; }); }; const validateSoftNavigationEntry = async (clicks, extraValidations, pushUrl) => { const [entries, options] = await new Promise(resolve => { (new PerformanceObserver((list, obs, options) => resolve( [list.getEntries(), options]))).observe( {type: 'soft-navigation', buffered: true}); }); const expectedClicks = Math.min(clicks, MAX_CLICKS); assert_equals(entries.length, expectedClicks, "Performance observer got an entry"); for (let i = 0; i < entries.length; ++i) { const entry = entries[i]; assert_true(entry.name.includes(pushUrl ? URL : document.location.href), "The soft navigation name is properly set"); const entryTimestamp = entry.startTime; assert_less_than_equal(timestamps[i]["syncPostInteraction"], entryTimestamp, "Entry timestamp is lower than the post interaction one"); assert_greater_than_equal( entryTimestamp, timestamps[i]['eventEnd'], 'Event start timestamp matches'); assert_not_equals(entry.navigationId, performance.getEntriesByType("navigation")[0].navigationId, "The navigation ID was re-generated and different from the initial one."); if (i > 0) { assert_not_equals(entry.navigationId, entries[i-1].navigationId, "The navigation ID was re-generated between clicks"); } } assert_equals(performance.getEntriesByType("soft-navigation").length, expectedClicks, "Performance timeline got an entry"); await extraValidations(entries, options); }; const validatePaintEntries = async (type, entries_number, first_navigation_id) => { if (!performance.softNavPaintMetricsSupported) { return; } const expected_entries_number = Math.min(entries_number, MAX_PAINT_ENTRIES); const entries = await new Promise(resolve => { const entries = []; (new PerformanceObserver(list => { entries.push(...list.getEntriesByName(type)); if (entries.length >= expected_entries_number) { resolve(entries); } })).observe( {type: 'paint', buffered: true, includeSoftNavigationObservations: true}); }); const entries_without_softnavs = await new Promise(resolve => { (new PerformanceObserver(list => resolve( list.getEntriesByName(type)))).observe( {type: 'paint', buffered: true}); }); assert_equals(entries.length, expected_entries_number, `There are ${entries_number} entries for ${type}`); assert_equals(entries_without_softnavs.length, 1, `There is one non-softnav entry for ${type}`); if (entries_number > 1) { assert_not_equals(entries[0].startTime, entries[1].startTime, "Entries have different timestamps for " + type); } if (expected_entries_number > entries_without_softnavs.length) { assert_equals(entries[entries_without_softnavs.length].navigationId, first_navigation_id, "First paint entry should have the same navigation ID as the last soft " + "navigation entry"); } }; const waitInitialLCP = () => { return new Promise(resolve => { new PerformanceObserver(list => resolve()).observe({ type: 'largest-contentful-paint', buffered: true }); }); } const waitOnSoftNav = () => { return new Promise(resolve => { (new PerformanceObserver(list => { const entries = list.getEntries(); assert_equals(entries.length, 1, "Only one soft navigation entry"); resolve(entries[0].navigationId); })).observe({ type: 'soft-navigation' }); }); }; const getLcpEntries = async () => { const entries = await new Promise(resolve => { (new PerformanceObserver(list => resolve( list.getEntries()))).observe( {type: 'largest-contentful-paint', buffered: true, includeSoftNavigationObservations: true}); }); return entries; }; const getLcpEntriesWithoutSoftNavs = async () => { const entries = await new Promise(resolve => { (new PerformanceObserver(list => resolve( list.getEntries()))).observe( {type: 'largest-contentful-paint', buffered: true}); }); return entries; }; const addImage = async (element, url="blue.png", id = "imagelcp") => { const img = new Image(); img.src = '/images/'+ url + "?" + Math.random(); img.id=id img.setAttribute("elementtiming", id); await img.decode(); element.appendChild(img); }; const addImageToMain = async (url="blue.png", id = "imagelcp") => { await addImage(document.getElementById('main'), url, id); }; const addTextParagraphToMain = (text, element_timing = "") => { const main = document.getElementById("main"); const p = document.createElement("p"); const textNode = document.createTextNode(text); p.appendChild(textNode); if (element_timing) { p.setAttribute("elementtiming", element_timing); } p.style = "font-size: 3em"; main.appendChild(p); return p; }; const addTextToDivOnMain = () => { const main = document.getElementById("main"); const prevDiv = document.getElementsByTagName("div")[0]; if (prevDiv) { main.removeChild(prevDiv); } const div = document.createElement("div"); const text = document.createTextNode("Lorem Ipsum"); div.appendChild(text); div.style = "font-size: 3em"; main.appendChild(div); } const waitOnPaintEntriesPromise = (expectLCP = true) => { return new Promise((resolve, reject) => { if (performance.softNavPaintMetricsSupported) { const paint_entries = [] new PerformanceObserver(list => { paint_entries.push(...list.getEntries()); if (paint_entries.length == 2) { resolve(); } else if (paint_entries.length > 2) { reject(); } }).observe({type: 'paint', includeSoftNavigationObservations: true}); } else if (expectLCP) { new PerformanceObserver(list => { resolve(); }).observe({ type: 'largest-contentful-paint', includeSoftNavigationObservations: true }); } else { step_timeout( () => requestAnimationFrame(() => requestAnimationFrame(resolve)), 100); } }); };