utils.js (18944B)
1 const STORE_URL = '/speculation-rules/prerender/resources/key-value-store.py'; 2 3 // Starts prerendering for `url`. 4 // 5 // `rule_extras` provides additional parameters for the speculation rule used 6 // to trigger prerendering. 7 function startPrerendering(url, rule_extras = {}) { 8 // Adds <script type="speculationrules"> and specifies a prerender candidate 9 // for the given URL. 10 // TODO(https://crbug.com/1174978): <script type="speculationrules"> may not 11 // start prerendering for some reason (e.g., resource limit). Implement a 12 // WebDriver API to force prerendering. 13 const script = document.createElement('script'); 14 script.type = 'speculationrules'; 15 script.text = JSON.stringify( 16 {prerender: [{source: 'list', urls: [url], ...rule_extras}]}); 17 document.head.appendChild(script); 18 return script; 19 } 20 21 class PrerenderChannel extends EventTarget { 22 #ids = new Set(); 23 #url; 24 #active = true; 25 26 constructor(name, uid = new URLSearchParams(location.search).get('uid')) { 27 super(); 28 this.#url = `/speculation-rules/prerender/resources/deprecated-broadcast-channel.py?name=${name}&uid=${uid}`; 29 (async() => { 30 while (this.#active) { 31 // Add the "keepalive" option to avoid fetch() results in unhandled 32 // rejection with fetch abortion due to window.close(). 33 // TODO(crbug.com/1356128): After this migration, "keepalive" will not 34 // be able to extend the lifetime of a Document, such that it cannot be 35 // used here to guarantee the promise resolution. 36 const messages = await (await fetch(this.#url, {keepalive: true})).json(); 37 for (const {data, id} of messages) { 38 if (!this.#ids.has(id)) 39 this.dispatchEvent(new MessageEvent('message', {data})); 40 this.#ids.add(id); 41 } 42 } 43 })(); 44 } 45 46 close() { 47 this.#active = false; 48 } 49 50 set onmessage(m) { 51 this.addEventListener('message', m) 52 } 53 54 async postMessage(data) { 55 const id = new Date().valueOf(); 56 this.#ids.add(id); 57 // Add the "keepalive" option to prevent messages from being lost due to 58 // window.close(). 59 await fetch(this.#url, {method: 'POST', body: JSON.stringify({data, id}), keepalive: true}); 60 } 61 } 62 63 // Reads the value specified by `key` from the key-value store on the server. 64 async function readValueFromServer(key) { 65 const serverUrl = `${STORE_URL}?key=${key}`; 66 const response = await fetch(serverUrl); 67 if (!response.ok) 68 throw new Error('An error happened in the server'); 69 const value = await response.text(); 70 71 // The value is not stored in the server. 72 if (value === "") 73 return { status: false }; 74 75 return { status: true, value: value }; 76 } 77 78 // Convenience wrapper around the above getter that will wait until a value is 79 // available on the server. 80 async function nextValueFromServer(key) { 81 let retry = 0; 82 while (true) { 83 // Fetches the test result from the server. 84 let success = true; 85 const { status, value } = await readValueFromServer(key).catch(e => { 86 if (retry++ >= 5) { 87 throw new Error('readValueFromServer failed'); 88 } 89 success = false; 90 }); 91 if (!success || !status) { 92 // The test result has not been stored yet. Retry after a while. 93 await new Promise(resolve => setTimeout(resolve, 100)); 94 continue; 95 } 96 97 return value; 98 } 99 } 100 101 // Writes `value` for `key` in the key-value store on the server. 102 async function writeValueToServer(key, value) { 103 const serverUrl = `${STORE_URL}?key=${key}&value=${value}`; 104 await fetch(serverUrl); 105 } 106 107 // Loads the initiator page, and navigates to the prerendered page after it 108 // receives the 'readyToActivate' message. 109 // 110 // `rule_extras` provides additional parameters for the speculation rule used 111 // to trigger prerendering. 112 function loadInitiatorPage(rule_extras = {}) { 113 // Used to communicate with the prerendering page. 114 const prerenderChannel = new PrerenderChannel('prerender-channel'); 115 window.addEventListener('pagehide', () => { 116 prerenderChannel.close(); 117 }); 118 119 // We need to wait for the 'readyToActivate' message before navigation 120 // since the prerendering implementation in Chromium can only activate if the 121 // response for the prerendering navigation has already been received and the 122 // prerendering document was created. 123 const readyToActivate = new Promise((resolve, reject) => { 124 prerenderChannel.addEventListener('message', e => { 125 if (e.data != 'readyToActivate') 126 reject(`The initiator page receives an unsupported message: ${e.data}`); 127 resolve(e.data); 128 }); 129 }); 130 131 const url = new URL(document.URL); 132 url.searchParams.append('prerendering', ''); 133 // Prerender a page that notifies the initiator page of the page's ready to be 134 // activated via the 'readyToActivate'. 135 startPrerendering(url.toString(), rule_extras); 136 137 // Navigate to the prerendered page after being informed. 138 readyToActivate.then(() => { 139 if (rule_extras['target_hint'] === '_blank') { 140 window.open(url.toString(), '_blank', 'noopener'); 141 } else { 142 window.location = url.toString(); 143 } 144 }).catch(e => { 145 const testChannel = new PrerenderChannel('test-channel'); 146 testChannel.postMessage( 147 `Failed to navigate the prerendered page: ${e.toString()}`); 148 testChannel.close(); 149 window.close(); 150 }); 151 } 152 153 // Returns messages received from the given PrerenderChannel 154 // so that callers do not need to add their own event listeners. 155 // nextMessage() returns a promise which resolves with the next message. 156 // 157 // Usage: 158 // const channel = new PrerenderChannel('channel-name'); 159 // const messageQueue = new BroadcastMessageQueue(channel); 160 // const message1 = await messageQueue.nextMessage(); 161 // const message2 = await messageQueue.nextMessage(); 162 // message1 and message2 are the messages received. 163 class BroadcastMessageQueue { 164 constructor(c) { 165 this.messages = []; 166 this.resolveFunctions = []; 167 this.channel = c; 168 this.channel.addEventListener('message', e => { 169 if (this.resolveFunctions.length > 0) { 170 const fn = this.resolveFunctions.shift(); 171 fn(e.data); 172 } else { 173 this.messages.push(e.data); 174 } 175 }); 176 } 177 178 // Returns a promise that resolves with the next message from this queue. 179 nextMessage() { 180 return new Promise(resolve => { 181 if (this.messages.length > 0) 182 resolve(this.messages.shift()) 183 else 184 this.resolveFunctions.push(resolve); 185 }); 186 } 187 } 188 189 // Returns <iframe> element upon load. 190 function createFrame(url) { 191 return new Promise(resolve => { 192 const frame = document.createElement('iframe'); 193 frame.src = url; 194 frame.onload = () => resolve(frame); 195 document.body.appendChild(frame); 196 }); 197 } 198 199 /** 200 * Creates a prerendered page. 201 * @param {Object} params - Additional query params for navigations. 202 * @param {URLSearchParams} [params.initiator] - For the page that triggers 203 * prerendering. 204 * @param {URLSearchParams} [params.prerendering] - For prerendering navigation. 205 * @param {URLSearchParams} [params.activating] - For activating navigation. 206 * @param {Object} opt - Controls creation of prerendered pages. 207 * @param {boolean} [opt.prefetch] - When this is true, prefetch is also 208 * triggered before prerendering. 209 * @param {Object} rule_extras - Additional params for the speculation rule used 210 * to trigger prerendering. 211 */ 212 async function create_prerendered_page(t, params = {}, opt = {}, rule_extras = {}) { 213 const baseUrl = '/speculation-rules/prerender/resources/exec.py'; 214 const init_uuid = token(); 215 const prerender_uuid = token(); 216 const discard_uuid = token(); 217 const init_remote = new RemoteContext(init_uuid); 218 const prerender_remote = new RemoteContext(prerender_uuid); 219 const discard_remote = new RemoteContext(discard_uuid); 220 221 const init_params = new URLSearchParams(); 222 init_params.set('uuid', init_uuid); 223 if ('initiator' in params) { 224 for (const [key, value] of params.initiator.entries()) { 225 init_params.set(key, value); 226 } 227 } 228 window.open(`${baseUrl}?${init_params.toString()}&init`, '_blank', 'noopener'); 229 230 // Construct a URL for prerendering. 231 const prerendering_params = new URLSearchParams(); 232 prerendering_params.set('uuid', prerender_uuid); 233 prerendering_params.set('discard_uuid', discard_uuid); 234 if ('prerendering' in params) { 235 for (const [key, value] of params.prerendering.entries()) { 236 prerendering_params.set(key, value); 237 } 238 } 239 const prerendering_url = `${baseUrl}?${prerendering_params.toString()}`; 240 241 // Construct a URL for activation. If `params.activating` is provided, the 242 // URL is constructed with the params. Otherwise, the URL is the same as 243 // `prerendering_url`. 244 const activating_url = (() => { 245 if ('activating' in params) { 246 const activating_params = new URLSearchParams(); 247 activating_params.set('uuid', prerender_uuid); 248 activating_params.set('discard_uuid', discard_uuid); 249 for (const [key, value] of params.activating.entries()) { 250 activating_params.set(key, value); 251 } 252 return `${baseUrl}?${activating_params.toString()}`; 253 } else { 254 return prerendering_url; 255 } 256 })(); 257 258 if (opt.prefetch) { 259 await init_remote.execute_script((prerendering_url, rule_extras) => { 260 const a = document.createElement('a'); 261 a.href = prerendering_url; 262 a.innerText = 'Activate (prefetch)'; 263 document.body.appendChild(a); 264 const rules = document.createElement('script'); 265 rules.type = "speculationrules"; 266 rules.text = JSON.stringify( 267 {prefetch: [{source: 'list', urls: [prerendering_url], ...rule_extras}]}); 268 document.head.appendChild(rules); 269 }, [prerendering_url, rule_extras]); 270 271 // Wait for the completion of the prefetch. 272 await new Promise(resolve => t.step_timeout(resolve, 3000)); 273 } 274 275 await init_remote.execute_script((prerendering_url, rule_extras) => { 276 const a = document.createElement('a'); 277 a.href = prerendering_url; 278 a.innerText = 'Activate'; 279 document.body.appendChild(a); 280 const rules = document.createElement('script'); 281 rules.type = "speculationrules"; 282 rules.text = JSON.stringify({prerender: [{source: 'list', urls: [prerendering_url], ...rule_extras}]}); 283 document.head.appendChild(rules); 284 }, [prerendering_url, rule_extras]); 285 286 await Promise.any([ 287 prerender_remote.execute_script(() => { 288 window.import_script_to_prerendered_page = src => { 289 const script = document.createElement('script'); 290 script.src = src; 291 document.head.appendChild(script); 292 return new Promise(resolve => script.addEventListener('load', resolve)); 293 } 294 }), new Promise(r => t.step_timeout(r, 3000)) 295 ]); 296 297 t.add_cleanup(() => { 298 init_remote.execute_script(() => window.close()); 299 discard_remote.execute_script(() => window.close()); 300 prerender_remote.execute_script(() => window.close()); 301 }); 302 303 async function tryToActivate() { 304 const prerendering = prerender_remote.execute_script(() => new Promise(resolve => { 305 if (!document.prerendering) 306 resolve('activated'); 307 else document.addEventListener('prerenderingchange', () => resolve('activated')); 308 })); 309 310 const discarded = discard_remote.execute_script(() => Promise.resolve('discarded')); 311 312 init_remote.execute_script((activating_url, target_hint) => { 313 if (target_hint === '_blank') { 314 window.open(activating_url, '_blank', 'noopener'); 315 } else { 316 window.location = activating_url; 317 } 318 }, [activating_url, rule_extras['target_hint']]); 319 return Promise.any([prerendering, discarded]); 320 } 321 322 async function activate() { 323 const prerendering = await tryToActivate(); 324 if (prerendering !== 'activated') 325 throw new Error('Should not be prerendering at this point') 326 } 327 328 // Get the number of network requests for exec.py. This doesn't care about 329 // differences in search params. 330 async function getNetworkRequestCount() { 331 return await (await fetch(prerendering_url + '&get-fetch-count')).text(); 332 } 333 334 return { 335 exec: (fn, args) => prerender_remote.execute_script(fn, args), 336 activate, 337 tryToActivate, 338 getNetworkRequestCount, 339 prerenderingURL: (new URL(prerendering_url, document.baseURI)).href, 340 activatingURL: (new URL(activating_url, document.baseURI)).href 341 }; 342 } 343 344 345 function test_prerender_restricted(fn, expected, label) { 346 promise_test(async t => { 347 const {exec} = await create_prerendered_page(t); 348 let result = null; 349 try { 350 await exec(fn); 351 result = "OK"; 352 } catch (e) { 353 result = e.name; 354 } 355 356 assert_equals(result, expected); 357 }, label); 358 } 359 360 function test_prerender_defer(fn, label) { 361 promise_test(async t => { 362 const {exec, activate} = await create_prerendered_page(t); 363 let activated = false; 364 const deferred = exec(fn); 365 366 const post = new Promise(resolve => 367 deferred.then(result => { 368 assert_true(activated, "Deferred operation should occur only after activation"); 369 resolve(result); 370 })); 371 372 await activate(); 373 activated = true; 374 await post; 375 }, label); 376 } 377 378 // If you want access to these, be sure to include 379 // /html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js 380 // and /speculation-rules/resources/utils.js. So as to avoid requiring everyone 381 // to do that, we only conditionally define this infrastructure. 382 if (globalThis.PreloadingRemoteContextHelper) { 383 class PrerenderingRemoteContextWrapper extends PreloadingRemoteContextHelper.RemoteContextWrapper { 384 /** 385 * Activates a prerendered page represented by `destinationRC` by navigating 386 * the page currently displayed in this `PrerenderingRemoteContextWrapper` to 387 * it. If the navigation does not result in a prerender activation, the 388 * returned promise will be rejected with a testharness.js AssertionError. 389 * 390 * @param {PrerenderingRemoteContextWrapper} destinationRC - The 391 * `PrerenderingRemoteContextWrapper` pointing to the prerendered 392 * content. This is monitored to ensure the navigation results in a 393 * prerendering activation. 394 * @param {(string) => Promise<undefined>} [navigateFn] - An optional 395 * function to customize the navigation. It will be passed the URL of the 396 * prerendered content, and will run as a script in this (see 397 * `RemoteContextWrapper.prototype.executeScript`). If not given, 398 * navigation will be done via the `location.href` setter (see 399 * `RemoteContextWrapper.prototype.navigateTo`). 400 * @returns {Promise<undefined>} 401 */ 402 async navigateExpectingPrerenderingActivation(destinationRC, navigateFn) { 403 // Store a promise that will fulfill when the `prerenderingchange` event 404 // fires. 405 await destinationRC.executeScript(() => { 406 window.activatedPromise = new Promise(resolve => { 407 document.addEventListener("prerenderingchange", () => resolve("activated"), { once: true }); 408 }); 409 }); 410 411 if (navigateFn === undefined) { 412 await this.navigateTo(destinationRC.url); 413 } else { 414 await this.navigate(navigateFn, [destinationRC.url]); 415 } 416 417 // Wait until that event fires. If the activation fails and a normal 418 // navigation happens instead, then `destinationRC` will start pointing to 419 // that other page, where `window.activatedPromise` is undefined. In that 420 // case this assert will fail since `undefined !== "activated"`. 421 assert_equals( 422 await destinationRC.executeScript(() => window.activatedPromise), 423 "activated", 424 "The prerendered page must be activated; instead a normal navigation happened." 425 ); 426 } 427 428 /** 429 * Navigates to the URL identified by `destinationRC`, but expects that the 430 * navigation does not cause a prerendering activation. (E.g., because the 431 * prerender was canceled by something in the test code.) If the navigation 432 * results in a prerendering activation, the returned promise will be 433 * rejected with a testharness.js AssertionError. 434 * @param {RemoteContextWrapper} destinationRC - The `RemoteContextWrapper` 435 * pointing to the destination URL. Usually this is obtained by 436 * prerendering (e.g., via `addPrerender()`), even though we are testing 437 * that the prerendering does not activate. 438 * @param {(string) => Promise<undefined>} [navigateFn] - An optional 439 * function to customize the navigation. It will be passed the URL of the 440 * prerendered content, and will run as a script in this (see 441 * `RemoteContextWrapper.prototype.executeScript`). If not given, 442 * navigation will be done via the `location.href` setter (see 443 * `RemoteContextWrapper.prototype.navigateTo`). 444 * @returns {Promise<undefined>} 445 */ 446 async navigateExpectingNoPrerenderingActivation(destinationRC, navigateFn) { 447 if (navigateFn === undefined) { 448 await this.navigateTo(destinationRC.url); 449 } else { 450 await this.navigate(navigateFn, [destinationRC.url]); 451 } 452 453 assert_equals( 454 await destinationRC.executeScript(() => { 455 return performance.getEntriesByType("navigation")[0].activationStart; 456 }), 457 0, 458 "The prerendered page must not be activated." 459 ); 460 } 461 462 /** 463 * Starts prerendering a page with this `PreloadingRemoteContextWrapper` as the 464 * referrer, using `<script type="speculationrules">`. 465 * 466 * @param {object} [extrasInSpeculationRule] - Additional properties to add 467 * to the speculation rule JSON. 468 * @param {RemoteContextConfig|object} [extraConfig] - Additional remote 469 * context configuration for the preloaded context. 470 * @returns {Promise<PreloadingRemoteContextWrapper>} 471 */ 472 addPrerender(options) { 473 return this.addPreload("prerender", options); 474 } 475 } 476 477 globalThis.PrerenderingRemoteContextHelper = class extends PreloadingRemoteContextHelper { 478 static RemoteContextWrapper = PrerenderingRemoteContextWrapper; 479 }; 480 } 481 482 // Used by the opened window, to tell the main test runner to terminate a 483 // failed test. 484 function failTest(reason, uid) { 485 const bc = new PrerenderChannel('test-channel', uid); 486 bc.postMessage({result: 'FAILED', reason}); 487 bc.close(); 488 } 489 490 // Retrieves a target hint from URLSearchParams of the current window and 491 // returns it. Throw an Error if it doesn't have the valid target hint param. 492 function getTargetHint() { 493 const params = new URLSearchParams(window.location.search); 494 const target_hint = params.get('target_hint'); 495 if (target_hint === null) 496 throw new Error('window.location does not have a target hint param'); 497 if (target_hint !== '_self' && target_hint !== '_blank') 498 throw new Error('window.location does not have a valid target hint param'); 499 return target_hint; 500 }