soft-navigation-helper.js (14047B)
1 var counter = 0; 2 var timestamps = []; 3 4 const SOFT_NAV_ENTRY_BUFFER_LIMIT = 50; 5 // this is used by injected scripts 6 const DEFAULTURL = 'foobar.html'; 7 const DEFAULTIMG = '/soft-navigation-heuristics/resources/images/lcp-256x256-alt-1.png'; 8 9 /** 10 * Common Utils not related to these tests. 11 * TODO: Could be moved out? 12 */ 13 14 // Helper method for use with history.back(), when we want to be 15 // sure that its asynchronous effect has completed. 16 async function waitForUrlToEndWith(url) { 17 return new Promise((resolve, reject) => { 18 window.addEventListener('popstate', () => { 19 if (location.href.endsWith(url)) { 20 resolve(); 21 } else { 22 reject( 23 'Got ' + location.href + ' - expected URL ends with "' + url + '"'); 24 } 25 }, { once: true }); 26 }); 27 }; 28 29 function getNextEntry(type) { 30 return new Promise(resolve => { 31 new PerformanceObserver((list, observer) => { 32 const entries = list.getEntries(); 33 observer.disconnect(); 34 assert_equals(entries.length, 1, 'Only one entry.'); 35 resolve(entries[0]); 36 }).observe({ type }); 37 }); 38 } 39 40 function getBufferedEntries(type) { 41 return new Promise((resolve, reject) => { 42 new PerformanceObserver((list, observer, options) => { 43 if (options.droppedEntriesCount) { 44 reject(options.droppedEntriesCount); 45 } 46 resolve(list.getEntries()); 47 observer.disconnect(); 48 }).observe({ type, buffered: true }); 49 }); 50 } 51 52 /** 53 * Helpers somewhat specific to these test types, "exported" and used by tests. 54 */ 55 56 async function addImageToMain(url = DEFAULTIMG, id = 'imagelcp') { 57 const main = document.getElementById('main'); 58 const img = new Image(); 59 img.src = url + '?' + Math.random(); 60 img.id = id; 61 img.setAttribute('elementtiming', id); 62 main.appendChild(img); 63 return img; 64 } 65 66 function addTextParagraphToMain(text, element_timing = '') { 67 const main = document.getElementById('main'); 68 const p = document.createElement('p'); 69 const textNode = document.createTextNode(text); 70 p.setAttribute('elementtiming', element_timing); 71 p.style = 'font-size: 3em'; 72 p.appendChild(textNode); 73 main.appendChild(p); 74 return p; 75 } 76 77 function addTextToDivOnMain() { 78 const main = document.getElementById('main'); 79 const prevDiv = document.getElementsByTagName('div')[0]; 80 if (prevDiv) { 81 main.removeChild(prevDiv); 82 } 83 const div = document.createElement('div'); 84 const text = document.createTextNode('Lorem Ipsum'); 85 div.style = 'font-size: 3em'; 86 div.appendChild(text); 87 main.appendChild(div); 88 return div; 89 } 90 91 92 /** 93 * Internal Helpers 94 */ 95 96 async function _withTimeoutMessage(t, promise, message, timeout = 1000) { 97 return Promise.race([ 98 promise, 99 new Promise((resolve, reject) => { 100 t.step_timeout(() => { 101 reject(new Error(message)); 102 }, timeout); 103 }), 104 ]); 105 } 106 107 function _maybeAddUrlCleanupForTesting(t, numClicks) { 108 // TODO: any way to early-exit if we are running headless? 109 if (numClicks > 50) return; 110 t.add_cleanup(async () => { 111 // Go back to the original URL 112 for (let i = 0; i < numClicks; i++) { 113 history.back(); 114 await new Promise(resolve => { 115 addEventListener('popstate', resolve, { once: true }); 116 }); 117 } 118 }); 119 } 120 121 122 /** 123 * Test body and validations 124 */ 125 126 function testSoftNavigation(options) { 127 const testName = options.testName; 128 if (!testName) throw new Error("testName is a required option."); 129 130 promise_test(async t => { 131 const { 132 clickTarget = document.getElementById("link"), 133 eventListenerCb = () => { }, 134 interactionFunc = () => { if (test_driver) test_driver.click(clickTarget); }, 135 registerInteractionEvent = (cb) => clickTarget.addEventListener('click', cb), 136 registerRouteChange = (cb) => registerInteractionEvent(async (event) => { 137 // The default route change handler is ClickEvent + Yield, in order to: 138 // - mark timeOrigin. 139 // - ensure task tracking is working properly. 140 await new Promise(r => t.step_timeout(r, 0)); 141 cb(event); 142 }), 143 numClicks = 1, 144 145 addContent = () => addTextParagraphToMain(), 146 clearContent = () => { }, 147 pushState = url => { history.pushState({}, '', url); }, 148 pushUrl = DEFAULTURL, 149 dontExpectSoftNavs = false, 150 onRouteChange = async (event) => { 151 await pushState(`${pushUrl}?${counter}`); 152 // Wait 10 ms to make sure the timestamps are correct. 153 await new Promise(r => t.step_timeout(r, 10)); 154 await clearContent(); 155 await addContent(); 156 }, 157 158 extraSetup = () => { }, 159 extraValidations = () => { }, 160 } = options; 161 162 _maybeAddUrlCleanupForTesting(t); 163 164 await extraSetup(t); 165 166 // Allow things to settle before starting the test. Specifically, 167 // wait for final LCP candidate to arrive. 168 // TODO: Make this explicitly wait by marking the candidate, or just making 169 // the image `blocking=rendering`? 170 await new Promise((r) => { 171 requestAnimationFrame(() => { 172 t.step_timeout(r, 1000); 173 }) 174 }) 175 176 const lcps_before = await _withTimeoutMessage(t, 177 getBufferedEntries('largest-contentful-paint'), 178 'Timed out waiting for LCP entries'); 179 180 // This "click event" starts the user interaction. 181 registerInteractionEvent(async event => { 182 eventListenerCb(event); 183 184 // Event listener is no-op and yields immediately. Mark its sync end time: 185 // TODO: This is very brittle, as some tests "customize" it. 186 if (!timestamps[counter]['eventEnd']) { 187 timestamps[counter]['eventEnd'] = performance.now(); 188 } 189 }); 190 191 // This "route event" starts the UI/URL changes. Often also the event. 192 registerRouteChange(async event => { 193 await onRouteChange(event); 194 ++counter; 195 }); 196 197 const softNavEntries = []; 198 const icps = []; 199 for (let i = 0; i < numClicks; ++i) { 200 // Use getNextEntry instead of getBufferedEntries so that: 201 // - For tests with more than 1 click, we wait for all expectations 202 // to arrive between clicks 203 // - For tests with more than buffer-limit clicks, we actually measure. 204 const soft_nav_promise = getNextEntry('soft-navigation'); 205 const icp_promise = getNextEntry('interaction-contentful-paint'); 206 207 await interactionFunc(); 208 timestamps[counter] = { 'syncPostInteraction': performance.now() }; 209 210 // TODO: is it possible to still await these entries, but change to 211 // expect a timeout without resolution, to actually expect non arrives? 212 if (dontExpectSoftNavs) continue; 213 214 softNavEntries.push(await _withTimeoutMessage(t, 215 soft_nav_promise, 'Timed out waiting for soft navigation', 3000)); 216 217 icps.push(await _withTimeoutMessage(t, 218 icp_promise, 'Timed out waiting for icp', 3000)); 219 } 220 221 const lcps_after = await getBufferedEntries('largest-contentful-paint'); 222 223 const expectedNumberOfSoftNavs = (dontExpectSoftNavs) ? 0 : numClicks; 224 225 await _withTimeoutMessage(t, 226 validateSoftNavigationEntries(t, softNavEntries, expectedNumberOfSoftNavs, pushUrl), 227 'Timed out waiting for soft navigation entry validation'); 228 229 await _withTimeoutMessage(t, 230 validateIcpEntries(t, softNavEntries, lcps_before, icps, lcps_after), 231 'Timed out waiting for ICP entry validations'); 232 233 await _withTimeoutMessage(t, 234 extraValidations(t, softNavEntries, lcps_before, icps), 235 'Timed out waiting for extra validations'); 236 }, testName); 237 } 238 239 // TODO: Find a way to remove the need for this 240 function testNavigationApi(testName, navigateEventHandler, link) { 241 navigation.addEventListener('navigate', navigateEventHandler); 242 testSoftNavigation({ 243 testName, 244 link, 245 pushState: () => { }, 246 }); 247 } 248 249 async function validateSoftNavigationEntries(t, softNavEntries, expectedNumSoftNavs, pushUrl) { 250 assert_equals(softNavEntries.length, expectedNumSoftNavs, 251 'Soft Navigations detected are the same as the number of clicks'); 252 253 const hardNavEntry = performance.getEntriesByType('navigation')[0]; 254 const all_navigation_ids = new Set( 255 [hardNavEntry.navigationId, ...softNavEntries.map(entry => entry.navigationId)]); 256 257 assert_equals( 258 all_navigation_ids.size, expectedNumSoftNavs + 1, 259 'The navigation ID was re-generated between all hard and soft navs'); 260 261 if (expectedNumSoftNavs > SOFT_NAV_ENTRY_BUFFER_LIMIT) { 262 // TODO: Consider exposing args to `extraValidationsSN` so the 263 // dropped entry count test can make these assertions directly. 264 // Having it here has the advantage of testing ALL tests, but, it has 265 // the disadvantage of not being able to assert that for sure we hit this 266 // code path in that specific test. (tested locally that it does, but 267 // what if buffer sizes change in the future?) 268 const expectedDroppedEntriesCount = expectedNumSoftNavs - SOFT_NAV_ENTRY_BUFFER_LIMIT; 269 await promise_rejects_exactly(t, expectedDroppedEntriesCount, 270 getBufferedEntries('soft-navigation'), 271 "This should reject with the number of dropped entries") 272 } 273 274 for (let i = 0; i < softNavEntries.length; ++i) { 275 const softNavEntry = softNavEntries[i]; 276 assert_regexp_match( 277 softNavEntry.name, new RegExp(pushUrl), 278 'The soft navigation name is properly set'); 279 280 // TODO: Carefully look at these and re-enable, also: assert_between_inclusive 281 // const timeOrigin = softNavEntry.startTime; 282 // assert_greater_than_equal( 283 // timeOrigin, timestamps[i]['eventEnd'], 284 // 'Event start timestamp matches'); 285 // assert_less_than_equal( 286 // timeOrigin, timestamps[i]['syncPostInteraction'], 287 // 'Entry timestamp is lower than the post interaction one'); 288 } 289 } 290 291 292 async function validateIcpEntries(t, softNavEntries, lcps, icps, lcps_after) { 293 assert_equals( 294 lcps.length, lcps_after.length, 295 'Soft navigation should not have triggered more LCP entries.'); 296 297 assert_greater_than_equal( 298 icps.length, softNavEntries.length, 299 'Should have at least one ICP entry per soft navigation.'); 300 301 const lcp = lcps.at(-1); 302 303 // Group ICP entries by their navigation ID. 304 const icpsByNavId = new Map(); 305 for (const icp of icps) { 306 if (!icpsByNavId.has(icp.navigationId)) { 307 icpsByNavId.set(icp.navigationId, []); 308 } 309 icpsByNavId.get(icp.navigationId).push(icp); 310 } 311 312 // For each soft navigation, find and validate its corresponding ICP entry. 313 for (const softNav of softNavEntries) { 314 const navId = softNav.navigationId; 315 assert_true(icpsByNavId.has(navId), 316 `An ICP entry should be present for navigationId ${navId}`); 317 318 // Get the largest ICP entry for this specific navigation. 319 // TODO: validate multiple candidates (i.e. each is newer + larger). 320 const icp = icpsByNavId.get(navId).at(-1); 321 322 assert_not_equals(lcp.size, icp.size, 323 `LCP element should not have identical size to ICP element for navigationId ${navId}.`); 324 assert_not_equals(lcp.startTime, icp.startTime, 325 `LCP element should not have identical startTime to ICP element for navigationId ${navId}.`); 326 } 327 } 328 329 330 // Receives an image InteractionContentfulPaint |entry| and checks |entry|'s attribute values. 331 // The |timeLowerBound| parameter is a lower bound on the loadTime value of the entry. 332 // The |options| parameter may contain some string values specifying the following: 333 // * 'renderTimeIs0': the renderTime should be 0 (image does not pass Timing-Allow-Origin checks). 334 // When not present, the renderTime should not be 0 (image passes the checks). 335 // * 'sizeLowerBound': the |expectedSize| is only a lower bound on the size attribute value. 336 // When not present, |expectedSize| must be exactly equal to the size attribute value. 337 // * 'approximateSize': the |expectedSize| is only approximate to the size attribute value. 338 // This option is mutually exclusive to 'sizeLowerBound'. 339 function checkImage(entry, expectedUrl, expectedID, expectedSize, timeLowerBound, options = []) { 340 assert_equals(entry.name, '', "Entry name should be the empty string"); 341 assert_equals(entry.entryType, 'interaction-contentful-paint', 342 "Entry type should be interaction-contentful-paint"); 343 assert_equals(entry.duration, 0, "Entry duration should be 0"); 344 // The entry's url can be truncated. 345 assert_equals(expectedUrl.substr(0, 100), entry.url.substr(0, 100), 346 `Expected URL ${expectedUrl} should at least start with the entry's URL ${entry.url}`); 347 assert_equals(entry.id, expectedID, "Entry ID matches expected one"); 348 assert_equals(entry.element, document.getElementById(expectedID), 349 "Entry element is expected one"); 350 if (options.includes('skip')) { 351 return; 352 } 353 assert_greater_than_equal(performance.now(), entry.renderTime, 354 'renderTime should occur before the entry is dispatched to the observer.'); 355 assert_approx_equals(entry.startTime, entry.renderTime, 0.001, 356 'startTime should be equal to renderTime to the precision of 1 millisecond.'); 357 if (options.includes('sizeLowerBound')) { 358 assert_greater_than(entry.size, expectedSize); 359 } else if (options.includes('approximateSize')) { 360 assert_approx_equals(entry.size, expectedSize, 1); 361 } else { 362 assert_equals(entry.size, expectedSize); 363 } 364 365 assert_greater_than_equal(entry.paintTime, timeLowerBound, 366 'paintTime should represent the time when the UA started painting'); 367 368 // PaintTimingMixin 369 if ("presentationTime" in entry && entry.presentationTime !== null) { 370 assert_greater_than(entry.presentationTime, entry.paintTime); 371 assert_equals(entry.presentationTime, entry.renderTime); 372 } else { 373 assert_equals(entry.renderTime, entry.paintTime); 374 } 375 376 if (options.includes('animated')) { 377 assert_less_than(entry.renderTime, image_delay, 378 'renderTime should be smaller than the delay applied to the second frame'); 379 assert_greater_than(entry.renderTime, 0, 380 'renderTime should be larger than 0'); 381 } 382 else { 383 assert_between_inclusive(entry.loadTime, timeLowerBound, entry.renderTime, 384 'loadTime should occur between the lower bound and the renderTime'); 385 } 386 }