Add support for PAC (per-test proxy configuration) (#34145)

Tests can now delcare that they use a PAC file
(Proxy Auto-Configuration, see https://developer.mozilla.org/en-US/docs/Web/HTTP/Proxy_servers_and_tunneling/Proxy_Auto-Configuration_PAC_file)

The syntax:

`<meta name="pac" content="relative/url/to/pac-file.js">`

See RFC112
diff --git a/infrastructure/metadata/infrastructure/server/test-pac.html.ini b/infrastructure/metadata/infrastructure/server/test-pac.html.ini
new file mode 100644
index 0000000..26ea697
--- /dev/null
+++ b/infrastructure/metadata/infrastructure/server/test-pac.html.ini
@@ -0,0 +1,4 @@
+[test-pac.html]
+  [test that PAC metadata is respected]
+    expected:
+        if product == "safari": FAIL # Safari WebDriver does not support PAC
\ No newline at end of file
diff --git a/infrastructure/resources/ok.txt b/infrastructure/resources/ok.txt
new file mode 100644
index 0000000..a0aba93
--- /dev/null
+++ b/infrastructure/resources/ok.txt
@@ -0,0 +1 @@
+OK
\ No newline at end of file
diff --git a/infrastructure/resources/ok.txt.headers b/infrastructure/resources/ok.txt.headers
new file mode 100644
index 0000000..23de552
--- /dev/null
+++ b/infrastructure/resources/ok.txt.headers
@@ -0,0 +1 @@
+Access-Control-Allow-Origin: *
\ No newline at end of file
diff --git a/infrastructure/server/resources/proxy.sub.pac b/infrastructure/server/resources/proxy.sub.pac
new file mode 100644
index 0000000..78ce023
--- /dev/null
+++ b/infrastructure/server/resources/proxy.sub.pac
@@ -0,0 +1,7 @@
+function FindProxyForURL(url, host) {
+    if (dnsDomainIs(host, '.wpt.test')) {
+        return "PROXY 127.0.0.1:{{ports[http][0]}}"
+    }
+
+    return "DIRECT";
+}
diff --git a/infrastructure/server/test-pac.html b/infrastructure/server/test-pac.html
new file mode 100644
index 0000000..598836d
--- /dev/null
+++ b/infrastructure/server/test-pac.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<title>test behavior of PROXY configuration (PAC)</title>
+<meta name="pac" content="resources/proxy.sub.pac">
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<script>
+    promise_test(async t => {
+        const response = await fetch('http://not-a-real-domain.wpt.test/infrastructure/resources/ok.txt');
+        const text = await response.text();
+        assert_equals(text, 'OK');
+    }, 'test that PAC metadata is respected');
+</script>
diff --git a/tools/manifest/item.py b/tools/manifest/item.py
index ec61c9e..02a72ee 100644
--- a/tools/manifest/item.py
+++ b/tools/manifest/item.py
@@ -189,6 +189,11 @@
         return self._extras.get("timeout")
 
     @property
+    def pac(self):
+        # type: () -> Optional[Text]
+        return self._extras.get("pac")
+
+    @property
     def testdriver(self):
         # type: () -> Optional[Text]
         return self._extras.get("testdriver")
@@ -208,6 +213,8 @@
         rv = super().to_json()
         if self.timeout is not None:
             rv[-1]["timeout"] = self.timeout
+        if self.pac is not None:
+            rv[-1]["pac"] = self.pac
         if self.testdriver:
             rv[-1]["testdriver"] = self.testdriver
         if self.jsshell:
diff --git a/tools/manifest/sourcefile.py b/tools/manifest/sourcefile.py
index 88da179..3919b5a 100644
--- a/tools/manifest/sourcefile.py
+++ b/tools/manifest/sourcefile.py
@@ -478,6 +478,14 @@
         return self.root.findall(".//{http://www.w3.org/1999/xhtml}meta[@name='timeout']")
 
     @cached_property
+    def pac_nodes(self):
+        # type: () -> List[ElementTree.Element]
+        """List of ElementTree Elements corresponding to nodes in a test that
+        specify PAC (proxy auto-config)"""
+        assert self.root is not None
+        return self.root.findall(".//{http://www.w3.org/1999/xhtml}meta[@name='pac']")
+
+    @cached_property
     def script_metadata(self):
         # type: () -> Optional[List[Tuple[Text, Text]]]
         if self.name_is_worker or self.name_is_multi_global or self.name_is_window:
@@ -510,6 +518,23 @@
         return None
 
     @cached_property
+    def pac(self):
+        # type: () -> Optional[Text]
+        """The PAC (proxy config) of a test or reference file. A URL or null"""
+        if self.script_metadata:
+            for (meta, content) in self.script_metadata:
+                if meta == 'pac':
+                    return content
+
+        if self.root is None:
+            return None
+
+        if self.pac_nodes:
+            return self.pac_nodes[0].attrib.get("content", None)
+
+        return None
+
+    @cached_property
     def viewport_nodes(self):
         # type: () -> List[ElementTree.Element]
         """List of ElementTree Elements corresponding to nodes in a test that
@@ -1008,6 +1033,7 @@
                     self.url_base,
                     global_variant_url(self.rel_url, suffix) + variant,
                     timeout=self.timeout,
+                    pac=self.pac,
                     jsshell=jsshell,
                     script_metadata=self.script_metadata
                 )
@@ -1025,6 +1051,7 @@
                     self.url_base,
                     test_url + variant,
                     timeout=self.timeout,
+                    pac=self.pac,
                     script_metadata=self.script_metadata
                 )
                 for variant in self.test_variants
@@ -1040,6 +1067,7 @@
                     self.url_base,
                     test_url + variant,
                     timeout=self.timeout,
+                    pac=self.pac,
                     script_metadata=self.script_metadata
                 )
                 for variant in self.test_variants
@@ -1066,6 +1094,7 @@
                     self.url_base,
                     url,
                     timeout=self.timeout,
+                    pac=self.pac,
                     testdriver=testdriver,
                     script_metadata=self.script_metadata
                 ))
diff --git a/tools/manifest/tests/test_manifest.py b/tools/manifest/tests/test_manifest.py
index 2a38d23..a7f3d31 100644
--- a/tools/manifest/tests/test_manifest.py
+++ b/tools/manifest/tests/test_manifest.py
@@ -295,7 +295,7 @@
     m = manifest.Manifest.from_json("/", json_str)
 
     # Update it with timeout="long"
-    s2 = SourceFileWithTest("test1", "1"*40, item.TestharnessTest, timeout="long")
+    s2 = SourceFileWithTest("test1", "1"*40, item.TestharnessTest, timeout="long", pac="proxy.pac")
     tree, sourcefile_mock = tree_and_sourcefile_mocks([(s2, None, True)])
     with mock.patch("tools.manifest.manifest.SourceFile", side_effect=sourcefile_mock):
         m.update(tree)
@@ -303,7 +303,7 @@
     assert json_str == {
         'items': {'testharness': {'test1': [
             "1"*40,
-            (None, {'timeout': 'long'})
+            (None, {'timeout': 'long', 'pac': 'proxy.pac'})
         ]}},
         'url_base': '/',
         'version': 8
diff --git a/tools/manifest/tests/test_sourcefile.py b/tools/manifest/tests/test_sourcefile.py
index 98a1783..c0b281d 100644
--- a/tools/manifest/tests/test_sourcefile.py
+++ b/tools/manifest/tests/test_sourcefile.py
@@ -849,7 +849,6 @@
     assert s.content_is_ref_node
     assert s.fuzzy == expected
 
-
 @pytest.mark.parametrize("fuzzy, expected", [
     ([b"1;200"], {None: [[1, 1], [200, 200]]}),
     ([b"ref-2.html:0-1;100-200"], {("/foo/test.html", "/foo/ref-2.html", "=="): [[0, 1], [100, 200]]}),
@@ -868,6 +867,15 @@
     assert s.content_is_ref_node
     assert s.fuzzy == expected
 
+@pytest.mark.parametrize("pac, expected", [
+    (b"proxy.pac", "proxy.pac")])
+def test_pac(pac, expected):
+    content = b"""
+<meta name=pac content="%s">
+""" % pac
+
+    s = create("foo/test.html", content)
+    assert s.pac == expected
 
 @pytest.mark.parametrize("page_ranges, expected", [
     (b"1-2", [[1, 2]]),
diff --git a/tools/wptrunner/wptrunner/browsers/base.py b/tools/wptrunner/wptrunner/browsers/base.py
index 4f73efb..65db5f6 100644
--- a/tools/wptrunner/wptrunner/browsers/base.py
+++ b/tools/wptrunner/wptrunner/browsers/base.py
@@ -159,6 +159,9 @@
         log. Returns a boolean indicating whether a crash occured."""
         return False
 
+    @property
+    def pac(self):
+        return None
 
 class NullBrowser(Browser):
     def __init__(self, logger, **kwargs):
@@ -289,7 +292,7 @@
 
     def __init__(self, logger, binary=None, webdriver_binary=None,
                  webdriver_args=None, host="127.0.0.1", port=None, base_path="/",
-                 env=None, **kwargs):
+                 env=None, supports_pac=True, **kwargs):
         super().__init__(logger)
 
         if webdriver_binary is None:
@@ -302,6 +305,7 @@
 
         self.host = host
         self._port = port
+        self._supports_pac = supports_pac
 
         self.base_path = base_path
         self.env = os.environ.copy() if env is None else env
@@ -312,6 +316,7 @@
         self._output_handler = None
         self._cmd = None
         self._proc = None
+        self._pac = None
 
     def make_command(self):
         """Returns the full command for starting the server process as a list."""
@@ -400,4 +405,13 @@
     def executor_browser(self):
         return ExecutorBrowser, {"webdriver_url": self.url,
                                  "host": self.host,
-                                 "port": self.port}
+                                 "port": self.port,
+                                 "pac": self.pac}
+
+    def settings(self, test):
+        self._pac = test.environment.get("pac", None) if self._supports_pac else None
+        return {"pac": self._pac}
+
+    @property
+    def pac(self):
+        return self._pac
diff --git a/tools/wptrunner/wptrunner/browsers/safari.py b/tools/wptrunner/wptrunner/browsers/safari.py
index 7aed3a4..ba533f4 100644
--- a/tools/wptrunner/wptrunner/browsers/safari.py
+++ b/tools/wptrunner/wptrunner/browsers/safari.py
@@ -157,6 +157,7 @@
                          webdriver_binary,
                          webdriver_args=webdriver_args,
                          port=None,
+                         supports_pac=False,
                          env=env)
 
         if "/" not in webdriver_binary:
diff --git a/tools/wptrunner/wptrunner/executors/executormarionette.py b/tools/wptrunner/wptrunner/executors/executormarionette.py
index 418b6bf..051821c 100644
--- a/tools/wptrunner/wptrunner/executors/executormarionette.py
+++ b/tools/wptrunner/wptrunner/executors/executormarionette.py
@@ -780,6 +780,16 @@
             self.executor.original_pref_values[name] = self.prefs.get(name)
             self.prefs.set(name, value)
 
+        pac = new_environment.get("pac", None)
+
+        if pac != old_environment.get("pac", None):
+            if pac is None:
+                self.prefs.clear("network.proxy.type")
+                self.prefs.clear("network.proxy.autoconfig_url")
+            else:
+                self.prefs.set("network.proxy.type", 2)
+                self.prefs.set("network.proxy.autoconfig_url",
+                               urljoin(self.executor.server_url("http"), pac))
 
 class ExecuteAsyncScriptRun(TimedRunner):
     def set_timeout(self):
diff --git a/tools/wptrunner/wptrunner/executors/executorwebdriver.py b/tools/wptrunner/wptrunner/executors/executorwebdriver.py
index 9fbaf2b..4ba78b2 100644
--- a/tools/wptrunner/wptrunner/executors/executorwebdriver.py
+++ b/tools/wptrunner/wptrunner/executors/executorwebdriver.py
@@ -350,6 +350,18 @@
                 self.capabilities = browser.capabilities
             else:
                 merge_dicts(self.capabilities, browser.capabilities)
+
+        pac = browser.pac
+        if pac is not None:
+            if self.capabilities is None:
+                self.capabilities = {}
+            merge_dicts(self.capabilities, {"proxy":
+                {
+                    "proxyType": "pac",
+                    "proxyAutoconfigUrl": urljoin(executor.server_url("http"), pac)
+                }
+            })
+
         self.url = browser.webdriver_url
         self.webdriver = None
 
diff --git a/tools/wptrunner/wptrunner/wpttest.py b/tools/wptrunner/wptrunner/wpttest.py
index 96fc108..e40a535 100644
--- a/tools/wptrunner/wptrunner/wpttest.py
+++ b/tools/wptrunner/wptrunner/wpttest.py
@@ -211,12 +211,13 @@
     result_cls = None  # type: ClassVar[Type[Result]]
     subtest_result_cls = None  # type: ClassVar[Type[SubtestResult]]
     test_type = None  # type: ClassVar[str]
+    pac = None
 
     default_timeout = 10  # seconds
     long_timeout = 60  # seconds
 
     def __init__(self, url_base, tests_root, url, inherit_metadata, test_metadata,
-                 timeout=None, path=None, protocol="http", subdomain=False):
+                 timeout=None, path=None, protocol="http", subdomain=False, pac=None):
         self.url_base = url_base
         self.tests_root = tests_root
         self.url = url
@@ -229,6 +230,9 @@
                             "protocol": protocol,
                             "prefs": self.prefs}
 
+        if pac is not None:
+            self.environment["pac"] = urljoin(self.url, pac)
+
     def __eq__(self, other):
         if not isinstance(other, Test):
             return False
@@ -458,9 +462,9 @@
 
     def __init__(self, url_base, tests_root, url, inherit_metadata, test_metadata,
                  timeout=None, path=None, protocol="http", testdriver=False,
-                 jsshell=False, scripts=None, subdomain=False):
+                 jsshell=False, scripts=None, subdomain=False, pac=None):
         Test.__init__(self, url_base, tests_root, url, inherit_metadata, test_metadata, timeout,
-                      path, protocol, subdomain)
+                      path, protocol, subdomain, pac)
 
         self.testdriver = testdriver
         self.jsshell = jsshell
@@ -469,6 +473,7 @@
     @classmethod
     def from_manifest(cls, manifest_file, manifest_item, inherit_metadata, test_metadata):
         timeout = cls.long_timeout if manifest_item.timeout == "long" else cls.default_timeout
+        pac = manifest_item.pac
         testdriver = manifest_item.testdriver if hasattr(manifest_item, "testdriver") else False
         jsshell = manifest_item.jsshell if hasattr(manifest_item, "jsshell") else False
         script_metadata = manifest_item.script_metadata or []
@@ -480,6 +485,7 @@
                    inherit_metadata,
                    test_metadata,
                    timeout=timeout,
+                   pac=pac,
                    path=os.path.join(manifest_file.tests_root, manifest_item.path),
                    protocol=server_protocol(manifest_item),
                    testdriver=testdriver,