From a98cc8cc7e2c01dde425d4315d85027f6273d97d Mon Sep 17 00:00:00 2001
From: Yoshisato Yanagisawa <yyanagisawa@chromium.org>
Date: Mon, 17 Jul 2023 23:21:06 -0700
Subject: [PATCH] WPT: ServiceWorker static routing API for subresource loads.

This CL adds the Web Platform Tests to test ServiceWorker static
routing API for subresources.

WICG proposal: https://github.com/WICG/proposals/issues/102
Spec PR: https://github.com/w3c/ServiceWorker/pull/1686

Change-Id: I7379d85b5a2208f248878abe9d1a920ad97d47ab
Bug: 1371756
---
 .../tentative/static-router/README.md         |   4 +
 .../static-router/resources/direct.txt        |   1 +
 .../static-router/resources/simple.html       |   3 +
 .../resources/static-router-sw.js             |  19 ++
 .../resources/test-helpers.sub.js             | 303 ++++++++++++++++++
 .../static-router-subresource.https.html      |  48 +++
 6 files changed, 378 insertions(+)
 create mode 100644 service-workers/service-worker/tentative/static-router/README.md
 create mode 100644 service-workers/service-worker/tentative/static-router/resources/direct.txt
 create mode 100644 service-workers/service-worker/tentative/static-router/resources/simple.html
 create mode 100644 service-workers/service-worker/tentative/static-router/resources/static-router-sw.js
 create mode 100644 service-workers/service-worker/tentative/static-router/resources/test-helpers.sub.js
 create mode 100644 service-workers/service-worker/tentative/static-router/static-router-subresource.https.html

diff --git a/service-workers/service-worker/tentative/static-router/README.md b/service-workers/service-worker/tentative/static-router/README.md
new file mode 100644
index 000000000000000..8826b3c78276fbf
--- /dev/null
+++ b/service-workers/service-worker/tentative/static-router/README.md
@@ -0,0 +1,4 @@
+A test stuite for the ServiceWorker Static Routing API.
+
+WICG proposal: https://github.com/WICG/proposals/issues/102
+Specification PR: https://github.com/w3c/ServiceWorker/pull/1686
diff --git a/service-workers/service-worker/tentative/static-router/resources/direct.txt b/service-workers/service-worker/tentative/static-router/resources/direct.txt
new file mode 100644
index 000000000000000..f3d9861c137b2dd
--- /dev/null
+++ b/service-workers/service-worker/tentative/static-router/resources/direct.txt
@@ -0,0 +1 @@
+Network
diff --git a/service-workers/service-worker/tentative/static-router/resources/simple.html b/service-workers/service-worker/tentative/static-router/resources/simple.html
new file mode 100644
index 000000000000000..0c3e3e78707b14d
--- /dev/null
+++ b/service-workers/service-worker/tentative/static-router/resources/simple.html
@@ -0,0 +1,3 @@
+<!DOCTYPE html>
+<title>Simple</title>
+Here's a simple html file.
diff --git a/service-workers/service-worker/tentative/static-router/resources/static-router-sw.js b/service-workers/service-worker/tentative/static-router/resources/static-router-sw.js
new file mode 100644
index 000000000000000..deb7a3e0b47c58f
--- /dev/null
+++ b/service-workers/service-worker/tentative/static-router/resources/static-router-sw.js
@@ -0,0 +1,19 @@
+'use strict';
+
+self.addEventListener('install', e => {
+  e.registerRouter({
+    condition: {urlPattern: "*.txt"},
+    source: "network"
+  });
+  self.skipWaiting();
+});
+
+self.addEventListener('activate', e => {
+  e.waitUntil(clients.claim());
+});
+
+self.addEventListener('fetch', function(event) {
+  const url = new URL(event.request.url);
+  const nonce = url.searchParams.get('nonce');
+  event.respondWith(new Response(nonce));
+});
diff --git a/service-workers/service-worker/tentative/static-router/resources/test-helpers.sub.js b/service-workers/service-worker/tentative/static-router/resources/test-helpers.sub.js
new file mode 100644
index 000000000000000..64a7f7d24fd2d36
--- /dev/null
+++ b/service-workers/service-worker/tentative/static-router/resources/test-helpers.sub.js
@@ -0,0 +1,303 @@
+// Copied from
+// service-workers/service-worker/resources/testharness-helpers.js to be used under tentative.
+
+// Adapter for testharness.js-style tests with Service Workers
+
+/**
+ * @param options an object that represents RegistrationOptions except for scope.
+ * @param options.type a WorkerType.
+ * @param options.updateViaCache a ServiceWorkerUpdateViaCache.
+ * @see https://w3c.github.io/ServiceWorker/#dictdef-registrationoptions
+ */
+function service_worker_unregister_and_register(test, url, scope, options) {
+  if (!scope || scope.length == 0)
+    return Promise.reject(new Error('tests must define a scope'));
+
+  if (options && options.scope)
+    return Promise.reject(new Error('scope must not be passed in options'));
+
+  options = Object.assign({ scope: scope }, options);
+  return service_worker_unregister(test, scope)
+    .then(function() {
+        return navigator.serviceWorker.register(url, options);
+      })
+    .catch(unreached_rejection(test,
+                               'unregister and register should not fail'));
+}
+
+// This unregisters the registration that precisely matches scope. Use this
+// when unregistering by scope. If no registration is found, it just resolves.
+function service_worker_unregister(test, scope) {
+  var absoluteScope = (new URL(scope, window.location).href);
+  return navigator.serviceWorker.getRegistration(scope)
+    .then(function(registration) {
+        if (registration && registration.scope === absoluteScope)
+          return registration.unregister();
+      })
+    .catch(unreached_rejection(test, 'unregister should not fail'));
+}
+
+function service_worker_unregister_and_done(test, scope) {
+  return service_worker_unregister(test, scope)
+    .then(test.done.bind(test));
+}
+
+function unreached_fulfillment(test, prefix) {
+  return test.step_func(function(result) {
+      var error_prefix = prefix || 'unexpected fulfillment';
+      assert_unreached(error_prefix + ': ' + result);
+    });
+}
+
+// Rejection-specific helper that provides more details
+function unreached_rejection(test, prefix) {
+  return test.step_func(function(error) {
+      var reason = error.message || error.name || error;
+      var error_prefix = prefix || 'unexpected rejection';
+      assert_unreached(error_prefix + ': ' + reason);
+    });
+}
+
+/**
+ * Adds an iframe to the document and returns a promise that resolves to the
+ * iframe when it finishes loading. The caller is responsible for removing the
+ * iframe later if needed.
+ *
+ * @param {string} url
+ * @returns {HTMLIFrameElement}
+ */
+function with_iframe(url) {
+  return new Promise(function(resolve) {
+      var frame = document.createElement('iframe');
+      frame.className = 'test-iframe';
+      frame.src = url;
+      frame.onload = function() { resolve(frame); };
+      document.body.appendChild(frame);
+    });
+}
+
+function normalizeURL(url) {
+  return new URL(url, self.location).toString().replace(/#.*$/, '');
+}
+
+function wait_for_update(test, registration) {
+  if (!registration || registration.unregister == undefined) {
+    return Promise.reject(new Error(
+      'wait_for_update must be passed a ServiceWorkerRegistration'));
+  }
+
+  return new Promise(test.step_func(function(resolve) {
+      var handler = test.step_func(function() {
+        registration.removeEventListener('updatefound', handler);
+        resolve(registration.installing);
+      });
+      registration.addEventListener('updatefound', handler);
+    }));
+}
+
+// Return true if |state_a| is more advanced than |state_b|.
+function is_state_advanced(state_a, state_b) {
+  if (state_b === 'installing') {
+    switch (state_a) {
+      case 'installed':
+      case 'activating':
+      case 'activated':
+      case 'redundant':
+        return true;
+    }
+  }
+
+  if (state_b === 'installed') {
+    switch (state_a) {
+      case 'activating':
+      case 'activated':
+      case 'redundant':
+        return true;
+    }
+  }
+
+  if (state_b === 'activating') {
+    switch (state_a) {
+      case 'activated':
+      case 'redundant':
+        return true;
+    }
+  }
+
+  if (state_b === 'activated') {
+    switch (state_a) {
+      case 'redundant':
+        return true;
+    }
+  }
+  return false;
+}
+
+function wait_for_state(test, worker, state) {
+  if (!worker || worker.state == undefined) {
+    return Promise.reject(new Error(
+      'wait_for_state needs a ServiceWorker object to be passed.'));
+  }
+  if (worker.state === state)
+    return Promise.resolve(state);
+
+  if (is_state_advanced(worker.state, state)) {
+    return Promise.reject(new Error(
+      `Waiting for ${state} but the worker is already ${worker.state}.`));
+  }
+  return new Promise(test.step_func(function(resolve, reject) {
+      worker.addEventListener('statechange', test.step_func(function() {
+          if (worker.state === state)
+            resolve(state);
+
+          if (is_state_advanced(worker.state, state)) {
+            reject(new Error(
+              `The state of the worker becomes ${worker.state} while waiting` +
+                `for ${state}.`));
+          }
+        }));
+    }));
+}
+
+// Declare a test that runs entirely in the ServiceWorkerGlobalScope. The |url|
+// is the service worker script URL. This function:
+// - Instantiates a new test with the description specified in |description|.
+//   The test will succeed if the specified service worker can be successfully
+//   registered and installed.
+// - Creates a new ServiceWorker registration with a scope unique to the current
+//   document URL. Note that this doesn't allow more than one
+//   service_worker_test() to be run from the same document.
+// - Waits for the new worker to begin installing.
+// - Imports tests results from tests running inside the ServiceWorker.
+function service_worker_test(url, description) {
+  // If the document URL is https://example.com/document and the script URL is
+  // https://example.com/script/worker.js, then the scope would be
+  // https://example.com/script/scope/document.
+  var scope = new URL('scope' + window.location.pathname,
+                      new URL(url, window.location)).toString();
+  promise_test(function(test) {
+      return service_worker_unregister_and_register(test, url, scope)
+        .then(function(registration) {
+            add_completion_callback(function() {
+                registration.unregister();
+              });
+            return wait_for_update(test, registration)
+              .then(function(worker) {
+                  return fetch_tests_from_worker(worker);
+                });
+          });
+    }, description);
+}
+
+function base_path() {
+  return location.pathname.replace(/\/[^\/]*$/, '/');
+}
+
+function test_login(test, origin, username, password, cookie) {
+  return new Promise(function(resolve, reject) {
+      with_iframe(
+        origin + base_path() +
+        'resources/fetch-access-control-login.html')
+        .then(test.step_func(function(frame) {
+            var channel = new MessageChannel();
+            channel.port1.onmessage = test.step_func(function() {
+                frame.remove();
+                resolve();
+              });
+            frame.contentWindow.postMessage(
+              {username: username, password: password, cookie: cookie},
+              origin, [channel.port2]);
+          }));
+    });
+}
+
+function test_websocket(test, frame, url) {
+  return new Promise(function(resolve, reject) {
+      var ws = new frame.contentWindow.WebSocket(url, ['echo', 'chat']);
+      var openCalled = false;
+      ws.addEventListener('open', test.step_func(function(e) {
+          assert_equals(ws.readyState, 1, "The WebSocket should be open");
+          openCalled = true;
+          ws.close();
+        }), true);
+
+      ws.addEventListener('close', test.step_func(function(e) {
+          assert_true(openCalled, "The WebSocket should be closed after being opened");
+          resolve();
+        }), true);
+
+      ws.addEventListener('error', reject);
+    });
+}
+
+function login_https(test) {
+  var host_info = get_host_info();
+  return test_login(test, host_info.HTTPS_REMOTE_ORIGIN,
+                    'username1s', 'password1s', 'cookie1')
+    .then(function() {
+        return test_login(test, host_info.HTTPS_ORIGIN,
+                          'username2s', 'password2s', 'cookie2');
+      });
+}
+
+function websocket(test, frame) {
+  return test_websocket(test, frame, get_websocket_url());
+}
+
+function get_websocket_url() {
+  return 'wss://{{host}}:{{ports[wss][0]}}/echo';
+}
+
+// The navigator.serviceWorker.register() method guarantees that the newly
+// installing worker is available as registration.installing when its promise
+// resolves. However some tests test installation using a <link> element where
+// it is possible for the installing worker to have already become the waiting
+// or active worker. So this method is used to get the newest worker when these
+// tests need access to the ServiceWorker itself.
+function get_newest_worker(registration) {
+  if (registration.installing)
+    return registration.installing;
+  if (registration.waiting)
+    return registration.waiting;
+  if (registration.active)
+    return registration.active;
+}
+
+function register_using_link(script, options) {
+  var scope = options.scope;
+  var link = document.createElement('link');
+  link.setAttribute('rel', 'serviceworker');
+  link.setAttribute('href', script);
+  link.setAttribute('scope', scope);
+  document.getElementsByTagName('head')[0].appendChild(link);
+  return new Promise(function(resolve, reject) {
+        link.onload = resolve;
+        link.onerror = reject;
+      })
+    .then(() => navigator.serviceWorker.getRegistration(scope));
+}
+
+function with_sandboxed_iframe(url, sandbox) {
+  return new Promise(function(resolve) {
+      var frame = document.createElement('iframe');
+      frame.sandbox = sandbox;
+      frame.src = url;
+      frame.onload = function() { resolve(frame); };
+      document.body.appendChild(frame);
+    });
+}
+
+// Registers, waits for activation, then unregisters on a sample scope.
+//
+// This can be used to wait for a period of time needed to register,
+// activate, and then unregister a service worker.  When checking that
+// certain behavior does *NOT* happen, this is preferable to using an
+// arbitrary delay.
+async function wait_for_activation_on_sample_scope(t, window_or_workerglobalscope) {
+  const script = '/service-workers/service-worker/resources/empty-worker.js';
+  const scope = 'resources/there/is/no/there/there?' + Date.now();
+  let registration = await window_or_workerglobalscope.navigator.serviceWorker.register(script, { scope });
+  await wait_for_state(t, registration.installing, 'activated');
+  await registration.unregister();
+}
+
diff --git a/service-workers/service-worker/tentative/static-router/static-router-subresource.https.html b/service-workers/service-worker/tentative/static-router/static-router-subresource.https.html
new file mode 100644
index 000000000000000..721c2797603bb56
--- /dev/null
+++ b/service-workers/service-worker/tentative/static-router/static-router-subresource.https.html
@@ -0,0 +1,48 @@
+<!DOCTYPE html>
+<meta charset="utf-8">
+<title>Static Router: simply skip fetch handler if pattern matches</title>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script src="resources/test-helpers.sub.js"></script>
+<body>
+<script>
+const SCRIPT = 'resources/static-router-sw.js';
+const SCOPE = 'resources/';
+const HTML_FILE = 'resources/simple.html';
+const TXT_FILE = 'resources/direct.txt';
+
+// Register a service worker, then create an iframe at url.
+function iframeTest(url, callback, name) {
+  return promise_test(async t => {
+    const reg = await service_worker_unregister_and_register(t, SCRIPT, SCOPE);
+    add_completion_callback(() => reg.unregister());
+    await wait_for_state(t, reg.installing, 'activated');
+    const iframe = await with_iframe(url);
+    const iwin = iframe.contentWindow;
+    t.add_cleanup(() => iframe.remove());
+    await callback(t, iwin);
+  }, name);
+}
+
+function randomString() {
+  let result = "";
+  for (let i = 0; i < 5; i++) {
+    result += String.fromCharCode(97 + Math.floor(Math.random() * 26));
+  }
+  return result;
+}
+
+iframeTest(HTML_FILE, async (t, iwin) => {
+  const rnd = randomString();
+  const response = await iwin.fetch('?nonce=' + rnd);
+  assert_equals(await response.text(), rnd);
+}, 'Subresource load not matched with the condition');
+
+iframeTest(TXT_FILE, async (t, iwin) => {
+  const rnd = randomString();
+  const response = await iwin.fetch('?nonce=' + rnd);
+  assert_equals(await response.text(), "Network\n");
+}, 'Subresource load matched with the condition');
+
+</script>
+</body>