fetch-later-helper.js (16217B)
1 /** 2 * IMPORTANT: Before using this file, you must also import the following files: 3 * - /common/utils.js 4 */ 5 'use strict'; 6 7 const ROOT_NAME = 'fetch/fetch-later'; 8 9 function parallelPromiseTest(func, description) { 10 async_test((t) => { 11 Promise.resolve(func(t)).then(() => t.done()).catch(t.step_func((e) => { 12 throw e; 13 })); 14 }, description); 15 } 16 17 /** @enum {string} */ 18 const BeaconDataType = { 19 String: 'String', 20 ArrayBuffer: 'ArrayBuffer', 21 FormData: 'FormData', 22 URLSearchParams: 'URLSearchParams', 23 Blob: 'Blob', 24 File: 'File', 25 }; 26 27 /** @enum {string} */ 28 const BeaconDataTypeToSkipCharset = { 29 String: '', 30 ArrayBuffer: '', 31 FormData: '\n\r', // CRLF characters will be normalized by FormData 32 URLSearchParams: ';,/?:@&=+$', // reserved URI characters 33 Blob: '', 34 File: '', 35 }; 36 37 const BEACON_PAYLOAD_KEY = 'payload'; 38 39 // Creates beacon data of the given `dataType` from `data`. 40 // @param {string} data - A string representation of the beacon data. Note that 41 // it cannot contain UTF-16 surrogates for all `BeaconDataType` except BLOB. 42 // @param {BeaconDataType} dataType - must be one of `BeaconDataType`. 43 // @param {string} contentType - Request Content-Type. 44 function makeBeaconData(data, dataType, contentType) { 45 switch (dataType) { 46 case BeaconDataType.String: 47 return data; 48 case BeaconDataType.ArrayBuffer: 49 return new TextEncoder().encode(data).buffer; 50 case BeaconDataType.FormData: 51 const formData = new FormData(); 52 if (data.length > 0) { 53 formData.append(BEACON_PAYLOAD_KEY, data); 54 } 55 return formData; 56 case BeaconDataType.URLSearchParams: 57 if (data.length > 0) { 58 return new URLSearchParams(`${BEACON_PAYLOAD_KEY}=${data}`); 59 } 60 return new URLSearchParams(); 61 case BeaconDataType.Blob: { 62 const options = {type: contentType || undefined}; 63 return new Blob([data], options); 64 } 65 case BeaconDataType.File: { 66 const options = {type: contentType || 'text/plain'}; 67 return new File([data], 'file.txt', options); 68 } 69 default: 70 throw Error(`Unsupported beacon dataType: ${dataType}`); 71 } 72 } 73 74 // Create a string of `end`-`begin` characters, with characters starting from 75 // UTF-16 code unit `begin` to `end`-1. 76 function generateSequentialData(begin, end, skip) { 77 const codeUnits = Array(end - begin).fill().map((el, i) => i + begin); 78 if (skip) { 79 return String.fromCharCode( 80 ...codeUnits.filter(c => !skip.includes(String.fromCharCode(c)))); 81 } 82 return String.fromCharCode(...codeUnits); 83 } 84 85 function generatePayload(size) { 86 if (size == 0) { 87 return ''; 88 } 89 const prefix = String(size) + ':'; 90 if (size < prefix.length) { 91 return Array(size).fill('*').join(''); 92 } 93 if (size == prefix.length) { 94 return prefix; 95 } 96 97 return prefix + Array(size - prefix.length).fill('*').join(''); 98 } 99 100 function generateSetBeaconURL(uuid, options) { 101 const host = (options && options.host) || ''; 102 let url = `${host}/${ROOT_NAME}/resources/set_beacon.py?uuid=${uuid}`; 103 if (options) { 104 if (options.expectOrigin !== undefined) { 105 url = `${url}&expectOrigin=${options.expectOrigin}`; 106 } 107 if (options.expectPreflight !== undefined) { 108 url = `${url}&expectPreflight=${options.expectPreflight}`; 109 } 110 if (options.expectCredentials !== undefined) { 111 url = `${url}&expectCredentials=${options.expectCredentials}`; 112 } 113 114 if (options.useRedirectHandler) { 115 const redirect = `${host}/common/redirect.py` + 116 `?location=${encodeURIComponent(url)}`; 117 url = redirect; 118 } 119 } 120 return url; 121 } 122 123 async function poll(asyncFunc, expected) { 124 const maxRetries = 30; 125 const waitInterval = 100; // milliseconds. 126 const delay = ms => new Promise(res => setTimeout(res, ms)); 127 128 let result = {data: []}; 129 for (let i = 0; i < maxRetries; i++) { 130 result = await asyncFunc(); 131 if (!expected(result)) { 132 await delay(waitInterval); 133 continue; 134 } 135 return result; 136 } 137 return result; 138 } 139 140 // Waits until the `options.count` number of beacon data available from the 141 // server. Defaults to 1. 142 // If `options.data` is set, it will be used to compare with the data from the 143 // response. 144 async function expectBeacon(uuid, options) { 145 const expectedCount = 146 (options && options.count !== undefined) ? options.count : 1; 147 148 const res = await poll( 149 async () => { 150 const res = await fetch( 151 `/${ROOT_NAME}/resources/get_beacon.py?uuid=${uuid}`, 152 {cache: 'no-store'}); 153 return await res.json(); 154 }, 155 (res) => { 156 if (expectedCount == 0) { 157 // If expecting no beacon, we should try to wait as long as possible. 158 // So always returning false here until `poll()` decides to terminate 159 // itself. 160 return false; 161 } 162 return res.data.length == expectedCount; 163 }); 164 if (!options || !options.data) { 165 assert_equals( 166 res.data.length, expectedCount, 167 'Number of sent beacons does not match expected count:'); 168 return; 169 } 170 171 if (expectedCount == 0) { 172 assert_equals( 173 res.data.length, 0, 174 'Number of sent beacons does not match expected count:'); 175 return; 176 } 177 178 const decoder = options && options.percentDecoded ? (s) => { 179 // application/x-www-form-urlencoded serializer encodes space as '+' 180 // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent 181 s = s.replace(/\+/g, '%20'); 182 return decodeURIComponent(s); 183 } : (s) => s; 184 185 assert_equals( 186 res.data.length, options.data.length, 187 `The size of beacon data ${ 188 res.data.length} from server does not match expected value ${ 189 options.data.length}.`); 190 for (let i = 0; i < options.data.length; i++) { 191 assert_equals( 192 decoder(res.data[i]), options.data[i], 193 'The beacon data does not match expected value.'); 194 } 195 } 196 197 function generateHTML(script) { 198 return `<!DOCTYPE html><body><script>${script}</script></body>`; 199 } 200 201 // Loads `script` into an iframe and appends it to the current document. 202 // Returns the loaded iframe element. 203 async function loadScriptAsIframe(script) { 204 const iframe = document.createElement('iframe'); 205 iframe.srcdoc = generateHTML(script); 206 const iframeLoaded = new Promise(resolve => iframe.onload = resolve); 207 document.body.appendChild(iframe); 208 await iframeLoaded; 209 return iframe; 210 } 211 212 /** 213 * A helper to make a fetchLater request and wait for it being received. 214 * 215 * This function can also be used when the caller does not care about where a 216 * fetchLater() makes request to. 217 * 218 * @param {!RequestInit} init The request config to pass into fetchLater() call. 219 */ 220 async function expectFetchLater( 221 init, {targetUrl = undefined, uuid = undefined} = {}) { 222 if ((targetUrl && !uuid) || (!targetUrl && uuid)) { 223 throw new Error('uuid and targetUrl must be provided together.'); 224 } 225 if (uuid && targetUrl && !targetUrl.includes(uuid)) { 226 throw new Error(`Conflicting uuid=${ 227 uuid} is provided: must also be included in the targetUrl ${ 228 targetUrl}`); 229 } 230 if (!uuid) { 231 uuid = token(); 232 } 233 if (!targetUrl) { 234 targetUrl = generateSetBeaconURL(uuid); 235 } 236 237 fetchLater(targetUrl, init); 238 239 await expectBeacon(uuid, {count: 1}); 240 } 241 242 /** 243 * A helper to append `el` into document and wait for it being loaded. 244 * @param {!Element} el 245 */ 246 async function loadElement(el) { 247 const loaded = new Promise(resolve => el.onload = resolve); 248 document.body.appendChild(el); 249 await loaded; 250 } 251 252 /** 253 * The options to configure a fetchLater() call in an iframe. 254 * @record 255 */ 256 class FetchLaterIframeOptions { 257 constructor() { 258 /** 259 * @type {string=} The url to pass to the fetchLater() call. 260 */ 261 this.targetUrl; 262 263 /** 264 * @type {string=} The uuid to wait for. Must also be part of `targetUrl`. 265 */ 266 this.uuid; 267 268 /** 269 * @type {number=} The activateAfter field of DeferredRequestInit to pass 270 * to the fetchLater() call. 271 * https://whatpr.org/fetch/1647.html#dictdef-deferredrequestinit 272 */ 273 this.activateAfter; 274 275 /** 276 * @type {string=} The method field of DeferredRequestInit to pass to the 277 * fetchLater() call. 278 * https://whatpr.org/fetch/1647.html#dictdef-deferredrequestinit 279 */ 280 this.method; 281 282 /** 283 * @type {string=} The referrer field of DeferredRequestInit to pass to the 284 * fetchLater() call. 285 * https://whatpr.org/fetch/1647.html#requestinit 286 */ 287 this.referrer; 288 289 /** 290 * @type {string=} One of the `BeaconDataType` to tell the iframe how to 291 * generate the body for its fetchLater() call. 292 */ 293 this.bodyType; 294 295 /** 296 * @type {number=} The size to tell the iframe how to generate the body of 297 * its fetchLater() call. 298 */ 299 this.bodySize; 300 301 /** 302 * @type {bool} Whether to set allow="deferred-fetch" attribute for the 303 * iframe. Combing with a Permissions-Policy header, this will enable 304 * fetchLater() being used in a cross-origin iframe. 305 */ 306 this.allowDeferredFetch; 307 308 /** 309 * @type {string=} The sandbox attribute to apply to the iframe. 310 */ 311 this.sandbox; 312 313 /** 314 * @type {FetchLaterIframeExpectation=} The expectation on the iframe's 315 * behavior. 316 */ 317 this.expect; 318 } 319 } 320 321 /** 322 * The enum to classify the messages posted from an iframe that has called 323 * fetchLater() API. 324 * @enum {string} 325 */ 326 const FetchLaterIframeMessageType = { 327 // Tells that a fetchLater() call has been executed without any error thrown. 328 DONE: 'fetchLater.done', 329 // Tells that there are some error thrown from a fetchLater() call. 330 ERROR: 'fetchLater.error', 331 }; 332 333 /** 334 * The enum to indicate what type of iframe behavior the caller is expecting. 335 * @enum {number} 336 */ 337 const FetchLaterExpectationType = { 338 // A fetchLater() call should have been made without any errors. 339 DONE: 0, 340 // A fetchLater() call is made and an JS error is thrown. 341 ERROR_JS: 1, 342 // A fetchLater() call is made and an DOMException is thrown. 343 ERROR_DOM: 2, 344 }; 345 346 class FetchLaterExpectationError extends Error { 347 constructor(src, actual, expected) { 348 const message = `iframe[src=${src}] threw ${actual}, expected ${expected}`; 349 super(message); 350 } 351 } 352 353 class FetchLaterIframeExpectation { 354 constructor(expectationType, expectedError) { 355 this.expectationType = expectationType; 356 if (expectationType == FetchLaterExpectationType.DONE && !expectedError) { 357 this.expectedErrorType = undefined; 358 } else if ( 359 expectationType == FetchLaterExpectationType.ERROR_JS && 360 typeof expectedError == 'function') { 361 this.expectedErrorType = expectedError; 362 } else if ( 363 expectationType == FetchLaterExpectationType.ERROR_DOM && 364 typeof expectedError == 'string') { 365 this.expectedDomErrorName = expectedError; 366 } else { 367 throw Error(`Expectation type "${expectationType}" and expected error "${ 368 expectedError}" do not match`); 369 } 370 } 371 372 /** 373 * Verifies the message from `e` against the configured expectation. 374 * 375 * @param {MessageEvent} e 376 * @param {string} url The source URL of the iframe where `e` is dispatched 377 * from. 378 * @return {bool} 379 * - Returns true if the expected message event is passed into the function 380 * and the expectation is fulfilled. The caller should be able to safely 381 * remove the message event listener afterwards. 382 * - Returns false if the passed in event is not of the expected type. The 383 * caller should continue waiting for another message event and call this 384 * function again. 385 * @throws {Error} Throws an error if the expected message event is passed but 386 * the expectation fails. The caller should remove the message event 387 * listener and perform test failure handling. 388 */ 389 run(e, url) { 390 if (this.expectationType === FetchLaterExpectationType.DONE) { 391 if (e.data.type === FetchLaterIframeMessageType.DONE) { 392 return true; 393 } 394 if (e.data.type === FetchLaterIframeMessageType.ERROR && 395 e.data.error !== undefined) { 396 throw new FetchLaterExpectationError( 397 url, e.data.error.name, 'no error'); 398 } 399 } 400 401 if (this.expectationType === FetchLaterExpectationType.ERROR_JS) { 402 if (e.data.type === FetchLaterIframeMessageType.DONE) { 403 throw new FetchLaterExpectationError( 404 url, 'nothing', this.expectedErrorType.name); 405 } 406 if (e.data.type === FetchLaterIframeMessageType.ERROR) { 407 if (e.data.error.name === this.expectedErrorType.name) { 408 return true; 409 } 410 throw new FetchLaterExpectationError( 411 url, e.data.error, this.expectedErrorType.name); 412 } 413 } 414 415 if (this.expectationType === FetchLaterExpectationType.ERROR_DOM) { 416 if (e.data.type === FetchLaterIframeMessageType.DONE) { 417 throw new FetchLaterExpectationError( 418 url, 'nothing', this.expectedDomErrorName); 419 } 420 if (e.data.type === FetchLaterIframeMessageType.ERROR) { 421 const actual = e.data.error.name || e.data.error.type; 422 if (this.expectedDomErrorName === 'QuotaExceededError') { 423 return actual == this.expectedDomErrorName; 424 } else if (actual == this.expectedDomErrorName) { 425 return true; 426 } 427 throw new FetchLaterExpectationError( 428 url, actual, this.expectedDomErrorName); 429 } 430 } 431 432 return false; 433 } 434 } 435 436 /** 437 * A helper to load an iframe of the specified `origin` that makes a fetchLater 438 * request to `targetUrl`. 439 * 440 * If `targetUrl` is not provided, this function generates a target URL by 441 * itself. 442 * 443 * If `expect` is not provided: 444 * - If `targetUrl` is not provided, this function will wait for the fetchLater 445 * request being received by the test server before returning. 446 * - If `targetUrl` is provided and `uuid` is missing, it will NOT wait for the 447 * request. 448 * - If both `targetUrl` and `uuid` are provided, it will wait for the request. 449 * 450 * Note that the iframe posts various messages back to its parent document. 451 * 452 * @param {!string} origin The origin URL of the iframe to load. 453 * @param {FetchLaterIframeOptions=} nameIgnored 454 * @return {!HTMLIFrameElement} the loaded iframe. 455 */ 456 async function loadFetchLaterIframe(origin, { 457 targetUrl = undefined, 458 uuid = undefined, 459 activateAfter = undefined, 460 referrer = undefined, 461 method = undefined, 462 bodyType = undefined, 463 bodySize = undefined, 464 allowDeferredFetch = false, 465 sandbox = undefined, 466 expect = undefined 467 } = {}) { 468 if (uuid && targetUrl && !targetUrl.includes(uuid)) { 469 throw new Error(`Conflicted uuid=${ 470 uuid} is provided: must also be included in the targetUrl ${ 471 targetUrl}`); 472 } 473 if (!uuid) { 474 uuid = targetUrl ? undefined : token(); 475 } 476 targetUrl = targetUrl || generateSetBeaconURL(uuid); 477 const params = new URLSearchParams(Object.assign( 478 {}, 479 {url: encodeURIComponent(targetUrl)}, 480 activateAfter !== undefined ? {activateAfter} : null, 481 referrer !== undefined ? {referrer} : null, 482 method !== undefined ? {method} : null, 483 bodyType !== undefined ? {bodyType} : null, 484 bodySize !== undefined ? {bodySize} : null, 485 )); 486 const url = 487 `${origin}/fetch/fetch-later/resources/fetch-later.html?${params}`; 488 expect = 489 expect || new FetchLaterIframeExpectation(FetchLaterExpectationType.DONE); 490 491 const iframe = document.createElement('iframe'); 492 if (allowDeferredFetch) { 493 iframe.allow = 'deferred-fetch'; 494 } 495 if (sandbox) { 496 iframe.sandbox = sandbox; 497 } 498 iframe.src = url; 499 500 const messageReceived = new Promise((resolve, reject) => { 501 addEventListener('message', function handler(e) { 502 if (e.source !== iframe.contentWindow) { 503 return; 504 } 505 try { 506 if (expect.run(e, url)) { 507 removeEventListener('message', handler); 508 resolve(e.data.type); 509 } 510 } catch (err) { 511 reject(err); 512 } 513 }); 514 }); 515 516 await loadElement(iframe); 517 const messageType = await messageReceived; 518 if (messageType === FetchLaterIframeMessageType.DONE && uuid) { 519 await expectBeacon(uuid, {count: 1}); 520 } 521 522 return iframe; 523 }