fix(wpt): serve service worker import and update fixtures

Model service worker script redirects, versioned imports and update response sequences, including file-based and installation updates. Preserve request methods, cache metadata, raw script bytes and per-resource state shared across fixture origins.

(cherry picked from commit 5d0ddf013b)
This commit is contained in:
ldm0
2026-09-29 19:03:23 +08:00
committed by Donough Liu
parent 3ef21f694e
commit e5ac01642f
3 changed files with 605 additions and 28 deletions
@@ -838,7 +838,7 @@ def _supported_wptserve_handler_references(
) -> tuple[re.Pattern[str], ...]:
supported: tuple[re.Pattern[str], ...] = ()
if rel is not None:
supported += _service_worker_registration_handler_reference_patterns(posixpath.dirname(rel) or ".")
supported += _service_worker_script_handler_reference_patterns(posixpath.dirname(rel) or ".")
if rel is not None and rel.startswith("fetch/api/"):
supported += _empty_location_handler_reference_patterns(posixpath.dirname(rel))
if rel is not None:
@@ -1636,15 +1636,19 @@ def explicit_case(wpt_root: Path, case_path: str) -> WptCase:
@lru_cache(maxsize=None)
def _service_worker_registration_handler_reference_patterns(directory: str) -> tuple[re.Pattern[str], ...]:
def _service_worker_script_handler_reference_patterns(directory: str) -> tuple[re.Pattern[str], ...]:
references = []
resources = (
"service-workers/service-worker/resources/mime-type-worker.py",
"service-workers/service-worker/resources/import-mime-type-worker.py",
"service-workers/service-worker/resources/malformed-worker.py",
"service-workers/service-worker/resources/invalid-chunked-encoding.py",
"service-workers/service-worker/resources/invalid-chunked-encoding-with-flush.py",
)
resources = tuple(
"service-workers/service-worker/resources/" + name
for name in (
"redirect.py", "update-worker.py", "update-worker-from-file.py",
"mime-type-worker.py", "import-mime-type-worker.py", "malformed-worker.py",
"invalid-chunked-encoding.py", "invalid-chunked-encoding-with-flush.py",
"update-during-installation-worker.py",
"import-scripts-version.py", "import-scripts-get.py", "import-scripts-echo.py",
"subdir/import-scripts-echo.py", "scope2/import-scripts-echo.py",
)
) + ("service-workers/service-worker/ServiceWorkerGlobalScope/resources/update-worker.py",)
for resource in resources:
relative = posixpath.relpath(resource, directory)
references.extend(("/" + resource, relative, "./" + relative))
+134 -19
View File
@@ -29,6 +29,10 @@ import http.client
import json
import math
import mimetypes
import os
import random
from datetime import datetime
import re
import socket
import struct
@@ -137,12 +141,22 @@ NAVIGATION_SECOND_VISIT_PATH = (
"/navigation-api/navigation-methods/return-value/resources/"
"204-205-download-on-second-visit.py"
)
SERVICE_WORKER_REGISTRATION_RESOURCE_PATHS = {
SERVICE_WORKER_SCRIPT_RESOURCE_PATHS = {
"/service-workers/service-worker/resources/mime-type-worker.py",
"/service-workers/service-worker/resources/import-mime-type-worker.py",
"/service-workers/service-worker/resources/malformed-worker.py",
"/service-workers/service-worker/resources/invalid-chunked-encoding.py",
"/service-workers/service-worker/resources/invalid-chunked-encoding-with-flush.py",
"/service-workers/service-worker/resources/redirect.py",
"/service-workers/service-worker/resources/update-worker.py",
"/service-workers/service-worker/resources/update-worker-from-file.py",
"/service-workers/service-worker/resources/update-during-installation-worker.py",
"/service-workers/service-worker/ServiceWorkerGlobalScope/resources/update-worker.py",
"/service-workers/service-worker/resources/import-scripts-version.py",
"/service-workers/service-worker/resources/import-scripts-get.py",
"/service-workers/service-worker/resources/import-scripts-echo.py",
"/service-workers/service-worker/resources/subdir/import-scripts-echo.py",
"/service-workers/service-worker/resources/scope2/import-scripts-echo.py",
}
SERVICE_WORKER_MALFORMED_SCRIPTS = {
"parse-error": 'var foo = function() {;',
@@ -1918,8 +1932,8 @@ requestExecutor("{executor_uuid}", {start_on_js});
return
if self._serve_empty_location_resource(emit_body=self.command != "HEAD"):
return
if unquote(urlsplit(self.path).path) in SERVICE_WORKER_REGISTRATION_RESOURCE_PATHS:
self._serve_service_worker_registration_resource()
if unquote(urlsplit(self.path).path) in SERVICE_WORKER_SCRIPT_RESOURCE_PATHS:
self._serve_service_worker_script_resource()
return
if self._serve_xhr_response_resource():
return
@@ -1959,8 +1973,8 @@ requestExecutor("{executor_uuid}", {start_on_js});
return
if self._serve_empty_location_resource(emit_body=self.command != "HEAD"):
return
if unquote(urlsplit(self.path).path) in SERVICE_WORKER_REGISTRATION_RESOURCE_PATHS:
self._serve_service_worker_registration_resource()
if unquote(urlsplit(self.path).path) in SERVICE_WORKER_SCRIPT_RESOURCE_PATHS:
self._serve_service_worker_script_resource()
return
if self._serve_xhr_response_resource():
return
@@ -2021,8 +2035,8 @@ requestExecutor("{executor_uuid}", {start_on_js});
return
if self._serve_empty_location_resource(emit_body=self.command != "HEAD"):
return
if unquote(urlsplit(self.path).path) in SERVICE_WORKER_REGISTRATION_RESOURCE_PATHS:
self._serve_service_worker_registration_resource()
if unquote(urlsplit(self.path).path) in SERVICE_WORKER_SCRIPT_RESOURCE_PATHS:
self._serve_service_worker_script_resource()
return
if self._serve_xhr_response_resource():
return
@@ -2068,8 +2082,8 @@ requestExecutor("{executor_uuid}", {start_on_js});
} | XHR_BODY_RESOURCE_PATHS:
self._serve_xhr_response_resource()
return
if unquote(urlsplit(self.path).path) in SERVICE_WORKER_REGISTRATION_RESOURCE_PATHS:
self._serve_service_worker_registration_resource()
if unquote(urlsplit(self.path).path) in SERVICE_WORKER_SCRIPT_RESOURCE_PATHS:
self._serve_service_worker_script_resource()
return
parsed = urlparse(self.path)
if unquote(parsed.path) == NAVIGATION_SECOND_VISIT_PATH:
@@ -2289,8 +2303,8 @@ requestExecutor("{executor_uuid}", {start_on_js});
return
if self._serve_empty_location_resource(emit_body=emit_body):
return
if unquote(urlsplit(self.path).path) in SERVICE_WORKER_REGISTRATION_RESOURCE_PATHS:
self._serve_service_worker_registration_resource()
if unquote(urlsplit(self.path).path) in SERVICE_WORKER_SCRIPT_RESOURCE_PATHS:
self._serve_service_worker_script_resource()
return
if self._serve_xhr_response_resource(emit_body=emit_body):
return
@@ -2802,7 +2816,7 @@ requestExecutor("{executor_uuid}", {start_on_js});
return
def _serve_service_worker_registration_resource(self) -> None:
def _serve_service_worker_script_resource(self) -> None:
parsed = urlsplit(self.path)
path = unquote(parsed.path)
if path.endswith(("/invalid-chunked-encoding.py", "/invalid-chunked-encoding-with-flush.py")):
@@ -2812,31 +2826,132 @@ requestExecutor("{executor_uuid}", {start_on_js});
return
params = parse_qs(parsed.query, keep_blank_values=True, encoding="latin-1")
headers: list[tuple[str, str]] = []
status = 200
body = b""
try:
if path.endswith("/mime-type-worker.py"):
if path.endswith("/redirect.py"):
status = int(params.get("Status", ["302"])[0])
headers.append(("Location", params["Redirect"][0]))
if "ACAOrigin" in params:
headers.extend(
("Access-Control-Allow-Origin", value)
for value in params["ACAOrigin"][0].split(",")
)
for suffix in ("Headers", "Methods", "Credentials"):
if "ACA" + suffix in params:
headers.append((
"Access-Control-Allow-" + suffix,
params["ACA" + suffix][0],
))
if "ACEHeaders" in params:
headers.append(("Access-Control-Expose-Headers", params["ACEHeaders"][0]))
elif path.endswith("/mime-type-worker.py"):
if "mime" in params:
headers.append(("Content-Type", params["mime"][0]))
elif path.endswith("/import-mime-type-worker.py"):
headers.append(("Content-Type", "application/javascript"))
suffix = "?mime=" + params["mime"][0] if "mime" in params else ""
body = f"importScripts('./mime-type-worker.py{suffix}');".encode("latin-1")
else:
elif path.endswith("/malformed-worker.py"):
# Upstream selects on the complete, undecoded query.
script = SERVICE_WORKER_MALFORMED_SCRIPTS.get(parsed.query)
if script is None:
self.send_error(500)
return
headers.append(("Content-Type", "application/javascript"))
body = script.encode("utf-8")
else:
headers = [
("Cache-Control", "no-cache, must-revalidate"),
("Pragma", "no-cache"),
("Content-Type", "application/javascript"),
]
if path.endswith("/update-worker-from-file.py"):
count = fetch_stash.increment(params["Key"][0], path=parsed.path)
if count > 2:
self.send_error(500, "Unknown update worker state")
return
filename = os.fsdecode(params["First" if count == 1 else "Second"][0].encode("latin-1"))
source = ((wpt_root / path.lstrip("/")).parent / filename).resolve()
source.relative_to(wpt_root.resolve())
body = source.read_bytes()
elif path.endswith("/ServiceWorkerGlobalScope/resources/update-worker.py"):
headers = [
("Cache-Control", "max-age: 0"),
("Content-Type", "application/javascript"),
]
source = (wpt_root / path.lstrip("/")).with_suffix(".js")
script = source.read_text(encoding="utf-8")
body = f"// {time.time()}\n{script}".encode("utf-8")
elif path.endswith("/update-during-installation-worker.py"):
headers = [
("Content-Type", "application/javascript"),
("Cache-Control", "max-age=0"),
]
body = (
f"// {random.random()}\n"
"importScripts('update-during-installation-worker.js');"
).encode("ascii")
elif path.endswith("/import-scripts-version.py"):
# Match the upstream delay so update checks see new bytes.
if stopping.wait(0.1):
self.close_connection = True
return
version = (datetime.now() - datetime(1970, 1, 1)).total_seconds()
body = f'version = "{version}";\n'.encode("ascii")
elif path.endswith("/import-scripts-get.py"):
body = ('%s = "%s";\n' % (
params["output"][0], params["msg"][0],
)).encode("latin-1")
elif path.endswith("/import-scripts-echo.py"):
directory = path.rsplit("/", 2)[-2]
suffix = f" ({directory}/)" if directory in ("subdir", "scope2") else ""
body = ('echo_output = "%s%s";\n' % (
params["msg"][0], suffix,
)).encode("latin-1")
else:
mode = params["Mode"][0]
count = fetch_stash.increment(params["Key"][0], path=parsed.path)
extra_body = ""
if count == 2:
if mode == "bad_mime_type":
headers[-1] = ("Content-Type", "text/html")
elif mode == "not_found":
status = 404
headers = [("Content-Type", "text/plain")]
elif mode == "redirect":
status = 301
location = unquote(params.get("Redirect", ["empty.js"])[0])
headers.append(("Location", location))
elif mode == "syntax_error":
extra_body = "badsyntax(isbad;"
elif mode == "throw_install":
extra_body = (
"addEventListener('install', function(e) { "
"throw new Error('boom'); });"
)
if status == 404:
body = b"Page not found"
elif status == 301:
body = f"/* {count} */".encode("ascii")
else:
body = f"/* {count} */ {extra_body}".encode("utf-8")
if not 100 <= status <= 599:
raise ValueError("invalid response status")
for _, value in headers:
if "\r" in value or "\n" in value:
raise ValueError("invalid response header")
value.encode("latin-1")
except (ValueError, UnicodeError):
except OSError:
self.send_error(500)
return
except (KeyError, ValueError, UnicodeError):
self.send_error(400)
return
self._send_bytes(None, body, emit_body=self.command != "HEAD",
extra_headers=headers, cache_control=None)
self._send_bytes(
None, body, emit_body=self.command != "HEAD",
extra_headers=headers, status_code=status, cache_control=None,
)
def _serve_xhr_delay(self, query: str, *, emit_body: bool) -> None:
@@ -2894,8 +3009,8 @@ requestExecutor("{executor_uuid}", {start_on_js});
return self._serve_navigation_second_visit
if name.startswith("do_") and unquote(urlparse(self.path).path) == FETCH_EMPTY_LOCATION_PATH:
return self._serve_empty_location_resource
if name.startswith("do_") and unquote(urlsplit(self.path).path) in SERVICE_WORKER_REGISTRATION_RESOURCE_PATHS:
return self._serve_service_worker_registration_resource
if name.startswith("do_") and unquote(urlsplit(self.path).path) in SERVICE_WORKER_SCRIPT_RESOURCE_PATHS:
return self._serve_service_worker_script_resource
if name.startswith("do_") and unquote(urlparse(self.path).path) in XHR_RESPONSE_RESOURCE_PATHS:
return self._serve_xhr_response_resource
if name.startswith("do_") and unquote(urlparse(self.path).path) in FETCH_RANGE_RESOURCE_PATHS:
@@ -0,0 +1,458 @@
from __future__ import annotations
import re
import socket
import tempfile
import time
import unittest
import uuid
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack
from datetime import datetime
from http.client import HTTPConnection, IncompleteRead
from pathlib import Path
from threading import Event
from urllib.parse import urlencode
from unittest.mock import patch
from moli_benchmark.wpt_cross.case_set import enumerate_cases
from moli_benchmark.wpt_cross.server import WptFixtureServer
DIRECTORY = "service-workers/service-worker"
RESOURCES = "/" + DIRECTORY + "/resources/"
class ServiceWorkerScriptFixtureTests(unittest.TestCase):
def setUp(self) -> None:
self.stack = ExitStack()
self.addCleanup(self.stack.close)
self.root = Path(self.stack.enter_context(tempfile.TemporaryDirectory()))
(self.root / "resources").mkdir()
for name in ("testharness.js", "testharnessreport.js"):
(self.root / "resources" / name).write_text("// harness")
resources = self.root / DIRECTORY / "resources"
resources.mkdir(parents=True)
for name in (
"mime-type-worker.py", "import-mime-type-worker.py", "malformed-worker.py",
"invalid-chunked-encoding.py", "invalid-chunked-encoding-with-flush.py",
"redirect.py", "update-worker.py", "update-worker-from-file.py", "import-scripts-version.py",
"update-during-installation-worker.py",
"import-scripts-get.py", "import-scripts-echo.py",
"subdir/import-scripts-echo.py", "scope2/import-scripts-echo.py",
):
(resources / name).parent.mkdir(exist_ok=True)
(resources / name).write_text("# Python source must not be sent as a script")
global_resources = self.root / DIRECTORY / "ServiceWorkerGlobalScope/resources"
global_resources.mkdir(parents=True)
(global_resources / "update-worker.py").write_text("# Python source must not be sent as a script")
(global_resources / "update-worker.js").write_bytes(b"// caf\xc3\xa9\r\nself.ready = true;\r\n")
self.stack.enter_context(patch(
"moli_benchmark.wpt_cross.server._global_ipv6_address", return_value=None
))
def server(self) -> WptFixtureServer:
return self.stack.enter_context(WptFixtureServer(self.root))
def request(
self, port: int, resource: str, query: str = "", *, method: str = "GET",
) -> tuple[int, list[tuple[str, str]], bytes]:
connection = HTTPConnection("127.0.0.1", port, timeout=5)
try:
path = resource if resource.startswith("/") else RESOURCES + resource
connection.request(method, path + "?" + query)
response = connection.getresponse()
return (
response.status,
[(name.lower(), value) for name, value in response.getheaders()],
response.read(),
)
finally:
connection.close()
def test_redirect_preserves_status_location_and_repeated_cors_headers(self) -> None:
server = self.server()
query = urlencode([
("Redirect", "../target.js?one=1&two=%20"),
("Redirect", "ignored.js"),
("Status", "307"),
("ACAOrigin", "https://one.test,https://two.test"),
("ACAHeaders", "X-One, X-Two"),
("ACAMethods", "GET, OPTIONS"),
("ACACredentials", "true"),
("ACEHeaders", "X-Result"),
])
for method in ("GET", "HEAD", "POST", "OPTIONS", "PUT", "YO", "CUSTOM"):
with self.subTest(method=method):
status, headers, body = self.request(server.port, "redirect.py", query, method=method)
self.assertEqual(status, 307)
self.assertEqual(body, b"")
self.assertEqual(dict(headers)["location"], "../target.js?one=1&two=%20")
self.assertEqual(
[value for name, value in headers if name == "access-control-allow-origin"],
["https://one.test", "https://two.test"],
)
for name, value in (
("access-control-allow-headers", "X-One, X-Two"),
("access-control-allow-methods", "GET, OPTIONS"),
("access-control-allow-credentials", "true"),
("access-control-expose-headers", "X-Result"),
):
self.assertEqual(dict(headers)[name], value)
self.assertNotIn("content-type", dict(headers))
self.assertNotIn("cache-control", dict(headers))
status, headers, _ = self.request(server.port, "%72edirect.py", "Redirect=%FF")
self.assertEqual(status, 302)
self.assertEqual(dict(headers)["location"], "\xff")
def test_registration_mime_handlers_preserve_missing_empty_and_raw_values(self) -> None:
server = self.server()
for query, mime in (
("", None), ("mime=", ""), ("mime=text%2Fjavascript", "text/javascript"),
("mime=text%2Fplain&mime=ignored", "text/plain"),
("mime=application%2Fjavascript%3B+charset%3Dutf-8", "application/javascript; charset=utf-8"),
("mime=%FF%27%26", "\xff'&"),
):
for method in ("GET", "HEAD", "POST", "CUSTOM"):
with self.subTest(query=query, method=method):
status, headers, body = self.request(server.port, "mime-type-worker.py", query, method=method)
self.assertEqual((status, body), (200, b""))
self.assertEqual(dict(headers).get("content-type"), mime)
self.assertNotIn("cache-control", dict(headers))
status, headers, body = self.request(server.alternate_port, "import-mime-type-worker.py", query, method=method)
suffix = b"?mime=" + mime.encode("latin-1") if mime is not None else b""
expected = b"importScripts('./mime-type-worker.py" + suffix + b"');"
self.assertEqual((status, body), (200, b"" if method == "HEAD" else expected))
self.assertEqual(dict(headers)["content-type"], "application/javascript")
self.assertEqual(dict(headers)["content-length"], str(len(expected)))
self.assertNotIn("cache-control", dict(headers))
def test_malformed_worker_selects_on_the_complete_undecoded_query(self) -> None:
server = self.server()
for query, expected in (
("parse-error", b"var foo = function() {;"),
("caught-exception", b"try { throw new Error; } catch(e) {}"),
("import-malformed-script", b'importScripts("malformed-worker.py?parse-error");'),
("instantiation-error-and-top-level-await", b'import nonexistent from "./imported-module-script.js"; await Promise.resolve(1);'),
):
for method in ("GET", "HEAD", "OPTIONS"):
with self.subTest(query=query, method=method):
status, headers, body = self.request(server.port, "%6dalformed-worker.py", query, method=method)
self.assertEqual((status, body), (200, b"" if method == "HEAD" else expected))
self.assertEqual(dict(headers)["content-type"], "application/javascript")
self.assertEqual(dict(headers)["content-length"], str(len(expected)))
self.assertNotIn("cache-control", dict(headers))
for query in ("", "unknown", "parse%2Derror", "parse-error=", "parse-error&ignored"):
with self.subTest(query=query):
self.assertEqual(self.request(server.port, "malformed-worker.py", query)[0], 500)
def test_invalid_chunked_responses_keep_raw_bytes_and_distinct_head_behavior(self) -> None:
server = self.server()
with patch.object(server._stopping, "wait", return_value=False):
for delayed in (False, True):
resource = "invalid-chunked-encoding" + ("-with-flush" if delayed else "") + ".py"
for method in ("GET", "HEAD", "CUSTOM"):
with self.subTest(delayed=delayed, method=method), socket.create_connection(
("127.0.0.1", server.port), timeout=3,
) as connection:
connection.sendall((
f"{method} {RESOURCES}{resource} HTTP/1.1\r\n"
f"Host: localhost:{server.port}\r\n\r\n"
).encode())
with connection.makefile("rb") as response:
head, body = response.read().split(b"\r\n\r\n", 1)
fields = head.lower().split(b"\r\n")[1:]
self.assertTrue(head.startswith(b"HTTP/1.1 200 "))
self.assertIn(b"content-type: application/javascript", fields)
self.assertIn(b"transfer-encoding: chunked", fields)
self.assertEqual(b"content-length: 6" in fields, not delayed)
self.assertEqual(body, b"" if method == "HEAD" and not delayed else b"XX\r\n\r\n")
connection = HTTPConnection("127.0.0.1", server.port, timeout=3)
try:
connection.request("GET", RESOURCES + resource)
response = connection.getresponse()
self.assertTrue(response.chunked)
with self.assertRaises(IncompleteRead):
response.read()
finally:
connection.close()
def test_invalid_chunk_flushes_headers_before_waiting_and_does_not_read_upload(self) -> None:
server = self.server()
waiting, release = Event(), Event()
def wait(timeout: float) -> bool:
self.assertEqual(timeout, 1)
waiting.set()
release.wait(3)
return False
with patch.object(server._stopping, "wait", side_effect=wait), socket.create_connection(
("127.0.0.1", server.port), timeout=3,
) as connection:
try:
connection.sendall((
f"POST {RESOURCES}invalid-chunked-encoding-with-flush.py HTTP/1.1\r\n"
f"Host: localhost:{server.port}\r\nContent-Length: 1000000\r\n\r\n"
).encode())
self.assertTrue(waiting.wait(2))
with connection.makefile("rb") as response:
headers = []
while (line := response.readline()) != b"\r\n":
self.assertTrue(line)
headers.append(line)
self.assertIn(b"Transfer-Encoding: chunked\r\n", headers)
release.set()
self.assertEqual(response.read(), b"XX\r\n\r\n")
finally:
release.set()
def test_update_modes_only_change_the_second_response(self) -> None:
server = self.server()
modes = (
("normal", 200, "application/javascript", b"/* 2 */ "),
("bad_mime_type", 200, "text/html", b"/* 2 */ "),
("not_found", 404, "text/plain", b"Page not found"),
("redirect", 301, "application/javascript", b"/* 2 */"),
("syntax_error", 200, "application/javascript", b"/* 2 */ badsyntax(isbad;"),
("throw_install", 200, "application/javascript", b"/* 2 */ addEventListener('install', function(e) { throw new Error('boom'); });"),
("unknown", 200, "application/javascript", b"/* 2 */ "),
)
for mode, expected_status, expected_type, expected_body in modes:
query = urlencode({"Key": str(uuid.uuid4()), "Mode": mode})
with self.subTest(mode=mode):
self.assertEqual(self.request(server.port, "update-worker.py", query)[2], b"/* 1 */ ")
status, headers, body = self.request(server.port, "update-worker.py", query)
self.assertEqual((status, dict(headers)["content-type"], body),
(expected_status, expected_type, expected_body))
if mode == "redirect":
self.assertEqual(dict(headers)["location"], "empty.js")
if mode == "not_found":
self.assertNotIn("cache-control", dict(headers))
else:
self.assertEqual(dict(headers)["cache-control"], "no-cache, must-revalidate")
self.assertEqual(dict(headers)["pragma"], "no-cache")
self.assertEqual(self.request(server.port, "update-worker.py", query)[2], b"/* 3 */ ")
def test_update_state_is_shared_across_origins_and_modes_but_not_servers(self) -> None:
first, second = self.server(), self.server()
key = str(uuid.uuid4())
first.fetch_stash.put(key, 50)
first_query = urlencode({"Key": key, "Mode": "normal"})
self.assertEqual(self.request(first.port, "update-worker.py", first_query)[2], b"/* 1 */ ")
# Equivalent UUID spelling, first repeated parameter, and a second
# percent decode of Redirect all follow the upstream handler.
redirect_query = urlencode([
("Key", "{" + key.upper() + "}"), ("Mode", "redirect"),
("Mode", "not_found"), ("Redirect", "target.js?one=1%26two=2"),
])
status, headers, body = self.request(first.alternate_port, "update-worker.py", redirect_query)
self.assertEqual((status, body), (301, b"/* 2 */"))
self.assertEqual(dict(headers)["location"], "target.js?one=1&two=2")
self.assertEqual(self.request(second.port, "update-worker.py", first_query)[2], b"/* 1 */ ")
self.assertEqual(self.request(first.port, "update-worker.py", first_query)[2], b"/* 3 */ ")
self.assertEqual(first.fetch_stash.take(key), 50)
def test_concurrent_updates_keep_every_visit(self) -> None:
server = self.server()
query = urlencode({"Key": str(uuid.uuid4()), "Mode": "normal"})
def visit(index: int) -> int:
port = server.port if index % 2 else server.alternate_port
status, _, body = self.request(port, "update-worker.py", query)
self.assertEqual(status, 200)
match = re.fullmatch(rb"/\* (\d+) \*/ ", body)
self.assertIsNotNone(match)
return int(match[1])
with ThreadPoolExecutor(max_workers=6) as pool:
self.assertEqual(sorted(pool.map(visit, range(18))), list(range(1, 19)))
def test_head_runs_the_update_handler_without_emitting_its_body(self) -> None:
server = self.server()
query = urlencode({"Key": str(uuid.uuid4()), "Mode": "not_found"})
for expected_status, expected_length in ((200, 8), (404, 14), (200, 8)):
status, headers, body = self.request(server.port, "update-worker.py", query, method="HEAD")
self.assertEqual(status, expected_status)
self.assertEqual(dict(headers)["content-length"], str(expected_length))
self.assertEqual(body, b"")
def test_imported_version_changes_with_revalidation_headers(self) -> None:
server = self.server()
versions = []
for port in (server.port, server.alternate_port, server.port):
before = (datetime.now() - datetime(1970, 1, 1)).total_seconds()
status, headers, body = self.request(port, "import-scripts-version.py")
after = (datetime.now() - datetime(1970, 1, 1)).total_seconds()
self.assertEqual(status, 200)
self.assertEqual(dict(headers)["content-type"], "application/javascript")
self.assertEqual(dict(headers)["cache-control"], "no-cache, must-revalidate")
self.assertEqual(dict(headers)["pragma"], "no-cache")
match = re.fullmatch(rb'version = "([0-9.]+)";\n', body)
self.assertIsNotNone(match)
versions.append(float(match[1]))
self.assertLessEqual(before, versions[-1])
self.assertLessEqual(versions[-1], after)
self.assertLess(versions[0], versions[1])
self.assertLess(versions[1], versions[2])
def test_update_from_file_preserves_bytes_and_shares_only_its_own_stash(self) -> None:
first, second = self.server(), self.server()
resources = self.root / DIRECTORY / "resources"
before, after = b"// before\r\n\xff\n", b"// after\x00\n"
(resources / "café.js").write_bytes(before)
(resources / "after.js").write_bytes(after)
key = str(uuid.uuid4())
query = urlencode([
("Key", key), ("First", "café.js"), ("First", "ignored.js"),
("Second", "after.js"),
])
for port, expected in (
(first.port, before),
(second.port, before),
(first.alternate_port, after),
):
status, headers, body = self.request(port, "update-worker-from-file.py", query)
self.assertEqual((status, body), (200, expected))
self.assertEqual(dict(headers)["content-type"], "application/javascript")
self.assertEqual(dict(headers)["cache-control"], "no-cache, must-revalidate")
self.assertEqual(dict(headers)["pragma"], "no-cache")
self.assertEqual(self.request(first.port, "update-worker-from-file.py", query)[0], 500)
self.assertEqual(
self.request(first.port, "update-worker.py", urlencode({"Key": key, "Mode": "normal"}))[2],
b"/* 1 */ ",
)
head_query = urlencode({"Key": str(uuid.uuid4()), "First": "café.js", "Second": "after.js"})
status, headers, body = self.request(first.port, "update-worker-from-file.py", head_query, method="HEAD")
self.assertEqual((status, dict(headers)["content-length"], body), (200, str(len(before)), b""))
self.assertEqual(self.request(first.port, "update-worker-from-file.py", head_query, method="POST")[2], after)
failure_query = urlencode({"Key": str(uuid.uuid4()), "First": "missing.js", "Second": "after.js"})
self.assertEqual(self.request(first.port, "update-worker-from-file.py", failure_query)[0], 500)
self.assertEqual(self.request(first.port, "update-worker-from-file.py", failure_query)[2], after)
def test_global_scope_update_script_uses_timestamp_and_original_text(self) -> None:
server = self.server()
resource = "/" + DIRECTORY + "/ServiceWorkerGlobalScope/resources/update-worker.py"
script = "// café\nself.ready = true;\n".encode("utf-8")
versions = []
for method in ("GET", "POST", "OPTIONS", "HEAD"):
before = time.time()
status, headers, body = self.request(server.port, resource, method=method)
after = time.time()
self.assertEqual(status, 200)
self.assertEqual(dict(headers)["cache-control"], "max-age: 0")
self.assertEqual(dict(headers)["content-type"], "application/javascript")
self.assertNotIn("pragma", dict(headers))
if method == "HEAD":
self.assertEqual(body, b"")
self.assertGreater(int(dict(headers)["content-length"]), len(script))
continue
stamp, source = body.split(b"\n", 1)
self.assertEqual(source, script)
self.assertTrue(stamp.startswith(b"// "))
versions.append(float(stamp[3:]))
self.assertLessEqual(before, versions[-1])
self.assertLessEqual(versions[-1], after)
self.assertEqual(versions, sorted(set(versions)))
def test_installation_update_script_changes_without_rewriting_its_import(self) -> None:
server = self.server()
versions = []
for method in ("GET", "POST", "OPTIONS", "HEAD"):
status, headers, body = self.request(server.port, "update-during-installation-worker.py", method=method)
self.assertEqual(status, 200)
self.assertEqual(dict(headers)["content-type"], "application/javascript")
self.assertEqual(dict(headers)["cache-control"], "max-age=0")
self.assertNotIn("pragma", dict(headers))
if method == "HEAD":
self.assertEqual(body, b"")
continue
version, script = body.split(b"\n", 1)
self.assertEqual(script, b"importScripts('update-during-installation-worker.js');")
self.assertTrue(version.startswith(b"// "))
versions.append(float(version[3:]))
self.assertGreaterEqual(versions[-1], 0)
self.assertLess(versions[-1], 1)
self.assertEqual(len(set(versions)), 3)
def test_imported_assignments_preserve_raw_parameters_and_directory(self) -> None:
server = self.server()
for resource, query, expected in (
("import-scripts-get.py", "output=echo1&msg=test1", b'echo1 = "test1";\n'),
("import-scripts-get.py", "output=x&output=y&msg=%FF%22&msg=ignored", b'x = "\xff\"";\n'),
("import-scripts-echo.py", "msg=top+level", b'echo_output = "top level";\n'),
("import-scripts-echo.py", "msg=", b'echo_output = "";\n'),
("subdir/import-scripts-echo.py", "msg=install", b'echo_output = "install (subdir/)";\n'),
("scope2/import-scripts-echo.py", "msg=message", b'echo_output = "message (scope2/)";\n'),
):
for method in ("GET", "HEAD", "POST", "OPTIONS"):
with self.subTest(resource=resource, query=query, method=method):
status, headers, body = self.request(server.port, resource, query, method=method)
self.assertEqual(status, 200)
self.assertEqual(body, b"" if method == "HEAD" else expected)
self.assertEqual(dict(headers)["content-length"], str(len(expected)))
self.assertEqual(dict(headers)["content-type"], "application/javascript")
self.assertEqual(dict(headers)["cache-control"], "no-cache, must-revalidate")
self.assertEqual(dict(headers)["pragma"], "no-cache")
def test_malformed_parameters_are_not_served_as_javascript(self) -> None:
server = self.server()
for resource, query in (
("redirect.py", ""),
("redirect.py", "Redirect=x&Status=invalid"),
("redirect.py", "Redirect=x&Status=999"),
("redirect.py", "Redirect=%0D%0AX-Injected:yes"),
("redirect.py", "Redirect=x&ACAOrigin=%0Ainjected"),
("update-worker.py", "Mode=normal"),
("update-worker.py", "Key=invalid&Mode=normal"),
("update-worker.py", "Key=" + str(uuid.uuid4())),
("update-worker-from-file.py", "First=x&Second=y"),
("update-worker-from-file.py", urlencode({"Key": str(uuid.uuid4()), "First": "../../../../outside.js"})),
("import-scripts-get.py", "output=x"),
("import-scripts-get.py", "msg=x"),
("import-scripts-echo.py", ""),
):
with self.subTest(resource=resource, query=query):
self.assertEqual(self.request(server.port, resource, query)[0], 400)
def test_discovery_only_accepts_supported_handler_locations(self) -> None:
cases = {
"mime.html": ("resources/mime-type-worker.py", True),
"import-mime.html": ("resources/import-mime-type-worker.py", True),
"malformed.html": ("resources/malformed-worker.py", True),
"chunked.html": ("resources/invalid-chunked-encoding.py", True),
"chunked-flush.html": ("resources/invalid-chunked-encoding-with-flush.py", True),
"sub/malformed.html": ("../resources/malformed-worker.py", True),
"sub/malformed-wrong.html": ("resources/malformed-worker.py", False),
"malformed-suffix.html": ("resources/malformed-worker.py.extra", False),
"redirect-relative.html": ("resources/redirect.py", True),
"update-absolute.html": (RESOURCES + "update-worker.py", True),
"update-from-file.html": ("resources/update-worker-from-file.py", True),
"update-installing.html": ("resources/update-during-installation-worker.py", True),
"ServiceWorkerGlobalScope/update.html": ("resources/update-worker.py", True),
"ServiceWorkerGlobalScope/update-wrong.html": ("resources/update-worker-from-file.py", False),
"sub/version-relative.html": ("../resources/import-scripts-version.py", True),
"sub/redirect-dot-relative.html": ("./../resources/redirect.py", True),
"sub/redirect-wrong-relative.html": ("resources/redirect.py", False),
"redirect-suffix.html": ("resources/redirect.py.extra", False),
"redirect-other.html": ("/unrelated/resources/redirect.py", False),
"get.html": ("resources/import-scripts-get.py", True),
"echo.html": ("resources/import-scripts-echo.py", True),
"echo-subdir.html": ("resources/subdir/import-scripts-echo.py", True),
"echo-scope2.html": (RESOURCES + "scope2/import-scripts-echo.py", True),
"echo-wrong.html": ("resources/wrong/import-scripts-echo.py", False),
"unknown.html": ("resources/unknown.py", False),
}
for name, (reference, _) in cases.items():
path = self.root / DIRECTORY / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
'<!doctype html><script src="/resources/testharness.js"></script>'
'<script src="/resources/testharnessreport.js"></script>'
f'<script src="{reference}?Key=ignored"></script>'
)
discovered = enumerate_cases(self.root, dir_prefixes=(DIRECTORY,))
self.assertEqual(
sorted(case.case_path for case in discovered),
sorted(DIRECTORY + "/" + name for name, (_, allowed) in cases.items() if allowed),
)
if __name__ == "__main__":
unittest.main()