diff options
Diffstat (limited to 'testing/web-platform/tests/pending-beacon')
14 files changed, 863 insertions, 0 deletions
diff --git a/testing/web-platform/tests/pending-beacon/META.yml b/testing/web-platform/tests/pending-beacon/META.yml new file mode 100644 index 0000000000..04b88b5f72 --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/META.yml @@ -0,0 +1,4 @@ +spec: https://wicg.github.io/pending-beacon/ +suggested_reviewers: + - mingyc + - clelland diff --git a/testing/web-platform/tests/pending-beacon/pending_beacon-basic.tentative.https.window.js b/testing/web-platform/tests/pending-beacon/pending_beacon-basic.tentative.https.window.js new file mode 100644 index 0000000000..c63ae4e39d --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/pending_beacon-basic.tentative.https.window.js @@ -0,0 +1,87 @@ +// META: script=./resources/pending_beacon-helper.js + +'use strict'; + +test(() => { + assert_throws_js(TypeError, () => new PendingBeacon('/')); +}, `PendingBeacon cannot be constructed directly.`); + +for (const beaconType of BeaconTypes) { + test(() => { + assert_throws_js(TypeError, () => new beaconType.type()); + assert_throws_js(TypeError, () => new beaconType.type(undefined)); + assert_throws_js(TypeError, () => new beaconType.type(null)); + }, `${beaconType.name}: constructor throws TypeError if URL is missing.`); + + test(() => { + assert_throws_js( + TypeError, () => new beaconType.type('http://www.google.com')); + assert_throws_js(TypeError, () => new beaconType.type('file://tmp')); + assert_throws_js(TypeError, () => new beaconType.type('ssh://example.com')); + assert_throws_js(TypeError, () => new beaconType.type('wss://example.com')); + assert_throws_js(TypeError, () => new beaconType.type('about:blank')); + assert_throws_js( + TypeError, () => new beaconType.type(`javascript:alert('');`)); + }, `${beaconType.name}: constructor throws TypeError on non-HTTPS URL.`); + + test(() => { + const beacon = new beaconType.type('/'); + assert_equals(beacon.url, '/'); + assert_equals(beacon.method, beaconType.expectedMethod); + assert_equals(beacon.backgroundTimeout, -1); + assert_equals(beacon.timeout, -1); + assert_true(beacon.pending); + }, `${beaconType.name}: constructor sets default properties if missing.`); + + test(() => { + const beacon = new beaconType.type( + 'https://www.google.com', {backgroundTimeout: 200, timeout: 100}); + assert_equals(beacon.url, 'https://www.google.com'); + assert_equals(beacon.method, beaconType.expectedMethod); + assert_equals(beacon.backgroundTimeout, 200); + assert_equals(beacon.timeout, 100); + assert_true(beacon.pending); + }, `${beaconType.name}: constructor can set properties.`); + + test(() => { + let beacon = new beaconType.type( + 'https://www.google.com', + {method: 'GET', backgroundTimeout: 200, timeout: 100}); + + beacon.backgroundTimeout = 400; + assert_equals(beacon.backgroundTimeout, 400); + + beacon.timeout = 600; + assert_equals(beacon.timeout, 600); + }, `${beaconType.name}: 'backgroundTimeout' & 'timeout' can be mutated.`); + + test( + () => { + let beacon = new beaconType.type('https://www.google.com'); + + assert_throws_js(TypeError, () => beacon.url = '/'); + assert_throws_js(TypeError, () => beacon.method = 'FOO'); + assert_throws_js(TypeError, () => beacon.pending = false); + }, + `${beaconType.name}: throws TypeError when mutating ` + + `'url', 'method', 'pending'.`); +} + +test(() => { + let beacon = new PendingGetBeacon('/'); + + assert_throws_js(TypeError, () => new beacon.setURL()); + assert_throws_js(TypeError, () => new beacon.setURL(undefined)); + assert_throws_js(TypeError, () => new beacon.setURL(null)); +}, `PendingGetBeacon: setURL() throws TypeError if URL is missing.`); + +test(() => { + let beacon = new PendingGetBeacon('/'); + + assert_throws_js(TypeError, () => beacon.setURL('http://www.google.com')); + assert_throws_js(TypeError, () => beacon.setURL('file://tmp')); + assert_throws_js(TypeError, () => beacon.setURL('ssh://example.com')); + assert_throws_js(TypeError, () => beacon.setURL('wss://example.com')); + assert_throws_js(TypeError, () => beacon.setURL('about:blank')); + assert_throws_js(TypeError, () => beacon.setURL(`javascript:alert('');`)); +}, `PendingGetBeacon: setURL() throws TypeError on non-HTTPS URL.`); diff --git a/testing/web-platform/tests/pending-beacon/pending_beacon-basic.window.js b/testing/web-platform/tests/pending-beacon/pending_beacon-basic.window.js new file mode 100644 index 0000000000..01b37547f7 --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/pending_beacon-basic.window.js @@ -0,0 +1,12 @@ +// META: script=/resources/testharness.js +// META: script=/resources/testharnessreport.js + +'use strict'; + +test(() => { + assert_false(window.hasOwnProperty('PendingGetBeacon')); +}, `PendingGetBeacon is not supported in non-secure context.`); + +test(() => { + assert_false(window.hasOwnProperty('PendingPostBeacon')); +}, `PendingPostBeacon is not supported in non-secure context.`); diff --git a/testing/web-platform/tests/pending-beacon/pending_beacon-deactivate.tentative.https.window.js b/testing/web-platform/tests/pending-beacon/pending_beacon-deactivate.tentative.https.window.js new file mode 100644 index 0000000000..74c0852adc --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/pending_beacon-deactivate.tentative.https.window.js @@ -0,0 +1,12 @@ +// META: script=./resources/pending_beacon-helper.js + +'use strict'; + +for (const beaconType of BeaconTypes) { + test(() => { + const beacon = new beaconType.type('https://www.google.com'); + assert_true(beacon.pending); + beacon.deactivate(); + assert_false(beacon.pending); + }, `${beaconType.name}: deactivate() changes 'pending' state.`); +} diff --git a/testing/web-platform/tests/pending-beacon/pending_beacon-sendnow.tentative.https.window.js b/testing/web-platform/tests/pending-beacon/pending_beacon-sendnow.tentative.https.window.js new file mode 100644 index 0000000000..00baccd0c1 --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/pending_beacon-sendnow.tentative.https.window.js @@ -0,0 +1,44 @@ +// META: script=/common/utils.js +// META: script=./resources/pending_beacon-helper.js + +'use strict'; + +promise_test(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + + // Create and send a beacon. + const beacon = new PendingGetBeacon(url); + beacon.sendNow(); + + await expectBeacon(uuid, {count: 1}); +}, 'sendNow() sends a beacon immediately.'); + +promise_test(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + + // Create and send a beacon. + const beacon = new PendingGetBeacon(url); + beacon.sendNow(); + await expectBeacon(uuid, {count: 1}); + + // Try to send the beacon again, and verify no beacon arrives. + beacon.sendNow(); + await expectBeacon(uuid, {count: 1}); +}, 'sendNow() doesn\'t send the same beacon twice.'); + +promise_test(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + + // Create and send 1st beacon. + const beacon1 = new PendingGetBeacon(url); + beacon1.sendNow(); + + // Create and send 2st beacon. + const beacon2 = new PendingGetBeacon(url); + beacon2.sendNow(); + + await expectBeacon(uuid, {count: 2}); +}, 'sendNow() sends multiple beacons.'); diff --git a/testing/web-platform/tests/pending-beacon/pending_beacon-sendondiscard.tentative.https.window.js b/testing/web-platform/tests/pending-beacon/pending_beacon-sendondiscard.tentative.https.window.js new file mode 100644 index 0000000000..55704388b7 --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/pending_beacon-sendondiscard.tentative.https.window.js @@ -0,0 +1,97 @@ +// META: script=/resources/testharness.js +// META: script=/resources/testharnessreport.js +// META: script=/common/utils.js +// META: script=./resources/pending_beacon-helper.js + +'use strict'; + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + const numPerMethod = 20; + const total = numPerMethod * 2; + + // Loads an iframe that creates `numPerMethod` GET & POST beacons. + const iframe = await loadScriptAsIframe(` + const url = "${url}"; + for (let i = 0; i < ${numPerMethod}; i++) { + let get = new PendingGetBeacon(url); + let post = new PendingPostBeacon(url); + } + `); + + // Delete the iframe to trigger beacon sending. + document.body.removeChild(iframe); + + // The iframe should have sent all beacons. + await expectBeacon(uuid, {count: total}); +}, 'A discarded document sends all its beacons with default config.'); + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + + // Loads an iframe that creates a GET beacon, + // then sends it out with `sendNow()`. + const iframe = await loadScriptAsIframe(` + const url = "${url}"; + let beacon = new PendingGetBeacon(url); + beacon.sendNow(); + `); + + // Delete the document and verify no more beacons are sent. + document.body.removeChild(iframe); + + // The iframe should have sent only 1 beacon. + await expectBeacon(uuid, {count: 1}); +}, 'A discarded document does not send an already sent beacon.'); + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + const numPerMethod = 20; + const total = numPerMethod * 2; + + // Loads an iframe that creates `numPerMethod` GET & POST beacons with + // different timeouts. + const iframe = await loadScriptAsIframe(` + const url = "${url}"; + for (let i = 0; i < ${numPerMethod}; i++) { + let get = new PendingGetBeacon(url, {timeout: 100*i}); + let post = new PendingPostBeacon(url, {timeout: 100*i}); + } + `); + + // Delete the iframe to trigger beacon sending. + document.body.removeChild(iframe); + + // Even beacons are configured with different timeouts, + // the iframe should have sent all beacons when it is discarded. + await expectBeacon(uuid, {count: total}); +}, `A discarded document sends all its beacons of which timeouts are not + default.`); + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + const numPerMethod = 20; + const total = numPerMethod * 2; + + // Loads an iframe that creates `numPerMethod` GET & POST beacons with + // different backgroundTimeouts. + const iframe = await loadScriptAsIframe(` + const url = "${url}"; + for (let i = 0; i < ${numPerMethod}; i++) { + let get = new PendingGetBeacon(url, {backgroundTimeout: 100*i}); + let post = new PendingPostBeacon(url, {backgroundTimeout: 100*i}); + } + `); + + // Delete the iframe to trigger beacon sending. + document.body.removeChild(iframe); + + // Even beacons are configured with different backgroundTimeouts, + // the iframe should have sent all beacons when it is discarded. + await expectBeacon(uuid, {count: total}); +}, `A discarded document sends all its beacons of which backgroundTimeouts are + not default.`); diff --git a/testing/web-platform/tests/pending-beacon/pending_beacon-sendonhidden.tentative.https.window.js b/testing/web-platform/tests/pending-beacon/pending_beacon-sendonhidden.tentative.https.window.js new file mode 100644 index 0000000000..8d7ff24e3f --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/pending_beacon-sendonhidden.tentative.https.window.js @@ -0,0 +1,83 @@ +// META: script=/resources/testharness.js +// META: script=/resources/testharnessreport.js +// META: script=/common/dispatcher/dispatcher.js +// META: script=/common/get-host-info.sub.js +// META: script=/common/utils.js +// META: script=/html/browsers/browsing-the-web/remote-context-helper/resources/remote-context-helper.js +// META: script=/html/browsers/browsing-the-web/back-forward-cache/resources/rc-helper.js +// META: script=./resources/pending_beacon-helper.js + +'use strict'; + +for (const beaconType of BeaconTypes) { + const beaconName = beaconType.name; + + parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + // backgroundTimeout = 0s means `beacon should be sent out right on + // entering `hidden` state after navigating away. + const options = {backgroundTimeout: 0}; + const helper = new RemoteContextHelper(); + // Opens a window with noopener so that BFCache will work. + const rc1 = await helper.addWindow( + /*config=*/ null, /*options=*/ {features: 'noopener'}); + + // Creates a PendingBeacon in remote which should only be sent on navigating + // away. + await rc1.executeScript((beaconName, url, options) => { + const beacon = beaconName == 'PendingGetBeacon' ? + new PendingGetBeacon(url, options) : + new PendingPostBeacon(url, options); + }, [beaconName, url, options]); + + await expectBeacon(uuid, {count: 0}); + }, `${beaconName}: does not send without page navigation.`); + + parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + // backgroundTimeout = 0s means `beacon should be sent out right on + // entering `hidden` state after navigating away. + const options = {backgroundTimeout: 0}; + const helper = new RemoteContextHelper(); + // Opens a window with noopener so that BFCache will work. + const rc1 = await helper.addWindow( + /*config=*/ null, /*options=*/ {features: 'noopener'}); + + // Creates a PendingBeacon in remote which should only be sent on navigating + // away. + await rc1.executeScript((beaconName, url, options) => { + const beacon = beaconName == 'PendingGetBeacon' ? + new PendingGetBeacon(url, options) : + new PendingPostBeacon(url, options); + }, [beaconName, url, options]); + // Navigates away to trigger beacon sending. + rc1.navigateToNew(); + + await expectBeacon(uuid, {count: 1}); + }, `${beaconName}: sends on page entering hidden state (w/ BFCache).`); + + parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + // backgroundTimeout = 0s means `beacon should be sent out right on + // entering `hidden` state after navigating away. + const options = {backgroundTimeout: 0}; + const helper = new RemoteContextHelper(); + // Opens a window without BFCache. + const rc1 = await helper.addWindow(); + + // Creates a PendingBeacon in remote which should only be sent on navigating + // away. + await rc1.executeScript((beaconName, url, options) => { + const beacon = beaconName == 'PendingGetBeacon' ? + new PendingGetBeacon(url, options) : + new PendingPostBeacon(url, options); + }, [beaconName, url, options]); + // Navigates away to trigger beacon sending. + rc1.navigateToNew(); + + await expectBeacon(uuid, {count: 1}); + }, `${beaconName}: sends on page entering hidden state (w/o BFCache).`); +} diff --git a/testing/web-platform/tests/pending-beacon/pending_get_beacon-cors.tentative.https.window.js b/testing/web-platform/tests/pending-beacon/pending_get_beacon-cors.tentative.https.window.js new file mode 100644 index 0000000000..10bb3a0bed --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/pending_get_beacon-cors.tentative.https.window.js @@ -0,0 +1,28 @@ +// META: script=/common/get-host-info.sub.js +// META: script=/common/utils.js +// META: script=./resources/pending_beacon-helper.js + +'use strict'; + +const {HTTPS_ORIGIN, HTTPS_NOTSAMESITE_ORIGIN} = get_host_info(); + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid, {host: HTTPS_ORIGIN}); + + let beacon = new PendingGetBeacon(url); + beacon.sendNow(); + + await expectBeacon(uuid, {count: 1}); +}, 'PendingGetBeacon: same-origin'); + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL( + uuid, {host: HTTPS_NOTSAMESITE_ORIGIN, expectOrigin: HTTPS_ORIGIN}); + + let beacon = new PendingGetBeacon(url); + beacon.sendNow(); + + await expectBeacon(uuid, {count: 1}); +}, 'PendingGetBeacon: cross-origin'); diff --git a/testing/web-platform/tests/pending-beacon/pending_get_beacon-send.tentative.https.window.js b/testing/web-platform/tests/pending-beacon/pending_get_beacon-send.tentative.https.window.js new file mode 100644 index 0000000000..8c6e826af5 --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/pending_get_beacon-send.tentative.https.window.js @@ -0,0 +1,38 @@ +// META: script=/common/utils.js +// META: script=./resources/pending_beacon-helper.js + +'use strict'; + +const baseUrl = `${location.protocol}//${location.host}`; + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + + let beacon = new PendingGetBeacon('/'); + + beacon.setURL(url); + assert_equals(beacon.url, url); + beacon.sendNow(); + + await expectBeacon(uuid, {count: 1}); +}, 'PendingGetBeacon is sent to the updated URL'); + +parallelPromiseTest(async t => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + + let beacon = new PendingGetBeacon('/0'); + + for (let i = 0; i < 10; i++) { + const transientUrl = `/${i}`; + beacon.setURL(transientUrl); + assert_equals(beacon.url, transientUrl); + } + beacon.setURL(url); + assert_equals(beacon.url, url); + + beacon.sendNow(); + + await expectBeacon(uuid, {count: 1}); +}, 'PendingGetBeacon is sent to the last updated URL'); diff --git a/testing/web-platform/tests/pending-beacon/pending_post_beacon-cors.tentative.https.window.js b/testing/web-platform/tests/pending-beacon/pending_post_beacon-cors.tentative.https.window.js new file mode 100644 index 0000000000..01511d22c1 --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/pending_post_beacon-cors.tentative.https.window.js @@ -0,0 +1,66 @@ +// META: script=/common/get-host-info.sub.js +// META: script=/common/utils.js +// META: script=./resources/pending_beacon-helper.js + +'use strict'; + +const {HTTPS_ORIGIN, HTTPS_NOTSAMESITE_ORIGIN} = get_host_info(); +const SMALL_SIZE = 500; + +for (const dataType in BeaconDataType) { + postBeaconSendDataTest( + dataType, generatePayload(SMALL_SIZE), + `PendingPostBeacon[${dataType}]: same-origin`, + {urlOptions: {host: HTTPS_ORIGIN, expectOrigin: HTTPS_ORIGIN}}); + + postBeaconSendDataTest( + dataType, generatePayload(SMALL_SIZE), + `PendingPostBeacon[${dataType}]: cross-origin, ` + + `CORS-safelisted Content-Type`, + { + urlOptions: { + host: HTTPS_NOTSAMESITE_ORIGIN, + expectOrigin: HTTPS_ORIGIN, + } + }); + + postBeaconSendDataTest( + dataType, generatePayload(SMALL_SIZE), + `PendingPostBeacon[${dataType}]: cross-origin, ` + + 'CORS-safelisted Content-Type => ' + + 'non-CORS response (from redirect handler) ' + + 'should be rejected by browser', + { + expectCount: 0, + urlOptions: { + useRedirectHandler: true, + host: HTTPS_NOTSAMESITE_ORIGIN, + } + }); + + postBeaconSendDataTest( + dataType, generatePayload(SMALL_SIZE), + `PendingPostBeacon[${dataType}]: cross-origin, ` + + 'CORS-safelisted Content-Type => no cookie expected', + { + setCookie: 'test_beacon_cookie', + urlOptions: { + host: HTTPS_NOTSAMESITE_ORIGIN, + expectOrigin: HTTPS_ORIGIN, + expectCredentials: false, + } + }); +} + +postBeaconSendDataTest( + BeaconDataType.Blob, generatePayload(SMALL_SIZE), + 'PendingPostBeacon[Blob]: cross-origin, non-CORS-safelisted Content-Type' + + ' => preflight expected', + { + urlOptions: { + host: HTTPS_NOTSAMESITE_ORIGIN, + contentType: 'application/octet-stream', + expectOrigin: HTTPS_ORIGIN, + expectPreflight: true, + } + }); diff --git a/testing/web-platform/tests/pending-beacon/pending_post_beacon-sendwithdata.tentative.https.window.js b/testing/web-platform/tests/pending-beacon/pending_post_beacon-sendwithdata.tentative.https.window.js new file mode 100644 index 0000000000..77e91479e8 --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/pending_post_beacon-sendwithdata.tentative.https.window.js @@ -0,0 +1,43 @@ +// META: script=/common/utils.js +// META: script=./resources/pending_beacon-helper.js + +'use strict'; + +// Test empty data. +for (const dataType in BeaconDataType) { + postBeaconSendDataTest( + dataType, '', `Sent empty ${dataType}, and server got no data.`, { + expectNoData: true, + }); +} + +// Test small payload. +for (const [dataType, skipCharset] of Object.entries( + BeaconDataTypeToSkipCharset)) { + postBeaconSendDataTest( + dataType, generateSequentialData(0, 1024, skipCharset), + 'Encoded and sent in POST request.'); +} + +// Test large payload. +for (const dataType in BeaconDataType) { + postBeaconSendDataTest( + dataType, generatePayload(65536), 'Sent out big data.'); +} + +test(() => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + let beacon = new PendingPostBeacon(url); + assert_throws_js(TypeError, () => beacon.setData(new ReadableStream())); +}, 'setData() does not support ReadableStream.'); + +test(() => { + const uuid = token(); + const url = generateSetBeaconURL(uuid); + let beacon = new PendingPostBeacon(url); + const formData = new FormData(); + formData.append('part1', 'value1'); + formData.append('part2', new Blob(['value2']), 'file.txt'); + assert_throws_js(RangeError, () => beacon.setData(formData)); +}, 'setData() does not support multi-parts data.'); diff --git a/testing/web-platform/tests/pending-beacon/resources/get_beacon.py b/testing/web-platform/tests/pending-beacon/resources/get_beacon.py new file mode 100644 index 0000000000..32cb9a9ba3 --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/resources/get_beacon.py @@ -0,0 +1,30 @@ +"""An HTTP request handler for WPT that handles /get_beacon.py requests.""" + +import json + +_BEACON_ID_KEY = b"uuid" +_BEACON_DATA_PATH = "beacon_data" + + +def main(request, response): + """Retrieves the beacon data keyed by the given uuid from server storage. + + The response content is a JSON string in one of the following formats: + - "{'data': ['abc', null, '123',...]}" + - "{'data': []}" indicates that no data has been set for this uuid. + """ + if _BEACON_ID_KEY not in request.GET: + response.status = 400 + return "Must provide a UUID to store beacon data" + uuid = request.GET.first(_BEACON_ID_KEY) + + with request.server.stash.lock: + body = {'data': []} + data = request.server.stash.take(key=uuid, path=_BEACON_DATA_PATH) + if data: + body['data'] = data + # The stash is read-once/write-once, so it has to be put back after + # reading if `data` is not None. + request.server.stash.put( + key=uuid, value=data, path=_BEACON_DATA_PATH) + return [(b'Content-Type', b'text/plain')], json.dumps(body) diff --git a/testing/web-platform/tests/pending-beacon/resources/pending_beacon-helper.js b/testing/web-platform/tests/pending-beacon/resources/pending_beacon-helper.js new file mode 100644 index 0000000000..3e8bd20f76 --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/resources/pending_beacon-helper.js @@ -0,0 +1,236 @@ +'use strict'; + +const ROOT_NAME = 'pending-beacon'; + +function parallelPromiseTest(func, description) { + async_test((t) => { + Promise.resolve(func(t)).then(() => t.done()).catch(t.step_func((e) => { + throw e; + })); + }, description); +} + +const BeaconTypes = [ + {type: PendingPostBeacon, name: 'PendingPostBeacon', expectedMethod: 'POST'}, + {type: PendingGetBeacon, name: 'PendingGetBeacon', expectedMethod: 'GET'}, +]; + +/** @enum {string} */ +const BeaconDataType = { + String: 'String', + ArrayBuffer: 'ArrayBuffer', + FormData: 'FormData', + URLSearchParams: 'URLSearchParams', + Blob: 'Blob', + File: 'File', +}; + +/** @enum {string} */ +const BeaconDataTypeToSkipCharset = { + String: '', + ArrayBuffer: '', + FormData: '\n\r', // CRLF characters will be normalized by FormData + URLSearchParams: ';,/?:@&=+$', // reserved URI characters + Blob: '', + File: '', +}; + +const BEACON_PAYLOAD_KEY = 'payload'; + +// Creates beacon data of the given `dataType` from `data`. +// @param {string} data - A string representation of the beacon data. Note that +// it cannot contain UTF-16 surrogates for all `BeaconDataType` except BLOB. +// @param {BeaconDataType} dataType - must be one of `BeaconDataType`. +// @param {string} contentType - Request Content-Type. +function makeBeaconData(data, dataType, contentType) { + switch (dataType) { + case BeaconDataType.String: + return data; + case BeaconDataType.ArrayBuffer: + return new TextEncoder().encode(data).buffer; + case BeaconDataType.FormData: + const formData = new FormData(); + if (data.length > 0) { + formData.append(BEACON_PAYLOAD_KEY, data); + } + return formData; + case BeaconDataType.URLSearchParams: + if (data.length > 0) { + return new URLSearchParams(`${BEACON_PAYLOAD_KEY}=${data}`); + } + return new URLSearchParams(); + case BeaconDataType.Blob: { + const options = {type: contentType || undefined}; + return new Blob([data], options); + } + case BeaconDataType.File: { + const options = {type: contentType || 'text/plain'}; + return new File([data], 'file.txt', options); + } + default: + throw Error(`Unsupported beacon dataType: ${dataType}`); + } +} + +// Create a string of `end`-`begin` characters, with characters starting from +// UTF-16 code unit `begin` to `end`-1. +function generateSequentialData(begin, end, skip) { + const codeUnits = Array(end - begin).fill().map((el, i) => i + begin); + if (skip) { + return String.fromCharCode( + ...codeUnits.filter(c => !skip.includes(String.fromCharCode(c)))); + } + return String.fromCharCode(...codeUnits); +} + +function generatePayload(size) { + let data = ''; + if (size > 0) { + const prefix = String(size) + ':'; + data = prefix + Array(size - prefix.length).fill('*').join(''); + } + return data; +} + +function generateSetBeaconURL(uuid, options) { + const host = (options && options.host) || ''; + let url = `${host}/${ROOT_NAME}/resources/set_beacon.py?uuid=${uuid}`; + if (options) { + if (options.expectOrigin !== undefined) { + url = `${url}&expectOrigin=${options.expectOrigin}`; + } + if (options.expectPreflight !== undefined) { + url = `${url}&expectPreflight=${options.expectPreflight}`; + } + if (options.expectCredentials !== undefined) { + url = `${url}&expectCredentials=${options.expectCredentials}`; + } + + if (options.useRedirectHandler) { + const redirect = `${host}/common/redirect.py` + + `?location=${encodeURIComponent(url)}`; + url = redirect; + } + } + return url; +} + +async function poll(asyncFunc, expected) { + const maxRetries = 30; + const waitInterval = 100; // milliseconds. + const delay = ms => new Promise(res => setTimeout(res, ms)); + + let result = {data: []}; + for (let i = 0; i < maxRetries; i++) { + result = await asyncFunc(); + if (!expected(result)) { + await delay(waitInterval); + continue; + } + return result; + } + return result; +} + +// Waits until the `options.count` number of beacon data available from the +// server. Defaults to 1. +// If `options.data` is set, it will be used to compare with the data from the +// response. +async function expectBeacon(uuid, options) { + const expectedCount = + (options && options.count !== undefined) ? options.count : 1; + + const res = await poll( + async () => { + const res = await fetch( + `/${ROOT_NAME}/resources/get_beacon.py?uuid=${uuid}`, + {cache: 'no-store'}); + return await res.json(); + }, + (res) => { + if (expectedCount == 0) { + // If expecting no beacon, we should try to wait as long as possible. + // So always returning false here until `poll()` decides to terminate + // itself. + return false; + } + return res.data.length == expectedCount; + }); + if (!options || !options.data) { + assert_equals( + res.data.length, expectedCount, + 'Number of sent beacons does not match expected count:'); + return; + } + + if (expectedCount == 0) { + assert_equals( + res.data.length, 0, + 'Number of sent beacons does not match expected count:'); + return; + } + + const decoder = options && options.percentDecoded ? (s) => { + // application/x-www-form-urlencoded serializer encodes space as '+' + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent + s = s.replace(/\+/g, '%20'); + return decodeURIComponent(s); + } : (s) => s; + + assert_equals( + res.data.length, options.data.length, + `The size of beacon data ${ + res.data.length} from server does not match expected value ${ + options.data.length}.`); + for (let i = 0; i < options.data.length; i++) { + assert_equals( + decoder(res.data[i]), options.data[i], + 'The beacon data does not match expected value.'); + } +} + +function postBeaconSendDataTest(dataType, testData, description, options) { + parallelPromiseTest(async t => { + const expectNoData = options && options.expectNoData; + const expectCount = (options && options.expectCount !== undefined) ? + options.expectCount : + 1; + const uuid = token(); + const url = + generateSetBeaconURL(uuid, (options && options.urlOptions) || {}); + const beacon = new PendingPostBeacon(url); + assert_equals(beacon.method, 'POST', 'must be POST to call setData().'); + + if (options && options.setCookie) { + document.cookie = options.setCookie; + } + + beacon.setData(makeBeaconData( + testData, dataType, (options && options.contentType) || {})); + beacon.sendNow(); + + const expectedData = expectNoData ? null : testData; + const percentDecoded = + !expectNoData && dataType === BeaconDataType.URLSearchParams; + await expectBeacon(uuid, { + count: expectCount, + data: [expectedData], + percentDecoded: percentDecoded + }); + }, `PendingPostBeacon(${dataType}): ${description}`); +} + +function generateHTML(script) { + return `<!DOCTYPE html><body><script>${script}</script></body>`; +} + +// Loads `script` into an iframe and appends it to the current document. +// Returns the loaded iframe element. +async function loadScriptAsIframe(script) { + const iframe = document.createElement('iframe'); + iframe.srcdoc = generateHTML(script); + const iframeLoaded = new Promise(resolve => iframe.onload = resolve); + document.body.appendChild(iframe); + await iframeLoaded; + return iframe; +} diff --git a/testing/web-platform/tests/pending-beacon/resources/set_beacon.py b/testing/web-platform/tests/pending-beacon/resources/set_beacon.py new file mode 100644 index 0000000000..1c71f23e57 --- /dev/null +++ b/testing/web-platform/tests/pending-beacon/resources/set_beacon.py @@ -0,0 +1,83 @@ +"""An HTTP request handler for WPT that handles /set_beacon.py requests.""" + +_BEACON_ID_KEY = b"uuid" +_BEACON_DATA_PATH = "beacon_data" +_BEACON_FORM_PAYLOAD_KEY = b"payload" +_BEACON_BODY_PAYLOAD_KEY = "payload=" +_BEACON_EXPECT_ORIGIN_KEY = b"expectOrigin" +_BEACON_EXPECT_PREFLIGHT_KEY = b"expectPreflight" +_BEACON_EXPECT_CREDS_KEY = b"expectCredentials" + + +def main(request, response): + """Stores the given beacon's data keyed by uuid in the server. + + For GET request, this handler assumes no data. + For POST request, this handler extracts data from request body: + - Content-Type=multipart/form-data: data keyed by 'payload'. + - the entire request body. + + Multiple data can be added for the same uuid. + + The data is stored as UTF-8 format. + """ + if _BEACON_ID_KEY not in request.GET: + response.status = 400 + return "Must provide a UUID to store beacon data" + uuid = request.GET.first(_BEACON_ID_KEY) + + expected_origin = request.GET.get(_BEACON_EXPECT_ORIGIN_KEY) + if b"origin" in request.headers: + origin = request.headers.get(b"origin") + if expected_origin: + assert origin == expected_origin, f"expected {expected_origin}, got {origin}" + response.headers.set(b"Access-Control-Allow-Origin", origin) + else: + assert expected_origin is None, f"expected None, got {expected_origin}" + + # Handles preflight request first. + if request.method == u"OPTIONS": + assert request.GET.get( + _BEACON_EXPECT_PREFLIGHT_KEY) == b"true", "Preflight not expected." + + # preflight must not have cookies. + assert b"Cookie" not in request.headers + + requested_headers = request.headers.get( + b"Access-Control-Request-Headers") + assert b"content-type" in requested_headers, f"expected content-type, got {requested_headers}" + response.headers.set(b"Access-Control-Allow-Headers", b"content-type") + + requested_method = request.headers.get(b"Access-Control-Request-Method") + assert requested_method == b"POST", f"expected POST, got {requested_method}" + response.headers.set(b"Access-Control-Allow-Methods", b"POST") + + return response + + expect_creds = request.GET.get(_BEACON_EXPECT_CREDS_KEY) == b"true" + if expect_creds: + assert b"Cookie" in request.headers + else: + assert b"Cookie" not in request.headers + + data = None + if request.method == u"POST": + if b"multipart/form-data" in request.headers.get(b"Content-Type", b""): + if _BEACON_FORM_PAYLOAD_KEY in request.POST: + data = request.POST.first(_BEACON_FORM_PAYLOAD_KEY).decode( + 'utf-8') + elif request.body: + data = request.body.decode('utf-8') + if data.startswith(_BEACON_BODY_PAYLOAD_KEY): + data = data.split(_BEACON_BODY_PAYLOAD_KEY)[1] + + with request.server.stash.lock: + saved_data = request.server.stash.take(key=uuid, path=_BEACON_DATA_PATH) + if not saved_data: + saved_data = [data] + else: + saved_data.append(data) + request.server.stash.put( + key=uuid, value=saved_data, path=_BEACON_DATA_PATH) + + response.status = 200 |