summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/fetch/range
diff options
context:
space:
mode:
Diffstat (limited to 'testing/web-platform/tests/fetch/range')
-rw-r--r--testing/web-platform/tests/fetch/range/blob.any.js233
-rw-r--r--testing/web-platform/tests/fetch/range/data.any.js29
-rw-r--r--testing/web-platform/tests/fetch/range/general.any.js140
-rw-r--r--testing/web-platform/tests/fetch/range/general.window.js29
-rw-r--r--testing/web-platform/tests/fetch/range/non-matching-range-response.html34
-rw-r--r--testing/web-platform/tests/fetch/range/resources/basic.html1
-rw-r--r--testing/web-platform/tests/fetch/range/resources/long-wav.py134
-rw-r--r--testing/web-platform/tests/fetch/range/resources/partial-script.py29
-rw-r--r--testing/web-platform/tests/fetch/range/resources/partial-text.py53
-rw-r--r--testing/web-platform/tests/fetch/range/resources/range-sw.js218
-rw-r--r--testing/web-platform/tests/fetch/range/resources/stash-take.py7
-rw-r--r--testing/web-platform/tests/fetch/range/resources/utils.js36
-rw-r--r--testing/web-platform/tests/fetch/range/resources/video-with-range.py43
-rw-r--r--testing/web-platform/tests/fetch/range/sw.https.window.js228
14 files changed, 1214 insertions, 0 deletions
diff --git a/testing/web-platform/tests/fetch/range/blob.any.js b/testing/web-platform/tests/fetch/range/blob.any.js
new file mode 100644
index 0000000000..7bcd4b9d11
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/blob.any.js
@@ -0,0 +1,233 @@
+// META: script=/common/utils.js
+
+const supportedBlobRange = [
+ {
+ name: "A simple blob range request.",
+ data: ["A simple Hello, World! example"],
+ type: "text/plain",
+ range: "bytes=9-21",
+ content_length: 13,
+ content_range: "bytes 9-21/30",
+ result: "Hello, World!",
+ },
+ {
+ name: "A blob range request with no type.",
+ data: ["A simple Hello, World! example"],
+ type: undefined,
+ range: "bytes=9-21",
+ content_length: 13,
+ content_range: "bytes 9-21/30",
+ result: "Hello, World!",
+ },
+ {
+ name: "A blob range request with no end.",
+ data: ["Range with no end"],
+ type: "text/plain",
+ range: "bytes=11-",
+ content_length: 6,
+ content_range: "bytes 11-16/17",
+ result: "no end",
+ },
+ {
+ name: "A blob range request with no start.",
+ data: ["Range with no start"],
+ type: "text/plain",
+ range: "bytes=-8",
+ content_length: 8,
+ content_range: "bytes 11-18/19",
+ result: "no start",
+ },
+ {
+ name: "A simple blob range request with whitespace.",
+ data: ["A simple Hello, World! example"],
+ type: "text/plain",
+ range: "bytes= \t9-21",
+ content_length: 13,
+ content_range: "bytes 9-21/30",
+ result: "Hello, World!",
+ },
+ {
+ name: "Blob content with short content and a large range end",
+ data: ["Not much here"],
+ type: "text/plain",
+ range: "bytes=4-100000000000",
+ content_length: 9,
+ content_range: "bytes 4-12/13",
+ result: "much here",
+ },
+ {
+ name: "Blob content with short content and a range end matching content length",
+ data: ["Not much here"],
+ type: "text/plain",
+ range: "bytes=4-13",
+ content_length: 9,
+ content_range: "bytes 4-12/13",
+ result: "much here",
+ },
+ {
+ name: "Blob range with whitespace before and after hyphen",
+ data: ["Valid whitespace #1"],
+ type: "text/plain",
+ range: "bytes=5 - 10",
+ content_length: 6,
+ content_range: "bytes 5-10/19",
+ result: " white",
+ },
+ {
+ name: "Blob range with whitespace after hyphen",
+ data: ["Valid whitespace #2"],
+ type: "text/plain",
+ range: "bytes=-\t 5",
+ content_length: 5,
+ content_range: "bytes 14-18/19",
+ result: "ce #2",
+ },
+ {
+ name: "Blob range with whitespace around equals sign",
+ data: ["Valid whitespace #3"],
+ type: "text/plain",
+ range: "bytes \t =\t 6-",
+ content_length: 13,
+ content_range: "bytes 6-18/19",
+ result: "whitespace #3",
+ },
+];
+
+const unsupportedBlobRange = [
+ {
+ name: "Blob range with no value",
+ data: ["Blob range should have a value"],
+ type: "text/plain",
+ range: "",
+ },
+ {
+ name: "Blob range with incorrect range header",
+ data: ["A"],
+ type: "text/plain",
+ range: "byte=0-"
+ },
+ {
+ name: "Blob range with incorrect range header #2",
+ data: ["A"],
+ type: "text/plain",
+ range: "bytes"
+ },
+ {
+ name: "Blob range with incorrect range header #3",
+ data: ["A"],
+ type: "text/plain",
+ range: "bytes\t \t"
+ },
+ {
+ name: "Blob range request with multiple range values",
+ data: ["Multiple ranges are not currently supported"],
+ type: "text/plain",
+ range: "bytes=0-5,15-",
+ },
+ {
+ name: "Blob range request with multiple range values and whitespace",
+ data: ["Multiple ranges are not currently supported"],
+ type: "text/plain",
+ range: "bytes=0-5, 15-",
+ },
+ {
+ name: "Blob range request with trailing comma",
+ data: ["Range with invalid trailing comma"],
+ type: "text/plain",
+ range: "bytes=0-5,",
+ },
+ {
+ name: "Blob range with no start or end",
+ data: ["Range with no start or end"],
+ type: "text/plain",
+ range: "bytes=-",
+ },
+ {
+ name: "Blob range request with short range end",
+ data: ["Range end should be greater than range start"],
+ type: "text/plain",
+ range: "bytes=10-5",
+ },
+ {
+ name: "Blob range start should be an ASCII digit",
+ data: ["Range start must be an ASCII digit"],
+ type: "text/plain",
+ range: "bytes=x-5",
+ },
+ {
+ name: "Blob range should have a dash",
+ data: ["Blob range should have a dash"],
+ type: "text/plain",
+ range: "bytes=5",
+ },
+ {
+ name: "Blob range end should be an ASCII digit",
+ data: ["Range end must be an ASCII digit"],
+ type: "text/plain",
+ range: "bytes=5-x",
+ },
+ {
+ name: "Blob range should include '-'",
+ data: ["Range end must include '-'"],
+ type: "text/plain",
+ range: "bytes=x",
+ },
+ {
+ name: "Blob range should include '='",
+ data: ["Range end must include '='"],
+ type: "text/plain",
+ range: "bytes 5-",
+ },
+ {
+ name: "Blob range should include 'bytes='",
+ data: ["Range end must include 'bytes='"],
+ type: "text/plain",
+ range: "5-",
+ },
+ {
+ name: "Blob content with short content and a large range start",
+ data: ["Not much here"],
+ type: "text/plain",
+ range: "bytes=100000-",
+ },
+ {
+ name: "Blob content with short content and a range start matching the content length",
+ data: ["Not much here"],
+ type: "text/plain",
+ range: "bytes=13-",
+ },
+];
+
+supportedBlobRange.forEach(({ name, data, type, range, content_length, content_range, result }) => {
+ promise_test(async t => {
+ const blob = new Blob(data, { "type" : type });
+ const blobURL = URL.createObjectURL(blob);
+ t.add_cleanup(() => URL.revokeObjectURL(blobURL));
+ const resp = await fetch(blobURL, {
+ "headers": {
+ "Range": range
+ }
+ });
+ assert_equals(resp.status, 206, "HTTP status is 206");
+ assert_equals(resp.type, "basic", "response type is basic");
+ assert_equals(resp.headers.get("Content-Type"), type || "", "Content-Type is " + resp.headers.get("Content-Type"));
+ assert_equals(resp.headers.get("Content-Length"), content_length.toString(), "Content-Length is " + resp.headers.get("Content-Length"));
+ assert_equals(resp.headers.get("Content-Range"), content_range, "Content-Range is " + resp.headers.get("Content-Range"));
+ const text = await resp.text();
+ assert_equals(text, result, "Response's body is correct");
+ }, name);
+});
+
+unsupportedBlobRange.forEach(({ name, data, type, range }) => {
+ promise_test(t => {
+ const blob = new Blob(data, { "type" : type });
+ const blobURL = URL.createObjectURL(blob);
+ t.add_cleanup(() => URL.revokeObjectURL(blobURL));
+ const promise = fetch(blobURL, {
+ "headers": {
+ "Range": range
+ }
+ });
+ return promise_rejects_js(t, TypeError, promise);
+ }, name);
+});
diff --git a/testing/web-platform/tests/fetch/range/data.any.js b/testing/web-platform/tests/fetch/range/data.any.js
new file mode 100644
index 0000000000..22ef11e931
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/data.any.js
@@ -0,0 +1,29 @@
+// META: script=/common/utils.js
+
+promise_test(async () => {
+ return fetch("data:text/plain;charset=US-ASCII,paddingHello%2C%20World%21padding", {
+ "method": "GET",
+ "Range": "bytes=13-26"
+ }).then(function(resp) {
+ assert_equals(resp.status, 200, "HTTP status is 200");
+ assert_equals(resp.type, "basic", "response type is basic");
+ assert_equals(resp.headers.get("Content-Type"), "text/plain;charset=US-ASCII", "Content-Type is " + resp.headers.get("Content-Type"));
+ return resp.text();
+ }).then(function(text) {
+ assert_equals(text, 'paddingHello, World!padding', "Response's body ignores range");
+ });
+}, "data: URL and Range header");
+
+promise_test(async () => {
+ return fetch("data:text/plain;charset=US-ASCII,paddingHello%2C%20paddingWorld%21padding", {
+ "method": "GET",
+ "Range": "bytes=7-14,21-27"
+ }).then(function(resp) {
+ assert_equals(resp.status, 200, "HTTP status is 200");
+ assert_equals(resp.type, "basic", "response type is basic");
+ assert_equals(resp.headers.get("Content-Type"), "text/plain;charset=US-ASCII", "Content-Type is " + resp.headers.get("Content-Type"));
+ return resp.text();
+ }).then(function(text) {
+ assert_equals(text, 'paddingHello, paddingWorld!padding', "Response's body ignores range");
+ });
+}, "data: URL and Range header with multiple ranges");
diff --git a/testing/web-platform/tests/fetch/range/general.any.js b/testing/web-platform/tests/fetch/range/general.any.js
new file mode 100644
index 0000000000..64b225a60b
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/general.any.js
@@ -0,0 +1,140 @@
+// META: timeout=long
+// META: global=window,worker
+// META: script=/common/get-host-info.sub.js
+// META: script=/common/utils.js
+
+// Helpers that return headers objects with a particular guard
+function headersGuardNone(fill) {
+ if (fill) return new Headers(fill);
+ return new Headers();
+}
+
+function headersGuardResponse(fill) {
+ const opts = {};
+ if (fill) opts.headers = fill;
+ return new Response('', opts).headers;
+}
+
+function headersGuardRequest(fill) {
+ const opts = {};
+ if (fill) opts.headers = fill;
+ return new Request('./', opts).headers;
+}
+
+function headersGuardRequestNoCors(fill) {
+ const opts = { mode: 'no-cors' };
+ if (fill) opts.headers = fill;
+ return new Request('./', opts).headers;
+}
+
+const headerGuardTypes = [
+ ['none', headersGuardNone],
+ ['response', headersGuardResponse],
+ ['request', headersGuardRequest]
+];
+
+for (const [guardType, createHeaders] of headerGuardTypes) {
+ test(() => {
+ // There are three ways to set headers.
+ // Filling, appending, and setting. Test each:
+ let headers = createHeaders({ Range: 'foo' });
+ assert_equals(headers.get('Range'), 'foo');
+
+ headers = createHeaders();
+ headers.append('Range', 'foo');
+ assert_equals(headers.get('Range'), 'foo');
+
+ headers = createHeaders();
+ headers.set('Range', 'foo');
+ assert_equals(headers.get('Range'), 'foo');
+ }, `Range header setting allowed for guard type: ${guardType}`);
+}
+
+test(() => {
+ let headers = headersGuardRequestNoCors({ Range: 'foo' });
+ assert_false(headers.has('Range'));
+
+ headers = headersGuardRequestNoCors();
+ headers.append('Range', 'foo');
+ assert_false(headers.has('Range'));
+
+ headers = headersGuardRequestNoCors();
+ headers.set('Range', 'foo');
+ assert_false(headers.has('Range'));
+}, `Privileged header not allowed for guard type: request-no-cors`);
+
+promise_test(async () => {
+ const wavURL = new URL('resources/long-wav.py', location);
+ const stashTakeURL = new URL('resources/stash-take.py', location);
+
+ function changeToken() {
+ const stashToken = token();
+ wavURL.searchParams.set('accept-encoding-key', stashToken);
+ stashTakeURL.searchParams.set('key', stashToken);
+ }
+
+ const rangeHeaders = [
+ 'bytes=0-10',
+ 'foo=0-10',
+ 'foo',
+ ''
+ ];
+
+ for (const rangeHeader of rangeHeaders) {
+ changeToken();
+
+ await fetch(wavURL, {
+ headers: { Range: rangeHeader }
+ });
+
+ const response = await fetch(stashTakeURL);
+
+ assert_regexp_match(await response.json(),
+ /.*\bidentity\b.*/,
+ `Expect identity accept-encoding if range header is ${JSON.stringify(rangeHeader)}`);
+ }
+}, `Fetch with range header will be sent with Accept-Encoding: identity`);
+
+promise_test(async () => {
+ const wavURL = new URL(get_host_info().HTTP_REMOTE_ORIGIN + '/fetch/range/resources/long-wav.py');
+ const stashTakeURL = new URL('resources/stash-take.py', location);
+
+ function changeToken() {
+ const stashToken = token();
+ wavURL.searchParams.set('accept-encoding-key', stashToken);
+ stashTakeURL.searchParams.set('key', stashToken);
+ }
+
+ const rangeHeaders = [
+ 'bytes=10-9',
+ 'bytes=-0',
+ 'bytes=0000000000000000000000000000000000000000000000000000000000011-0000000000000000000000000000000000000000000000000000000000111',
+ ];
+
+ for (const rangeHeader of rangeHeaders) {
+ changeToken();
+ await fetch(wavURL, { headers: { Range : rangeHeader} }).then(() => { throw "loaded with range header " + rangeHeader }, () => { });
+ }
+}, `Cross Origin Fetch with non safe range header`);
+
+promise_test(async () => {
+ const wavURL = new URL(get_host_info().HTTP_REMOTE_ORIGIN + '/fetch/range/resources/long-wav.py');
+ const stashTakeURL = new URL('resources/stash-take.py', location);
+
+ function changeToken() {
+ const stashToken = token();
+ wavURL.searchParams.set('accept-encoding-key', stashToken);
+ stashTakeURL.searchParams.set('key', stashToken);
+ }
+
+ const rangeHeaders = [
+ 'bytes=0-10',
+ 'bytes=0-',
+ 'bytes=00000000000000000000000000000000000000000000000000000000011-00000000000000000000000000000000000000000000000000000000000111',
+ ];
+
+ for (const rangeHeader of rangeHeaders) {
+ changeToken();
+ await fetch(wavURL, { headers: { Range: rangeHeader } }).then(() => { }, () => { throw "failed load with range header " + rangeHeader });
+ }
+}, `Cross Origin Fetch with safe range header`);
diff --git a/testing/web-platform/tests/fetch/range/general.window.js b/testing/web-platform/tests/fetch/range/general.window.js
new file mode 100644
index 0000000000..afe80d63a6
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/general.window.js
@@ -0,0 +1,29 @@
+// META: script=resources/utils.js
+// META: script=/common/utils.js
+
+const onload = new Promise(r => window.addEventListener('load', r));
+
+// It's weird that browsers do this, but it should continue to work.
+promise_test(async t => {
+ await loadScript('resources/partial-script.py?pretend-offset=90000');
+ assert_true(self.scriptExecuted);
+}, `Script executed from partial response`);
+
+promise_test(async () => {
+ const wavURL = new URL('resources/long-wav.py', location);
+ const stashTakeURL = new URL('resources/stash-take.py', location);
+ const stashToken = token();
+ wavURL.searchParams.set('accept-encoding-key', stashToken);
+ stashTakeURL.searchParams.set('key', stashToken);
+
+ // The testing framework waits for window onload. If the audio element
+ // is appended before onload, it extends it, and the test times out.
+ await onload;
+
+ const audio = appendAudio(document, wavURL);
+ await new Promise(r => audio.addEventListener('progress', r));
+ audio.remove();
+
+ const response = await fetch(stashTakeURL);
+ assert_equals(await response.json(), 'identity', `Expect identity accept-encoding on media request`);
+}, `Fetch with range header will be sent with Accept-Encoding: identity`);
diff --git a/testing/web-platform/tests/fetch/range/non-matching-range-response.html b/testing/web-platform/tests/fetch/range/non-matching-range-response.html
new file mode 100644
index 0000000000..ba76c36766
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/non-matching-range-response.html
@@ -0,0 +1,34 @@
+<!DOCTYPE html>
+<meta name="timeout" content="long">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<body>
+<script>
+function range_rewrite_test(rewrites, expect, label) {
+ promise_test(async t => {
+ const url = new URL('resources/video-with-range.py', location.href);
+ const params = new URLSearchParams();
+ params.set('rewrites', JSON.stringify(rewrites));
+ url.search = params.toString();
+ const video = document.createElement('video');
+ video.autoplay = true;
+ video.muted = true;
+ video.src = url.toString();
+ const timeout = new Promise(resolve => t.step_timeout(() => resolve('timeout'), 10000));
+ const ok = new Promise(resolve => video.addEventListener('play', () => resolve('ok')));
+ t.add_cleanup(() => video.remove());
+ document.body.appendChild(video);
+ const result = await Promise.any([timeout, ok]);
+ assert_equals(result, 'ok');
+ }, `${label} should ${expect === 'ok' ? 'succeed' : 'fail'}`);
+}
+
+range_rewrite_test([], 'ok', 'Range requests with no rewrites');
+range_rewrite_test(
+ [
+ {request: ['0', '*'], response: [0, 100]},
+ {request: ['100', '*'], response: [50, 2000]}
+ ], 'ok', 'Range response out of range of request');
+range_rewrite_test([{request: ['0', '*'], status: 200}], 'ok', 'Range requests ignored (200 status)');
+</script>
+</body>
diff --git a/testing/web-platform/tests/fetch/range/resources/basic.html b/testing/web-platform/tests/fetch/range/resources/basic.html
new file mode 100644
index 0000000000..0e76edd65b
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/resources/basic.html
@@ -0,0 +1 @@
+<!DOCTYPE html>
diff --git a/testing/web-platform/tests/fetch/range/resources/long-wav.py b/testing/web-platform/tests/fetch/range/resources/long-wav.py
new file mode 100644
index 0000000000..acfc81a718
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/resources/long-wav.py
@@ -0,0 +1,134 @@
+"""
+This generates a 30 minute silent wav, and is capable of
+responding to Range requests.
+"""
+import time
+import re
+import struct
+
+from wptserve.utils import isomorphic_decode
+
+def create_wav_header(sample_rate, bit_depth, channels, duration):
+ bytes_per_sample = int(bit_depth / 8)
+ block_align = bytes_per_sample * channels
+ byte_rate = sample_rate * block_align
+ sub_chunk_2_size = duration * byte_rate
+
+ data = b''
+ # ChunkID
+ data += b'RIFF'
+ # ChunkSize
+ data += struct.pack('<L', 36 + sub_chunk_2_size)
+ # Format
+ data += b'WAVE'
+ # Subchunk1ID
+ data += b'fmt '
+ # Subchunk1Size
+ data += struct.pack('<L', 16)
+ # AudioFormat
+ data += struct.pack('<H', 1)
+ # NumChannels
+ data += struct.pack('<H', channels)
+ # SampleRate
+ data += struct.pack('<L', sample_rate)
+ # ByteRate
+ data += struct.pack('<L', byte_rate)
+ # BlockAlign
+ data += struct.pack('<H', block_align)
+ # BitsPerSample
+ data += struct.pack('<H', bit_depth)
+ # Subchunk2ID
+ data += b'data'
+ # Subchunk2Size
+ data += struct.pack('<L', sub_chunk_2_size)
+
+ return data
+
+
+def main(request, response):
+ if request.method == u"OPTIONS":
+ response.status = (404, b"Not Found")
+ response.headers.set(b"Content-Type", b"text/plain")
+ return b"Preflight not accepted"
+
+ response.headers.set(b"Content-Type", b"audio/wav")
+ response.headers.set(b"Accept-Ranges", b"bytes")
+ response.headers.set(b"Cache-Control", b"no-cache")
+ response.headers.set(b"Access-Control-Allow-Origin", request.headers.get(b'Origin', b''))
+
+ range_header = request.headers.get(b'Range', b'')
+ range_header_match = range_header and re.search(r'^bytes=(\d*)-(\d*)$', isomorphic_decode(range_header))
+ range_received_key = request.GET.first(b'range-received-key', b'')
+ accept_encoding_key = request.GET.first(b'accept-encoding-key', b'')
+
+ if range_received_key and range_header:
+ # Remove any current value
+ request.server.stash.take(range_received_key, b'/fetch/range/')
+ # This is later collected using stash-take.py
+ request.server.stash.put(range_received_key, u'range-header-received', b'/fetch/range/')
+
+ if accept_encoding_key:
+ # Remove any current value
+ request.server.stash.take(
+ accept_encoding_key,
+ b'/fetch/range/'
+ )
+ # This is later collected using stash-take.py
+ request.server.stash.put(
+ accept_encoding_key,
+ isomorphic_decode(request.headers.get(b'Accept-Encoding', b'')),
+ b'/fetch/range/'
+ )
+
+ # Audio details
+ sample_rate = 8000
+ bit_depth = 8
+ channels = 1
+ duration = 60 * 5
+
+ total_length = int((sample_rate * bit_depth * channels * duration) / 8)
+ bytes_remaining_to_send = total_length
+ initial_write = b''
+
+ if range_header_match:
+ response.status = 206
+ start, end = range_header_match.groups()
+
+ start = int(start)
+ end = int(end) if end else 0
+
+ if end:
+ bytes_remaining_to_send = (end + 1) - start
+ else:
+ bytes_remaining_to_send = total_length - start
+
+ wav_header = create_wav_header(sample_rate, bit_depth, channels, duration)
+
+ if start < len(wav_header):
+ initial_write = wav_header[start:]
+
+ if bytes_remaining_to_send < len(initial_write):
+ initial_write = initial_write[0:bytes_remaining_to_send]
+
+ content_range = b"bytes %d-%d/%d" % (start, end or total_length - 1, total_length)
+
+ response.headers.set(b"Content-Range", content_range)
+ else:
+ initial_write = create_wav_header(sample_rate, bit_depth, channels, duration)
+
+ response.headers.set(b"Content-Length", bytes_remaining_to_send)
+
+ response.write_status_headers()
+ response.writer.write(initial_write)
+
+ bytes_remaining_to_send -= len(initial_write)
+
+ while bytes_remaining_to_send > 0:
+ to_send = b'\x00' * min(bytes_remaining_to_send, sample_rate)
+ bytes_remaining_to_send -= len(to_send)
+
+ if not response.writer.write(to_send):
+ break
+
+ # Throttle the stream
+ time.sleep(0.5)
diff --git a/testing/web-platform/tests/fetch/range/resources/partial-script.py b/testing/web-platform/tests/fetch/range/resources/partial-script.py
new file mode 100644
index 0000000000..a9570ec355
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/resources/partial-script.py
@@ -0,0 +1,29 @@
+"""
+This generates a partial response containing valid JavaScript.
+"""
+
+def main(request, response):
+ require_range = request.GET.first(b'require-range', b'')
+ pretend_offset = int(request.GET.first(b'pretend-offset', b'0'))
+ range_header = request.headers.get(b'Range', b'')
+
+ if require_range and not range_header:
+ response.set_error(412, u"Range header required")
+ response.write()
+ return
+
+ response.headers.set(b"Content-Type", b"text/plain")
+ response.headers.set(b"Accept-Ranges", b"bytes")
+ response.headers.set(b"Cache-Control", b"no-cache")
+ response.status = 206
+
+ to_send = b'self.scriptExecuted = true;'
+ length = len(to_send)
+
+ content_range = b"bytes %d-%d/%d" % (
+ pretend_offset, pretend_offset + length - 1, pretend_offset + length)
+
+ response.headers.set(b"Content-Range", content_range)
+ response.headers.set(b"Content-Length", length)
+
+ response.content = to_send
diff --git a/testing/web-platform/tests/fetch/range/resources/partial-text.py b/testing/web-platform/tests/fetch/range/resources/partial-text.py
new file mode 100644
index 0000000000..fa3d1171b6
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/resources/partial-text.py
@@ -0,0 +1,53 @@
+"""
+This generates a partial response for a 100-byte text file.
+"""
+import re
+
+from wptserve.utils import isomorphic_decode
+
+def main(request, response):
+ total_length = int(request.GET.first(b'length', b'100'))
+ partial_code = int(request.GET.first(b'partial', b'206'))
+ content_type = request.GET.first(b'type', b'text/plain')
+ range_header = request.headers.get(b'Range', b'')
+
+ # Send a 200 if there is no range request
+ if not range_header:
+ to_send = ''.zfill(total_length)
+ response.headers.set(b"Content-Type", content_type)
+ response.headers.set(b"Cache-Control", b"no-cache")
+ response.headers.set(b"Content-Length", total_length)
+ response.content = to_send
+ return
+
+ # Simple range parsing, requires specifically "bytes=xxx-xxxx"
+ range_header_match = re.search(r'^bytes=(\d*)-(\d*)$', isomorphic_decode(range_header))
+ start, end = range_header_match.groups()
+ start = int(start)
+ end = int(end) if end else total_length
+ length = end - start
+
+ # Error the request if the range goes beyond the length
+ if length <= 0 or end > total_length:
+ response.set_error(416, u"Range Not Satisfiable")
+ # set_error sets the MIME type to application/json, which - for a
+ # no-cors media request - will be blocked by ORB. We'll just force
+ # the expected MIME type here, whichfixes the test, but doesn't make
+ # sense in general.
+ response.headers = [(b"Content-Type", content_type)]
+ response.write()
+ return
+
+ # Generate a partial response of the requested length
+ to_send = ''.zfill(length)
+ response.headers.set(b"Content-Type", content_type)
+ response.headers.set(b"Accept-Ranges", b"bytes")
+ response.headers.set(b"Cache-Control", b"no-cache")
+ response.status = partial_code
+
+ content_range = b"bytes %d-%d/%d" % (start, end, total_length)
+
+ response.headers.set(b"Content-Range", content_range)
+ response.headers.set(b"Content-Length", length)
+
+ response.content = to_send
diff --git a/testing/web-platform/tests/fetch/range/resources/range-sw.js b/testing/web-platform/tests/fetch/range/resources/range-sw.js
new file mode 100644
index 0000000000..b47823f03b
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/resources/range-sw.js
@@ -0,0 +1,218 @@
+importScripts('/resources/testharness.js');
+
+setup({ explicit_done: true });
+
+function assert_range_request(request, expectedRangeHeader, name) {
+ assert_equals(request.headers.get('Range'), expectedRangeHeader, name);
+}
+
+async function broadcast(msg) {
+ for (const client of await clients.matchAll()) {
+ client.postMessage(msg);
+ }
+}
+
+addEventListener('fetch', async event => {
+ /** @type Request */
+ const request = event.request;
+ const url = new URL(request.url);
+ const action = url.searchParams.get('action');
+
+ switch (action) {
+ case 'range-header-filter-test':
+ rangeHeaderFilterTest(request);
+ return;
+ case 'range-header-passthrough-test':
+ rangeHeaderPassthroughTest(event);
+ return;
+ case 'store-ranged-response':
+ storeRangedResponse(event);
+ return;
+ case 'use-stored-ranged-response':
+ useStoredRangeResponse(event);
+ return;
+ case 'broadcast-accept-encoding':
+ broadcastAcceptEncoding(event);
+ return;
+ case 'record-media-range-request':
+ return recordMediaRangeRequest(event);
+ case 'use-media-range-request':
+ useMediaRangeRequest(event);
+ return;
+ }
+});
+
+/**
+ * @param {Request} request
+ */
+function rangeHeaderFilterTest(request) {
+ const rangeValue = request.headers.get('Range');
+
+ test(() => {
+ assert_range_request(new Request(request), rangeValue, `Untampered`);
+ assert_range_request(new Request(request, {}), rangeValue, `Untampered (no init props set)`);
+ assert_range_request(new Request(request, { __foo: 'bar' }), rangeValue, `Untampered (only invalid props set)`);
+ assert_range_request(new Request(request, { mode: 'cors' }), rangeValue, `More permissive mode`);
+ assert_range_request(request.clone(), rangeValue, `Clone`);
+ }, "Range headers correctly preserved");
+
+ test(() => {
+ assert_range_request(new Request(request, { headers: { Range: 'foo' } }), null, `Tampered - range header set`);
+ assert_range_request(new Request(request, { headers: {} }), null, `Tampered - empty headers set`);
+ assert_range_request(new Request(request, { mode: 'no-cors' }), null, `Tampered – mode set`);
+ assert_range_request(new Request(request, { cache: 'no-cache' }), null, `Tampered – cache mode set`);
+ }, "Range headers correctly removed");
+
+ test(() => {
+ let headers;
+
+ headers = new Request(request).headers;
+ headers.delete('does-not-exist');
+ assert_equals(headers.get('Range'), rangeValue, `Preserved if no header actually removed`);
+
+ headers = new Request(request).headers;
+ headers.append('foo', 'bar');
+ assert_equals(headers.get('Range'), rangeValue, `Preserved if silent-failure on append (due to request-no-cors guard)`);
+
+ headers = new Request(request).headers;
+ headers.set('foo', 'bar');
+ assert_equals(headers.get('Range'), rangeValue, `Preserved if silent-failure on set (due to request-no-cors guard)`);
+
+ headers = new Request(request).headers;
+ headers.append('Range', 'foo');
+ assert_equals(headers.get('Range'), rangeValue, `Preserved if silent-failure on append (due to request-no-cors guard)`);
+
+ headers = new Request(request).headers;
+ headers.set('Range', 'foo');
+ assert_equals(headers.get('Range'), rangeValue, `Preserved if silent-failure on set (due to request-no-cors guard)`);
+
+ headers = new Request(request).headers;
+ headers.append('Accept', 'whatever');
+ assert_equals(headers.get('Range'), null, `Stripped if header successfully appended`);
+
+ headers = new Request(request).headers;
+ headers.set('Accept', 'whatever');
+ assert_equals(headers.get('Range'), null, `Stripped if header successfully set`);
+
+ headers = new Request(request).headers;
+ headers.delete('Accept');
+ assert_equals(headers.get('Range'), null, `Stripped if header successfully deleted`);
+
+ headers = new Request(request).headers;
+ headers.delete('Range');
+ assert_equals(headers.get('Range'), null, `Stripped if range header successfully deleted`);
+ }, "Headers correctly filtered");
+
+ done();
+}
+
+function rangeHeaderPassthroughTest(event) {
+ /** @type Request */
+ const request = event.request;
+ const url = new URL(request.url);
+ const key = url.searchParams.get('range-received-key');
+
+ event.waitUntil(new Promise(resolve => {
+ promise_test(async () => {
+ await fetch(event.request);
+ const response = await fetch('stash-take.py?key=' + key);
+ assert_equals(await response.json(), 'range-header-received');
+ resolve();
+ }, `Include range header in network request`);
+
+ done();
+ }));
+
+ // Just send back any response, it isn't important for the test.
+ event.respondWith(new Response(''));
+}
+
+let storedRangeResponseP;
+
+function storeRangedResponse(event) {
+ /** @type Request */
+ const request = event.request;
+ const id = new URL(request.url).searchParams.get('id');
+
+ storedRangeResponseP = fetch(event.request);
+ broadcast({ id });
+
+ // Just send back any response, it isn't important for the test.
+ event.respondWith(new Response(''));
+}
+
+function useStoredRangeResponse(event) {
+ event.respondWith(async function() {
+ const response = await storedRangeResponseP;
+ if (!response) throw Error("Expected stored range response");
+ return response.clone();
+ }());
+}
+
+function broadcastAcceptEncoding(event) {
+ /** @type Request */
+ const request = event.request;
+ const id = new URL(request.url).searchParams.get('id');
+
+ broadcast({
+ id,
+ acceptEncoding: request.headers.get('Accept-Encoding')
+ });
+
+ // Just send back any response, it isn't important for the test.
+ event.respondWith(new Response(''));
+}
+
+let rangeResponse = {};
+
+async function recordMediaRangeRequest(event) {
+ /** @type Request */
+ const request = event.request;
+ const url = new URL(request.url);
+ const urlParams = new URLSearchParams(url.search);
+ const size = urlParams.get("size");
+ const id = urlParams.get('id');
+ const key = 'size' + size;
+
+ if (key in rangeResponse) {
+ // Don't re-fetch ranges we already have.
+ const clonedResponse = rangeResponse[key].clone();
+ event.respondWith(clonedResponse);
+ } else if (event.request.headers.get("range") === "bytes=0-") {
+ // Generate a bogus 206 response to trigger subsequent range requests
+ // of the desired size.
+ const length = urlParams.get("length") + 100;
+ const body = "A".repeat(Number(size));
+ event.respondWith(new Response(body, {status: 206, headers: {
+ "Content-Type": "audio/mp4",
+ "Content-Range": `bytes 0-1/${length}`
+ }}));
+ } else if (event.request.headers.get("range") === `bytes=${Number(size)}-`) {
+ // Pass through actual range requests which will attempt to fetch up to the
+ // length in the original response which is bigger than the actual resource
+ // to make sure 206 and 416 responses are treated the same.
+ rangeResponse[key] = await fetch(event.request);
+
+ // Let the client know we have the range response for the given ID
+ broadcast({id});
+ } else {
+ event.respondWith(Promise.reject(Error("Invalid Request")));
+ }
+}
+
+function useMediaRangeRequest(event) {
+ /** @type Request */
+ const request = event.request;
+ const url = new URL(request.url);
+ const urlParams = new URLSearchParams(url.search);
+ const size = urlParams.get("size");
+ const key = 'size' + size;
+
+ // Send a clone of the range response to preload.
+ if (key in rangeResponse) {
+ const clonedResponse = rangeResponse[key].clone();
+ event.respondWith(clonedResponse);
+ } else {
+ event.respondWith(Promise.reject(Error("Invalid Request")));
+ }
+}
diff --git a/testing/web-platform/tests/fetch/range/resources/stash-take.py b/testing/web-platform/tests/fetch/range/resources/stash-take.py
new file mode 100644
index 0000000000..6cf6ff585b
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/resources/stash-take.py
@@ -0,0 +1,7 @@
+from wptserve.handlers import json_handler
+
+
+@json_handler
+def main(request, response):
+ key = request.GET.first(b"key")
+ return request.server.stash.take(key, b'/fetch/range/')
diff --git a/testing/web-platform/tests/fetch/range/resources/utils.js b/testing/web-platform/tests/fetch/range/resources/utils.js
new file mode 100644
index 0000000000..ad2853b33d
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/resources/utils.js
@@ -0,0 +1,36 @@
+function loadScript(url, { doc = document }={}) {
+ return new Promise((resolve, reject) => {
+ const script = doc.createElement('script');
+ script.onload = () => resolve();
+ script.onerror = () => reject(Error("Script load failed"));
+ script.src = url;
+ doc.body.appendChild(script);
+ })
+}
+
+function preloadImage(url, { doc = document }={}) {
+ return new Promise((resolve, reject) => {
+ const preload = doc.createElement('link');
+ preload.rel = 'preload';
+ preload.as = 'image';
+ preload.onload = () => resolve();
+ preload.onerror = () => resolve();
+ preload.href = url;
+ doc.body.appendChild(preload);
+ })
+}
+
+/**
+ *
+ * @param {Document} document
+ * @param {string|URL} url
+ * @returns {HTMLAudioElement}
+ */
+function appendAudio(document, url) {
+ const audio = document.createElement('audio');
+ audio.muted = true;
+ audio.src = url;
+ audio.preload = true;
+ document.body.appendChild(audio);
+ return audio;
+}
diff --git a/testing/web-platform/tests/fetch/range/resources/video-with-range.py b/testing/web-platform/tests/fetch/range/resources/video-with-range.py
new file mode 100644
index 0000000000..2d15ccf3c4
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/resources/video-with-range.py
@@ -0,0 +1,43 @@
+import re
+import os
+import json
+from wptserve.utils import isomorphic_decode
+
+def main(request, response):
+ path = os.path.join(request.doc_root, u"media", "sine440.mp3")
+ total_size = os.path.getsize(path)
+ rewrites = json.loads(request.GET.first(b'rewrites', '[]'))
+ range_header = request.headers.get(b'Range')
+ range_header_match = range_header and re.search(r'^bytes=(\d*)-(\d*)$', isomorphic_decode(range_header))
+ start = None
+ end = None
+ if range_header_match:
+ response.status = 206
+ start, end = range_header_match.groups()
+ if range_header:
+ status = 206
+ else:
+ status = 200
+ for rewrite in rewrites:
+ req_start, req_end = rewrite['request']
+ if start == req_start or req_start == '*':
+ if end == req_end or req_end == '*':
+ if 'response' in rewrite:
+ start, end = rewrite['response']
+ if 'status' in rewrite:
+ status = rewrite['status']
+
+ start = int(start or 0)
+ end = int(end or total_size)
+ headers = []
+ if status == 206:
+ headers.append((b"Content-Range", b"bytes %d-%d/%d" % (start, end - 1, total_size)))
+ headers.append((b"Accept-Ranges", b"bytes"))
+
+ headers.append((b"Content-Type", b"audio/mp3"))
+ headers.append((b"Content-Length", str(end - start)))
+ headers.append((b"Cache-Control", b"no-cache"))
+ video_file = open(path, "rb")
+ video_file.seek(start)
+ content = video_file.read(end)
+ return status, headers, content
diff --git a/testing/web-platform/tests/fetch/range/sw.https.window.js b/testing/web-platform/tests/fetch/range/sw.https.window.js
new file mode 100644
index 0000000000..62ad894da3
--- /dev/null
+++ b/testing/web-platform/tests/fetch/range/sw.https.window.js
@@ -0,0 +1,228 @@
+// META: script=../../../service-workers/service-worker/resources/test-helpers.sub.js
+// META: script=/common/utils.js
+// META: script=/common/get-host-info.sub.js
+// META: script=resources/utils.js
+
+const { REMOTE_HOST } = get_host_info();
+const BASE_SCOPE = 'resources/basic.html?';
+
+async function cleanup() {
+ for (const iframe of document.querySelectorAll('.test-iframe')) {
+ iframe.parentNode.removeChild(iframe);
+ }
+
+ for (const reg of await navigator.serviceWorker.getRegistrations()) {
+ await reg.unregister();
+ }
+}
+
+async function setupRegistration(t, scope) {
+ await cleanup();
+ const reg = await navigator.serviceWorker.register('resources/range-sw.js', { scope });
+ await wait_for_state(t, reg.installing, 'activated');
+ return reg;
+}
+
+function awaitMessage(obj, id) {
+ return new Promise(resolve => {
+ obj.addEventListener('message', function listener(event) {
+ if (event.data.id !== id) return;
+ obj.removeEventListener('message', listener);
+ resolve(event.data);
+ });
+ });
+}
+
+promise_test(async t => {
+ const scope = BASE_SCOPE + Math.random();
+ const reg = await setupRegistration(t, scope);
+ const iframe = await with_iframe(scope);
+ const w = iframe.contentWindow;
+
+ // Trigger a cross-origin range request using media
+ const url = new URL('long-wav.py?action=range-header-filter-test', w.location);
+ url.hostname = REMOTE_HOST;
+ appendAudio(w.document, url);
+
+ // See rangeHeaderFilterTest in resources/range-sw.js
+ await fetch_tests_from_worker(reg.active);
+}, `Defer range header filter tests to service worker`);
+
+promise_test(async t => {
+ const scope = BASE_SCOPE + Math.random();
+ const reg = await setupRegistration(t, scope);
+ const iframe = await with_iframe(scope);
+ const w = iframe.contentWindow;
+
+ // Trigger a cross-origin range request using media
+ const url = new URL('long-wav.py', w.location);
+ url.searchParams.set('action', 'range-header-passthrough-test');
+ url.searchParams.set('range-received-key', token());
+ url.hostname = REMOTE_HOST;
+ appendAudio(w.document, url);
+
+ // See rangeHeaderPassthroughTest in resources/range-sw.js
+ await fetch_tests_from_worker(reg.active);
+}, `Defer range header passthrough tests to service worker`);
+
+promise_test(async t => {
+ const scope = BASE_SCOPE + Math.random();
+ await setupRegistration(t, scope);
+ const iframe = await with_iframe(scope);
+ const w = iframe.contentWindow;
+ const id = Math.random() + '';
+ const storedRangeResponse = awaitMessage(w.navigator.serviceWorker, id);
+
+ // Trigger a cross-origin range request using media
+ const url = new URL('partial-script.py', w.location);
+ url.searchParams.set('require-range', '1');
+ url.searchParams.set('action', 'store-ranged-response');
+ url.searchParams.set('id', id);
+ url.hostname = REMOTE_HOST;
+
+ appendAudio(w.document, url);
+
+ await storedRangeResponse;
+
+ // Fetching should reject
+ const fetchPromise = w.fetch('?action=use-stored-ranged-response', { mode: 'no-cors' });
+ await promise_rejects_js(t, w.TypeError, fetchPromise);
+
+ // Script loading should error too
+ const loadScriptPromise = loadScript('?action=use-stored-ranged-response', { doc: w.document });
+ await promise_rejects_js(t, Error, loadScriptPromise);
+
+ await loadScriptPromise.catch(() => {});
+
+ assert_false(!!w.scriptExecuted, `Partial response shouldn't be executed`);
+}, `Ranged response not allowed following no-cors ranged request`);
+
+promise_test(async t => {
+ const scope = BASE_SCOPE + Math.random();
+ await setupRegistration(t, scope);
+ const iframe = await with_iframe(scope);
+ const w = iframe.contentWindow;
+ const id = Math.random() + '';
+ const storedRangeResponse = awaitMessage(w.navigator.serviceWorker, id);
+
+ // Trigger a range request using media
+ const url = new URL('partial-script.py', w.location);
+ url.searchParams.set('require-range', '1');
+ url.searchParams.set('action', 'store-ranged-response');
+ url.searchParams.set('id', id);
+
+ appendAudio(w.document, url);
+
+ await storedRangeResponse;
+
+ // This should not throw
+ await w.fetch('?action=use-stored-ranged-response');
+
+ // This shouldn't throw either
+ await loadScript('?action=use-stored-ranged-response', { doc: w.document });
+
+ assert_true(w.scriptExecuted, `Partial response should be executed`);
+}, `Non-opaque ranged response executed`);
+
+promise_test(async t => {
+ const scope = BASE_SCOPE + Math.random();
+ await setupRegistration(t, scope);
+ const iframe = await with_iframe(scope);
+ const w = iframe.contentWindow;
+ const fetchId = Math.random() + '';
+ const fetchBroadcast = awaitMessage(w.navigator.serviceWorker, fetchId);
+ const audioId = Math.random() + '';
+ const audioBroadcast = awaitMessage(w.navigator.serviceWorker, audioId);
+
+ const url = new URL('long-wav.py', w.location);
+ url.searchParams.set('action', 'broadcast-accept-encoding');
+ url.searchParams.set('id', fetchId);
+
+ await w.fetch(url, {
+ headers: { Range: 'bytes=0-10' }
+ });
+
+ assert_equals((await fetchBroadcast).acceptEncoding, null, "Accept-Encoding should not be set for fetch");
+
+ url.searchParams.set('id', audioId);
+ appendAudio(w.document, url);
+
+ assert_equals((await audioBroadcast).acceptEncoding, null, "Accept-Encoding should not be set for media");
+}, `Accept-Encoding should not appear in a service worker`);
+
+promise_test(async t => {
+ const scope = BASE_SCOPE + Math.random();
+ await setupRegistration(t, scope);
+ const iframe = await with_iframe(scope);
+ const w = iframe.contentWindow;
+ const length = 100;
+ const count = 3;
+ const counts = {};
+
+ // test a single range request size
+ async function testSizedRange(size, partialResponseCode) {
+ const rangeId = Math.random() + '';
+ const rangeBroadcast = awaitMessage(w.navigator.serviceWorker, rangeId);
+
+ // Create a bogus audio element to trick the browser into sending
+ // cross-origin range requests that can be manipulated by the service worker.
+ const sound_url = new URL('partial-text.py', w.location);
+ sound_url.hostname = REMOTE_HOST;
+ sound_url.searchParams.set('action', 'record-media-range-request');
+ sound_url.searchParams.set('length', length);
+ sound_url.searchParams.set('size', size);
+ sound_url.searchParams.set('partial', partialResponseCode);
+ sound_url.searchParams.set('id', rangeId);
+ sound_url.searchParams.set('type', 'audio/mp4');
+ appendAudio(w.document, sound_url);
+
+ // wait for the range requests to happen
+ await rangeBroadcast;
+
+ // Create multiple preload requests and count the number of resource timing
+ // entries that get created to make sure 206 and 416 range responses are treated
+ // the same.
+ const url = new URL('partial-text.py', w.location);
+ url.searchParams.set('action', 'use-media-range-request');
+ url.searchParams.set('size', size);
+ url.searchParams.set('type', 'audio/mp4');
+ counts['size' + size] = 0;
+ for (let i = 0; i < count; i++) {
+ await preloadImage(url, { doc: w.document });
+ }
+ }
+
+ // Test range requests from 1 smaller than the correct size to 1 larger than
+ // the correct size to exercise the various permutations using the default 206
+ // response code for successful range requests.
+ for (let size = length - 1; size <= length + 1; size++) {
+ await testSizedRange(size, '206');
+ }
+
+ // Test a successful range request using a 200 response.
+ await testSizedRange(length - 2, '200');
+
+ // Check the resource timing entries and count the reported number of fetches of each type
+ const resources = w.performance.getEntriesByType("resource");
+ for (const entry of resources) {
+ const url = new URL(entry.name);
+ if (url.searchParams.has('action') &&
+ url.searchParams.get('action') == 'use-media-range-request' &&
+ url.searchParams.has('size')) {
+ counts['size' + url.searchParams.get('size')]++;
+ }
+ }
+
+ // Make sure there are a non-zero number of preload requests and they are all the same
+ let counts_valid = true;
+ const first = 'size' + (length - 2);
+ for (let size = length - 2; size <= length + 1; size++) {
+ let key = 'size' + size;
+ if (!(key in counts) || counts[key] <= 0 || counts[key] != counts[first]) {
+ counts_valid = false;
+ break;
+ }
+ }
+
+ assert_true(counts_valid, `Opaque range request preloads were different for error and success`);
+}, `Opaque range preload successes and failures should be indistinguishable`);