tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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 }