summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/tools/wptrunner
diff options
context:
space:
mode:
Diffstat (limited to 'testing/web-platform/tests/tools/wptrunner')
-rw-r--r--testing/web-platform/tests/tools/wptrunner/.gitignore8
-rw-r--r--testing/web-platform/tests/tools/wptrunner/MANIFEST.in6
-rw-r--r--testing/web-platform/tests/tools/wptrunner/README.rst14
-rw-r--r--testing/web-platform/tests/tools/wptrunner/docs/architecture.svg1
-rw-r--r--testing/web-platform/tests/tools/wptrunner/docs/commands.rst79
-rw-r--r--testing/web-platform/tests/tools/wptrunner/docs/design.rst108
-rw-r--r--testing/web-platform/tests/tools/wptrunner/docs/expectation.rst366
-rw-r--r--testing/web-platform/tests/tools/wptrunner/docs/internals.rst23
-rw-r--r--testing/web-platform/tests/tools/wptrunner/requirements.txt9
-rw-r--r--testing/web-platform/tests/tools/wptrunner/requirements_chromium.txt4
-rw-r--r--testing/web-platform/tests/tools/wptrunner/requirements_edge.txt1
-rw-r--r--testing/web-platform/tests/tools/wptrunner/requirements_firefox.txt9
-rw-r--r--testing/web-platform/tests/tools/wptrunner/requirements_ie.txt2
-rw-r--r--testing/web-platform/tests/tools/wptrunner/requirements_opera.txt2
-rw-r--r--testing/web-platform/tests/tools/wptrunner/requirements_safari.txt1
-rw-r--r--testing/web-platform/tests/tools/wptrunner/requirements_sauce.txt2
-rw-r--r--testing/web-platform/tests/tools/wptrunner/setup.py66
-rw-r--r--testing/web-platform/tests/tools/wptrunner/tox.ini25
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner.default.ini11
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/__init__.py0
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/__init__.py45
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/android_weblayer.py105
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/android_webview.py103
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/base.py409
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome.py157
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_android.py244
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_ios.py58
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_spki_certs.py13
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chromium.py57
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/content_shell.py203
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edge.py109
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edge_webdriver.py27
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edgechromium.py97
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/epiphany.py75
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/firefox.py969
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/firefox_android.py367
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/ie.py50
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/opera.py70
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/safari.py207
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce.py249
-rwxr-xr-xtesting/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce_setup/edge-prerun.bat9
-rwxr-xr-xtesting/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce_setup/safari-prerun.sh3
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/servo.py118
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/servodriver.py184
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/webkit.py83
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/webkitgtk_minibrowser.py82
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/config.py63
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/environment.py331
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/__init__.py5
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/actions.py269
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/base.py781
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorchrome.py114
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorcontentshell.py269
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executormarionette.py1323
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorselenium.py485
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorservo.py363
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorservodriver.py303
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorwebdriver.py694
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/process.py22
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/protocol.py689
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/pytestrunner/__init__.py1
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/pytestrunner/runner.py171
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/reftest.js1
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/runner.js1
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/test-wait.js55
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/testharness_servodriver.js2
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/testharness_webdriver_resume.js5
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/executors/window-loaded.js9
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/expected.py16
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/expectedtree.py132
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/font.py144
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/__init__.py0
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/chromium.py335
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/tests/__init__.py0
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/tests/test_chromium.py828
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/wptreport.py137
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/wptscreenshot.py49
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/instruments.py121
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/manifestexpected.py542
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/manifestinclude.py156
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/manifestupdate.py967
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/metadata.py836
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/mpcontext.py13
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/print_reftest_runner.html33
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/products.py67
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/stability.py417
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-extra.js259
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-vendor.js1
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/testharness_runner.html6
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-content-shell.js25
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-servo.js17
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-servodriver.js23
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport.js88
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/testloader.py534
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/testrunner.py984
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/__init__.py9
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/base.py63
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/__init__.py0
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/test_sauce.py170
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/test_webkitgtk.py74
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_executors.py17
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_expectedtree.py120
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_formatters.py152
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_manifestexpected.py36
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_metadata.py47
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_products.py57
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_stability.py186
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_testloader.py95
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_update.py1853
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_wpttest.py232
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/update/__init__.py47
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/update/base.py69
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/update/metadata.py62
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/update/state.py159
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/update/sync.py150
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/update/tree.py407
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/update/update.py191
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/vcs.py67
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptcommandline.py777
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptlogging.py109
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/__init__.py5
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/__init__.py0
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/base.py221
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/conditional.py402
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/static.py102
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/node.py173
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/parser.py873
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/serializer.py160
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/__init__.py0
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_conditional.py143
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_parser.py155
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_serializer.py356
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_static.py98
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_tokenizer.py385
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wptrunner.py536
-rw-r--r--testing/web-platform/tests/tools/wptrunner/wptrunner/wpttest.py715
136 files changed, 26989 insertions, 0 deletions
diff --git a/testing/web-platform/tests/tools/wptrunner/.gitignore b/testing/web-platform/tests/tools/wptrunner/.gitignore
new file mode 100644
index 0000000000..495616ef1d
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/.gitignore
@@ -0,0 +1,8 @@
+*.py[co]
+*~
+*#
+\#*
+_virtualenv
+test/test.cfg
+test/metadata/MANIFEST.json
+wptrunner.egg-info
diff --git a/testing/web-platform/tests/tools/wptrunner/MANIFEST.in b/testing/web-platform/tests/tools/wptrunner/MANIFEST.in
new file mode 100644
index 0000000000..d36344f966
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/MANIFEST.in
@@ -0,0 +1,6 @@
+exclude MANIFEST.in
+include requirements.txt
+include wptrunner.default.ini
+include wptrunner/testharness_runner.html
+include wptrunner/*.js
+include wptrunner/executors/*.js
diff --git a/testing/web-platform/tests/tools/wptrunner/README.rst b/testing/web-platform/tests/tools/wptrunner/README.rst
new file mode 100644
index 0000000000..dae7d6ade7
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/README.rst
@@ -0,0 +1,14 @@
+wptrunner: A web-platform-tests harness
+=======================================
+
+wptrunner is a harness for running the W3C `web-platform-tests testsuite`_.
+
+.. toctree::
+ :maxdepth: 2
+
+ docs/expectation
+ docs/commands
+ docs/design
+ docs/internals
+
+.. _`web-platform-tests testsuite`: https://github.com/web-platform-tests/wpt
diff --git a/testing/web-platform/tests/tools/wptrunner/docs/architecture.svg b/testing/web-platform/tests/tools/wptrunner/docs/architecture.svg
new file mode 100644
index 0000000000..b8d5aa21c1
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/docs/architecture.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="780px" height="1087px" version="1.1"><defs><linearGradient x1="0%" y1="0%" x2="0%" y2="100%" id="mx-gradient-a9c4eb-1-a9c4eb-1-s-0"><stop offset="0%" style="stop-color:#A9C4EB"/><stop offset="100%" style="stop-color:#A9C4EB"/></linearGradient></defs><g transform="translate(0.5,0.5)"><rect x="498" y="498" width="120" height="60" fill="#e6d0de" stroke="#000000" pointer-events="none"/><g transform="translate(500,521)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">TestRunner</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><rect x="338" y="778" width="120" height="60" fill="#f19c99" stroke="#000000" pointer-events="none"/><g transform="translate(340,801)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">Product under test</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><rect x="338" y="388" width="120" height="60" fill="#e6d0de" stroke="#000000" pointer-events="none"/><g transform="translate(340,411)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">TestRunnerManager</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><rect x="338" y="228" width="120" height="60" fill="#e6d0de" stroke="#000000" pointer-events="none"/><g transform="translate(340,251)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">ManagerGroup</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><rect x="658" y="608" width="120" height="60" fill="#ffce9f" stroke="#000000" pointer-events="none"/><g transform="translate(660,631)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">Executor</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><rect x="338" y="498" width="120" height="60" fill="url(#mx-gradient-a9c4eb-1-a9c4eb-1-s-0)" stroke="#000000" pointer-events="none"/><g transform="translate(340,521)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">Browser</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><path d="M 398 288 L 398 382" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 398 387 L 395 380 L 398 382 L 402 380 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 398 448 L 398 492" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 398 497 L 395 490 L 398 492 L 402 490 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 618 528 L 684 603" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 687 607 L 680 604 L 684 603 L 685 600 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="498" y="608" width="120" height="60" fill="#a9c4eb" stroke="#000000" pointer-events="none"/><g transform="translate(500,631)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">ExecutorBrowser</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><path d="M 624 638 L 658 638" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 619 638 L 626 635 L 624 638 L 626 642 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 428 448 L 552 496" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><path d="M 557 498 L 549 498 L 552 496 L 552 492 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 398 558 L 398 772" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><path d="M 398 777 L 395 770 L 398 772 L 402 770 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="338" y="48" width="120" height="60" fill="#e6d0de" stroke="#000000" pointer-events="none"/><g transform="translate(340,71)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">run_tests</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><path d="M 458 78 L 652 78" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 657 78 L 650 82 L 652 78 L 650 75 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="658" y="48" width="120" height="60" fill="#e6d0de" stroke="#000000" pointer-events="none"/><g transform="translate(660,71)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">TestLoader</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><rect x="71" y="48" width="120" height="60" fill="#e6d0de" stroke="#000000" pointer-events="none"/><g transform="translate(73,71)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">TestEnvironment</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><rect x="151" y="618" width="120" height="60" fill="#b9e0a5" stroke="#000000" pointer-events="none"/><g transform="translate(153,641)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">wptserve</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><rect x="1" y="618" width="120" height="60" fill="#b9e0a5" stroke="#000000" pointer-events="none"/><g transform="translate(3,641)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">pywebsocket</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><path d="M 338 78 L 197 78" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 192 78 L 199 75 L 197 78 L 199 82 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 101 308 L 62 612" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><path d="M 61 617 L 59 610 L 62 612 L 66 610 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 161 308 L 204 612" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><path d="M 204 617 L 200 610 L 204 612 L 207 609 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 338 823 L 61 678" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><path d="M 211 678 L 338 793" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><path d="M 398 108 L 398 222" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 398 227 L 395 220 L 398 222 L 402 220 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 706 288 L 618 513" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><rect x="658" y="388" width="70" height="40" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="693" y="412">Queue.get</text></g><path d="M 458 808 L 718 668" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><rect x="71" y="248" width="120" height="60" fill="#b9e0a5" stroke="#000000" pointer-events="none"/><g transform="translate(73,271)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">serve.py</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><path d="M 131 108 L 131 242" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><path d="M 131 247 L 128 240 L 131 242 L 135 240 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 88 973 L 132 973" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 137 973 L 130 977 L 132 973 L 130 970 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="138" y="1018" width="180" height="30" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="228" y="1037">Communication (cross process)</text></g><path d="M 88 1002 L 132 1002" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><path d="M 137 1002 L 130 1006 L 132 1002 L 130 999 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="138" y="958" width="180" height="30" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="228" y="977">Ownership (same process)</text></g><path d="M 88 1033 L 138 1033" fill="none" stroke="#000000" stroke-miterlimit="10" stroke-dasharray="3 3" pointer-events="none"/><rect x="143" y="988" width="180" height="30" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="233" y="1007">Ownership (cross process)</text></g><rect x="428" y="966" width="50" height="15" fill="#e6d0de" stroke="#000000" pointer-events="none"/><rect x="428" y="990" width="50" height="15" fill="#a9c4eb" stroke="#000000" pointer-events="none"/><rect x="428" y="1015" width="50" height="15" fill="#ffce9f" stroke="#000000" pointer-events="none"/><rect x="428" y="1063" width="50" height="15" fill="#f19c99" stroke="#000000" pointer-events="none"/><rect x="428" y="1038" width="50" height="15" fill="#b9e0a5" stroke="#000000" pointer-events="none"/><rect x="485" y="958" width="90" height="30" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="530" y="977">wptrunner class</text></g><rect x="486" y="983" width="150" height="30" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="561" y="1002">Per-product wptrunner class</text></g><rect x="486" y="1008" width="150" height="30" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="561" y="1027">Per-protocol wptrunner class</text></g><rect x="491" y="1031" width="150" height="30" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="566" y="1050">Web-platform-tests component</text></g><rect x="486" y="1055" width="90" height="30" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="531" y="1074">Browser process</text></g><path d="M 398 8 L 398 42" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 398 47 L 395 40 L 398 42 L 402 40 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="478" y="388" width="120" height="60" fill-opacity="0.5" fill="#e6d0de" stroke="#000000" stroke-opacity="0.5" pointer-events="none"/><g transform="translate(480,411)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">TestRunnerManager</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><path d="M 398 288 L 533 384" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 537 387 L 529 386 L 533 384 L 533 380 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="198" y="388" width="120" height="60" fill-opacity="0.5" fill="#e6d0de" stroke="#000000" stroke-opacity="0.5" pointer-events="none"/><g transform="translate(200,411)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">TestRunnerManager</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><path d="M 398 288 L 263 384" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 259 387 L 263 380 L 263 384 L 267 386 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><rect x="575" y="748" width="110" height="40" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="630" y="758">Browser control</text><text x="630" y="772">protocol</text><text x="630" y="786">(e.g. WebDriver)</text></g><rect x="258" y="708" width="80" height="40" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="298" y="732">HTTP</text></g><rect x="111" y="728" width="80" height="40" fill="none" stroke="none" pointer-events="none"/><g fill="#000000" font-family="Helvetica" text-anchor="middle" font-size="12px"><text x="151" y="752">websockets</text></g><rect x="658" y="228" width="120" height="60" fill="#e6d0de" stroke="#000000" pointer-events="none"/><g transform="translate(660,251)"><switch><foreignObject pointer-events="all" width="116" height="15" requiredFeatures="http://www.w3.org/TR/SVG11/feature#Extensibility"><div xmlns="http://www.w3.org/1999/xhtml" style="display: inline-block; font-size: 12px; font-family: Helvetica; color: rgb(0, 0, 0); line-height: 1.26; vertical-align: top; width: 116px; white-space: normal; text-align: center;">Tests Queue</div></foreignObject><text x="58" y="14" fill="#000000" text-anchor="middle" font-size="12px" font-family="Helvetica">[Not supported by viewer]</text></switch></g><path d="M 718 108 L 718 222" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 718 227 L 715 220 L 718 222 L 722 220 Z" fill="#000000" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/><path d="M 428 970 L 428 970" fill="none" stroke="#000000" stroke-miterlimit="10" pointer-events="none"/></g></svg>
diff --git a/testing/web-platform/tests/tools/wptrunner/docs/commands.rst b/testing/web-platform/tests/tools/wptrunner/docs/commands.rst
new file mode 100644
index 0000000000..02147a7129
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/docs/commands.rst
@@ -0,0 +1,79 @@
+commands.json
+=============
+
+:code:`commands.json` files define how subcommands are executed by the
+:code:`./wpt` command. :code:`wpt` searches all command.json files under the top
+directory and sets up subcommands from these JSON files. A typical commands.json
+would look like the following::
+
+ {
+ "foo": {
+ "path": "foo.py",
+ "script": "run",
+ "parser": "get_parser",
+ "help": "Run foo"
+ },
+ "bar": {
+ "path": "bar.py",
+ "script": "run",
+ "virtualenv": true,
+ "requirements": [
+ "requirements.txt"
+ ]
+ }
+ }
+
+Each key of the top level object defines a name of a subcommand, and its value
+(a properties object) specifies how the subcommand is executed. Each properties
+object must contain :code:`path` and :code:`script` fields and may contain
+additional fields. All paths are relative to the commands.json.
+
+:code:`path`
+ The path to a Python script that implements the subcommand.
+
+:code:`script`
+ The name of a function that is used as the entry point of the subcommand.
+
+:code:`parser`
+ The name of a function that creates an argparse parser for the subcommand.
+
+:code:`parse_known`
+ When True, `parse_known_args() <https://docs.python.org/3/library/argparse.html#argparse.ArgumentParser.parse_known_args>`_
+ is used instead of parse_args() for the subcommand. Default to False.
+
+:code:`help`
+ Brief description of the subcommand.
+
+:code:`virtualenv`
+ When True, the subcommand is executed with a virtualenv environment. Default
+ to True.
+
+:code:`requirements`
+ A list of paths where each path specifies a requirements.txt. All requirements
+ listed in these files are installed into the virtualenv environment before
+ running the subcommand. :code:`virtualenv` must be true when this field is
+ set.
+
+:code:`conditional_requirements`
+ A key-value object. Each key represents a condition, and value represents
+ additional requirements when the condition is met. The requirements have the
+ same format as :code:`requirements`. Currently "commandline_flag" is the only
+ supported key. "commandline_flag" is used to specify requirements needed for a
+ certain command line flag of the subcommand. For example, given the following
+ commands.json::
+
+ "baz": {
+ "path": "baz.py",
+ "script": "run",
+ "virtualenv": true,
+ "conditional_requirements": {
+ "commandline_flag": {
+ "enable_feature1": [
+ "requirements_feature1.txt"
+ ]
+ }
+ }
+ }
+
+ Requirements in :code:`requirements_features1.txt` are installed only when
+ :code:`--enable-feature1` is specified to :code:`./wpt baz`.
diff --git a/testing/web-platform/tests/tools/wptrunner/docs/design.rst b/testing/web-platform/tests/tools/wptrunner/docs/design.rst
new file mode 100644
index 0000000000..30f82711a5
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/docs/design.rst
@@ -0,0 +1,108 @@
+wptrunner Design
+================
+
+The design of wptrunner is intended to meet the following
+requirements:
+
+ * Possible to run tests from W3C web-platform-tests.
+
+ * Tests should be run as fast as possible. In particular it should
+ not be necessary to restart the browser between tests, or similar.
+
+ * As far as possible, the tests should run in a "normal" browser and
+ browsing context. In particular many tests assume that they are
+ running in a top-level browsing context, so we must avoid the use
+ of an ``iframe`` test container.
+
+ * It must be possible to deal with all kinds of behaviour of the
+ browser under test, for example, crashing, hanging, etc.
+
+ * It should be possible to add support for new platforms and browsers
+ with minimal code changes.
+
+ * It must be possible to run tests in parallel to further improve
+ performance.
+
+ * Test output must be in a machine readable form.
+
+Architecture
+------------
+
+In order to meet the above requirements, wptrunner is designed to
+push as much of the test scheduling as possible into the harness. This
+allows the harness to monitor the state of the browser and perform
+appropriate action if it gets into an unwanted state e.g. kill the
+browser if it appears to be hung.
+
+The harness will typically communicate with the browser via some remote
+control protocol such as WebDriver. However for browsers where no such
+protocol is supported, other implementation strategies are possible,
+typically at the expense of speed.
+
+The overall architecture of wptrunner is shown in the diagram below:
+
+.. image:: architecture.svg
+
+.. currentmodule:: wptrunner
+
+The main entry point to the code is :py:func:`~wptrunner.run_tests` in
+``wptrunner.py``. This is responsible for setting up the test
+environment, loading the list of tests to be executed, and invoking
+the remainder of the code to actually execute some tests.
+
+The test environment is encapsulated in the
+:py:class:`~environment.TestEnvironment` class. This defers to code in
+``web-platform-tests`` which actually starts the required servers to
+run the tests.
+
+The set of tests to run is defined by the
+:py:class:`~testloader.TestLoader`. This is constructed with a
+:py:class:`~testloader.TestFilter` (not shown), which takes any filter arguments
+from the command line to restrict the set of tests that will be
+run. The :py:class:`~testloader.TestLoader` reads both the ``web-platform-tests``
+JSON manifest and the expectation data stored in ini files and
+produces a :py:class:`multiprocessing.Queue` of tests to run, and
+their expected results.
+
+Actually running the tests happens through the
+:py:class:`~testrunner.ManagerGroup` object. This takes the :py:class:`~multiprocessing.Queue` of
+tests to be run and starts a :py:class:`~testrunner.TestRunnerManager` for each
+instance of the browser under test that will be started. These
+:py:class:`~testrunner.TestRunnerManager` instances are each started in their own
+thread.
+
+A :py:class:`~testrunner.TestRunnerManager` coordinates starting the product under
+test, and outputting results from the test. In the case that the test
+has timed out or the browser has crashed, it has to restart the
+browser to ensure the test run can continue. The functionality for
+initialising the browser under test, and probing its state
+(e.g. whether the process is still alive) is implemented through a
+:py:class:`~browsers.base.Browser` object. An implementation of this class must be
+provided for each product that is supported.
+
+The functionality for actually running the tests is provided by a
+:py:class:`~testrunner.TestRunner` object. :py:class:`~testrunner.TestRunner` instances are
+run in their own child process created with the
+:py:mod:`multiprocessing` module. This allows them to run concurrently
+and to be killed and restarted as required. Communication between the
+:py:class:`~testrunner.TestRunnerManager` and the :py:class:`~testrunner.TestRunner` is
+provided by a pair of queues, one for sending messages in each
+direction. In particular test results are sent from the
+:py:class:`~testrunner.TestRunner` to the :py:class:`~testrunner.TestRunnerManager` using one
+of these queues.
+
+The :py:class:`~testrunner.TestRunner` object is generic in that the same
+:py:class:`~testrunner.TestRunner` is used regardless of the product under
+test. However the details of how to run the test may vary greatly with
+the product since different products support different remote control
+protocols (or none at all). These protocol-specific parts are placed
+in the :py:class:`~executors.base.TestExecutor` object. There is typically a different
+:py:class:`~executors.base.TestExecutor` class for each combination of control protocol
+and test type. The :py:class:`~testrunner.TestRunner` is responsible for pulling
+each test off the :py:class:`multiprocessing.Queue` of tests and passing it down to
+the :py:class:`~executors.base.TestExecutor`.
+
+The executor often requires access to details of the particular
+browser instance that it is testing so that it knows e.g. which port
+to connect to to send commands to the browser. These details are
+encapsulated in the :py:class:`~browsers.base.ExecutorBrowser` class.
diff --git a/testing/web-platform/tests/tools/wptrunner/docs/expectation.rst b/testing/web-platform/tests/tools/wptrunner/docs/expectation.rst
new file mode 100644
index 0000000000..fea676565b
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/docs/expectation.rst
@@ -0,0 +1,366 @@
+Test Metadata
+=============
+
+Directory Layout
+----------------
+
+Metadata files must be stored under the ``metadata`` directory passed
+to the test runner. The directory layout follows that of
+web-platform-tests with each test source path having a corresponding
+metadata file. Because the metadata path is based on the source file
+path, files that generate multiple URLs e.g. tests with multiple
+variants, or multi-global tests generated from an ``any.js`` input
+file, share the same metadata file for all their corresponding
+tests. The metadata path under the ``metadata`` directory is the same
+as the source path under the ``tests`` directory, with an additional
+``.ini`` suffix.
+
+For example a test with URL::
+
+ /spec/section/file.html?query=param
+
+generated from a source file with path::
+
+ <tests root>/spec/section.file.html
+
+would have a metadata file ::
+
+ <metadata root>/spec/section/file.html.ini
+
+As an optimisation, files which produce only default results
+(i.e. ``PASS`` or ``OK``), and which don't have any other associated
+metadata, don't require a corresponding metadata file.
+
+Directory Metadata
+~~~~~~~~~~~~~~~~~~
+
+In addition to per-test metadata, default metadata can be applied to
+all the tests in a given source location, using a ``__dir__.ini``
+metadata file. For example to apply metadata to all tests under
+``<tests root>/spec/`` add the metadata in ``<tests
+root>/spec/__dir__.ini``.
+
+Metadata Format
+---------------
+The format of the metadata files is based on the ini format. Files are
+divided into sections, each (apart from the root section) having a
+heading enclosed in square braces. Within each section are key-value
+pairs. There are several notable differences from standard .ini files,
+however:
+
+ * Sections may be hierarchically nested, with significant whitespace
+ indicating nesting depth.
+
+ * Only ``:`` is valid as a key/value separator
+
+A simple example of a metadata file is::
+
+ root_key: root_value
+
+ [section]
+ section_key: section_value
+
+ [subsection]
+ subsection_key: subsection_value
+
+ [another_section]
+ another_key: [list, value]
+
+Conditional Values
+~~~~~~~~~~~~~~~~~~
+
+In order to support values that depend on some external data, the
+right hand side of a key/value pair can take a set of conditionals
+rather than a plain value. These values are placed on a new line
+following the key, with significant indentation. Conditional values
+are prefixed with ``if`` and terminated with a colon, for example::
+
+ key:
+ if cond1: value1
+ if cond2: value2
+ value3
+
+In this example, the value associated with ``key`` is determined by
+first evaluating ``cond1`` against external data. If that is true,
+``key`` is assigned the value ``value1``, otherwise ``cond2`` is
+evaluated in the same way. If both ``cond1`` and ``cond2`` are false,
+the unconditional ``value3`` is used.
+
+Conditions themselves use a Python-like expression syntax. Operands
+can either be variables, corresponding to data passed in, numbers
+(integer or floating point; exponential notation is not supported) or
+quote-delimited strings. Equality is tested using ``==`` and
+inequality by ``!=``. The operators ``and``, ``or`` and ``not`` are
+used in the expected way. Parentheses can also be used for
+grouping. For example::
+
+ key:
+ if (a == 2 or a == 3) and b == "abc": value1
+ if a == 1 or b != "abc": value2
+ value3
+
+Here ``a`` and ``b`` are variables, the value of which will be
+supplied when the metadata is used.
+
+Web-Platform-Tests Metadata
+---------------------------
+
+When used for expectation data, metadata files have the following format:
+
+ * A section per test URL provided by the corresponding source file,
+ with the section heading being the part of the test URL following
+ the last ``/`` in the path (this allows multiple tests in a single
+ metadata file with the same path part of the URL, but different
+ query parts). This may be omitted if there's no non-default
+ metadata for the test.
+
+ * A subsection per subtest, with the heading being the title of the
+ subtest. This may be omitted if there's no non-default metadata for
+ the subtest.
+
+ * The following known keys:
+
+ :expected:
+ The expectation value or values of each (sub)test. In
+ the case this value is a list, the first value represents the
+ typical expected test outcome, and subsequent values indicate
+ known intermittent outcomes e.g. ``expected: [PASS, ERROR]``
+ would indicate a test that usually passes but has a known-flaky
+ ``ERROR`` outcome.
+
+ :disabled:
+ Any values apart from the special value ``@False``
+ indicates that the (sub)test is disabled and should either not be
+ run (for tests) or that its results should be ignored (subtests).
+
+ :restart-after:
+ Any value apart from the special value ``@False``
+ indicates that the runner should restart the browser after running
+ this test (e.g. to clear out unwanted state).
+
+ :fuzzy:
+ Used for reftests. This is interpreted as a list containing
+ entries like ``<meta name=fuzzy>`` content value, which consists of
+ an optional reference identifier followed by a colon, then a range
+ indicating the maximum permitted pixel difference per channel, then
+ semicolon, then a range indicating the maximum permitted total
+ number of differing pixels. The reference identifier is either a
+ single relative URL, resolved against the base test URL, in which
+ case the fuzziness applies to any comparison with that URL, or
+ takes the form lhs URL, comparison, rhs URL, in which case the
+ fuzziness only applies for any comparison involving that specific
+ pair of URLs. Some illustrative examples are given below.
+
+ :implementation-status:
+ One of the values ``implementing``,
+ ``not-implementing`` or ``default``. This is used in conjunction
+ with the ``--skip-implementation-status`` command line argument to
+ ``wptrunner`` to ignore certain features where running the test is
+ low value.
+
+ :tags:
+ A list of labels associated with a given test that can be
+ used in conjunction with the ``--tag`` command line argument to
+ ``wptrunner`` for test selection.
+
+ In addition there are extra arguments which are currently tied to
+ specific implementations. For example Gecko-based browsers support
+ ``min-asserts``, ``max-asserts``, ``prefs``, ``lsan-disabled``,
+ ``lsan-allowed``, ``lsan-max-stack-depth``, ``leak-allowed``, and
+ ``leak-threshold`` properties.
+
+ * Variables taken from the ``RunInfo`` data which describe the
+ configuration of the test run. Common properties include:
+
+ :product: A string giving the name of the browser under test
+ :browser_channel: A string giving the release channel of the browser under test
+ :debug: A Boolean indicating whether the build is a debug build
+ :os: A string the operating system
+ :version: A string indicating the particular version of that operating system
+ :processor: A string indicating the processor architecture.
+
+ This information is typically provided by :py:mod:`mozinfo`, but
+ different environments may add additional information, and not all
+ the properties above are guaranteed to be present in all
+ environments. The definitive list of available properties for a
+ specific run may be determined by looking at the ``run_info`` key
+ in the ``wptreport.json`` output for the run.
+
+ * Top level keys are taken as defaults for the whole file. So, for
+ example, a top level key with ``expected: FAIL`` would indicate
+ that all tests and subtests in the file are expected to fail,
+ unless they have an ``expected`` key of their own.
+
+An simple example metadata file might look like::
+
+ [test.html?variant=basic]
+ type: testharness
+
+ [Test something unsupported]
+ expected: FAIL
+
+ [Test with intermittent statuses]
+ expected: [PASS, TIMEOUT]
+
+ [test.html?variant=broken]
+ expected: ERROR
+
+ [test.html?variant=unstable]
+ disabled: http://test.bugs.example.org/bugs/12345
+
+A more complex metadata file with conditional properties might be::
+
+ [canvas_test.html]
+ expected:
+ if os == "mac": FAIL
+ if os == "windows" and version == "XP": FAIL
+ PASS
+
+Note that ``PASS`` in the above works, but is unnecessary since it's
+the default expected result.
+
+A metadata file with fuzzy reftest values might be::
+
+ [reftest.html]
+ fuzzy: [10;200, ref1.html:20;200-300, subtest1.html==ref2.html:10-15;20]
+
+In this case the default fuzziness for any comparison would be to
+require a maximum difference per channel of less than or equal to 10
+and less than or equal to 200 total pixels different. For any
+comparison involving ref1.html on the right hand side, the limits
+would instead be a difference per channel not more than 20 and a total
+difference count of not less than 200 and not more than 300. For the
+specific comparison ``subtest1.html == ref2.html`` (both resolved against
+the test URL) these limits would instead be 10 to 15 and 0 to 20,
+respectively.
+
+Generating Expectation Files
+----------------------------
+
+wpt provides the tool ``wpt update-expectations`` command to generate
+expectation files from the results of a set of test runs. The basic
+syntax for this is::
+
+ ./wpt update-expectations [options] [logfile]...
+
+Each ``logfile`` is a wptreport log file from a previous run. These
+can be generated from wptrunner using the ``--log-wptreport`` option
+e.g. ``--log-wptreport=wptreport.json``.
+
+``update-expectations`` takes several options:
+
+--full Overwrite all the expectation data for any tests that have a
+ result in the passed log files, not just data for the same run
+ configuration.
+
+--disable-intermittent When updating test results, disable tests that
+ have inconsistent results across many
+ runs. This can precede a message providing a
+ reason why that test is disable. If no message
+ is provided, ``unstable`` is the default text.
+
+--update-intermittent When this option is used, the ``expected`` key
+ stores expected intermittent statuses in
+ addition to the primary expected status. If
+ there is more than one status, it appears as a
+ list. The default behaviour of this option is to
+ retain any existing intermittent statuses in the
+ list unless ``--remove-intermittent`` is
+ specified.
+
+--remove-intermittent This option is used in conjunction with
+ ``--update-intermittent``. When the
+ ``expected`` statuses are updated, any obsolete
+ intermittent statuses that did not occur in the
+ specified log files are removed from the list.
+
+Property Configuration
+~~~~~~~~~~~~~~~~~~~~~~
+
+In cases where the expectation depends on the run configuration ``wpt
+update-expectations`` is able to generate conditional values. Because
+the relevant variables depend on the range of configurations that need
+to be covered, it's necessary to specify the list of configuration
+variables that should be used. This is done using a ``json`` format
+file that can be specified with the ``--properties-file`` command line
+argument to ``wpt update-expectations``. When this isn't supplied the
+defaults from ``<metadata root>/update_properties.json`` are used, if
+present.
+
+Properties File Format
+++++++++++++++++++++++
+
+The file is JSON formatted with two top-level keys:
+
+:``properties``:
+ A list of property names to consider for conditionals
+ e.g ``["product", "os"]``.
+
+:``dependents``:
+ An optional dictionary containing properties that
+ should only be used as "tie-breakers" when differentiating based on a
+ specific top-level property has failed. This is useful when the
+ dependent property is always more specific than the top-level
+ property, but less understandable when used directly. For example the
+ ``version`` property covering different OS versions is typically
+ unique amongst different operating systems, but using it when the
+ ``os`` property would do instead is likely to produce metadata that's
+ too specific to the current configuration and more difficult to
+ read. But where there are multiple versions of the same operating
+ system with different results, it can be necessary. So specifying
+ ``{"os": ["version"]}`` as a dependent property means that the
+ ``version`` property will only be used if the condition already
+ contains the ``os`` property and further conditions are required to
+ separate the observed results.
+
+So an example ``update-properties.json`` file might look like::
+
+ {
+ "properties": ["product", "os"],
+ "dependents": {"product": ["browser_channel"], "os": ["version"]}
+ }
+
+Examples
+~~~~~~~~
+
+Update all the expectations from a set of cross-platform test runs::
+
+ wpt update-expectations --full osx.log linux.log windows.log
+
+Add expectation data for some new tests that are expected to be
+platform-independent::
+
+ wpt update-expectations tests.log
+
+Why a Custom Format?
+--------------------
+
+Introduction
+------------
+
+Given the use of the metadata files in CI systems, it was desirable to
+have something with the following properties:
+
+ * Human readable
+
+ * Human editable
+
+ * Machine readable / writable
+
+ * Capable of storing key-value pairs
+
+ * Suitable for storing in a version control system (i.e. text-based)
+
+The need for different results per platform means either having
+multiple expectation files for each platform, or having a way to
+express conditional values within a certain file. The former would be
+rather cumbersome for humans updating the expectation files, so the
+latter approach has been adopted, leading to the requirement:
+
+ * Capable of storing result values that are conditional on the platform.
+
+There are few extant formats that clearly meet these requirements. In
+particular although conditional properties could be expressed in many
+existing formats, the representation would likely be cumbersome and
+error-prone for hand authoring. Therefore it was decided that a custom
+format offered the best tradeoffs given the requirements.
diff --git a/testing/web-platform/tests/tools/wptrunner/docs/internals.rst b/testing/web-platform/tests/tools/wptrunner/docs/internals.rst
new file mode 100644
index 0000000000..780df872ed
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/docs/internals.rst
@@ -0,0 +1,23 @@
+wptrunner Internals
+===================
+
+.. These modules are intentionally referenced as submodules from the parent
+ directory. This ensures that Sphinx interprets them as packages.
+
+.. automodule:: wptrunner.browsers.base
+ :members:
+
+.. automodule:: wptrunner.environment
+ :members:
+
+.. automodule:: wptrunner.executors.base
+ :members:
+
+.. automodule:: wptrunner.wptrunner
+ :members:
+
+.. automodule:: wptrunner.testloader
+ :members:
+
+.. automodule:: wptrunner.testrunner
+ :members:
diff --git a/testing/web-platform/tests/tools/wptrunner/requirements.txt b/testing/web-platform/tests/tools/wptrunner/requirements.txt
new file mode 100644
index 0000000000..dea3bbaa0a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/requirements.txt
@@ -0,0 +1,9 @@
+html5lib==1.1
+mozdebug==0.3.0
+mozinfo==1.2.2 # https://bugzilla.mozilla.org/show_bug.cgi?id=1621226
+mozlog==7.1.0
+mozprocess==1.3.0
+pillow==8.4.0
+requests==2.27.1
+six==1.16.0
+urllib3[secure]==1.26.9
diff --git a/testing/web-platform/tests/tools/wptrunner/requirements_chromium.txt b/testing/web-platform/tests/tools/wptrunner/requirements_chromium.txt
new file mode 100644
index 0000000000..4e347c647c
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/requirements_chromium.txt
@@ -0,0 +1,4 @@
+# aioquic 0.9.15 is the last to support Python 3.6, but doesn't have prebuilt
+# wheels for Python 3.10, so use a different version depending on Python.
+aioquic==0.9.15; python_version == '3.6'
+aioquic==0.9.19; python_version != '3.6'
diff --git a/testing/web-platform/tests/tools/wptrunner/requirements_edge.txt b/testing/web-platform/tests/tools/wptrunner/requirements_edge.txt
new file mode 100644
index 0000000000..12920a9956
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/requirements_edge.txt
@@ -0,0 +1 @@
+selenium==4.3.0
diff --git a/testing/web-platform/tests/tools/wptrunner/requirements_firefox.txt b/testing/web-platform/tests/tools/wptrunner/requirements_firefox.txt
new file mode 100644
index 0000000000..222c91622d
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/requirements_firefox.txt
@@ -0,0 +1,9 @@
+marionette_driver==3.1.0
+mozcrash==2.1.0
+mozdevice==4.0.3
+mozinstall==2.0.1
+mozleak==0.2
+mozprofile==2.5.0
+mozrunner==8.2.1
+mozversion==2.3.0
+psutil==5.9.1
diff --git a/testing/web-platform/tests/tools/wptrunner/requirements_ie.txt b/testing/web-platform/tests/tools/wptrunner/requirements_ie.txt
new file mode 100644
index 0000000000..1726afa607
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/requirements_ie.txt
@@ -0,0 +1,2 @@
+mozprocess==1.3.0
+selenium==4.3.0
diff --git a/testing/web-platform/tests/tools/wptrunner/requirements_opera.txt b/testing/web-platform/tests/tools/wptrunner/requirements_opera.txt
new file mode 100644
index 0000000000..1726afa607
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/requirements_opera.txt
@@ -0,0 +1,2 @@
+mozprocess==1.3.0
+selenium==4.3.0
diff --git a/testing/web-platform/tests/tools/wptrunner/requirements_safari.txt b/testing/web-platform/tests/tools/wptrunner/requirements_safari.txt
new file mode 100644
index 0000000000..8d303aa452
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/requirements_safari.txt
@@ -0,0 +1 @@
+psutil==5.9.1
diff --git a/testing/web-platform/tests/tools/wptrunner/requirements_sauce.txt b/testing/web-platform/tests/tools/wptrunner/requirements_sauce.txt
new file mode 100644
index 0000000000..5089b0c183
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/requirements_sauce.txt
@@ -0,0 +1,2 @@
+selenium==4.3.0
+requests==2.27.1
diff --git a/testing/web-platform/tests/tools/wptrunner/setup.py b/testing/web-platform/tests/tools/wptrunner/setup.py
new file mode 100644
index 0000000000..3a0c1a1f73
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/setup.py
@@ -0,0 +1,66 @@
+import glob
+import os
+import sys
+import textwrap
+
+from setuptools import setup, find_packages
+
+here = os.path.dirname(__file__)
+
+PACKAGE_NAME = 'wptrunner'
+PACKAGE_VERSION = '1.14'
+
+# Dependencies
+with open(os.path.join(here, "requirements.txt")) as f:
+ deps = f.read().splitlines()
+
+# Browser-specific requirements
+requirements_files = glob.glob("requirements_*.txt")
+
+profile_dest = None
+dest_exists = False
+
+setup(name=PACKAGE_NAME,
+ version=PACKAGE_VERSION,
+ description="Harness for running the W3C web-platform-tests against various products",
+ author='Mozilla Automation and Testing Team',
+ author_email='tools@lists.mozilla.org',
+ license='MPL 2.0',
+ packages=find_packages(exclude=["tests", "metadata", "prefs"]),
+ entry_points={
+ 'console_scripts': [
+ 'wptrunner = wptrunner.wptrunner:main',
+ 'wptupdate = wptrunner.update:main',
+ ]
+ },
+ zip_safe=False,
+ platforms=['Any'],
+ classifiers=['Development Status :: 4 - Beta',
+ 'Environment :: Console',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: BSD License',
+ 'Operating System :: OS Independent'],
+ package_data={"wptrunner": ["executors/testharness_marionette.js",
+ "executors/testharness_webdriver.js",
+ "executors/reftest.js",
+ "executors/reftest-wait.js",
+ "testharnessreport.js",
+ "testharness_runner.html",
+ "wptrunner.default.ini",
+ "browsers/sauce_setup/*",
+ "prefs/*"]},
+ include_package_data=True,
+ data_files=[("requirements", requirements_files)],
+ )
+
+if "install" in sys.argv:
+ path = os.path.relpath(os.path.join(sys.prefix, "requirements"), os.curdir)
+ print(textwrap.fill("""In order to use with one of the built-in browser
+products, you will need to install the extra dependencies. These are provided
+as requirements_[name].txt in the %s directory and can be installed using
+e.g.""" % path, 80))
+
+ print("""
+
+pip install -r %s/requirements_firefox.txt
+""" % path)
diff --git a/testing/web-platform/tests/tools/wptrunner/tox.ini b/testing/web-platform/tests/tools/wptrunner/tox.ini
new file mode 100644
index 0000000000..3a1afda216
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/tox.ini
@@ -0,0 +1,25 @@
+[pytest]
+xfail_strict=true
+
+[tox]
+envlist = py310-{base,chrome,edge,firefox,ie,opera,safari,sauce,servo,webkit,webkitgtk_minibrowser,epiphany},{py36,py37,py38,py39}-base
+skip_missing_interpreters = False
+
+[testenv]
+deps =
+ -r{toxinidir}/../requirements_pytest.txt
+ -r{toxinidir}/requirements.txt
+ chrome: -r{toxinidir}/requirements_chromium.txt
+ edge: -r{toxinidir}/requirements_edge.txt
+ firefox: -r{toxinidir}/requirements_firefox.txt
+ ie: -r{toxinidir}/requirements_ie.txt
+ opera: -r{toxinidir}/requirements_opera.txt
+ safari: -r{toxinidir}/requirements_safari.txt
+ sauce: -r{toxinidir}/requirements_sauce.txt
+
+commands = pytest {posargs}
+
+setenv = CURRENT_TOX_ENV = {envname}
+
+passenv =
+ TASKCLUSTER_ROOT_URL
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner.default.ini b/testing/web-platform/tests/tools/wptrunner/wptrunner.default.ini
new file mode 100644
index 0000000000..19462bc317
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner.default.ini
@@ -0,0 +1,11 @@
+[products]
+
+[web-platform-tests]
+remote_url = https://github.com/web-platform-tests/wpt.git
+branch = master
+sync_path = %(pwd)s/sync
+
+[manifest:default]
+tests = %(pwd)s/tests
+metadata = %(pwd)s/meta
+url_base = / \ No newline at end of file
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/__init__.py
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/__init__.py
new file mode 100644
index 0000000000..b2a53ca23a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/__init__.py
@@ -0,0 +1,45 @@
+"""Subpackage where each product is defined. Each product is created by adding a
+a .py file containing a __wptrunner__ variable in the global scope. This must be
+a dictionary with the fields
+
+"product": Name of the product, assumed to be unique.
+"browser": String indicating the Browser implementation used to launch that
+ product.
+"executor": Dictionary with keys as supported test types and values as the name
+ of the Executor implementation that will be used to run that test
+ type.
+"browser_kwargs": String naming function that takes product, binary,
+ prefs_root and the wptrunner.run_tests kwargs dict as arguments
+ and returns a dictionary of kwargs to use when creating the
+ Browser class.
+"executor_kwargs": String naming a function that takes http server url and
+ timeout multiplier and returns kwargs to use when creating
+ the executor class.
+"env_options": String naming a function of no arguments that returns the
+ arguments passed to the TestEnvironment.
+
+All classes and functions named in the above dict must be imported into the
+module global scope.
+"""
+
+product_list = ["android_weblayer",
+ "android_webview",
+ "chrome",
+ "chrome_android",
+ "chrome_ios",
+ "chromium",
+ "content_shell",
+ "edgechromium",
+ "edge",
+ "edge_webdriver",
+ "firefox",
+ "firefox_android",
+ "ie",
+ "safari",
+ "sauce",
+ "servo",
+ "servodriver",
+ "opera",
+ "webkit",
+ "webkitgtk_minibrowser",
+ "epiphany"]
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/android_weblayer.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/android_weblayer.py
new file mode 100644
index 0000000000..db23b64793
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/android_weblayer.py
@@ -0,0 +1,105 @@
+# mypy: allow-untyped-defs
+
+from .base import NullBrowser # noqa: F401
+from .base import require_arg
+from .base import get_timeout_multiplier # noqa: F401
+from .chrome import executor_kwargs as chrome_executor_kwargs
+from .chrome_android import ChromeAndroidBrowserBase
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorchrome import ChromeDriverPrintRefTestExecutor # noqa: F401
+from ..executors.executorwebdriver import (WebDriverCrashtestExecutor, # noqa: F401
+ WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor) # noqa: F401
+
+
+__wptrunner__ = {"product": "android_weblayer",
+ "check_args": "check_args",
+ "browser": {None: "WeblayerShell",
+ "wdspec": "NullBrowser"},
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor",
+ "print-reftest": "ChromeDriverPrintRefTestExecutor",
+ "wdspec": "WdspecExecutor",
+ "crashtest": "WebDriverCrashtestExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+_wptserve_ports = set()
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "webdriver_binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"binary": kwargs["binary"],
+ "adb_binary": kwargs["adb_binary"],
+ "device_serial": kwargs["device_serial"],
+ "webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs.get("webdriver_args"),
+ "stackwalk_binary": kwargs.get("stackwalk_binary"),
+ "symbols_path": kwargs.get("symbols_path")}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ # Use update() to modify the global list in place.
+ _wptserve_ports.update(set(
+ test_environment.config['ports']['http'] + test_environment.config['ports']['https'] +
+ test_environment.config['ports']['ws'] + test_environment.config['ports']['wss']
+ ))
+
+ executor_kwargs = chrome_executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs)
+ del executor_kwargs["capabilities"]["goog:chromeOptions"]["prefs"]
+ capabilities = executor_kwargs["capabilities"]
+ # Note that for WebLayer, we launch a test shell and have the test shell use
+ # WebLayer.
+ # https://cs.chromium.org/chromium/src/weblayer/shell/android/shell_apk/
+ capabilities["goog:chromeOptions"]["androidPackage"] = \
+ "org.chromium.weblayer.shell"
+ capabilities["goog:chromeOptions"]["androidActivity"] = ".WebLayerShellActivity"
+ capabilities["goog:chromeOptions"]["androidKeepAppDataDir"] = \
+ kwargs.get("keep_app_data_directory")
+
+ # Workaround: driver.quit() cannot quit WeblayerShell.
+ executor_kwargs["pause_after_test"] = False
+ # Workaround: driver.close() is not supported.
+ executor_kwargs["restart_after_test"] = True
+ executor_kwargs["close_after_done"] = False
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ # allow the use of host-resolver-rules in lieu of modifying /etc/hosts file
+ return {"server_host": "127.0.0.1"}
+
+
+class WeblayerShell(ChromeAndroidBrowserBase):
+ """Chrome is backed by chromedriver, which is supplied through
+ ``wptrunner.webdriver.ChromeDriverServer``.
+ """
+
+ def __init__(self, logger, binary,
+ webdriver_binary="chromedriver",
+ adb_binary=None,
+ remote_queue=None,
+ device_serial=None,
+ webdriver_args=None,
+ stackwalk_binary=None,
+ symbols_path=None):
+ """Creates a new representation of Chrome. The `binary` argument gives
+ the browser binary to use for testing."""
+ super().__init__(logger,
+ webdriver_binary, adb_binary, remote_queue,
+ device_serial, webdriver_args, stackwalk_binary,
+ symbols_path)
+ self.binary = binary
+ self.wptserver_ports = _wptserve_ports
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/android_webview.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/android_webview.py
new file mode 100644
index 0000000000..4ad7066178
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/android_webview.py
@@ -0,0 +1,103 @@
+# mypy: allow-untyped-defs
+
+from .base import NullBrowser # noqa: F401
+from .base import require_arg
+from .base import get_timeout_multiplier # noqa: F401
+from .chrome import executor_kwargs as chrome_executor_kwargs
+from .chrome_android import ChromeAndroidBrowserBase
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorchrome import ChromeDriverPrintRefTestExecutor # noqa: F401
+from ..executors.executorwebdriver import (WebDriverCrashtestExecutor, # noqa: F401
+ WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor) # noqa: F401
+
+
+__wptrunner__ = {"product": "android_webview",
+ "check_args": "check_args",
+ "browser": "SystemWebViewShell",
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor",
+ "print-reftest": "ChromeDriverPrintRefTestExecutor",
+ "wdspec": "WdspecExecutor",
+ "crashtest": "WebDriverCrashtestExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+_wptserve_ports = set()
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "webdriver_binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"binary": kwargs["binary"],
+ "adb_binary": kwargs["adb_binary"],
+ "device_serial": kwargs["device_serial"],
+ "webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs.get("webdriver_args"),
+ "stackwalk_binary": kwargs.get("stackwalk_binary"),
+ "symbols_path": kwargs.get("symbols_path")}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ # Use update() to modify the global list in place.
+ _wptserve_ports.update(set(
+ test_environment.config['ports']['http'] + test_environment.config['ports']['https'] +
+ test_environment.config['ports']['ws'] + test_environment.config['ports']['wss']
+ ))
+
+ executor_kwargs = chrome_executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs)
+ del executor_kwargs["capabilities"]["goog:chromeOptions"]["prefs"]
+ capabilities = executor_kwargs["capabilities"]
+ # Note that for WebView, we launch a test shell and have the test shell use WebView.
+ # https://chromium.googlesource.com/chromium/src/+/HEAD/android_webview/docs/webview-shell.md
+ capabilities["goog:chromeOptions"]["androidPackage"] = \
+ kwargs.get("package_name", "org.chromium.webview_shell")
+ capabilities["goog:chromeOptions"]["androidActivity"] = \
+ "org.chromium.webview_shell.WebPlatformTestsActivity"
+ capabilities["goog:chromeOptions"]["androidKeepAppDataDir"] = \
+ kwargs.get("keep_app_data_directory")
+
+ # Workaround: driver.quit() cannot quit SystemWebViewShell.
+ executor_kwargs["pause_after_test"] = False
+ # Workaround: driver.close() is not supported.
+ executor_kwargs["restart_after_test"] = True
+ executor_kwargs["close_after_done"] = False
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ # allow the use of host-resolver-rules in lieu of modifying /etc/hosts file
+ return {"server_host": "127.0.0.1"}
+
+
+class SystemWebViewShell(ChromeAndroidBrowserBase):
+ """Chrome is backed by chromedriver, which is supplied through
+ ``wptrunner.webdriver.ChromeDriverServer``.
+ """
+
+ def __init__(self, logger, binary, webdriver_binary="chromedriver",
+ adb_binary=None,
+ remote_queue=None,
+ device_serial=None,
+ webdriver_args=None,
+ stackwalk_binary=None,
+ symbols_path=None):
+ """Creates a new representation of Chrome. The `binary` argument gives
+ the browser binary to use for testing."""
+ super().__init__(logger,
+ webdriver_binary, adb_binary, remote_queue,
+ device_serial, webdriver_args, stackwalk_binary,
+ symbols_path)
+ self.binary = binary
+ self.wptserver_ports = _wptserve_ports
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/base.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/base.py
new file mode 100644
index 0000000000..5b590adf25
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/base.py
@@ -0,0 +1,409 @@
+# mypy: allow-untyped-defs
+
+import enum
+import errno
+import os
+import platform
+import socket
+import traceback
+from abc import ABCMeta, abstractmethod
+
+import mozprocess
+
+from ..environment import wait_for_service
+from ..wptcommandline import require_arg # noqa: F401
+
+here = os.path.dirname(__file__)
+
+
+def cmd_arg(name, value=None):
+ prefix = "-" if platform.system() == "Windows" else "--"
+ rv = prefix + name
+ if value is not None:
+ rv += "=" + value
+ return rv
+
+
+def maybe_add_args(required_args, current_args):
+ for required_arg in required_args:
+ # If the arg is in the form of "variable=value", only add it if
+ # no arg with another value for "variable" is already there.
+ if "=" in required_arg:
+ required_arg_prefix = "%s=" % required_arg.split("=")[0]
+ if not any(item.startswith(required_arg_prefix) for item in current_args):
+ current_args.append(required_arg)
+ else:
+ if required_arg not in current_args:
+ current_args.append(required_arg)
+ return current_args
+
+
+def certificate_domain_list(list_of_domains, certificate_file):
+ """Build a list of domains where certificate_file should be used"""
+ cert_list = []
+ for domain in list_of_domains:
+ cert_list.append({"host": domain, "certificateFile": certificate_file})
+ return cert_list
+
+
+def get_free_port():
+ """Get a random unbound port"""
+ while True:
+ s = socket.socket()
+ try:
+ s.bind(("127.0.0.1", 0))
+ except OSError:
+ continue
+ else:
+ return s.getsockname()[1]
+ finally:
+ s.close()
+
+
+def get_timeout_multiplier(test_type, run_info_data, **kwargs):
+ if kwargs["timeout_multiplier"] is not None:
+ return kwargs["timeout_multiplier"]
+ return 1
+
+
+def browser_command(binary, args, debug_info):
+ if debug_info:
+ if debug_info.requiresEscapedArgs:
+ args = [item.replace("&", "\\&") for item in args]
+ debug_args = [debug_info.path] + debug_info.args
+ else:
+ debug_args = []
+
+ command = [binary] + args
+
+ return debug_args, command
+
+
+class BrowserError(Exception):
+ pass
+
+
+class Browser:
+ """Abstract class serving as the basis for Browser implementations.
+
+ The Browser is used in the TestRunnerManager to start and stop the browser
+ process, and to check the state of that process.
+
+ :param logger: Structured logger to use for output.
+ """
+ __metaclass__ = ABCMeta
+
+ process_cls = None
+ init_timeout = 30
+
+ def __init__(self, logger):
+ self.logger = logger
+
+ def setup(self):
+ """Used for browser-specific setup that happens at the start of a test run"""
+ pass
+
+ def settings(self, test):
+ """Dictionary of metadata that is constant for a specific launch of a browser.
+
+ This is used to determine when the browser instance configuration changes, requiring
+ a relaunch of the browser. The test runner calls this method for each test, and if the
+ returned value differs from that for the previous test, the browser is relaunched.
+ """
+ return {}
+
+ @abstractmethod
+ def start(self, group_metadata, **kwargs):
+ """Launch the browser object and get it into a state where is is ready to run tests"""
+ pass
+
+ @abstractmethod
+ def stop(self, force=False):
+ """Stop the running browser process."""
+ pass
+
+ @abstractmethod
+ def pid(self):
+ """pid of the browser process or None if there is no pid"""
+ pass
+
+ @abstractmethod
+ def is_alive(self):
+ """Boolean indicating whether the browser process is still running"""
+ pass
+
+ def cleanup(self):
+ """Browser-specific cleanup that is run after the testrun is finished"""
+ pass
+
+ def executor_browser(self):
+ """Returns the ExecutorBrowser subclass for this Browser subclass and the keyword arguments
+ with which it should be instantiated"""
+ return ExecutorBrowser, {}
+
+ def maybe_parse_tombstone(self):
+ """Possibly parse tombstones on Android device for Android target"""
+ pass
+
+ def check_crash(self, process, test):
+ """Check if a crash occured and output any useful information to the
+ 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):
+ super().__init__(logger)
+
+ def start(self, **kwargs):
+ """No-op browser to use in scenarios where the TestRunnerManager shouldn't
+ actually own the browser process (e.g. Servo where we start one browser
+ per test)"""
+ pass
+
+ def stop(self, force=False):
+ pass
+
+ def pid(self):
+ return None
+
+ def is_alive(self):
+ return True
+
+
+class ExecutorBrowser:
+ """View of the Browser used by the Executor object.
+ This is needed because the Executor runs in a child process and
+ we can't ship Browser instances between processes on Windows.
+
+ Typically this will have a few product-specific properties set,
+ but in some cases it may have more elaborate methods for setting
+ up the browser from the runner process.
+ """
+ def __init__(self, **kwargs):
+ for k, v in kwargs.items():
+ setattr(self, k, v)
+
+
+@enum.unique
+class OutputHandlerState(enum.IntEnum):
+ BEFORE_PROCESS_START = 1
+ AFTER_PROCESS_START = 2
+ AFTER_HANDLER_START = 3
+ AFTER_PROCESS_STOP = 4
+
+
+class OutputHandler:
+ """Class for handling output from a browser process.
+
+ This class is responsible for consuming the logging from a browser process
+ and passing it into the relevant logger. A class instance is designed to
+ be passed as the processOutputLine argument to mozprocess.ProcessHandler.
+
+ The setup of this class is complex for various reasons:
+
+ * We need to create an instance of the class before starting the process
+ * We want access to data about the running process e.g. the pid
+ * We want to launch the process and later setup additional log handling
+ which is restrospectively applied to any existing output (this supports
+ prelaunching browsers for performance, but having log output depend on the
+ tests that are run e.g. for leak suppression).
+
+ Therefore the lifecycle is as follows::
+
+ output_handler = OutputHandler(logger, command, **output_handler_kwargs)
+ proc = ProcessHandler(command, ..., processOutputLine=output_handler)
+ output_handler.after_process_start(proc.pid)
+ [...]
+ # All logging to this point was buffered in-memory, but after start()
+ # it's actually sent to the logger.
+ output_handler.start(**output_logger_start_kwargs)
+ [...]
+ proc.wait()
+ output_handler.after_process_stop()
+
+ Since the process lifetime and the output handler lifetime are coupled (it doesn't
+ work to reuse an output handler for multiple processes), it might make sense to have
+ a single class that owns the process and the output processing for the process.
+ This is complicated by the fact that we don't always run the process directly,
+ but sometimes use a wrapper e.g. mozrunner.
+ """
+
+ def __init__(self, logger, command, **kwargs):
+ self.logger = logger
+ self.command = command
+ self.pid = None
+ self.state = OutputHandlerState.BEFORE_PROCESS_START
+ self.line_buffer = []
+
+ def after_process_start(self, pid):
+ assert self.state == OutputHandlerState.BEFORE_PROCESS_START
+ self.logger.debug("OutputHandler.after_process_start")
+ self.pid = pid
+ self.state = OutputHandlerState.AFTER_PROCESS_START
+
+ def start(self, **kwargs):
+ assert self.state == OutputHandlerState.AFTER_PROCESS_START
+ self.logger.debug("OutputHandler.start")
+ # Need to change the state here before we try to empty the buffer
+ # or we'll just re-buffer the existing output.
+ self.state = OutputHandlerState.AFTER_HANDLER_START
+ for item in self.line_buffer:
+ self(item)
+ self.line_buffer = None
+
+ def after_process_stop(self, clean_shutdown=True):
+ # If we didn't get as far as configure, just
+ # dump all logs with no configuration
+ self.logger.debug("OutputHandler.after_process_stop")
+ if self.state < OutputHandlerState.AFTER_HANDLER_START:
+ self.start()
+ self.state = OutputHandlerState.AFTER_PROCESS_STOP
+
+ def __call__(self, line):
+ if self.state < OutputHandlerState.AFTER_HANDLER_START:
+ self.line_buffer.append(line)
+ return
+
+ # Could assert that there's no output handled once we're in the
+ # after_process_stop phase, although technically there's a race condition
+ # here because we don't know the logging thread has finished draining the
+ # logs. The solution might be to move this into mozprocess itself.
+
+ self.logger.process_output(self.pid,
+ line.decode("utf8", "replace"),
+ command=" ".join(self.command) if self.command else "")
+
+
+class WebDriverBrowser(Browser):
+ __metaclass__ = ABCMeta
+
+ def __init__(self, logger, binary=None, webdriver_binary=None,
+ webdriver_args=None, host="127.0.0.1", port=None, base_path="/",
+ env=None, supports_pac=True, **kwargs):
+ super().__init__(logger)
+
+ if webdriver_binary is None:
+ raise ValueError("WebDriver server binary must be given "
+ "to --webdriver-binary argument")
+
+ self.logger = logger
+ self.binary = binary
+ self.webdriver_binary = webdriver_binary
+
+ 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
+ self.webdriver_args = webdriver_args if webdriver_args is not None else []
+
+ self.url = f"http://{self.host}:{self.port}{self.base_path}"
+
+ 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."""
+ return [self.webdriver_binary] + self.webdriver_args
+
+ def start(self, group_metadata, **kwargs):
+ try:
+ self._run_server(group_metadata, **kwargs)
+ except KeyboardInterrupt:
+ self.stop()
+
+ def create_output_handler(self, cmd):
+ """Return an instance of the class used to handle application output.
+
+ This can be overridden by subclasses which have particular requirements
+ for parsing, or otherwise using, the output."""
+ return OutputHandler(self.logger, cmd)
+
+ def _run_server(self, group_metadata, **kwargs):
+ cmd = self.make_command()
+ self._output_handler = self.create_output_handler(cmd)
+
+ self._proc = mozprocess.ProcessHandler(
+ cmd,
+ processOutputLine=self._output_handler,
+ env=self.env,
+ storeOutput=False)
+
+ self.logger.debug("Starting WebDriver: %s" % ' '.join(cmd))
+ try:
+ self._proc.run()
+ except OSError as e:
+ if e.errno == errno.ENOENT:
+ raise OSError(
+ "WebDriver executable not found: %s" % self.webdriver_binary)
+ raise
+ self._output_handler.after_process_start(self._proc.pid)
+
+ try:
+ wait_for_service(self.logger, self.host, self.port,
+ timeout=self.init_timeout)
+ except Exception:
+ self.logger.error(
+ "WebDriver was not accessible "
+ f"within the timeout:\n{traceback.format_exc()}")
+ raise
+ self._output_handler.start(group_metadata=group_metadata, **kwargs)
+ self.logger.debug("_run complete")
+
+ def stop(self, force=False):
+ self.logger.debug("Stopping WebDriver")
+ clean = True
+ if self.is_alive():
+ # Pass a timeout value to mozprocess Processhandler.kill()
+ # to ensure it always returns within it.
+ # See https://bugzilla.mozilla.org/show_bug.cgi?id=1760080
+ kill_result = self._proc.kill(timeout=5)
+ if force and kill_result != 0:
+ clean = False
+ self._proc.kill(9, timeout=5)
+ success = not self.is_alive()
+ if success and self._output_handler is not None:
+ # Only try to do output post-processing if we managed to shut down
+ self._output_handler.after_process_stop(clean)
+ self._output_handler = None
+ return success
+
+ def is_alive(self):
+ return hasattr(self._proc, "proc") and self._proc.poll() is None
+
+ @property
+ def pid(self):
+ if self._proc is not None:
+ return self._proc.pid
+
+ @property
+ def port(self):
+ # If no port is supplied, we'll get a free port right before we use it.
+ # Nothing guarantees an absence of race conditions here.
+ if self._port is None:
+ self._port = get_free_port()
+ return self._port
+
+ def cleanup(self):
+ self.stop()
+
+ def executor_browser(self):
+ return ExecutorBrowser, {"webdriver_url": self.url,
+ "host": self.host,
+ "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/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome.py
new file mode 100644
index 0000000000..2bcffbb5de
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome.py
@@ -0,0 +1,157 @@
+# mypy: allow-untyped-defs
+
+from . import chrome_spki_certs
+from .base import WebDriverBrowser, require_arg
+from .base import NullBrowser # noqa: F401
+from .base import get_timeout_multiplier # noqa: F401
+from .base import cmd_arg
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.executorwebdriver import (WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor, # noqa: F401
+ WebDriverCrashtestExecutor) # noqa: F401
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorchrome import ChromeDriverPrintRefTestExecutor # noqa: F401
+
+
+__wptrunner__ = {"product": "chrome",
+ "check_args": "check_args",
+ "browser": "ChromeBrowser",
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor",
+ "print-reftest": "ChromeDriverPrintRefTestExecutor",
+ "wdspec": "WdspecExecutor",
+ "crashtest": "WebDriverCrashtestExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "update_properties": "update_properties",
+ "timeout_multiplier": "get_timeout_multiplier",}
+
+def check_args(**kwargs):
+ require_arg(kwargs, "webdriver_binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"binary": kwargs["binary"],
+ "webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs.get("webdriver_args")}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data,
+ **kwargs)
+ executor_kwargs["close_after_done"] = True
+ executor_kwargs["supports_eager_pageload"] = False
+
+ capabilities = {
+ "goog:chromeOptions": {
+ "prefs": {
+ "profile": {
+ "default_content_setting_values": {
+ "popups": 1
+ }
+ }
+ },
+ "excludeSwitches": ["enable-automation"],
+ "w3c": True
+ }
+ }
+
+ if test_type == "testharness":
+ capabilities["pageLoadStrategy"] = "none"
+
+ chrome_options = capabilities["goog:chromeOptions"]
+ if kwargs["binary"] is not None:
+ chrome_options["binary"] = kwargs["binary"]
+
+ # Here we set a few Chrome flags that are always passed.
+ # ChromeDriver's "acceptInsecureCerts" capability only controls the current
+ # browsing context, whereas the CLI flag works for workers, too.
+ chrome_options["args"] = []
+
+ chrome_options["args"].append("--ignore-certificate-errors-spki-list=%s" %
+ ','.join(chrome_spki_certs.IGNORE_CERTIFICATE_ERRORS_SPKI_LIST))
+
+ # Allow audio autoplay without a user gesture.
+ chrome_options["args"].append("--autoplay-policy=no-user-gesture-required")
+ # Allow WebRTC tests to call getUserMedia and getDisplayMedia.
+ chrome_options["args"].append("--use-fake-device-for-media-stream")
+ chrome_options["args"].append("--use-fake-ui-for-media-stream")
+ # Shorten delay for Reporting <https://w3c.github.io/reporting/>.
+ chrome_options["args"].append("--short-reporting-delay")
+ # Point all .test domains to localhost for Chrome
+ chrome_options["args"].append("--host-resolver-rules=MAP nonexistent.*.test ~NOTFOUND, MAP *.test 127.0.0.1")
+ # Enable Secure Payment Confirmation for Chrome. This is normally disabled
+ # on Linux as it hasn't shipped there yet, but in WPT we enable virtual
+ # authenticator devices anyway for testing and so SPC works.
+ chrome_options["args"].append("--enable-features=SecurePaymentConfirmationBrowser")
+
+ # Classify `http-private`, `http-public` and https variants in the
+ # appropriate IP address spaces.
+ # For more details, see: https://github.com/web-platform-tests/rfcs/blob/master/rfcs/address_space_overrides.md
+ address_space_overrides_ports = [
+ ("http-private", "private"),
+ ("http-public", "public"),
+ ("https-private", "private"),
+ ("https-public", "public"),
+ ]
+ address_space_overrides_arg = ",".join(
+ f"127.0.0.1:{port_number}={address_space}"
+ for port_name, address_space in address_space_overrides_ports
+ for port_number in test_environment.config.ports.get(port_name, [])
+ )
+ if address_space_overrides_arg:
+ chrome_options["args"].append(
+ "--ip-address-space-overrides=" + address_space_overrides_arg)
+
+ if kwargs["enable_mojojs"]:
+ chrome_options["args"].append("--enable-blink-features=MojoJS,MojoJSTest")
+
+ if kwargs["enable_swiftshader"]:
+ # https://chromium.googlesource.com/chromium/src/+/HEAD/docs/gpu/swiftshader.md
+ chrome_options["args"].extend(["--use-gl=angle", "--use-angle=swiftshader"])
+
+ if kwargs["enable_experimental"]:
+ chrome_options["args"].extend(["--enable-experimental-web-platform-features"])
+
+ # Copy over any other flags that were passed in via --binary_args
+ if kwargs["binary_args"] is not None:
+ chrome_options["args"].extend(kwargs["binary_args"])
+
+ # Pass the --headless flag to Chrome if WPT's own --headless flag was set
+ # or if we're running print reftests because of crbug.com/753118
+ if ((kwargs["headless"] or test_type == "print-reftest") and
+ "--headless" not in chrome_options["args"]):
+ chrome_options["args"].append("--headless")
+
+ # For WebTransport tests.
+ webtranport_h3_port = test_environment.config.ports.get('webtransport-h3')
+ if webtranport_h3_port is not None:
+ chrome_options["args"].append(
+ f"--origin-to-force-quic-on=web-platform.test:{webtranport_h3_port[0]}")
+
+ executor_kwargs["capabilities"] = capabilities
+
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {"server_host": "127.0.0.1"}
+
+
+def update_properties():
+ return (["debug", "os", "processor"], {"os": ["version"], "processor": ["bits"]})
+
+
+class ChromeBrowser(WebDriverBrowser):
+ def make_command(self):
+ return [self.webdriver_binary,
+ cmd_arg("port", str(self.port)),
+ cmd_arg("url-base", self.base_path),
+ cmd_arg("enable-chrome-logs")] + self.webdriver_args
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_android.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_android.py
new file mode 100644
index 0000000000..820323e615
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_android.py
@@ -0,0 +1,244 @@
+# mypy: allow-untyped-defs
+
+import mozprocess
+import subprocess
+
+from .base import cmd_arg, require_arg
+from .base import get_timeout_multiplier # noqa: F401
+from .base import WebDriverBrowser # noqa: F401
+from .chrome import executor_kwargs as chrome_executor_kwargs
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorchrome import ChromeDriverPrintRefTestExecutor # noqa: F401
+from ..executors.executorwebdriver import (WebDriverCrashtestExecutor, # noqa: F401
+ WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor) # noqa: F401
+
+
+__wptrunner__ = {"product": "chrome_android",
+ "check_args": "check_args",
+ "browser": "ChromeAndroidBrowser",
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor",
+ "print-reftest": "ChromeDriverPrintRefTestExecutor",
+ "wdspec": "WdspecExecutor",
+ "crashtest": "WebDriverCrashtestExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+_wptserve_ports = set()
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "package_name")
+ require_arg(kwargs, "webdriver_binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"package_name": kwargs["package_name"],
+ "adb_binary": kwargs["adb_binary"],
+ "device_serial": kwargs["device_serial"],
+ "webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs.get("webdriver_args"),
+ "stackwalk_binary": kwargs.get("stackwalk_binary"),
+ "symbols_path": kwargs.get("symbols_path")}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ # Use update() to modify the global list in place.
+ _wptserve_ports.update(set(
+ test_environment.config['ports']['http'] + test_environment.config['ports']['https'] +
+ test_environment.config['ports']['ws'] + test_environment.config['ports']['wss']
+ ))
+
+ executor_kwargs = chrome_executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs)
+ # Remove unsupported options on mobile.
+ del executor_kwargs["capabilities"]["goog:chromeOptions"]["prefs"]
+
+ assert kwargs["package_name"], "missing --package-name"
+ capabilities = executor_kwargs["capabilities"]
+ capabilities["goog:chromeOptions"]["androidPackage"] = \
+ kwargs["package_name"]
+ capabilities["goog:chromeOptions"]["androidKeepAppDataDir"] = \
+ kwargs.get("keep_app_data_directory")
+
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ # allow the use of host-resolver-rules in lieu of modifying /etc/hosts file
+ return {"server_host": "127.0.0.1"}
+
+
+class LogcatRunner:
+ def __init__(self, logger, browser, remote_queue):
+ self.logger = logger
+ self.browser = browser
+ self.remote_queue = remote_queue
+
+ def start(self):
+ try:
+ self._run()
+ except KeyboardInterrupt:
+ self.stop()
+
+ def _run(self):
+ try:
+ # TODO: adb logcat -c fail randomly with message
+ # "failed to clear the 'main' log"
+ self.browser.clear_log()
+ except subprocess.CalledProcessError:
+ self.logger.error("Failed to clear logcat buffer")
+
+ self._cmd = self.browser.logcat_cmd()
+ self._proc = mozprocess.ProcessHandler(
+ self._cmd,
+ processOutputLine=self.on_output,
+ storeOutput=False)
+ self._proc.run()
+
+ def _send_message(self, command, *args):
+ try:
+ self.remote_queue.put((command, args))
+ except AssertionError:
+ self.logger.warning("Error when send to remote queue")
+
+ def stop(self, force=False):
+ if self.is_alive():
+ kill_result = self._proc.kill()
+ if force and kill_result != 0:
+ self._proc.kill(9)
+
+ def is_alive(self):
+ return hasattr(self._proc, "proc") and self._proc.poll() is None
+
+ def on_output(self, line):
+ data = {
+ "action": "process_output",
+ "process": "LOGCAT",
+ "command": "logcat",
+ "data": line
+ }
+ self._send_message("log", data)
+
+
+class ChromeAndroidBrowserBase(WebDriverBrowser):
+ def __init__(self,
+ logger,
+ webdriver_binary="chromedriver",
+ adb_binary=None,
+ remote_queue=None,
+ device_serial=None,
+ webdriver_args=None,
+ stackwalk_binary=None,
+ symbols_path=None):
+ super().__init__(logger,
+ binary=None,
+ webdriver_binary=webdriver_binary,
+ webdriver_args=webdriver_args,)
+ self.adb_binary = adb_binary or "adb"
+ self.device_serial = device_serial
+ self.stackwalk_binary = stackwalk_binary
+ self.symbols_path = symbols_path
+ self.remote_queue = remote_queue
+
+ if self.remote_queue is not None:
+ self.logcat_runner = LogcatRunner(self.logger, self, self.remote_queue)
+
+ def setup(self):
+ self.setup_adb_reverse()
+ if self.remote_queue is not None:
+ self.logcat_runner.start()
+
+ def _adb_run(self, args):
+ cmd = [self.adb_binary]
+ if self.device_serial:
+ cmd.extend(['-s', self.device_serial])
+ cmd.extend(args)
+ self.logger.info(' '.join(cmd))
+ subprocess.check_call(cmd)
+
+ def make_command(self):
+ return [self.webdriver_binary,
+ cmd_arg("port", str(self.port)),
+ cmd_arg("url-base", self.base_path),
+ cmd_arg("enable-chrome-logs")] + self.webdriver_args
+
+ def cleanup(self):
+ super().cleanup()
+ self._adb_run(['forward', '--remove-all'])
+ self._adb_run(['reverse', '--remove-all'])
+ if self.remote_queue is not None:
+ self.logcat_runner.stop(force=True)
+
+ def executor_browser(self):
+ cls, kwargs = super().executor_browser()
+ kwargs["capabilities"] = {
+ "goog:chromeOptions": {
+ "androidDeviceSerial": self.device_serial
+ }
+ }
+ return cls, kwargs
+
+ def clear_log(self):
+ self._adb_run(['logcat', '-c'])
+
+ def logcat_cmd(self):
+ cmd = [self.adb_binary]
+ if self.device_serial:
+ cmd.extend(['-s', self.device_serial])
+ cmd.extend(['logcat', '*:D'])
+ return cmd
+
+ def check_crash(self, process, test):
+ self.maybe_parse_tombstone()
+ # Existence of a tombstone does not necessarily mean test target has
+ # crashed. Always return False so we don't change the test results.
+ return False
+
+ def maybe_parse_tombstone(self):
+ if self.stackwalk_binary:
+ cmd = [self.stackwalk_binary, "-a", "-w"]
+ if self.device_serial:
+ cmd.extend(["--device", self.device_serial])
+ cmd.extend(["--output-directory", self.symbols_path])
+ raw_output = subprocess.check_output(cmd)
+ for line in raw_output.splitlines():
+ self.logger.process_output("TRACE", line, "logcat")
+
+ def setup_adb_reverse(self):
+ self._adb_run(['wait-for-device'])
+ self._adb_run(['forward', '--remove-all'])
+ self._adb_run(['reverse', '--remove-all'])
+ # "adb reverse" forwards network connection from device to host.
+ for port in self.wptserver_ports:
+ self._adb_run(['reverse', 'tcp:%d' % port, 'tcp:%d' % port])
+
+
+class ChromeAndroidBrowser(ChromeAndroidBrowserBase):
+ """Chrome is backed by chromedriver, which is supplied through
+ ``wptrunner.webdriver.ChromeDriverServer``.
+ """
+
+ def __init__(self, logger, package_name,
+ webdriver_binary="chromedriver",
+ adb_binary=None,
+ remote_queue = None,
+ device_serial=None,
+ webdriver_args=None,
+ stackwalk_binary=None,
+ symbols_path=None):
+ super().__init__(logger,
+ webdriver_binary, adb_binary, remote_queue,
+ device_serial, webdriver_args, stackwalk_binary,
+ symbols_path)
+ self.package_name = package_name
+ self.wptserver_ports = _wptserve_ports
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_ios.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_ios.py
new file mode 100644
index 0000000000..85c98f2994
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_ios.py
@@ -0,0 +1,58 @@
+# mypy: allow-untyped-defs
+
+from .base import WebDriverBrowser, require_arg
+from .base import get_timeout_multiplier # noqa: F401
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorwebdriver import (WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor) # noqa: F401
+
+
+__wptrunner__ = {"product": "chrome_ios",
+ "check_args": "check_args",
+ "browser": "ChromeiOSBrowser",
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+def check_args(**kwargs):
+ require_arg(kwargs, "webdriver_binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs.get("webdriver_args")}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data,
+ **kwargs)
+ executor_kwargs["close_after_done"] = True
+ executor_kwargs["capabilities"] = {}
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ # allow the use of host-resolver-rules in lieu of modifying /etc/hosts file
+ return {"server_host": "127.0.0.1"}
+
+
+class ChromeiOSBrowser(WebDriverBrowser):
+ """ChromeiOS is backed by CWTChromeDriver, which is supplied through
+ ``wptrunner.webdriver.CWTChromeDriverServer``.
+ """
+
+ init_timeout = 120
+
+ def make_command(self):
+ return ([self.webdriver_binary, f"--port={self.port}"] +
+ self.webdriver_args)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_spki_certs.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_spki_certs.py
new file mode 100644
index 0000000000..e1f133f572
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chrome_spki_certs.py
@@ -0,0 +1,13 @@
+# This file is automatically generated by 'wpt regen-certs'
+# DO NOT EDIT MANUALLY.
+
+# tools/certs/web-platform.test.pem
+WPT_FINGERPRINT = 'XreVR++++c9QamuUZu0YWHyqsL3PJarhG/0h87zEimI='
+
+# signed-exchange/resources/127.0.0.1.sxg.pem
+SXG_WPT_FINGERPRINT = '0Rt4mT6SJXojEMHTnKnlJ/hBKMBcI4kteBlhR1eTTdk='
+
+IGNORE_CERTIFICATE_ERRORS_SPKI_LIST = [
+ WPT_FINGERPRINT,
+ SXG_WPT_FINGERPRINT
+]
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chromium.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chromium.py
new file mode 100644
index 0000000000..13cb49aed2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/chromium.py
@@ -0,0 +1,57 @@
+# mypy: allow-untyped-defs
+
+from . import chrome
+from .base import NullBrowser # noqa: F401
+from .base import get_timeout_multiplier # noqa: F401
+from ..executors.executorwebdriver import (WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor, # noqa: F401
+ WebDriverCrashtestExecutor) # noqa: F401
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorchrome import ChromeDriverPrintRefTestExecutor # noqa: F401
+
+
+__wptrunner__ = {"product": "chromium",
+ "check_args": "check_args",
+ "browser": "ChromiumBrowser",
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor",
+ "print-reftest": "ChromeDriverPrintRefTestExecutor",
+ "wdspec": "WdspecExecutor",
+ "crashtest": "WebDriverCrashtestExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "update_properties": "update_properties",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+
+# Chromium will rarely need a product definition that is different from Chrome.
+# If any wptrunner options need to differ from Chrome, they can be added as
+# an additional step after the execution of Chrome's functions.
+def check_args(**kwargs):
+ chrome.check_args(**kwargs)
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return chrome.browser_kwargs(logger, test_type, run_info_data, config, **kwargs)
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data, **kwargs):
+ return chrome.executor_kwargs(logger, test_type, test_environment, run_info_data, **kwargs)
+
+
+def env_extras(**kwargs):
+ return chrome.env_extras(**kwargs)
+
+
+def env_options():
+ return chrome.env_options()
+
+
+def update_properties():
+ return chrome.update_properties()
+
+
+class ChromiumBrowser(chrome.ChromeBrowser):
+ pass
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/content_shell.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/content_shell.py
new file mode 100644
index 0000000000..a4b9c9b0d4
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/content_shell.py
@@ -0,0 +1,203 @@
+# mypy: allow-untyped-defs
+
+import os
+from multiprocessing import Queue, Event
+from subprocess import PIPE
+from threading import Thread
+from mozprocess import ProcessHandlerMixin
+
+from . import chrome_spki_certs
+from .base import Browser, ExecutorBrowser
+from .base import get_timeout_multiplier # noqa: F401
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.executorcontentshell import ( # noqa: F401
+ ContentShellCrashtestExecutor,
+ ContentShellPrintRefTestExecutor,
+ ContentShellRefTestExecutor,
+ ContentShellTestharnessExecutor,
+)
+
+
+__wptrunner__ = {"product": "content_shell",
+ "check_args": "check_args",
+ "browser": "ContentShellBrowser",
+ "executor": {
+ "crashtest": "ContentShellCrashtestExecutor",
+ "print-reftest": "ContentShellPrintRefTestExecutor",
+ "reftest": "ContentShellRefTestExecutor",
+ "testharness": "ContentShellTestharnessExecutor",
+ },
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "update_properties": "update_properties",
+ "timeout_multiplier": "get_timeout_multiplier",}
+
+
+def check_args(**kwargs):
+ pass
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ args = list(kwargs["binary_args"])
+
+ args.append("--ignore-certificate-errors-spki-list=%s" %
+ ','.join(chrome_spki_certs.IGNORE_CERTIFICATE_ERRORS_SPKI_LIST))
+
+ webtranport_h3_port = config.ports.get('webtransport-h3')
+ if webtranport_h3_port is not None:
+ args.append(
+ f"--origin-to-force-quic-on=web-platform.test:{webtranport_h3_port[0]}")
+
+ # These flags are specific to content_shell - they activate web test protocol mode.
+ args.append("--run-web-tests")
+ args.append("-")
+
+ return {"binary": kwargs["binary"],
+ "binary_args": args}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data,
+ **kwargs)
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {"server_host": "127.0.0.1",
+ "testharnessreport": "testharnessreport-content-shell.js"}
+
+
+def update_properties():
+ return (["debug", "os", "processor"], {"os": ["version"], "processor": ["bits"]})
+
+
+class ContentShellBrowser(Browser):
+ """Class that represents an instance of content_shell.
+
+ Upon startup, the stdout, stderr, and stdin pipes of the underlying content_shell
+ process are connected to multiprocessing Queues so that the runner process can
+ interact with content_shell through its protocol mode.
+ """
+
+ def __init__(self, logger, binary="content_shell", binary_args=[], **kwargs):
+ super().__init__(logger)
+
+ self._args = [binary] + binary_args
+ self._proc = None
+
+ def start(self, group_metadata, **kwargs):
+ self.logger.debug("Starting content shell: %s..." % self._args[0])
+
+ # Unfortunately we need to use the Process class directly because we do not
+ # want mozprocess to do any output handling at all.
+ self._proc = ProcessHandlerMixin.Process(self._args, stdin=PIPE, stdout=PIPE, stderr=PIPE)
+ if os.name == "posix":
+ self._proc.pgid = ProcessHandlerMixin._getpgid(self._proc.pid)
+ self._proc.detached_pid = None
+
+ self._stdout_queue = Queue()
+ self._stderr_queue = Queue()
+ self._stdin_queue = Queue()
+ self._io_stopped = Event()
+
+ self._stdout_reader = self._create_reader_thread(self._proc.stdout, self._stdout_queue)
+ self._stderr_reader = self._create_reader_thread(self._proc.stderr, self._stderr_queue)
+ self._stdin_writer = self._create_writer_thread(self._proc.stdin, self._stdin_queue)
+
+ # Content shell is likely still in the process of initializing. The actual waiting
+ # for the startup to finish is done in the ContentShellProtocol.
+ self.logger.debug("Content shell has been started.")
+
+ def stop(self, force=False):
+ self.logger.debug("Stopping content shell...")
+
+ if self.is_alive():
+ kill_result = self._proc.kill(timeout=5)
+ # This makes sure any left-over child processes get killed.
+ # See http://bugzilla.mozilla.org/show_bug.cgi?id=1760080
+ if force and kill_result != 0:
+ self._proc.kill(9, timeout=5)
+
+ # We need to shut down these queues cleanly to avoid broken pipe error spam in the logs.
+ self._stdout_reader.join(2)
+ self._stderr_reader.join(2)
+
+ self._stdin_queue.put(None)
+ self._stdin_writer.join(2)
+
+ for thread in [self._stdout_reader, self._stderr_reader, self._stdin_writer]:
+ if thread.is_alive():
+ self.logger.warning("Content shell IO threads did not shut down gracefully.")
+ return False
+
+ stopped = not self.is_alive()
+ if stopped:
+ self.logger.debug("Content shell has been stopped.")
+ else:
+ self.logger.warning("Content shell failed to stop.")
+
+ return stopped
+
+ def is_alive(self):
+ return self._proc is not None and self._proc.poll() is None
+
+ def pid(self):
+ return self._proc.pid if self._proc else None
+
+ def executor_browser(self):
+ """This function returns the `ExecutorBrowser` object that is used by other
+ processes to interact with content_shell. In our case, this consists of the three
+ multiprocessing Queues as well as an `io_stopped` event to signal when the
+ underlying pipes have reached EOF.
+ """
+ return ExecutorBrowser, {"stdout_queue": self._stdout_queue,
+ "stderr_queue": self._stderr_queue,
+ "stdin_queue": self._stdin_queue,
+ "io_stopped": self._io_stopped}
+
+ def check_crash(self, process, test):
+ return not self.is_alive()
+
+ def _create_reader_thread(self, stream, queue):
+ """This creates (and starts) a background thread which reads lines from `stream` and
+ puts them into `queue` until `stream` reports EOF.
+ """
+ def reader_thread(stream, queue, stop_event):
+ while True:
+ line = stream.readline()
+ if not line:
+ break
+
+ queue.put(line)
+
+ stop_event.set()
+ queue.close()
+ queue.join_thread()
+
+ result = Thread(target=reader_thread, args=(stream, queue, self._io_stopped), daemon=True)
+ result.start()
+ return result
+
+ def _create_writer_thread(self, stream, queue):
+ """This creates (and starts) a background thread which gets items from `queue` and
+ writes them into `stream` until it encounters a None item in the queue.
+ """
+ def writer_thread(stream, queue):
+ while True:
+ line = queue.get()
+ if not line:
+ break
+
+ stream.write(line)
+ stream.flush()
+
+ result = Thread(target=writer_thread, args=(stream, queue), daemon=True)
+ result.start()
+ return result
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edge.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edge.py
new file mode 100644
index 0000000000..c6936e77b2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edge.py
@@ -0,0 +1,109 @@
+# mypy: allow-untyped-defs
+
+import time
+import subprocess
+from .base import require_arg
+from .base import WebDriverBrowser
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorselenium import (SeleniumTestharnessExecutor, # noqa: F401
+ SeleniumRefTestExecutor) # noqa: F401
+
+__wptrunner__ = {"product": "edge",
+ "check_args": "check_args",
+ "browser": "EdgeBrowser",
+ "executor": {"testharness": "SeleniumTestharnessExecutor",
+ "reftest": "SeleniumRefTestExecutor",
+ "wdspec": "WdspecExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "run_info_extras": "run_info_extras",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+
+def get_timeout_multiplier(test_type, run_info_data, **kwargs):
+ if kwargs["timeout_multiplier"] is not None:
+ return kwargs["timeout_multiplier"]
+ if test_type == "wdspec":
+ return 10
+ return 1
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "webdriver_binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs.get("webdriver_args"),
+ "timeout_multiplier": get_timeout_multiplier(test_type,
+ run_info_data,
+ **kwargs)}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data, **kwargs)
+ executor_kwargs["close_after_done"] = True
+ executor_kwargs["timeout_multiplier"] = get_timeout_multiplier(test_type,
+ run_info_data,
+ **kwargs)
+ executor_kwargs["capabilities"] = {}
+ if test_type == "testharness":
+ executor_kwargs["capabilities"]["pageLoadStrategy"] = "eager"
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {"supports_debugger": False}
+
+
+class EdgeBrowser(WebDriverBrowser):
+ init_timeout = 60
+
+ def __init__(self, logger, binary, webdriver_binary, webdriver_args=None,
+ host="localhost", port=None, base_path="/", env=None, **kwargs):
+ super().__init__(logger, binary, webdriver_binary, webdriver_args=webdriver_args,
+ host=host, port=port, base_path=base_path, env=env, **kwargs)
+ self.host = "localhost"
+
+ def stop(self, force=False):
+ super(self).stop(force)
+ # Wait for Edge browser process to exit if driver process is found
+ edge_proc_name = 'MicrosoftEdge.exe'
+ for i in range(0, 5):
+ procs = subprocess.check_output(['tasklist', '/fi', 'ImageName eq ' + edge_proc_name])
+ if b'MicrosoftWebDriver.exe' not in procs:
+ # Edge driver process already exited, don't wait for browser process to exit
+ break
+ elif edge_proc_name.encode() in procs:
+ time.sleep(0.5)
+ else:
+ break
+
+ if edge_proc_name.encode() in procs:
+ # close Edge process if it is still running
+ subprocess.call(['taskkill.exe', '/f', '/im', 'microsoftedge*'])
+
+ def make_command(self):
+ return [self.webdriver_binary, f"--port={self.port}"] + self.webdriver_args
+
+
+def run_info_extras(**kwargs):
+ osReleaseCommand = r"(Get-ItemProperty 'HKLM:\Software\Microsoft\Windows NT\CurrentVersion').ReleaseId"
+ osBuildCommand = r"(Get-ItemProperty 'HKLM:\Software\Microsoft\Windows NT\CurrentVersion').BuildLabEx"
+ try:
+ os_release = subprocess.check_output(["powershell.exe", osReleaseCommand]).strip()
+ os_build = subprocess.check_output(["powershell.exe", osBuildCommand]).strip()
+ except (subprocess.CalledProcessError, OSError):
+ return {}
+
+ rv = {"os_build": os_build,
+ "os_release": os_release}
+ return rv
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edge_webdriver.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edge_webdriver.py
new file mode 100644
index 0000000000..e985361e41
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edge_webdriver.py
@@ -0,0 +1,27 @@
+from .base import NullBrowser # noqa: F401
+from .edge import (EdgeBrowser, # noqa: F401
+ check_args, # noqa: F401
+ browser_kwargs, # noqa: F401
+ executor_kwargs, # noqa: F401
+ env_extras, # noqa: F401
+ env_options, # noqa: F401
+ run_info_extras, # noqa: F401
+ get_timeout_multiplier) # noqa: F401
+
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorwebdriver import (WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor) # noqa: F401
+
+
+__wptrunner__ = {"product": "edge_webdriver",
+ "check_args": "check_args",
+ "browser": "EdgeBrowser",
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor",
+ "wdspec": "WdspecExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "run_info_extras": "run_info_extras",
+ "timeout_multiplier": "get_timeout_multiplier"}
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edgechromium.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edgechromium.py
new file mode 100644
index 0000000000..7dfc5d6c82
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/edgechromium.py
@@ -0,0 +1,97 @@
+# mypy: allow-untyped-defs
+
+from .base import cmd_arg, require_arg
+from .base import WebDriverBrowser
+from .base import get_timeout_multiplier # noqa: F401
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.executorwebdriver import (WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor) # noqa: F401
+
+
+__wptrunner__ = {"product": "edgechromium",
+ "check_args": "check_args",
+ "browser": "EdgeChromiumBrowser",
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor",
+ "wdspec": "WdspecExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "timeout_multiplier": "get_timeout_multiplier",}
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "webdriver_binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"binary": kwargs["binary"],
+ "webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs.get("webdriver_args")}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ executor_kwargs = base_executor_kwargs(test_type,
+ test_environment,
+ run_info_data,
+ **kwargs)
+ executor_kwargs["close_after_done"] = True
+ executor_kwargs["supports_eager_pageload"] = False
+
+ capabilities = {
+ "ms:edgeOptions": {
+ "prefs": {
+ "profile": {
+ "default_content_setting_values": {
+ "popups": 1
+ }
+ }
+ },
+ "useAutomationExtension": False,
+ "excludeSwitches": ["enable-automation"],
+ "w3c": True
+ }
+ }
+
+ if test_type == "testharness":
+ capabilities["pageLoadStrategy"] = "none"
+
+ for (kwarg, capability) in [("binary", "binary"), ("binary_args", "args")]:
+ if kwargs[kwarg] is not None:
+ capabilities["ms:edgeOptions"][capability] = kwargs[kwarg]
+
+ if kwargs["headless"]:
+ if "args" not in capabilities["ms:edgeOptions"]:
+ capabilities["ms:edgeOptions"]["args"] = []
+ if "--headless" not in capabilities["ms:edgeOptions"]["args"]:
+ capabilities["ms:edgeOptions"]["args"].append("--headless")
+ capabilities["ms:edgeOptions"]["args"].append("--use-fake-device-for-media-stream")
+
+ if kwargs["enable_experimental"]:
+ capabilities["ms:edgeOptions"]["args"].append("--enable-experimental-web-platform-features")
+
+ executor_kwargs["capabilities"] = capabilities
+
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {}
+
+
+class EdgeChromiumBrowser(WebDriverBrowser):
+ """MicrosoftEdge is backed by MSEdgeDriver, which is supplied through
+ ``wptrunner.webdriver.EdgeChromiumDriverServer``.
+ """
+
+ def make_command(self):
+ return [self.webdriver_binary,
+ cmd_arg("port", str(self.port)),
+ cmd_arg("url-base", self.base_path)] + self.webdriver_args
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/epiphany.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/epiphany.py
new file mode 100644
index 0000000000..912173a52e
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/epiphany.py
@@ -0,0 +1,75 @@
+# mypy: allow-untyped-defs
+
+from .base import (NullBrowser, # noqa: F401
+ certificate_domain_list,
+ get_timeout_multiplier, # noqa: F401
+ maybe_add_args)
+from .webkit import WebKitBrowser # noqa: F401
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorwebdriver import (WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor, # noqa: F401
+ WebDriverCrashtestExecutor) # noqa: F401
+
+__wptrunner__ = {"product": "epiphany",
+ "check_args": "check_args",
+ "browser": {None: "WebKitBrowser",
+ "wdspec": "NullBrowser"},
+ "browser_kwargs": "browser_kwargs",
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor",
+ "wdspec": "WdspecExecutor",
+ "crashtest": "WebDriverCrashtestExecutor"},
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "run_info_extras": "run_info_extras",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+
+def check_args(**kwargs):
+ pass
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ # Workaround for https://gitlab.gnome.org/GNOME/libsoup/issues/172
+ webdriver_required_args = ["--host=127.0.0.1"]
+ webdriver_args = maybe_add_args(webdriver_required_args, kwargs.get("webdriver_args"))
+ return {"binary": kwargs["binary"],
+ "webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": webdriver_args}
+
+
+def capabilities(server_config, **kwargs):
+ args = kwargs.get("binary_args", [])
+ if "--automation-mode" not in args:
+ args.append("--automation-mode")
+
+ return {
+ "browserName": "Epiphany",
+ "browserVersion": "3.31.4", # First version to support automation
+ "platformName": "ANY",
+ "webkitgtk:browserOptions": {
+ "binary": kwargs["binary"],
+ "args": args,
+ "certificates": certificate_domain_list(server_config.domains_set, kwargs["host_cert_path"])}}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data, **kwargs)
+ executor_kwargs["close_after_done"] = True
+ executor_kwargs["capabilities"] = capabilities(test_environment.config, **kwargs)
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {}
+
+
+def run_info_extras(**kwargs):
+ return {"webkit_port": "gtk"}
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/firefox.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/firefox.py
new file mode 100644
index 0000000000..267e7a868e
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/firefox.py
@@ -0,0 +1,969 @@
+# mypy: allow-untyped-defs
+
+import json
+import os
+import platform
+import signal
+import subprocess
+import tempfile
+import time
+from abc import ABCMeta, abstractmethod
+from http.client import HTTPConnection
+
+import mozinfo
+import mozleak
+import mozversion
+from mozprocess import ProcessHandler
+from mozprofile import FirefoxProfile, Preferences
+from mozrunner import FirefoxRunner
+from mozrunner.utils import test_environment, get_stack_fixer_function
+from mozcrash import mozcrash
+
+from .base import (Browser,
+ ExecutorBrowser,
+ WebDriverBrowser,
+ OutputHandler,
+ OutputHandlerState,
+ browser_command,
+ cmd_arg,
+ get_free_port,
+ require_arg)
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.executormarionette import (MarionetteTestharnessExecutor, # noqa: F401
+ MarionetteRefTestExecutor, # noqa: F401
+ MarionettePrintRefTestExecutor, # noqa: F401
+ MarionetteWdspecExecutor, # noqa: F401
+ MarionetteCrashtestExecutor) # noqa: F401
+
+
+
+__wptrunner__ = {"product": "firefox",
+ "check_args": "check_args",
+ "browser": {None: "FirefoxBrowser",
+ "wdspec": "FirefoxWdSpecBrowser"},
+ "executor": {"crashtest": "MarionetteCrashtestExecutor",
+ "testharness": "MarionetteTestharnessExecutor",
+ "reftest": "MarionetteRefTestExecutor",
+ "print-reftest": "MarionettePrintRefTestExecutor",
+ "wdspec": "MarionetteWdspecExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "run_info_extras": "run_info_extras",
+ "update_properties": "update_properties",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+
+def get_timeout_multiplier(test_type, run_info_data, **kwargs):
+ if kwargs["timeout_multiplier"] is not None:
+ return kwargs["timeout_multiplier"]
+
+ multiplier = 1
+ if run_info_data["verify"]:
+ if kwargs.get("chaos_mode_flags", None) is not None:
+ multiplier = 2
+
+ if test_type == "reftest":
+ if (run_info_data["debug"] or
+ run_info_data.get("asan") or
+ run_info_data.get("tsan")):
+ return 4 * multiplier
+ else:
+ return 2 * multiplier
+ elif (run_info_data["debug"] or
+ run_info_data.get("asan") or
+ run_info_data.get("tsan")):
+ if run_info_data.get("ccov"):
+ return 4 * multiplier
+ else:
+ return 3 * multiplier
+ elif run_info_data["os"] == "android":
+ return 4 * multiplier
+ # https://bugzilla.mozilla.org/show_bug.cgi?id=1538725
+ elif run_info_data["os"] == "win" and run_info_data["processor"] == "aarch64":
+ return 4 * multiplier
+ elif run_info_data.get("ccov"):
+ return 2 * multiplier
+ return 1 * multiplier
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"binary": kwargs["binary"],
+ "webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs["webdriver_args"],
+ "prefs_root": kwargs["prefs_root"],
+ "extra_prefs": kwargs["extra_prefs"],
+ "test_type": test_type,
+ "debug_info": kwargs["debug_info"],
+ "symbols_path": kwargs["symbols_path"],
+ "stackwalk_binary": kwargs["stackwalk_binary"],
+ "certutil_binary": kwargs["certutil_binary"],
+ "ca_certificate_path": config.ssl_config["ca_cert_path"],
+ "e10s": kwargs["gecko_e10s"],
+ "disable_fission": kwargs["disable_fission"],
+ "stackfix_dir": kwargs["stackfix_dir"],
+ "binary_args": kwargs["binary_args"],
+ "timeout_multiplier": get_timeout_multiplier(test_type,
+ run_info_data,
+ **kwargs),
+ "leak_check": run_info_data["debug"] and (kwargs["leak_check"] is not False),
+ "asan": run_info_data.get("asan"),
+ "stylo_threads": kwargs["stylo_threads"],
+ "chaos_mode_flags": kwargs["chaos_mode_flags"],
+ "config": config,
+ "browser_channel": kwargs["browser_channel"],
+ "headless": kwargs["headless"],
+ "preload_browser": kwargs["preload_browser"] and not kwargs["pause_after_test"] and not kwargs["num_test_groups"] == 1,
+ "specialpowers_path": kwargs["specialpowers_path"],
+ "debug_test": kwargs["debug_test"]}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data,
+ **kwargs)
+ executor_kwargs["close_after_done"] = test_type != "reftest"
+ executor_kwargs["timeout_multiplier"] = get_timeout_multiplier(test_type,
+ run_info_data,
+ **kwargs)
+ executor_kwargs["e10s"] = run_info_data["e10s"]
+ capabilities = {}
+ if test_type == "testharness":
+ capabilities["pageLoadStrategy"] = "eager"
+ if test_type in ("reftest", "print-reftest"):
+ executor_kwargs["reftest_internal"] = kwargs["reftest_internal"]
+ if test_type == "wdspec":
+ options = {"args": []}
+ if kwargs["binary"]:
+ options["binary"] = kwargs["binary"]
+ if kwargs["binary_args"]:
+ options["args"] = kwargs["binary_args"]
+
+ if not kwargs["binary"] and kwargs["headless"] and "--headless" not in options["args"]:
+ options["args"].append("--headless")
+
+ capabilities["moz:firefoxOptions"] = options
+
+ if kwargs["certutil_binary"] is None:
+ capabilities["acceptInsecureCerts"] = True
+ if capabilities:
+ executor_kwargs["capabilities"] = capabilities
+ executor_kwargs["debug"] = run_info_data["debug"]
+ executor_kwargs["ccov"] = run_info_data.get("ccov", False)
+ executor_kwargs["browser_version"] = run_info_data.get("browser_version")
+ executor_kwargs["debug_test"] = kwargs["debug_test"]
+ executor_kwargs["disable_fission"] = kwargs["disable_fission"]
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ # The server host is set to 127.0.0.1 as Firefox is configured (through the
+ # network.dns.localDomains preference set below) to resolve the test
+ # domains to localhost without relying on the network stack.
+ #
+ # https://github.com/web-platform-tests/wpt/pull/9480
+ return {"server_host": "127.0.0.1",
+ "supports_debugger": True}
+
+
+def run_info_extras(**kwargs):
+
+ def get_bool_pref_if_exists(pref):
+ for key, value in kwargs.get('extra_prefs', []):
+ if pref == key:
+ return value.lower() in ('true', '1')
+ return None
+
+ def get_bool_pref(pref):
+ pref_value = get_bool_pref_if_exists(pref)
+ return pref_value if pref_value is not None else False
+
+ # Default fission to on, unless we get --disable-fission
+ rv = {"e10s": kwargs["gecko_e10s"],
+ "wasm": kwargs.get("wasm", True),
+ "verify": kwargs["verify"],
+ "headless": kwargs.get("headless", False) or "MOZ_HEADLESS" in os.environ,
+ "fission": not kwargs.get("disable_fission"),
+ "sessionHistoryInParent": (not kwargs.get("disable_fission") or
+ get_bool_pref("fission.sessionHistoryInParent")),
+ "swgl": get_bool_pref("gfx.webrender.software")}
+
+ rv.update(run_info_browser_version(**kwargs))
+
+ return rv
+
+
+def run_info_browser_version(**kwargs):
+ try:
+ version_info = mozversion.get_version(kwargs["binary"])
+ except mozversion.errors.VersionError:
+ version_info = None
+ if version_info:
+ rv = {"browser_build_id": version_info.get("application_buildid", None),
+ "browser_changeset": version_info.get("application_changeset", None)}
+ if "browser_version" not in kwargs:
+ rv["browser_version"] = version_info.get("application_version")
+ return rv
+ return {}
+
+
+def update_properties():
+ return (["os", "debug", "fission", "processor", "swgl", "domstreams"],
+ {"os": ["version"], "processor": ["bits"]})
+
+
+def log_gecko_crashes(logger, process, test, profile_dir, symbols_path, stackwalk_binary):
+ dump_dir = os.path.join(profile_dir, "minidumps")
+
+ try:
+ return bool(mozcrash.log_crashes(logger,
+ dump_dir,
+ symbols_path=symbols_path,
+ stackwalk_binary=stackwalk_binary,
+ process=process,
+ test=test))
+ except OSError:
+ logger.warning("Looking for crash dump files failed")
+ return False
+
+
+def get_environ(logger, binary, debug_info, stylo_threads, headless,
+ chaos_mode_flags=None):
+ env = test_environment(xrePath=os.path.abspath(os.path.dirname(binary)),
+ debugger=debug_info is not None,
+ useLSan=True,
+ log=logger)
+
+ env["STYLO_THREADS"] = str(stylo_threads)
+ # Disable window occlusion. Bug 1733955
+ env["MOZ_WINDOW_OCCLUSION"] = "0"
+ if chaos_mode_flags is not None:
+ env["MOZ_CHAOSMODE"] = hex(chaos_mode_flags)
+ if headless:
+ env["MOZ_HEADLESS"] = "1"
+ return env
+
+
+def setup_leak_report(leak_check, profile, env):
+ leak_report_file = None
+ if leak_check:
+ filename = "runtests_leaks_%s.log" % os.getpid()
+ if profile is not None:
+ leak_report_file = os.path.join(profile.profile, filename)
+ else:
+ leak_report_file = os.path.join(tempfile.gettempdir(), filename)
+ if os.path.exists(leak_report_file):
+ os.remove(leak_report_file)
+ env["XPCOM_MEM_BLOAT_LOG"] = leak_report_file
+
+ return leak_report_file
+
+
+class FirefoxInstanceManager:
+ __metaclass__ = ABCMeta
+
+ def __init__(self, logger, binary, binary_args, profile_creator, debug_info,
+ chaos_mode_flags, headless, stylo_threads,
+ leak_check, stackfix_dir, symbols_path, asan):
+ """Object that manages starting and stopping instances of Firefox."""
+ self.logger = logger
+ self.binary = binary
+ self.binary_args = binary_args
+ self.base_profile = profile_creator.create()
+ self.debug_info = debug_info
+ self.chaos_mode_flags = chaos_mode_flags
+ self.headless = headless
+ self.stylo_threads = stylo_threads
+ self.leak_check = leak_check
+ self.stackfix_dir = stackfix_dir
+ self.symbols_path = symbols_path
+ self.asan = asan
+
+ self.previous = None
+ self.current = None
+
+ @abstractmethod
+ def teardown(self, force=False):
+ pass
+
+ @abstractmethod
+ def get(self):
+ """Get a BrowserInstance for a running Firefox.
+
+ This can only be called once per instance, and between calls stop_current()
+ must be called."""
+ pass
+
+ def stop_current(self, force=False):
+ """Shutdown the current instance of Firefox.
+
+ The BrowserInstance remains available through self.previous, since some
+ operations happen after shutdown."""
+ if not self.current:
+ return
+
+ self.current.stop(force)
+ self.previous = self.current
+ self.current = None
+
+ def start(self):
+ """Start an instance of Firefox, returning a BrowserInstance handle"""
+ profile = self.base_profile.clone(self.base_profile.profile)
+
+ marionette_port = get_free_port()
+ profile.set_preferences({"marionette.port": marionette_port})
+
+ env = get_environ(self.logger, self.binary, self.debug_info, self.stylo_threads,
+ self.headless, self.chaos_mode_flags)
+
+ args = self.binary_args[:] if self.binary_args else []
+ args += [cmd_arg("marionette"), "about:blank"]
+
+ debug_args, cmd = browser_command(self.binary,
+ args,
+ self.debug_info)
+
+ leak_report_file = setup_leak_report(self.leak_check, profile, env)
+ output_handler = FirefoxOutputHandler(self.logger,
+ cmd,
+ stackfix_dir=self.stackfix_dir,
+ symbols_path=self.symbols_path,
+ asan=self.asan,
+ leak_report_file=leak_report_file)
+ runner = FirefoxRunner(profile=profile,
+ binary=cmd[0],
+ cmdargs=cmd[1:],
+ env=env,
+ process_class=ProcessHandler,
+ process_args={"processOutputLine": [output_handler]})
+ instance = BrowserInstance(self.logger, runner, marionette_port,
+ output_handler, leak_report_file)
+
+ self.logger.debug("Starting Firefox")
+ runner.start(debug_args=debug_args,
+ interactive=self.debug_info and self.debug_info.interactive)
+ output_handler.after_process_start(runner.process_handler.pid)
+ self.logger.debug("Firefox Started")
+
+ return instance
+
+
+class SingleInstanceManager(FirefoxInstanceManager):
+ """FirefoxInstanceManager that manages a single Firefox instance"""
+ def get(self):
+ assert not self.current, ("Tried to call get() on InstanceManager that has "
+ "an existing instance")
+ if self.previous:
+ self.previous.cleanup()
+ self.previous = None
+ self.current = self.start()
+ return self.current
+
+ def teardown(self, force=False):
+ for instance in [self.previous, self.current]:
+ if instance:
+ instance.stop(force)
+ instance.cleanup()
+ self.base_profile.cleanup()
+
+
+class PreloadInstanceManager(FirefoxInstanceManager):
+ def __init__(self, *args, **kwargs):
+ """FirefoxInstanceManager that keeps once Firefox instance preloaded
+ to allow rapid resumption after an instance shuts down."""
+ super().__init__(*args, **kwargs)
+ self.pending = None
+
+ def get(self):
+ assert not self.current, ("Tried to call get() on InstanceManager that has "
+ "an existing instance")
+ if self.previous:
+ self.previous.cleanup()
+ self.previous = None
+ if not self.pending:
+ self.pending = self.start()
+ self.current = self.pending
+ self.pending = self.start()
+ return self.current
+
+ def teardown(self, force=False):
+ for instance, unused in [(self.previous, False),
+ (self.current, False),
+ (self.pending, True)]:
+ if instance:
+ instance.stop(force, unused)
+ instance.cleanup()
+ self.base_profile.cleanup()
+
+
+class BrowserInstance:
+ shutdown_timeout = 70
+
+ def __init__(self, logger, runner, marionette_port, output_handler, leak_report_file):
+ """Handle to a running Firefox instance"""
+ self.logger = logger
+ self.runner = runner
+ self.marionette_port = marionette_port
+ self.output_handler = output_handler
+ self.leak_report_file = leak_report_file
+
+ def stop(self, force=False, unused=False):
+ """Stop Firefox
+
+ :param force: Signal the firefox process without waiting for a clean shutdown
+ :param unused: This instance was not used for running tests and so
+ doesn't have an active marionette session and doesn't require
+ output postprocessing.
+ """
+ is_running = self.runner is not None and self.runner.is_running()
+ if is_running:
+ self.logger.debug("Stopping Firefox %s" % self.pid())
+ shutdown_methods = [(True, lambda: self.runner.wait(self.shutdown_timeout)),
+ (False, lambda: self.runner.stop(signal.SIGTERM,
+ self.shutdown_timeout))]
+ if hasattr(signal, "SIGKILL"):
+ shutdown_methods.append((False, lambda: self.runner.stop(signal.SIGKILL,
+ self.shutdown_timeout)))
+ if unused or force:
+ # Don't wait for the instance to close itself
+ shutdown_methods = shutdown_methods[1:]
+ try:
+ # For Firefox we assume that stopping the runner prompts the
+ # browser to shut down. This allows the leak log to be written
+ for i, (clean, stop_f) in enumerate(shutdown_methods):
+ self.logger.debug("Shutting down attempt %i/%i" % (i + 1, len(shutdown_methods)))
+ retcode = stop_f()
+ if retcode is not None:
+ self.logger.info("Browser exited with return code %s" % retcode)
+ break
+ except OSError:
+ # This can happen on Windows if the process is already dead
+ pass
+ elif self.runner:
+ # The browser was already stopped, which we assume was a crash
+ # TODO: Should we check the exit code here?
+ clean = False
+ if not unused:
+ self.output_handler.after_process_stop(clean_shutdown=clean)
+
+ def pid(self):
+ if self.runner.process_handler is None:
+ return None
+
+ try:
+ return self.runner.process_handler.pid
+ except AttributeError:
+ return None
+
+ def is_alive(self):
+ if self.runner:
+ return self.runner.is_running()
+ return False
+
+ def cleanup(self):
+ self.runner.cleanup()
+ self.runner = None
+
+
+class FirefoxOutputHandler(OutputHandler):
+ def __init__(self, logger, command, symbols_path=None, stackfix_dir=None, asan=False,
+ leak_report_file=None):
+ """Filter for handling Firefox process output.
+
+ This receives Firefox process output in the __call__ function, does
+ any additional processing that's required, and decides whether to log
+ the output. Because the Firefox process can be started before we know
+ which filters are going to be required, we buffer all output until
+ setup() is called. This is responsible for doing the final configuration
+ of the output handlers.
+ """
+
+ super().__init__(logger, command)
+
+ self.symbols_path = symbols_path
+ if stackfix_dir:
+ # We hide errors because they cause disconcerting `CRITICAL`
+ # warnings in web platform test output.
+ self.stack_fixer = get_stack_fixer_function(stackfix_dir,
+ self.symbols_path,
+ hideErrors=True)
+ else:
+ self.stack_fixer = None
+ self.asan = asan
+ self.leak_report_file = leak_report_file
+
+ # These are filled in after configure_handlers() is called
+ self.lsan_handler = None
+ self.mozleak_allowed = None
+ self.mozleak_thresholds = None
+ self.group_metadata = {}
+
+ def start(self, group_metadata=None, lsan_disabled=False, lsan_allowed=None,
+ lsan_max_stack_depth=None, mozleak_allowed=None, mozleak_thresholds=None,
+ **kwargs):
+ """Configure the output handler"""
+ if group_metadata is None:
+ group_metadata = {}
+ self.group_metadata = group_metadata
+
+ self.mozleak_allowed = mozleak_allowed
+ self.mozleak_thresholds = mozleak_thresholds
+
+ if self.asan:
+ self.lsan_handler = mozleak.LSANLeaks(self.logger,
+ scope=group_metadata.get("scope", "/"),
+ allowed=lsan_allowed,
+ maxNumRecordedFrames=lsan_max_stack_depth,
+ allowAll=lsan_disabled)
+ else:
+ self.lsan_handler = None
+ super().start()
+
+ def after_process_stop(self, clean_shutdown=True):
+ super().after_process_stop(clean_shutdown)
+ if self.lsan_handler:
+ self.lsan_handler.process()
+ if self.leak_report_file is not None:
+ if not clean_shutdown:
+ # If we didn't get a clean shutdown there probably isn't a leak report file
+ self.logger.warning("Firefox didn't exit cleanly, not processing leak logs")
+ else:
+ # We have to ignore missing leaks in the tab because it can happen that the
+ # content process crashed and in that case we don't want the test to fail.
+ # Ideally we would record which content process crashed and just skip those.
+ self.logger.info("PROCESS LEAKS %s" % self.leak_report_file)
+ mozleak.process_leak_log(
+ self.leak_report_file,
+ leak_thresholds=self.mozleak_thresholds,
+ ignore_missing_leaks=["tab", "gmplugin"],
+ log=self.logger,
+ stack_fixer=self.stack_fixer,
+ scope=self.group_metadata.get("scope"),
+ allowed=self.mozleak_allowed)
+ if os.path.exists(self.leak_report_file):
+ os.unlink(self.leak_report_file)
+
+ def __call__(self, line):
+ """Write a line of output from the firefox process to the log"""
+ if b"GLib-GObject-CRITICAL" in line:
+ return
+ if line:
+ if self.state < OutputHandlerState.AFTER_HANDLER_START:
+ self.line_buffer.append(line)
+ return
+ data = line.decode("utf8", "replace")
+ if self.stack_fixer:
+ data = self.stack_fixer(data)
+ if self.lsan_handler:
+ data = self.lsan_handler.log(data)
+ if data is not None:
+ self.logger.process_output(self.pid,
+ data,
+ command=" ".join(self.command))
+
+
+class ProfileCreator:
+ def __init__(self, logger, prefs_root, config, test_type, extra_prefs, e10s,
+ disable_fission, debug_test, browser_channel, binary, certutil_binary,
+ ca_certificate_path):
+ self.logger = logger
+ self.prefs_root = prefs_root
+ self.config = config
+ self.test_type = test_type
+ self.extra_prefs = extra_prefs
+ self.e10s = e10s
+ self.disable_fission = disable_fission
+ self.debug_test = debug_test
+ self.browser_channel = browser_channel
+ self.ca_certificate_path = ca_certificate_path
+ self.binary = binary
+ self.certutil_binary = certutil_binary
+ self.ca_certificate_path = ca_certificate_path
+
+ def create(self, **kwargs):
+ """Create a Firefox profile and return the mozprofile Profile object pointing at that
+ profile
+
+ :param kwargs: Additional arguments to pass into the profile constructor
+ """
+ preferences = self._load_prefs()
+
+ profile = FirefoxProfile(preferences=preferences,
+ restore=False,
+ **kwargs)
+ self._set_required_prefs(profile)
+ if self.ca_certificate_path is not None:
+ self._setup_ssl(profile)
+
+ return profile
+
+ def _load_prefs(self):
+ prefs = Preferences()
+
+ pref_paths = []
+
+ profiles = os.path.join(self.prefs_root, 'profiles.json')
+ if os.path.isfile(profiles):
+ with open(profiles) as fh:
+ for name in json.load(fh)['web-platform-tests']:
+ if self.browser_channel in (None, 'nightly'):
+ pref_paths.append(os.path.join(self.prefs_root, name, 'user.js'))
+ elif name != 'unittest-features':
+ pref_paths.append(os.path.join(self.prefs_root, name, 'user.js'))
+ else:
+ # Old preference files used before the creation of profiles.json (remove when no longer supported)
+ legacy_pref_paths = (
+ os.path.join(self.prefs_root, 'prefs_general.js'), # Used in Firefox 60 and below
+ os.path.join(self.prefs_root, 'common', 'user.js'), # Used in Firefox 61
+ )
+ for path in legacy_pref_paths:
+ if os.path.isfile(path):
+ pref_paths.append(path)
+
+ for path in pref_paths:
+ if os.path.exists(path):
+ prefs.add(Preferences.read_prefs(path))
+ else:
+ self.logger.warning("Failed to find base prefs file in %s" % path)
+
+ # Add any custom preferences
+ prefs.add(self.extra_prefs, cast=True)
+
+ return prefs()
+
+ def _set_required_prefs(self, profile):
+ """Set preferences required for wptrunner to function.
+
+ Note that this doesn't set the marionette port, since we don't always
+ know that at profile creation time. So the caller is responisble for
+ setting that once it's available."""
+ profile.set_preferences({
+ "network.dns.localDomains": ",".join(self.config.domains_set),
+ "dom.file.createInChild": True,
+ # TODO: Remove preferences once Firefox 64 is stable (Bug 905404)
+ "network.proxy.type": 0,
+ "places.history.enabled": False,
+ "network.preload": True,
+ })
+ if self.e10s:
+ profile.set_preferences({"browser.tabs.remote.autostart": True})
+
+ profile.set_preferences({"fission.autostart": True})
+ if self.disable_fission:
+ profile.set_preferences({"fission.autostart": False})
+
+ if self.test_type in ("reftest", "print-reftest"):
+ profile.set_preferences({"layout.interruptible-reflow.enabled": False})
+
+ if self.test_type == "print-reftest":
+ profile.set_preferences({"print.always_print_silent": True})
+
+ # Bug 1262954: winxp + e10s, disable hwaccel
+ if (self.e10s and platform.system() in ("Windows", "Microsoft") and
+ "5.1" in platform.version()):
+ profile.set_preferences({"layers.acceleration.disabled": True})
+
+ if self.debug_test:
+ profile.set_preferences({"devtools.console.stdout.content": True})
+
+ def _setup_ssl(self, profile):
+ """Create a certificate database to use in the test profile. This is configured
+ to trust the CA Certificate that has signed the web-platform.test server
+ certificate."""
+ if self.certutil_binary is None:
+ self.logger.info("--certutil-binary not supplied; Firefox will not check certificates")
+ return
+
+ self.logger.info("Setting up ssl")
+
+ # Make sure the certutil libraries from the source tree are loaded when using a
+ # local copy of certutil
+ # TODO: Maybe only set this if certutil won't launch?
+ env = os.environ.copy()
+ certutil_dir = os.path.dirname(self.binary or self.certutil_binary)
+ if mozinfo.isMac:
+ env_var = "DYLD_LIBRARY_PATH"
+ elif mozinfo.isUnix:
+ env_var = "LD_LIBRARY_PATH"
+ else:
+ env_var = "PATH"
+
+
+ env[env_var] = (os.path.pathsep.join([certutil_dir, env[env_var]])
+ if env_var in env else certutil_dir)
+
+ def certutil(*args):
+ cmd = [self.certutil_binary] + list(args)
+ self.logger.process_output("certutil",
+ subprocess.check_output(cmd,
+ env=env,
+ stderr=subprocess.STDOUT),
+ " ".join(cmd))
+
+ pw_path = os.path.join(profile.profile, ".crtdbpw")
+ with open(pw_path, "w") as f:
+ # Use empty password for certificate db
+ f.write("\n")
+
+ cert_db_path = profile.profile
+
+ # Create a new certificate db
+ certutil("-N", "-d", cert_db_path, "-f", pw_path)
+
+ # Add the CA certificate to the database and mark as trusted to issue server certs
+ certutil("-A", "-d", cert_db_path, "-f", pw_path, "-t", "CT,,",
+ "-n", "web-platform-tests", "-i", self.ca_certificate_path)
+
+ # List all certs in the database
+ certutil("-L", "-d", cert_db_path)
+
+
+class FirefoxBrowser(Browser):
+ init_timeout = 70
+
+ def __init__(self, logger, binary, prefs_root, test_type, extra_prefs=None, debug_info=None,
+ symbols_path=None, stackwalk_binary=None, certutil_binary=None,
+ ca_certificate_path=None, e10s=False, disable_fission=False,
+ stackfix_dir=None, binary_args=None, timeout_multiplier=None, leak_check=False,
+ asan=False, stylo_threads=1, chaos_mode_flags=None, config=None,
+ browser_channel="nightly", headless=None, preload_browser=False,
+ specialpowers_path=None, debug_test=False, **kwargs):
+ Browser.__init__(self, logger)
+
+ self.logger = logger
+
+ if timeout_multiplier:
+ self.init_timeout = self.init_timeout * timeout_multiplier
+
+ self.instance = None
+ self._settings = None
+
+ self.stackfix_dir = stackfix_dir
+ self.symbols_path = symbols_path
+ self.stackwalk_binary = stackwalk_binary
+
+ self.asan = asan
+ self.leak_check = leak_check
+
+ self.specialpowers_path = specialpowers_path
+
+ profile_creator = ProfileCreator(logger,
+ prefs_root,
+ config,
+ test_type,
+ extra_prefs,
+ e10s,
+ disable_fission,
+ debug_test,
+ browser_channel,
+ binary,
+ certutil_binary,
+ ca_certificate_path)
+
+ if preload_browser:
+ instance_manager_cls = PreloadInstanceManager
+ else:
+ instance_manager_cls = SingleInstanceManager
+ self.instance_manager = instance_manager_cls(logger,
+ binary,
+ binary_args,
+ profile_creator,
+ debug_info,
+ chaos_mode_flags,
+ headless,
+ stylo_threads,
+ leak_check,
+ stackfix_dir,
+ symbols_path,
+ asan)
+
+ def settings(self, test):
+ self._settings = {"check_leaks": self.leak_check and not test.leaks,
+ "lsan_disabled": test.lsan_disabled,
+ "lsan_allowed": test.lsan_allowed,
+ "lsan_max_stack_depth": test.lsan_max_stack_depth,
+ "mozleak_allowed": self.leak_check and test.mozleak_allowed,
+ "mozleak_thresholds": self.leak_check and test.mozleak_threshold,
+ "special_powers": self.specialpowers_path and test.url_base == "/_mozilla/"}
+ return self._settings
+
+ def start(self, group_metadata=None, **kwargs):
+ self.instance = self.instance_manager.get()
+ self.instance.output_handler.start(group_metadata,
+ **kwargs)
+
+ def stop(self, force=False):
+ self.instance_manager.stop_current(force)
+ self.logger.debug("stopped")
+
+ def pid(self):
+ return self.instance.pid()
+
+ def is_alive(self):
+ return self.instance and self.instance.is_alive()
+
+ def cleanup(self, force=False):
+ self.instance_manager.teardown(force)
+
+ def executor_browser(self):
+ assert self.instance is not None
+ extensions = []
+ if self._settings.get("special_powers", False):
+ extensions.append(self.specialpowers_path)
+ return ExecutorBrowser, {"marionette_port": self.instance.marionette_port,
+ "extensions": extensions,
+ "supports_devtools": True}
+
+ def check_crash(self, process, test):
+ return log_gecko_crashes(self.logger,
+ process,
+ test,
+ self.instance.runner.profile.profile,
+ self.symbols_path,
+ self.stackwalk_binary)
+
+
+class FirefoxWdSpecBrowser(WebDriverBrowser):
+ def __init__(self, logger, binary, prefs_root, webdriver_binary, webdriver_args,
+ extra_prefs=None, debug_info=None, symbols_path=None, stackwalk_binary=None,
+ certutil_binary=None, ca_certificate_path=None, e10s=False,
+ disable_fission=False, stackfix_dir=None, leak_check=False,
+ asan=False, stylo_threads=1, chaos_mode_flags=None, config=None,
+ browser_channel="nightly", headless=None, debug_test=False, **kwargs):
+
+ super().__init__(logger, binary, webdriver_binary, webdriver_args)
+ self.binary = binary
+ self.webdriver_binary = webdriver_binary
+
+ self.stackfix_dir = stackfix_dir
+ self.symbols_path = symbols_path
+ self.stackwalk_binary = stackwalk_binary
+
+ self.asan = asan
+ self.leak_check = leak_check
+ self.leak_report_file = None
+
+ self.env = self.get_env(binary, debug_info, stylo_threads, headless, chaos_mode_flags)
+
+ profile_creator = ProfileCreator(logger,
+ prefs_root,
+ config,
+ "wdspec",
+ extra_prefs,
+ e10s,
+ disable_fission,
+ debug_test,
+ browser_channel,
+ binary,
+ certutil_binary,
+ ca_certificate_path)
+
+ self.profile = profile_creator.create()
+ self.marionette_port = None
+
+ def get_env(self, binary, debug_info, stylo_threads, headless, chaos_mode_flags):
+ env = get_environ(self.logger,
+ binary,
+ debug_info,
+ stylo_threads,
+ headless,
+ chaos_mode_flags)
+ env["RUST_BACKTRACE"] = "1"
+ # This doesn't work with wdspec tests
+ # In particular tests can create a session without passing in the capabilites
+ # and in those cases we get the default geckodriver profile which doesn't
+ # guarantee zero network access
+ del env["MOZ_DISABLE_NONLOCAL_CONNECTIONS"]
+ return env
+
+ def create_output_handler(self, cmd):
+ return FirefoxOutputHandler(self.logger,
+ cmd,
+ stackfix_dir=self.stackfix_dir,
+ symbols_path=self.symbols_path,
+ asan=self.asan,
+ leak_report_file=self.leak_report_file)
+
+ def start(self, group_metadata, **kwargs):
+ self.leak_report_file = setup_leak_report(self.leak_check, self.profile, self.env)
+ super().start(group_metadata, **kwargs)
+
+ def stop(self, force=False):
+ # Initially wait for any WebDriver session to cleanly shutdown if the
+ # process doesn't have to be force stopped.
+ # When this is called the executor is usually sending an end session
+ # command to the browser. We don't have a synchronisation mechanism
+ # that allows us to know that process is ongoing, so poll the status
+ # endpoint until there isn't a session, before killing the driver.
+ if self.is_alive() and not force:
+ end_time = time.time() + BrowserInstance.shutdown_timeout
+ while time.time() < end_time:
+ self.logger.debug("Waiting for WebDriver session to end")
+ try:
+ self.logger.debug(f"Connecting to http://{self.host}:{self.port}/status")
+ conn = HTTPConnection(self.host, self.port)
+ conn.request("GET", "/status")
+ res = conn.getresponse()
+ self.logger.debug(f"Got response from http://{self.host}:{self.port}/status")
+ except Exception:
+ self.logger.debug(
+ f"Connecting to http://{self.host}:{self.port}/status failed")
+ break
+ if res.status != 200:
+ self.logger.debug(f"Connecting to http://{self.host}:{self.port}/status "
+ f"gave status {res.status}")
+ break
+ data = res.read()
+ try:
+ msg = json.loads(data)
+ except ValueError:
+ self.logger.debug("/status response was not valid JSON")
+ break
+ if msg.get("value", {}).get("ready") is True:
+ self.logger.debug("Got ready status")
+ break
+ self.logger.debug(f"Got status response {data}")
+ time.sleep(1)
+ else:
+ self.logger.debug("WebDriver session didn't end")
+ super().stop(force=force)
+
+ def cleanup(self):
+ super().cleanup()
+ self.profile.cleanup()
+
+ def settings(self, test):
+ return {"check_leaks": self.leak_check and not test.leaks,
+ "lsan_disabled": test.lsan_disabled,
+ "lsan_allowed": test.lsan_allowed,
+ "lsan_max_stack_depth": test.lsan_max_stack_depth,
+ "mozleak_allowed": self.leak_check and test.mozleak_allowed,
+ "mozleak_thresholds": self.leak_check and test.mozleak_threshold}
+
+ def make_command(self):
+ return [self.webdriver_binary,
+ "--host", self.host,
+ "--port", str(self.port)] + self.webdriver_args
+
+ def executor_browser(self):
+ cls, args = super().executor_browser()
+ args["supports_devtools"] = False
+ args["profile"] = self.profile.profile
+ return cls, args
+
+ def check_crash(self, process, test):
+ return log_gecko_crashes(self.logger,
+ process,
+ test,
+ self.profile.profile,
+ self.symbols_path,
+ self.stackwalk_binary)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/firefox_android.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/firefox_android.py
new file mode 100644
index 0000000000..fe23c027f4
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/firefox_android.py
@@ -0,0 +1,367 @@
+# mypy: allow-untyped-defs
+
+import os
+
+from mozrunner import FennecEmulatorRunner, get_app_context
+
+from .base import (get_free_port,
+ cmd_arg,
+ browser_command)
+from ..executors.executormarionette import (MarionetteTestharnessExecutor, # noqa: F401
+ MarionetteRefTestExecutor, # noqa: F401
+ MarionetteCrashtestExecutor, # noqa: F401
+ MarionetteWdspecExecutor) # noqa: F401
+from .base import (Browser,
+ ExecutorBrowser)
+from .firefox import (get_timeout_multiplier, # noqa: F401
+ run_info_extras as fx_run_info_extras,
+ update_properties, # noqa: F401
+ executor_kwargs as fx_executor_kwargs, # noqa: F401
+ FirefoxWdSpecBrowser,
+ ProfileCreator as FirefoxProfileCreator)
+
+
+__wptrunner__ = {"product": "firefox_android",
+ "check_args": "check_args",
+ "browser": {None: "FirefoxAndroidBrowser",
+ "wdspec": "FirefoxAndroidWdSpecBrowser"},
+ "executor": {"testharness": "MarionetteTestharnessExecutor",
+ "reftest": "MarionetteRefTestExecutor",
+ "crashtest": "MarionetteCrashtestExecutor",
+ "wdspec": "MarionetteWdspecExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "run_info_extras": "run_info_extras",
+ "update_properties": "update_properties",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+
+def check_args(**kwargs):
+ pass
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"adb_binary": kwargs["adb_binary"],
+ "webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs["webdriver_args"],
+ "package_name": kwargs["package_name"],
+ "device_serial": kwargs["device_serial"],
+ "prefs_root": kwargs["prefs_root"],
+ "extra_prefs": kwargs["extra_prefs"],
+ "test_type": test_type,
+ "debug_info": kwargs["debug_info"],
+ "symbols_path": kwargs["symbols_path"],
+ "stackwalk_binary": kwargs["stackwalk_binary"],
+ "certutil_binary": kwargs["certutil_binary"],
+ "ca_certificate_path": config.ssl_config["ca_cert_path"],
+ "stackfix_dir": kwargs["stackfix_dir"],
+ "binary_args": kwargs["binary_args"],
+ "timeout_multiplier": get_timeout_multiplier(test_type,
+ run_info_data,
+ **kwargs),
+ "e10s": run_info_data["e10s"],
+ "disable_fission": kwargs["disable_fission"],
+ # desktop only
+ "leak_check": False,
+ "stylo_threads": kwargs["stylo_threads"],
+ "chaos_mode_flags": kwargs["chaos_mode_flags"],
+ "config": config,
+ "install_fonts": kwargs["install_fonts"],
+ "tests_root": config.doc_root,
+ "specialpowers_path": kwargs["specialpowers_path"],
+ "debug_test": kwargs["debug_test"]}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ rv = fx_executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs)
+ if test_type == "wdspec":
+ rv["capabilities"]["moz:firefoxOptions"]["androidPackage"] = kwargs["package_name"]
+ return rv
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def run_info_extras(**kwargs):
+ rv = fx_run_info_extras(**kwargs)
+ package = kwargs["package_name"]
+ rv.update({"e10s": True if package is not None and "geckoview" in package else False,
+ "headless": False})
+ return rv
+
+
+def env_options():
+ return {"server_host": "127.0.0.1",
+ "supports_debugger": True}
+
+
+def get_environ(stylo_threads, chaos_mode_flags):
+ env = {}
+ env["MOZ_CRASHREPORTER"] = "1"
+ env["MOZ_CRASHREPORTER_SHUTDOWN"] = "1"
+ env["MOZ_DISABLE_NONLOCAL_CONNECTIONS"] = "1"
+ env["STYLO_THREADS"] = str(stylo_threads)
+ if chaos_mode_flags is not None:
+ env["MOZ_CHAOSMODE"] = hex(chaos_mode_flags)
+ return env
+
+
+class ProfileCreator(FirefoxProfileCreator):
+ def __init__(self, logger, prefs_root, config, test_type, extra_prefs,
+ disable_fission, debug_test, browser_channel, certutil_binary, ca_certificate_path):
+ super().__init__(logger, prefs_root, config, test_type, extra_prefs,
+ True, disable_fission, debug_test, browser_channel, None,
+ certutil_binary, ca_certificate_path)
+
+ def _set_required_prefs(self, profile):
+ profile.set_preferences({
+ "network.dns.localDomains": ",".join(self.config.domains_set),
+ "dom.disable_open_during_load": False,
+ "places.history.enabled": False,
+ "dom.send_after_paint_to_content": True,
+ "network.preload": True,
+ "browser.tabs.remote.autostart": True,
+ })
+
+ if self.test_type == "reftest":
+ self.logger.info("Setting android reftest preferences")
+ profile.set_preferences({
+ "browser.viewport.desktopWidth": 800,
+ # Disable high DPI
+ "layout.css.devPixelsPerPx": "1.0",
+ # Ensure that the full browser element
+ # appears in the screenshot
+ "apz.allow_zooming": False,
+ "android.widget_paints_background": False,
+ # Ensure that scrollbars are always painted
+ "layout.testing.overlay-scrollbars.always-visible": True,
+ })
+
+ profile.set_preferences({"fission.autostart": True})
+ if self.disable_fission:
+ profile.set_preferences({"fission.autostart": False})
+
+
+class FirefoxAndroidBrowser(Browser):
+ init_timeout = 300
+ shutdown_timeout = 60
+
+ def __init__(self, logger, prefs_root, test_type, package_name="org.mozilla.geckoview.test_runner",
+ device_serial=None, extra_prefs=None, debug_info=None,
+ symbols_path=None, stackwalk_binary=None, certutil_binary=None,
+ ca_certificate_path=None, e10s=False, stackfix_dir=None,
+ binary_args=None, timeout_multiplier=None, leak_check=False, asan=False,
+ stylo_threads=1, chaos_mode_flags=None, config=None, browser_channel="nightly",
+ install_fonts=False, tests_root=None, specialpowers_path=None, adb_binary=None,
+ debug_test=False, disable_fission=False, **kwargs):
+
+ super().__init__(logger)
+ self.prefs_root = prefs_root
+ self.test_type = test_type
+ self.package_name = package_name
+ self.device_serial = device_serial
+ self.debug_info = debug_info
+ self.symbols_path = symbols_path
+ self.stackwalk_binary = stackwalk_binary
+ self.certutil_binary = certutil_binary
+ self.ca_certificate_path = ca_certificate_path
+ self.e10s = True
+ self.stackfix_dir = stackfix_dir
+ self.binary_args = binary_args
+ self.timeout_multiplier = timeout_multiplier
+ self.leak_check = leak_check
+ self.asan = asan
+ self.stylo_threads = stylo_threads
+ self.chaos_mode_flags = chaos_mode_flags
+ self.config = config
+ self.browser_channel = browser_channel
+ self.install_fonts = install_fonts
+ self.tests_root = tests_root
+ self.specialpowers_path = specialpowers_path
+ self.adb_binary = adb_binary
+ self.disable_fission = disable_fission
+
+ self.profile_creator = ProfileCreator(logger,
+ prefs_root,
+ config,
+ test_type,
+ extra_prefs,
+ disable_fission,
+ debug_test,
+ browser_channel,
+ certutil_binary,
+ ca_certificate_path)
+
+ self.marionette_port = None
+ self.profile = None
+ self.runner = None
+ self._settings = {}
+
+ def settings(self, test):
+ self._settings = {"check_leaks": self.leak_check and not test.leaks,
+ "lsan_allowed": test.lsan_allowed,
+ "lsan_max_stack_depth": test.lsan_max_stack_depth,
+ "mozleak_allowed": self.leak_check and test.mozleak_allowed,
+ "mozleak_thresholds": self.leak_check and test.mozleak_threshold,
+ "special_powers": self.specialpowers_path and test.url_base == "/_mozilla/"}
+ return self._settings
+
+ def start(self, **kwargs):
+ if self.marionette_port is None:
+ self.marionette_port = get_free_port()
+
+ addons = [self.specialpowers_path] if self._settings.get("special_powers") else None
+ self.profile = self.profile_creator.create(addons=addons)
+ self.profile.set_preferences({"marionette.port": self.marionette_port})
+
+ if self.install_fonts:
+ self.logger.debug("Copying Ahem font to profile")
+ font_dir = os.path.join(self.profile.profile, "fonts")
+ if not os.path.exists(font_dir):
+ os.makedirs(font_dir)
+ with open(os.path.join(self.tests_root, "fonts", "Ahem.ttf"), "rb") as src:
+ with open(os.path.join(font_dir, "Ahem.ttf"), "wb") as dest:
+ dest.write(src.read())
+
+ self.leak_report_file = None
+
+ debug_args, cmd = browser_command(self.package_name,
+ self.binary_args if self.binary_args else [] +
+ [cmd_arg("marionette"), "about:blank"],
+ self.debug_info)
+
+ env = get_environ(self.stylo_threads, self.chaos_mode_flags)
+
+ self.runner = FennecEmulatorRunner(app=self.package_name,
+ profile=self.profile,
+ cmdargs=cmd[1:],
+ env=env,
+ symbols_path=self.symbols_path,
+ serial=self.device_serial,
+ # TODO - choose appropriate log dir
+ logdir=os.getcwd(),
+ adb_path=self.adb_binary,
+ explicit_cleanup=True)
+
+ self.logger.debug("Starting %s" % self.package_name)
+ # connect to a running emulator
+ self.runner.device.connect()
+
+ self.runner.stop()
+ self.runner.start(debug_args=debug_args,
+ interactive=self.debug_info and self.debug_info.interactive)
+
+ self.runner.device.device.forward(
+ local=f"tcp:{self.marionette_port}",
+ remote=f"tcp:{self.marionette_port}")
+
+ for ports in self.config.ports.values():
+ for port in ports:
+ self.runner.device.device.reverse(
+ local=f"tcp:{port}",
+ remote=f"tcp:{port}")
+
+ self.logger.debug("%s Started" % self.package_name)
+
+ def stop(self, force=False):
+ if self.runner is not None:
+ if self.runner.device.connected:
+ try:
+ self.runner.device.device.remove_forwards()
+ self.runner.device.device.remove_reverses()
+ except Exception as e:
+ self.logger.warning("Failed to remove forwarded or reversed ports: %s" % e)
+ # We assume that stopping the runner prompts the
+ # browser to shut down.
+ self.runner.cleanup()
+ self.logger.debug("stopped")
+
+ def pid(self):
+ if self.runner.process_handler is None:
+ return None
+
+ try:
+ return self.runner.process_handler.pid
+ except AttributeError:
+ return None
+
+ def is_alive(self):
+ if self.runner:
+ return self.runner.is_running()
+ return False
+
+ def cleanup(self, force=False):
+ self.stop(force)
+
+ def executor_browser(self):
+ return ExecutorBrowser, {"marionette_port": self.marionette_port,
+ # We never want marionette to install extensions because
+ # that doesn't work on Android; instead they are in the profile
+ "extensions": [],
+ "supports_devtools": False}
+
+ def check_crash(self, process, test):
+ if not os.environ.get("MINIDUMP_STACKWALK", "") and self.stackwalk_binary:
+ os.environ["MINIDUMP_STACKWALK"] = self.stackwalk_binary
+ return bool(self.runner.check_for_crashes(test_name=test))
+
+
+class FirefoxAndroidWdSpecBrowser(FirefoxWdSpecBrowser):
+ def __init__(self, logger, prefs_root, webdriver_binary, webdriver_args,
+ extra_prefs=None, debug_info=None, symbols_path=None, stackwalk_binary=None,
+ certutil_binary=None, ca_certificate_path=None, e10s=False,
+ disable_fission=False, stackfix_dir=None, leak_check=False,
+ asan=False, stylo_threads=1, chaos_mode_flags=None, config=None,
+ browser_channel="nightly", headless=None,
+ package_name="org.mozilla.geckoview.test_runner", device_serial=None,
+ adb_binary=None, **kwargs):
+
+ super().__init__(logger, None, prefs_root, webdriver_binary, webdriver_args,
+ extra_prefs=extra_prefs, debug_info=debug_info, symbols_path=symbols_path,
+ stackwalk_binary=stackwalk_binary, certutil_binary=certutil_binary,
+ ca_certificate_path=ca_certificate_path, e10s=e10s,
+ disable_fission=disable_fission, stackfix_dir=stackfix_dir,
+ leak_check=leak_check, asan=asan, stylo_threads=stylo_threads,
+ chaos_mode_flags=chaos_mode_flags, config=config,
+ browser_channel=browser_channel, headless=headless, **kwargs)
+
+ self.config = config
+ self.package_name = package_name
+ self.device_serial = device_serial
+ # This is just to support the same adb lookup as for other test types
+ context = get_app_context("fennec")(adb_path=adb_binary, device_serial=device_serial)
+ self.device = context.get_device(context.adb, self.device_serial)
+
+ def start(self, group_metadata, **kwargs):
+ for ports in self.config.ports.values():
+ for port in ports:
+ self.device.reverse(
+ local=f"tcp:{port}",
+ remote=f"tcp:{port}")
+ super().start(group_metadata, **kwargs)
+
+ def stop(self, force=False):
+ try:
+ self.device.remove_reverses()
+ except Exception as e:
+ self.logger.warning("Failed to remove forwarded or reversed ports: %s" % e)
+ super().stop(force=force)
+
+ def get_env(self, binary, debug_info, stylo_threads, headless, chaos_mode_flags):
+ env = get_environ(stylo_threads, chaos_mode_flags)
+ env["RUST_BACKTRACE"] = "1"
+ del env["MOZ_DISABLE_NONLOCAL_CONNECTIONS"]
+ return env
+
+ def executor_browser(self):
+ cls, args = super().executor_browser()
+ args["androidPackage"] = self.package_name
+ args["androidDeviceSerial"] = self.device_serial
+ args["env"] = self.env
+ args["supports_devtools"] = False
+ return cls, args
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/ie.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/ie.py
new file mode 100644
index 0000000000..87b989c028
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/ie.py
@@ -0,0 +1,50 @@
+# mypy: allow-untyped-defs
+
+from .base import require_arg, WebDriverBrowser
+from .base import get_timeout_multiplier # noqa: F401
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.base import WdspecExecutor # noqa: F401
+
+__wptrunner__ = {"product": "ie",
+ "check_args": "check_args",
+ "browser": "WebDriverBrowser",
+ "executor": {"wdspec": "WdspecExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "webdriver_binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs.get("webdriver_args")}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ options = {}
+ options["requireWindowFocus"] = True
+ capabilities = {}
+ capabilities["se:ieOptions"] = options
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data, **kwargs)
+ executor_kwargs["close_after_done"] = True
+ executor_kwargs["capabilities"] = capabilities
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {"supports_debugger": False}
+
+
+class InternetExplorerBrowser(WebDriverBrowser):
+ def make_command(self):
+ return [self.binary, f"--port={self.port}"] + self.webdriver_args
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/opera.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/opera.py
new file mode 100644
index 0000000000..a2448f4a90
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/opera.py
@@ -0,0 +1,70 @@
+# mypy: allow-untyped-defs
+
+from .base import require_arg
+from .base import get_timeout_multiplier # noqa: F401
+from .chrome import ChromeBrowser
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorselenium import (SeleniumTestharnessExecutor, # noqa: F401
+ SeleniumRefTestExecutor) # noqa: F401
+
+
+__wptrunner__ = {"product": "opera",
+ "check_args": "check_args",
+ "browser": "OperaBrowser",
+ "executor": {"testharness": "SeleniumTestharnessExecutor",
+ "reftest": "SeleniumRefTestExecutor",
+ "wdspec": "WdspecExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "webdriver_binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"binary": kwargs["binary"],
+ "webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs.get("webdriver_args")}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ from selenium.webdriver import DesiredCapabilities
+
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data, **kwargs)
+ executor_kwargs["close_after_done"] = True
+ capabilities = dict(DesiredCapabilities.OPERA.items())
+ capabilities.setdefault("operaOptions", {})["prefs"] = {
+ "profile": {
+ "default_content_setting_values": {
+ "popups": 1
+ }
+ }
+ }
+ for (kwarg, capability) in [("binary", "binary"), ("binary_args", "args")]:
+ if kwargs[kwarg] is not None:
+ capabilities["operaOptions"][capability] = kwargs[kwarg]
+ if test_type == "testharness":
+ capabilities["operaOptions"]["useAutomationExtension"] = False
+ capabilities["operaOptions"]["excludeSwitches"] = ["enable-automation"]
+ if test_type == "wdspec":
+ capabilities["operaOptions"]["w3c"] = True
+ executor_kwargs["capabilities"] = capabilities
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {}
+
+
+class OperaBrowser(ChromeBrowser):
+ pass
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/safari.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/safari.py
new file mode 100644
index 0000000000..ba533f4bc3
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/safari.py
@@ -0,0 +1,207 @@
+# mypy: allow-untyped-defs
+
+import os
+import plistlib
+from distutils.spawn import find_executable
+from distutils.version import LooseVersion
+
+import psutil
+
+from .base import WebDriverBrowser, require_arg
+from .base import get_timeout_multiplier # noqa: F401
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorwebdriver import (WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor, # noqa: F401
+ WebDriverCrashtestExecutor) # noqa: F401
+
+
+__wptrunner__ = {"product": "safari",
+ "check_args": "check_args",
+ "browser": "SafariBrowser",
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor",
+ "wdspec": "WdspecExecutor",
+ "crashtest": "WebDriverCrashtestExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "run_info_extras": "run_info_extras",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "webdriver_binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs.get("webdriver_args"),
+ "kill_safari": kwargs.get("kill_safari", False)}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data, **kwargs):
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data, **kwargs)
+ executor_kwargs["close_after_done"] = True
+ executor_kwargs["capabilities"] = {}
+ if test_type == "testharness":
+ executor_kwargs["capabilities"]["pageLoadStrategy"] = "eager"
+ if kwargs["binary"] is not None:
+ raise ValueError("Safari doesn't support setting executable location")
+
+ V = LooseVersion
+ browser_bundle_version = run_info_data["browser_bundle_version"]
+ if browser_bundle_version is not None and V(browser_bundle_version[2:]) >= V("613.1.7.1"):
+ logger.debug("using acceptInsecureCerts=True")
+ executor_kwargs["capabilities"]["acceptInsecureCerts"] = True
+ else:
+ logger.warning("not using acceptInsecureCerts, Safari will require certificates to be trusted")
+
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {}
+
+
+def run_info_extras(**kwargs):
+ webdriver_binary = kwargs["webdriver_binary"]
+ rv = {}
+
+ safari_bundle, safari_info = get_safari_info(webdriver_binary)
+
+ if safari_info is not None:
+ assert safari_bundle is not None # if safari_info is not None, this can't be
+ _, webkit_info = get_webkit_info(safari_bundle)
+ if webkit_info is None:
+ webkit_info = {}
+ else:
+ safari_info = {}
+ webkit_info = {}
+
+ rv["browser_marketing_version"] = safari_info.get("CFBundleShortVersionString")
+ rv["browser_bundle_version"] = safari_info.get("CFBundleVersion")
+ rv["browser_webkit_bundle_version"] = webkit_info.get("CFBundleVersion")
+
+ with open("/System/Library/CoreServices/SystemVersion.plist", "rb") as fp:
+ system_version = plistlib.load(fp)
+
+ rv["os_build"] = system_version["ProductBuildVersion"]
+
+ return rv
+
+
+def get_safari_info(wd_path):
+ bundle_paths = [
+ os.path.join(os.path.dirname(wd_path), "..", ".."), # bundled Safari (e.g. STP)
+ os.path.join(os.path.dirname(wd_path), "Safari.app"), # local Safari build
+ "/Applications/Safari.app", # system Safari
+ ]
+
+ for bundle_path in bundle_paths:
+ info_path = os.path.join(bundle_path, "Contents", "Info.plist")
+ if not os.path.isfile(info_path):
+ continue
+
+ with open(info_path, "rb") as fp:
+ info = plistlib.load(fp)
+
+ # check we have a Safari family bundle
+ ident = info.get("CFBundleIdentifier")
+ if not isinstance(ident, str) or not ident.startswith("com.apple.Safari"):
+ continue
+
+ return (bundle_path, info)
+
+ return (None, None)
+
+
+def get_webkit_info(safari_bundle_path):
+ framework_paths = [
+ os.path.join(os.path.dirname(safari_bundle_path), "Contents", "Frameworks"), # bundled Safari (e.g. STP)
+ os.path.join(os.path.dirname(safari_bundle_path), ".."), # local Safari build
+ "/System/Library/PrivateFrameworks",
+ "/Library/Frameworks",
+ "/System/Library/Frameworks",
+ ]
+
+ for framework_path in framework_paths:
+ info_path = os.path.join(framework_path, "WebKit.framework", "Versions", "Current", "Resources", "Info.plist")
+ if not os.path.isfile(info_path):
+ continue
+
+ with open(info_path, "rb") as fp:
+ info = plistlib.load(fp)
+ return (framework_path, info)
+
+ return (None, None)
+
+
+class SafariBrowser(WebDriverBrowser):
+ """Safari is backed by safaridriver, which is supplied through
+ ``wptrunner.webdriver.SafariDriverServer``.
+ """
+ def __init__(self, logger, binary=None, webdriver_binary=None, webdriver_args=None,
+ port=None, env=None, kill_safari=False, **kwargs):
+ """Creates a new representation of Safari. The `webdriver_binary`
+ argument gives the WebDriver binary to use for testing. (The browser
+ binary location cannot be specified, as Safari and SafariDriver are
+ coupled.) If `kill_safari` is True, then `Browser.stop` will stop Safari."""
+ super().__init__(logger,
+ binary,
+ webdriver_binary,
+ webdriver_args=webdriver_args,
+ port=None,
+ supports_pac=False,
+ env=env)
+
+ if "/" not in webdriver_binary:
+ wd_path = find_executable(webdriver_binary)
+ else:
+ wd_path = webdriver_binary
+ self.safari_path = self._find_safari_executable(wd_path)
+
+ logger.debug("WebDriver executable path: %s" % wd_path)
+ logger.debug("Safari executable path: %s" % self.safari_path)
+
+ self.kill_safari = kill_safari
+
+ def _find_safari_executable(self, wd_path):
+ bundle_path, info = get_safari_info(wd_path)
+
+ exe = info.get("CFBundleExecutable")
+ if not isinstance(exe, str):
+ return None
+
+ exe_path = os.path.join(bundle_path, "Contents", "MacOS", exe)
+ if not os.path.isfile(exe_path):
+ return None
+
+ return exe_path
+
+ def make_command(self):
+ return [self.webdriver_binary, f"--port={self.port}"] + self.webdriver_args
+
+ def stop(self, force=False):
+ super().stop(force)
+
+ if self.kill_safari:
+ self.logger.debug("Going to stop Safari")
+ for proc in psutil.process_iter(attrs=["exe"]):
+ if (proc.info["exe"] is not None and
+ os.path.samefile(proc.info["exe"], self.safari_path)):
+ self.logger.debug("Stopping Safari %s" % proc.pid)
+ try:
+ proc.terminate()
+ try:
+ proc.wait(10)
+ except psutil.TimeoutExpired:
+ proc.kill()
+ proc.wait(10)
+ except psutil.NoSuchProcess:
+ pass
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce.py
new file mode 100644
index 0000000000..0f7651638d
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce.py
@@ -0,0 +1,249 @@
+# mypy: allow-untyped-defs
+
+import glob
+import os
+import shutil
+import subprocess
+import tarfile
+import tempfile
+import time
+
+import requests
+
+from io import StringIO
+
+from .base import Browser, ExecutorBrowser, require_arg
+from .base import get_timeout_multiplier # noqa: F401
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.executorselenium import (SeleniumTestharnessExecutor, # noqa: F401
+ SeleniumRefTestExecutor) # noqa: F401
+
+here = os.path.dirname(__file__)
+# Number of seconds to wait between polling operations when detecting status of
+# Sauce Connect sub-process.
+sc_poll_period = 1
+
+
+__wptrunner__ = {"product": "sauce",
+ "check_args": "check_args",
+ "browser": "SauceBrowser",
+ "executor": {"testharness": "SeleniumTestharnessExecutor",
+ "reftest": "SeleniumRefTestExecutor"},
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+
+def get_capabilities(**kwargs):
+ browser_name = kwargs["sauce_browser"]
+ platform = kwargs["sauce_platform"]
+ version = kwargs["sauce_version"]
+ build = kwargs["sauce_build"]
+ tags = kwargs["sauce_tags"]
+ tunnel_id = kwargs["sauce_tunnel_id"]
+ prerun_script = {
+ "MicrosoftEdge": {
+ "executable": "sauce-storage:edge-prerun.bat",
+ "background": False,
+ },
+ "safari": {
+ "executable": "sauce-storage:safari-prerun.sh",
+ "background": False,
+ }
+ }
+ capabilities = {
+ "browserName": browser_name,
+ "build": build,
+ "disablePopupHandler": True,
+ "name": f"{browser_name} {version} on {platform}",
+ "platform": platform,
+ "public": "public",
+ "selenium-version": "3.3.1",
+ "tags": tags,
+ "tunnel-identifier": tunnel_id,
+ "version": version,
+ "prerun": prerun_script.get(browser_name)
+ }
+
+ return capabilities
+
+
+def get_sauce_config(**kwargs):
+ browser_name = kwargs["sauce_browser"]
+ sauce_user = kwargs["sauce_user"]
+ sauce_key = kwargs["sauce_key"]
+
+ hub_url = f"{sauce_user}:{sauce_key}@localhost:4445"
+ data = {
+ "url": "http://%s/wd/hub" % hub_url,
+ "browserName": browser_name,
+ "capabilities": get_capabilities(**kwargs)
+ }
+
+ return data
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "sauce_browser")
+ require_arg(kwargs, "sauce_platform")
+ require_arg(kwargs, "sauce_version")
+ require_arg(kwargs, "sauce_user")
+ require_arg(kwargs, "sauce_key")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ sauce_config = get_sauce_config(**kwargs)
+
+ return {"sauce_config": sauce_config}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data, **kwargs)
+
+ executor_kwargs["capabilities"] = get_capabilities(**kwargs)
+
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return [SauceConnect(**kwargs)]
+
+
+def env_options():
+ return {"supports_debugger": False}
+
+
+def get_tar(url, dest):
+ resp = requests.get(url, stream=True)
+ resp.raise_for_status()
+ with tarfile.open(fileobj=StringIO(resp.raw.read())) as f:
+ f.extractall(path=dest)
+
+
+class SauceConnect():
+
+ def __init__(self, **kwargs):
+ self.sauce_user = kwargs["sauce_user"]
+ self.sauce_key = kwargs["sauce_key"]
+ self.sauce_tunnel_id = kwargs["sauce_tunnel_id"]
+ self.sauce_connect_binary = kwargs.get("sauce_connect_binary")
+ self.sauce_connect_args = kwargs.get("sauce_connect_args")
+ self.sauce_init_timeout = kwargs.get("sauce_init_timeout")
+ self.sc_process = None
+ self.temp_dir = None
+ self.env_config = None
+
+ def __call__(self, env_options, env_config):
+ self.env_config = env_config
+
+ return self
+
+ def __enter__(self):
+ # Because this class implements the context manager protocol, it is
+ # possible for instances to be provided to the `with` statement
+ # directly. This class implements the callable protocol so that data
+ # which is not available during object initialization can be provided
+ # prior to this moment. Instances must be invoked in preparation for
+ # the context manager protocol, but this additional constraint is not
+ # itself part of the protocol.
+ assert self.env_config is not None, 'The instance has been invoked.'
+
+ if not self.sauce_connect_binary:
+ self.temp_dir = tempfile.mkdtemp()
+ get_tar("https://saucelabs.com/downloads/sc-4.4.9-linux.tar.gz", self.temp_dir)
+ self.sauce_connect_binary = glob.glob(os.path.join(self.temp_dir, "sc-*-linux/bin/sc"))[0]
+
+ self.upload_prerun_exec('edge-prerun.bat')
+ self.upload_prerun_exec('safari-prerun.sh')
+
+ self.sc_process = subprocess.Popen([
+ self.sauce_connect_binary,
+ "--user=%s" % self.sauce_user,
+ "--api-key=%s" % self.sauce_key,
+ "--no-remove-colliding-tunnels",
+ "--tunnel-identifier=%s" % self.sauce_tunnel_id,
+ "--metrics-address=0.0.0.0:9876",
+ "--readyfile=./sauce_is_ready",
+ "--tunnel-domains",
+ ",".join(self.env_config.domains_set)
+ ] + self.sauce_connect_args)
+
+ tot_wait = 0
+ while not os.path.exists('./sauce_is_ready') and self.sc_process.poll() is None:
+ if not self.sauce_init_timeout or (tot_wait >= self.sauce_init_timeout):
+ self.quit()
+
+ raise SauceException("Sauce Connect Proxy was not ready after %d seconds" % tot_wait)
+
+ time.sleep(sc_poll_period)
+ tot_wait += sc_poll_period
+
+ if self.sc_process.returncode is not None:
+ raise SauceException("Unable to start Sauce Connect Proxy. Process exited with code %s", self.sc_process.returncode)
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.env_config = None
+ self.quit()
+ if self.temp_dir and os.path.exists(self.temp_dir):
+ try:
+ shutil.rmtree(self.temp_dir)
+ except OSError:
+ pass
+
+ def upload_prerun_exec(self, file_name):
+ auth = (self.sauce_user, self.sauce_key)
+ url = f"https://saucelabs.com/rest/v1/storage/{self.sauce_user}/{file_name}?overwrite=true"
+
+ with open(os.path.join(here, 'sauce_setup', file_name), 'rb') as f:
+ requests.post(url, data=f, auth=auth)
+
+ def quit(self):
+ """The Sauce Connect process may be managing an active "tunnel" to the
+ Sauce Labs service. Issue a request to the process to close any tunnels
+ and exit. If this does not occur within 5 seconds, force the process to
+ close."""
+ kill_wait = 5
+ tot_wait = 0
+ self.sc_process.terminate()
+
+ while self.sc_process.poll() is None:
+ time.sleep(sc_poll_period)
+ tot_wait += sc_poll_period
+
+ if tot_wait >= kill_wait:
+ self.sc_process.kill()
+ break
+
+
+class SauceException(Exception):
+ pass
+
+
+class SauceBrowser(Browser):
+ init_timeout = 300
+
+ def __init__(self, logger, sauce_config, **kwargs):
+ Browser.__init__(self, logger)
+ self.sauce_config = sauce_config
+
+ def start(self, **kwargs):
+ pass
+
+ def stop(self, force=False):
+ pass
+
+ def pid(self):
+ return None
+
+ def is_alive(self):
+ # TODO: Should this check something about the connection?
+ return True
+
+ def cleanup(self):
+ pass
+
+ def executor_browser(self):
+ return ExecutorBrowser, {"webdriver_url": self.sauce_config["url"]}
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce_setup/edge-prerun.bat b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce_setup/edge-prerun.bat
new file mode 100755
index 0000000000..1a3e6fee30
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce_setup/edge-prerun.bat
@@ -0,0 +1,9 @@
+@echo off
+reg add "HKCU\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppContainer\Storage\microsoft.microsoftedge_8wekyb3d8bbwe\MicrosoftEdge\New Windows" /v "PopupMgr" /t REG_SZ /d no
+
+
+REM Download and install the Ahem font
+REM - https://wiki.saucelabs.com/display/DOCS/Downloading+Files+to+a+Sauce+Labs+Virtual+Machine+Prior+to+Testing
+REM - https://superuser.com/questions/201896/how-do-i-install-a-font-from-the-windows-command-prompt
+bitsadmin.exe /transfer "JobName" https://github.com/web-platform-tests/wpt/raw/master/fonts/Ahem.ttf "%WINDIR%\Fonts\Ahem.ttf"
+reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts" /v "Ahem (TrueType)" /t REG_SZ /d Ahem.ttf /f
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce_setup/safari-prerun.sh b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce_setup/safari-prerun.sh
new file mode 100755
index 0000000000..39390e618f
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/sauce_setup/safari-prerun.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+curl https://raw.githubusercontent.com/web-platform-tests/wpt/master/fonts/Ahem.ttf > ~/Library/Fonts/Ahem.ttf
+defaults write com.apple.Safari com.apple.Safari.ContentPageGroupIdentifier.WebKit2JavaScriptCanOpenWindowsAutomatically -bool true
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/servo.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/servo.py
new file mode 100644
index 0000000000..d57804f977
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/servo.py
@@ -0,0 +1,118 @@
+# mypy: allow-untyped-defs
+
+import os
+
+from .base import ExecutorBrowser, NullBrowser, WebDriverBrowser, require_arg
+from .base import get_timeout_multiplier # noqa: F401
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorservo import (ServoCrashtestExecutor, # noqa: F401
+ ServoTestharnessExecutor, # noqa: F401
+ ServoRefTestExecutor) # noqa: F401
+
+
+here = os.path.dirname(__file__)
+
+__wptrunner__ = {
+ "product": "servo",
+ "check_args": "check_args",
+ "browser": {None: "ServoBrowser",
+ "wdspec": "ServoWdspecBrowser"},
+ "executor": {
+ "crashtest": "ServoCrashtestExecutor",
+ "testharness": "ServoTestharnessExecutor",
+ "reftest": "ServoRefTestExecutor",
+ "wdspec": "WdspecExecutor",
+ },
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "timeout_multiplier": "get_timeout_multiplier",
+ "update_properties": "update_properties",
+}
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {
+ "binary": kwargs["binary"],
+ "debug_info": kwargs["debug_info"],
+ "binary_args": kwargs["binary_args"],
+ "user_stylesheets": kwargs.get("user_stylesheets"),
+ "ca_certificate_path": config.ssl_config["ca_cert_path"],
+ }
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ rv = base_executor_kwargs(test_type, test_environment, run_info_data, **kwargs)
+ rv["pause_after_test"] = kwargs["pause_after_test"]
+ if test_type == "wdspec":
+ rv["capabilities"] = {}
+ return rv
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {"server_host": "127.0.0.1",
+ "bind_address": False,
+ "testharnessreport": "testharnessreport-servo.js",
+ "supports_debugger": True}
+
+
+def update_properties():
+ return ["debug", "os", "processor"], {"os": ["version"], "processor": ["bits"]}
+
+
+class ServoBrowser(NullBrowser):
+ def __init__(self, logger, binary, debug_info=None, binary_args=None,
+ user_stylesheets=None, ca_certificate_path=None, **kwargs):
+ NullBrowser.__init__(self, logger)
+ self.binary = binary
+ self.debug_info = debug_info
+ self.binary_args = binary_args or []
+ self.user_stylesheets = user_stylesheets or []
+ self.ca_certificate_path = ca_certificate_path
+
+ def executor_browser(self):
+ return ExecutorBrowser, {
+ "binary": self.binary,
+ "debug_info": self.debug_info,
+ "binary_args": self.binary_args,
+ "user_stylesheets": self.user_stylesheets,
+ "ca_certificate_path": self.ca_certificate_path,
+ }
+
+
+class ServoWdspecBrowser(WebDriverBrowser):
+ # TODO: could share an implemenation with servodriver.py, perhaps
+ def __init__(self, logger, binary="servo", webdriver_args=None,
+ binary_args=None, host="127.0.0.1", env=None, port=None):
+
+ env = os.environ.copy() if env is None else env
+ env["RUST_BACKTRACE"] = "1"
+
+ super().__init__(logger,
+ binary,
+ None,
+ webdriver_args=webdriver_args,
+ host=host,
+ port=port,
+ env=env)
+ self.binary_args = binary_args
+
+ def make_command(self):
+ command = [self.binary,
+ f"--webdriver={self.port}",
+ "--hard-fail",
+ "--headless"] + self.webdriver_args
+ if self.binary_args:
+ command += self.binary_args
+ return command
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/servodriver.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/servodriver.py
new file mode 100644
index 0000000000..5195fa6442
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/servodriver.py
@@ -0,0 +1,184 @@
+# mypy: allow-untyped-defs
+
+import os
+import subprocess
+import tempfile
+
+from mozprocess import ProcessHandler
+
+from tools.serve.serve import make_hosts_file
+
+from .base import (Browser,
+ ExecutorBrowser,
+ OutputHandler,
+ require_arg,
+ get_free_port,
+ browser_command)
+from .base import get_timeout_multiplier # noqa: F401
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.executorservodriver import (ServoWebDriverTestharnessExecutor, # noqa: F401
+ ServoWebDriverRefTestExecutor) # noqa: F401
+
+here = os.path.dirname(__file__)
+
+__wptrunner__ = {
+ "product": "servodriver",
+ "check_args": "check_args",
+ "browser": "ServoWebDriverBrowser",
+ "executor": {
+ "testharness": "ServoWebDriverTestharnessExecutor",
+ "reftest": "ServoWebDriverRefTestExecutor",
+ },
+ "browser_kwargs": "browser_kwargs",
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "timeout_multiplier": "get_timeout_multiplier",
+ "update_properties": "update_properties",
+}
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "binary")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {
+ "binary": kwargs["binary"],
+ "binary_args": kwargs["binary_args"],
+ "debug_info": kwargs["debug_info"],
+ "server_config": config,
+ "user_stylesheets": kwargs.get("user_stylesheets"),
+ "headless": kwargs.get("headless"),
+ }
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data, **kwargs):
+ rv = base_executor_kwargs(test_type, test_environment, run_info_data, **kwargs)
+ return rv
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {"server_host": "127.0.0.1",
+ "testharnessreport": "testharnessreport-servodriver.js",
+ "supports_debugger": True}
+
+
+def update_properties():
+ return (["debug", "os", "processor"], {"os": ["version"], "processor": ["bits"]})
+
+
+def write_hosts_file(config):
+ hosts_fd, hosts_path = tempfile.mkstemp()
+ with os.fdopen(hosts_fd, "w") as f:
+ f.write(make_hosts_file(config, "127.0.0.1"))
+ return hosts_path
+
+
+class ServoWebDriverBrowser(Browser):
+ init_timeout = 300 # Large timeout for cases where we're booting an Android emulator
+
+ def __init__(self, logger, binary, debug_info=None, webdriver_host="127.0.0.1",
+ server_config=None, binary_args=None,
+ user_stylesheets=None, headless=None, **kwargs):
+ Browser.__init__(self, logger)
+ self.binary = binary
+ self.binary_args = binary_args or []
+ self.webdriver_host = webdriver_host
+ self.webdriver_port = None
+ self.proc = None
+ self.debug_info = debug_info
+ self.hosts_path = write_hosts_file(server_config)
+ self.server_ports = server_config.ports if server_config else {}
+ self.command = None
+ self.user_stylesheets = user_stylesheets if user_stylesheets else []
+ self.headless = headless if headless else False
+ self.ca_certificate_path = server_config.ssl_config["ca_cert_path"]
+ self.output_handler = None
+
+ def start(self, **kwargs):
+ self.webdriver_port = get_free_port()
+
+ env = os.environ.copy()
+ env["HOST_FILE"] = self.hosts_path
+ env["RUST_BACKTRACE"] = "1"
+ env["EMULATOR_REVERSE_FORWARD_PORTS"] = ",".join(
+ str(port)
+ for _protocol, ports in self.server_ports.items()
+ for port in ports
+ if port
+ )
+
+ debug_args, command = browser_command(
+ self.binary,
+ self.binary_args + [
+ "--hard-fail",
+ "--webdriver=%s" % self.webdriver_port,
+ "about:blank",
+ ],
+ self.debug_info
+ )
+
+ if self.headless:
+ command += ["--headless"]
+
+ if self.ca_certificate_path:
+ command += ["--certificate-path", self.ca_certificate_path]
+
+ for stylesheet in self.user_stylesheets:
+ command += ["--user-stylesheet", stylesheet]
+
+ self.command = command
+
+ self.command = debug_args + self.command
+
+ if not self.debug_info or not self.debug_info.interactive:
+ self.output_handler = OutputHandler(self.logger, self.command)
+ self.proc = ProcessHandler(self.command,
+ processOutputLine=[self.on_output],
+ env=env,
+ storeOutput=False)
+ self.proc.run()
+ self.output_handler.after_process_start(self.proc.pid)
+ self.output_handler.start()
+ else:
+ self.proc = subprocess.Popen(self.command, env=env)
+
+ self.logger.debug("Servo Started")
+
+ def stop(self, force=False):
+ self.logger.debug("Stopping browser")
+ if self.proc is not None:
+ try:
+ self.proc.kill()
+ except OSError:
+ # This can happen on Windows if the process is already dead
+ pass
+ if self.output_handler is not None:
+ self.output_handler.after_process_stop()
+
+ def pid(self):
+ if self.proc is None:
+ return None
+
+ try:
+ return self.proc.pid
+ except AttributeError:
+ return None
+
+ def is_alive(self):
+ return self.proc.poll() is None
+
+ def cleanup(self):
+ self.stop()
+ os.remove(self.hosts_path)
+
+ def executor_browser(self):
+ assert self.webdriver_port is not None
+ return ExecutorBrowser, {"webdriver_host": self.webdriver_host,
+ "webdriver_port": self.webdriver_port,
+ "init_timeout": self.init_timeout}
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/webkit.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/webkit.py
new file mode 100644
index 0000000000..cecfbe4e27
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/webkit.py
@@ -0,0 +1,83 @@
+# mypy: allow-untyped-defs
+
+from .base import WebDriverBrowser, require_arg
+from .base import get_timeout_multiplier, certificate_domain_list # noqa: F401
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorwebdriver import (WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor, # noqa: F401
+ WebDriverCrashtestExecutor) # noqa: F401
+
+
+__wptrunner__ = {"product": "webkit",
+ "check_args": "check_args",
+ "browser": "WebKitBrowser",
+ "browser_kwargs": "browser_kwargs",
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor",
+ "wdspec": "WdspecExecutor",
+ "crashtest": "WebDriverCrashtestExecutor"},
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "run_info_extras": "run_info_extras",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+
+def check_args(**kwargs):
+ require_arg(kwargs, "binary")
+ require_arg(kwargs, "webdriver_binary")
+ require_arg(kwargs, "webkit_port")
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ return {"binary": kwargs["binary"],
+ "webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": kwargs.get("webdriver_args")}
+
+
+def capabilities_for_port(server_config, **kwargs):
+ port_name = kwargs["webkit_port"]
+ if port_name in ["gtk", "wpe"]:
+ port_key_map = {"gtk": "webkitgtk"}
+ browser_options_port = port_key_map.get(port_name, port_name)
+ browser_options_key = "%s:browserOptions" % browser_options_port
+
+ return {
+ "browserName": "MiniBrowser",
+ "browserVersion": "2.20",
+ "platformName": "ANY",
+ browser_options_key: {
+ "binary": kwargs["binary"],
+ "args": kwargs.get("binary_args", []),
+ "certificates": certificate_domain_list(server_config.domains_set, kwargs["host_cert_path"])}}
+
+ return {}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data, **kwargs)
+ executor_kwargs["close_after_done"] = True
+ executor_kwargs["capabilities"] = capabilities_for_port(test_environment.config,
+ **kwargs)
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {}
+
+
+def run_info_extras(**kwargs):
+ return {"webkit_port": kwargs["webkit_port"]}
+
+
+class WebKitBrowser(WebDriverBrowser):
+ """Generic WebKit browser is backed by WebKit's WebDriver implementation"""
+
+ def make_command(self):
+ return [self.webdriver_binary, f"--port={self.port}"] + self.webdriver_args
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/webkitgtk_minibrowser.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/webkitgtk_minibrowser.py
new file mode 100644
index 0000000000..a574328c32
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/browsers/webkitgtk_minibrowser.py
@@ -0,0 +1,82 @@
+# mypy: allow-untyped-defs
+
+from .base import (NullBrowser, # noqa: F401
+ certificate_domain_list,
+ get_timeout_multiplier, # noqa: F401
+ maybe_add_args)
+from .webkit import WebKitBrowser
+from ..executors import executor_kwargs as base_executor_kwargs
+from ..executors.base import WdspecExecutor # noqa: F401
+from ..executors.executorwebdriver import (WebDriverTestharnessExecutor, # noqa: F401
+ WebDriverRefTestExecutor, # noqa: F401
+ WebDriverCrashtestExecutor) # noqa: F401
+
+__wptrunner__ = {"product": "webkitgtk_minibrowser",
+ "check_args": "check_args",
+ "browser": "WebKitGTKMiniBrowser",
+ "browser_kwargs": "browser_kwargs",
+ "executor": {"testharness": "WebDriverTestharnessExecutor",
+ "reftest": "WebDriverRefTestExecutor",
+ "wdspec": "WdspecExecutor",
+ "crashtest": "WebDriverCrashtestExecutor"},
+ "executor_kwargs": "executor_kwargs",
+ "env_extras": "env_extras",
+ "env_options": "env_options",
+ "run_info_extras": "run_info_extras",
+ "timeout_multiplier": "get_timeout_multiplier"}
+
+
+def check_args(**kwargs):
+ pass
+
+
+def browser_kwargs(logger, test_type, run_info_data, config, **kwargs):
+ # Workaround for https://gitlab.gnome.org/GNOME/libsoup/issues/172
+ webdriver_required_args = ["--host=127.0.0.1"]
+ webdriver_args = maybe_add_args(webdriver_required_args, kwargs.get("webdriver_args"))
+ return {"binary": kwargs["binary"],
+ "webdriver_binary": kwargs["webdriver_binary"],
+ "webdriver_args": webdriver_args}
+
+
+def capabilities(server_config, **kwargs):
+ browser_required_args = ["--automation",
+ "--javascript-can-open-windows-automatically=true",
+ "--enable-xss-auditor=false",
+ "--enable-media-capabilities=true",
+ "--enable-encrypted-media=true",
+ "--enable-media-stream=true",
+ "--enable-mock-capture-devices=true",
+ "--enable-webaudio=true"]
+ args = kwargs.get("binary_args", [])
+ args = maybe_add_args(browser_required_args, args)
+ return {
+ "browserName": "MiniBrowser",
+ "webkitgtk:browserOptions": {
+ "binary": kwargs["binary"],
+ "args": args,
+ "certificates": certificate_domain_list(server_config.domains_set, kwargs["host_cert_path"])}}
+
+
+def executor_kwargs(logger, test_type, test_environment, run_info_data,
+ **kwargs):
+ executor_kwargs = base_executor_kwargs(test_type, test_environment, run_info_data, **kwargs)
+ executor_kwargs["close_after_done"] = True
+ executor_kwargs["capabilities"] = capabilities(test_environment.config, **kwargs)
+ return executor_kwargs
+
+
+def env_extras(**kwargs):
+ return []
+
+
+def env_options():
+ return {}
+
+
+def run_info_extras(**kwargs):
+ return {"webkit_port": "gtk"}
+
+
+class WebKitGTKMiniBrowser(WebKitBrowser):
+ pass
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/config.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/config.py
new file mode 100644
index 0000000000..c114ee3e6a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/config.py
@@ -0,0 +1,63 @@
+# mypy: allow-untyped-defs
+
+from configparser import ConfigParser
+import os
+import sys
+from collections import OrderedDict
+from typing import Any, Dict
+
+here = os.path.dirname(__file__)
+
+class ConfigDict(Dict[str, Any]):
+ def __init__(self, base_path, *args, **kwargs):
+ self.base_path = base_path
+ dict.__init__(self, *args, **kwargs)
+
+ def get_path(self, key, default=None):
+ if key not in self:
+ return default
+ path = self[key]
+ os.path.expanduser(path)
+ return os.path.abspath(os.path.join(self.base_path, path))
+
+def read(config_path):
+ config_path = os.path.abspath(config_path)
+ config_root = os.path.dirname(config_path)
+ parser = ConfigParser()
+ success = parser.read(config_path)
+ assert config_path in success, success
+
+ subns = {"pwd": os.path.abspath(os.path.curdir)}
+
+ rv = OrderedDict()
+ for section in parser.sections():
+ rv[section] = ConfigDict(config_root)
+ for key in parser.options(section):
+ rv[section][key] = parser.get(section, key, raw=False, vars=subns)
+
+ return rv
+
+def path(argv=None):
+ if argv is None:
+ argv = []
+ path = None
+
+ for i, arg in enumerate(argv):
+ if arg == "--config":
+ if i + 1 < len(argv):
+ path = argv[i + 1]
+ elif arg.startswith("--config="):
+ path = arg.split("=", 1)[1]
+ if path is not None:
+ break
+
+ if path is None:
+ if os.path.exists("wptrunner.ini"):
+ path = os.path.abspath("wptrunner.ini")
+ else:
+ path = os.path.join(here, "..", "wptrunner.default.ini")
+
+ return os.path.abspath(path)
+
+def load():
+ return read(path(sys.argv))
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/environment.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/environment.py
new file mode 100644
index 0000000000..7edc68f998
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/environment.py
@@ -0,0 +1,331 @@
+# mypy: allow-untyped-defs
+
+import errno
+import json
+import os
+import signal
+import socket
+import sys
+import time
+
+from mozlog import get_default_logger, handlers
+
+from . import mpcontext
+from .wptlogging import LogLevelRewriter, QueueHandler, LogQueueThread
+
+here = os.path.dirname(__file__)
+repo_root = os.path.abspath(os.path.join(here, os.pardir, os.pardir, os.pardir))
+
+sys.path.insert(0, repo_root)
+from tools import localpaths # noqa: F401
+
+from wptserve.handlers import StringHandler
+
+serve = None
+
+
+def do_delayed_imports(logger, test_paths):
+ global serve
+
+ serve_root = serve_path(test_paths)
+ sys.path.insert(0, serve_root)
+
+ failed = []
+
+ try:
+ from tools.serve import serve
+ except ImportError:
+ failed.append("serve")
+
+ if failed:
+ logger.critical(
+ "Failed to import %s. Ensure that tests path %s contains web-platform-tests" %
+ (", ".join(failed), serve_root))
+ sys.exit(1)
+
+
+def serve_path(test_paths):
+ return test_paths["/"]["tests_path"]
+
+
+def webtranport_h3_server_is_running(host, port, timeout):
+ # TODO(bashi): Move the following import to the beginning of this file
+ # once WebTransportH3Server is enabled by default.
+ from webtransport.h3.webtransport_h3_server import server_is_running # type: ignore
+ return server_is_running(host, port, timeout)
+
+
+class TestEnvironmentError(Exception):
+ pass
+
+
+def get_server_logger():
+ logger = get_default_logger(component="wptserve")
+ log_filter = handlers.LogLevelFilter(lambda x: x, "info")
+ # Downgrade errors to warnings for the server
+ log_filter = LogLevelRewriter(log_filter, ["error"], "warning")
+ logger.component_filter = log_filter
+ return logger
+
+
+class ProxyLoggingContext:
+ """Context manager object that handles setup and teardown of a log queue
+ for handling logging messages from wptserve."""
+
+ def __init__(self, logger):
+ mp_context = mpcontext.get_context()
+ self.log_queue = mp_context.Queue()
+ self.logging_thread = LogQueueThread(self.log_queue, logger)
+ self.logger_handler = QueueHandler(self.log_queue)
+
+ def __enter__(self):
+ self.logging_thread.start()
+ return self.logger_handler
+
+ def __exit__(self, *args):
+ self.log_queue.put(None)
+ # Wait for thread to shut down but not for too long since it's a daemon
+ self.logging_thread.join(1)
+
+
+class TestEnvironment:
+ """Context manager that owns the test environment i.e. the http and
+ websockets servers"""
+ def __init__(self, test_paths, testharness_timeout_multipler,
+ pause_after_test, debug_test, debug_info, options, ssl_config, env_extras,
+ enable_webtransport=False, mojojs_path=None, inject_script=None):
+
+ self.test_paths = test_paths
+ self.server = None
+ self.config_ctx = None
+ self.config = None
+ self.server_logger = get_server_logger()
+ self.server_logging_ctx = ProxyLoggingContext(self.server_logger)
+ self.testharness_timeout_multipler = testharness_timeout_multipler
+ self.pause_after_test = pause_after_test
+ self.debug_test = debug_test
+ self.test_server_port = options.pop("test_server_port", True)
+ self.debug_info = debug_info
+ self.options = options if options is not None else {}
+
+ mp_context = mpcontext.get_context()
+ self.cache_manager = mp_context.Manager()
+ self.stash = serve.stash.StashServer(mp_context=mp_context)
+ self.env_extras = env_extras
+ self.env_extras_cms = None
+ self.ssl_config = ssl_config
+ self.enable_webtransport = enable_webtransport
+ self.mojojs_path = mojojs_path
+ self.inject_script = inject_script
+
+ def __enter__(self):
+ server_log_handler = self.server_logging_ctx.__enter__()
+ self.config_ctx = self.build_config()
+
+ self.config = self.config_ctx.__enter__()
+
+ self.stash.__enter__()
+ self.cache_manager.__enter__()
+
+ assert self.env_extras_cms is None, (
+ "A TestEnvironment object cannot be nested")
+
+ self.env_extras_cms = []
+
+ for env in self.env_extras:
+ cm = env(self.options, self.config)
+ cm.__enter__()
+ self.env_extras_cms.append(cm)
+
+ self.servers = serve.start(self.server_logger,
+ self.config,
+ self.get_routes(),
+ mp_context=mpcontext.get_context(),
+ log_handlers=[server_log_handler],
+ webtransport_h3=self.enable_webtransport)
+
+ if self.options.get("supports_debugger") and self.debug_info and self.debug_info.interactive:
+ self.ignore_interrupts()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.process_interrupts()
+
+ for servers in self.servers.values():
+ for _, server in servers:
+ server.request_shutdown()
+ for servers in self.servers.values():
+ for _, server in servers:
+ server.wait()
+ for cm in self.env_extras_cms:
+ cm.__exit__(exc_type, exc_val, exc_tb)
+
+ self.env_extras_cms = None
+
+ self.cache_manager.__exit__(exc_type, exc_val, exc_tb)
+ self.stash.__exit__()
+ self.config_ctx.__exit__(exc_type, exc_val, exc_tb)
+ self.server_logging_ctx.__exit__(exc_type, exc_val, exc_tb)
+
+ def ignore_interrupts(self):
+ signal.signal(signal.SIGINT, signal.SIG_IGN)
+
+ def process_interrupts(self):
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
+
+ def build_config(self):
+ override_path = os.path.join(serve_path(self.test_paths), "config.json")
+
+ config = serve.ConfigBuilder(self.server_logger)
+
+ ports = {
+ "http": [8000, 8001],
+ "http-private": [8002],
+ "http-public": [8003],
+ "https": [8443, 8444],
+ "https-private": [8445],
+ "https-public": [8446],
+ "ws": [8888],
+ "wss": [8889],
+ "h2": [9000],
+ "webtransport-h3": [11000],
+ }
+ config.ports = ports
+
+ if os.path.exists(override_path):
+ with open(override_path) as f:
+ override_obj = json.load(f)
+ config.update(override_obj)
+
+ config.check_subdomains = False
+
+ ssl_config = self.ssl_config.copy()
+ ssl_config["encrypt_after_connect"] = self.options.get("encrypt_after_connect", False)
+ config.ssl = ssl_config
+
+ if "browser_host" in self.options:
+ config.browser_host = self.options["browser_host"]
+
+ if "bind_address" in self.options:
+ config.bind_address = self.options["bind_address"]
+
+ config.server_host = self.options.get("server_host", None)
+ config.doc_root = serve_path(self.test_paths)
+ config.inject_script = self.inject_script
+
+ return config
+
+ def get_routes(self):
+ route_builder = serve.get_route_builder(
+ self.server_logger,
+ self.config.aliases,
+ self.config)
+
+ for path, format_args, content_type, route in [
+ ("testharness_runner.html", {}, "text/html", "/testharness_runner.html"),
+ ("print_reftest_runner.html", {}, "text/html", "/print_reftest_runner.html"),
+ (os.path.join(here, "..", "..", "third_party", "pdf_js", "pdf.js"), None,
+ "text/javascript", "/_pdf_js/pdf.js"),
+ (os.path.join(here, "..", "..", "third_party", "pdf_js", "pdf.worker.js"), None,
+ "text/javascript", "/_pdf_js/pdf.worker.js"),
+ (self.options.get("testharnessreport", "testharnessreport.js"),
+ {"output": self.pause_after_test,
+ "timeout_multiplier": self.testharness_timeout_multipler,
+ "explicit_timeout": "true" if self.debug_info is not None else "false",
+ "debug": "true" if self.debug_test else "false"},
+ "text/javascript;charset=utf8",
+ "/resources/testharnessreport.js")]:
+ path = os.path.normpath(os.path.join(here, path))
+ # Note that .headers. files don't apply to static routes, so we need to
+ # readd any static headers here.
+ headers = {"Cache-Control": "max-age=3600"}
+ route_builder.add_static(path, format_args, content_type, route,
+ headers=headers)
+
+ data = b""
+ with open(os.path.join(repo_root, "resources", "testdriver.js"), "rb") as fp:
+ data += fp.read()
+ with open(os.path.join(here, "testdriver-extra.js"), "rb") as fp:
+ data += fp.read()
+ route_builder.add_handler("GET", "/resources/testdriver.js",
+ StringHandler(data, "text/javascript"))
+
+ for url_base, paths in self.test_paths.items():
+ if url_base == "/":
+ continue
+ route_builder.add_mount_point(url_base, paths["tests_path"])
+
+ if "/" not in self.test_paths:
+ del route_builder.mountpoint_routes["/"]
+
+ if self.mojojs_path:
+ route_builder.add_mount_point("/gen/", self.mojojs_path)
+
+ return route_builder.get_routes()
+
+ def ensure_started(self):
+ # Pause for a while to ensure that the server has a chance to start
+ total_sleep_secs = 30
+ each_sleep_secs = 0.5
+ end_time = time.time() + total_sleep_secs
+ while time.time() < end_time:
+ failed, pending = self.test_servers()
+ if failed:
+ break
+ if not pending:
+ return
+ time.sleep(each_sleep_secs)
+ raise OSError("Servers failed to start: %s" %
+ ", ".join("%s:%s" % item for item in failed))
+
+ def test_servers(self):
+ failed = []
+ pending = []
+ host = self.config["server_host"]
+ for scheme, servers in self.servers.items():
+ for port, server in servers:
+ if not server.is_alive():
+ failed.append((scheme, port))
+
+ if not failed and self.test_server_port:
+ for scheme, servers in self.servers.items():
+ for port, server in servers:
+ if scheme == "webtransport-h3":
+ if not webtranport_h3_server_is_running(host, port, timeout=5.0):
+ pending.append((host, port))
+ continue
+ s = socket.socket()
+ s.settimeout(0.1)
+ try:
+ s.connect((host, port))
+ except OSError:
+ pending.append((host, port))
+ finally:
+ s.close()
+
+ return failed, pending
+
+
+def wait_for_service(logger, host, port, timeout=60):
+ """Waits until network service given as a tuple of (host, port) becomes
+ available or the `timeout` duration is reached, at which point
+ ``socket.error`` is raised."""
+ addr = (host, port)
+ logger.debug(f"Trying to connect to {host}:{port}")
+ end = time.time() + timeout
+ while end > time.time():
+ so = socket.socket()
+ try:
+ so.connect(addr)
+ except socket.timeout:
+ pass
+ except OSError as e:
+ if e.errno != errno.ECONNREFUSED:
+ raise
+ else:
+ logger.debug(f"Connected to {host}:{port}")
+ return True
+ finally:
+ so.close()
+ time.sleep(0.5)
+ raise OSError("Service is unavailable: %s:%i" % addr)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/__init__.py
new file mode 100644
index 0000000000..bf829d93e9
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/__init__.py
@@ -0,0 +1,5 @@
+# flake8: noqa (not ideal, but nicer than adding noqa: F401 to every line!)
+from .base import (executor_kwargs,
+ testharness_result_converter,
+ reftest_result_converter,
+ TestExecutor)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/actions.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/actions.py
new file mode 100644
index 0000000000..a4b689ba92
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/actions.py
@@ -0,0 +1,269 @@
+# mypy: allow-untyped-defs
+
+class ClickAction:
+ name = "click"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ selector = payload["selector"]
+ element = self.protocol.select.element_by_selector(selector)
+ self.logger.debug("Clicking element: %s" % selector)
+ self.protocol.click.element(element)
+
+
+class DeleteAllCookiesAction:
+ name = "delete_all_cookies"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ self.logger.debug("Deleting all cookies")
+ self.protocol.cookies.delete_all_cookies()
+
+
+class GetAllCookiesAction:
+ name = "get_all_cookies"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ self.logger.debug("Getting all cookies")
+ return self.protocol.cookies.get_all_cookies()
+
+
+class GetNamedCookieAction:
+ name = "get_named_cookie"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ name = payload["name"]
+ self.logger.debug("Getting cookie named %s" % name)
+ return self.protocol.cookies.get_named_cookie(name)
+
+
+class SendKeysAction:
+ name = "send_keys"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ selector = payload["selector"]
+ keys = payload["keys"]
+ element = self.protocol.select.element_by_selector(selector)
+ self.logger.debug("Sending keys to element: %s" % selector)
+ self.protocol.send_keys.send_keys(element, keys)
+
+
+class MinimizeWindowAction:
+ name = "minimize_window"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ return self.protocol.window.minimize()
+
+
+class SetWindowRectAction:
+ name = "set_window_rect"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ rect = payload["rect"]
+ self.protocol.window.set_rect(rect)
+
+
+class ActionSequenceAction:
+ name = "action_sequence"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+ self.requires_state_reset = False
+
+ def __call__(self, payload):
+ # TODO: some sort of shallow error checking
+ if self.requires_state_reset:
+ self.reset()
+ self.requires_state_reset = True
+ actions = payload["actions"]
+ for actionSequence in actions:
+ if actionSequence["type"] == "pointer":
+ for action in actionSequence["actions"]:
+ if (action["type"] == "pointerMove" and
+ isinstance(action["origin"], dict)):
+ action["origin"] = self.get_element(action["origin"]["selector"])
+ self.protocol.action_sequence.send_actions({"actions": actions})
+
+ def get_element(self, element_selector):
+ return self.protocol.select.element_by_selector(element_selector)
+
+ def reset(self):
+ self.protocol.action_sequence.release()
+ self.requires_state_reset = False
+
+
+class GenerateTestReportAction:
+ name = "generate_test_report"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ message = payload["message"]
+ self.logger.debug("Generating test report: %s" % message)
+ self.protocol.generate_test_report.generate_test_report(message)
+
+class SetPermissionAction:
+ name = "set_permission"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ permission_params = payload["permission_params"]
+ descriptor = permission_params["descriptor"]
+ name = descriptor["name"]
+ state = permission_params["state"]
+ self.logger.debug("Setting permission %s to %s" % (name, state))
+ self.protocol.set_permission.set_permission(descriptor, state)
+
+class AddVirtualAuthenticatorAction:
+ name = "add_virtual_authenticator"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ self.logger.debug("Adding virtual authenticator")
+ config = payload["config"]
+ authenticator_id = self.protocol.virtual_authenticator.add_virtual_authenticator(config)
+ self.logger.debug("Authenticator created with ID %s" % authenticator_id)
+ return authenticator_id
+
+class RemoveVirtualAuthenticatorAction:
+ name = "remove_virtual_authenticator"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ authenticator_id = payload["authenticator_id"]
+ self.logger.debug("Removing virtual authenticator %s" % authenticator_id)
+ return self.protocol.virtual_authenticator.remove_virtual_authenticator(authenticator_id)
+
+
+class AddCredentialAction:
+ name = "add_credential"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ authenticator_id = payload["authenticator_id"]
+ credential = payload["credential"]
+ self.logger.debug("Adding credential to virtual authenticator %s " % authenticator_id)
+ return self.protocol.virtual_authenticator.add_credential(authenticator_id, credential)
+
+class GetCredentialsAction:
+ name = "get_credentials"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ authenticator_id = payload["authenticator_id"]
+ self.logger.debug("Getting credentials from virtual authenticator %s " % authenticator_id)
+ return self.protocol.virtual_authenticator.get_credentials(authenticator_id)
+
+class RemoveCredentialAction:
+ name = "remove_credential"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ authenticator_id = payload["authenticator_id"]
+ credential_id = payload["credential_id"]
+ self.logger.debug("Removing credential %s from authenticator %s" % (credential_id, authenticator_id))
+ return self.protocol.virtual_authenticator.remove_credential(authenticator_id, credential_id)
+
+class RemoveAllCredentialsAction:
+ name = "remove_all_credentials"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ authenticator_id = payload["authenticator_id"]
+ self.logger.debug("Removing all credentials from authenticator %s" % authenticator_id)
+ return self.protocol.virtual_authenticator.remove_all_credentials(authenticator_id)
+
+class SetUserVerifiedAction:
+ name = "set_user_verified"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ authenticator_id = payload["authenticator_id"]
+ uv = payload["uv"]
+ self.logger.debug(
+ "Setting user verified flag on authenticator %s to %s" % (authenticator_id, uv["isUserVerified"]))
+ return self.protocol.virtual_authenticator.set_user_verified(authenticator_id, uv)
+
+class SetSPCTransactionModeAction:
+ name = "set_spc_transaction_mode"
+
+ def __init__(self, logger, protocol):
+ self.logger = logger
+ self.protocol = protocol
+
+ def __call__(self, payload):
+ mode = payload["mode"]
+ self.logger.debug("Setting SPC transaction mode to %s" % mode)
+ return self.protocol.spc_transactions.set_spc_transaction_mode(mode)
+
+actions = [ClickAction,
+ DeleteAllCookiesAction,
+ GetAllCookiesAction,
+ GetNamedCookieAction,
+ SendKeysAction,
+ MinimizeWindowAction,
+ SetWindowRectAction,
+ ActionSequenceAction,
+ GenerateTestReportAction,
+ SetPermissionAction,
+ AddVirtualAuthenticatorAction,
+ RemoveVirtualAuthenticatorAction,
+ AddCredentialAction,
+ GetCredentialsAction,
+ RemoveCredentialAction,
+ RemoveAllCredentialsAction,
+ SetUserVerifiedAction,
+ SetSPCTransactionModeAction]
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/base.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/base.py
new file mode 100644
index 0000000000..4bc193d038
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/base.py
@@ -0,0 +1,781 @@
+# mypy: allow-untyped-defs
+
+import base64
+import hashlib
+import io
+import json
+import os
+import threading
+import traceback
+import socket
+import sys
+from abc import ABCMeta, abstractmethod
+from typing import Any, Callable, ClassVar, Tuple, Type
+from urllib.parse import urljoin, urlsplit, urlunsplit
+
+from . import pytestrunner
+from .actions import actions
+from .protocol import Protocol, WdspecProtocol
+
+
+here = os.path.dirname(__file__)
+
+
+def executor_kwargs(test_type, test_environment, run_info_data, **kwargs):
+ timeout_multiplier = kwargs["timeout_multiplier"]
+ if timeout_multiplier is None:
+ timeout_multiplier = 1
+
+ executor_kwargs = {"server_config": test_environment.config,
+ "timeout_multiplier": timeout_multiplier,
+ "debug_info": kwargs["debug_info"]}
+
+ if test_type in ("reftest", "print-reftest"):
+ executor_kwargs["screenshot_cache"] = test_environment.cache_manager.dict()
+ executor_kwargs["reftest_screenshot"] = kwargs["reftest_screenshot"]
+
+ if test_type == "wdspec":
+ executor_kwargs["binary"] = kwargs.get("binary")
+ executor_kwargs["webdriver_binary"] = kwargs.get("webdriver_binary")
+ executor_kwargs["webdriver_args"] = kwargs.get("webdriver_args")
+
+ # By default the executor may try to cleanup windows after a test (to best
+ # associate any problems with the test causing them). If the user might
+ # want to view the results, however, the executor has to skip that cleanup.
+ if kwargs["pause_after_test"] or kwargs["pause_on_unexpected"]:
+ executor_kwargs["cleanup_after_test"] = False
+ executor_kwargs["debug_test"] = kwargs["debug_test"]
+ return executor_kwargs
+
+
+def strip_server(url):
+ """Remove the scheme and netloc from a url, leaving only the path and any query
+ or fragment.
+
+ url - the url to strip
+
+ e.g. http://example.org:8000/tests?id=1#2 becomes /tests?id=1#2"""
+
+ url_parts = list(urlsplit(url))
+ url_parts[0] = ""
+ url_parts[1] = ""
+ return urlunsplit(url_parts)
+
+
+class TestharnessResultConverter:
+ harness_codes = {0: "OK",
+ 1: "ERROR",
+ 2: "TIMEOUT",
+ 3: "PRECONDITION_FAILED"}
+
+ test_codes = {0: "PASS",
+ 1: "FAIL",
+ 2: "TIMEOUT",
+ 3: "NOTRUN",
+ 4: "PRECONDITION_FAILED"}
+
+ def __call__(self, test, result, extra=None):
+ """Convert a JSON result into a (TestResult, [SubtestResult]) tuple"""
+ result_url, status, message, stack, subtest_results = result
+ assert result_url == test.url, ("Got results from %s, expected %s" %
+ (result_url, test.url))
+ harness_result = test.result_cls(self.harness_codes[status], message, extra=extra, stack=stack)
+ return (harness_result,
+ [test.subtest_result_cls(st_name, self.test_codes[st_status], st_message, st_stack)
+ for st_name, st_status, st_message, st_stack in subtest_results])
+
+
+testharness_result_converter = TestharnessResultConverter()
+
+
+def hash_screenshots(screenshots):
+ """Computes the sha1 checksum of a list of base64-encoded screenshots."""
+ return [hashlib.sha1(base64.b64decode(screenshot)).hexdigest()
+ for screenshot in screenshots]
+
+
+def _ensure_hash_in_reftest_screenshots(extra):
+ """Make sure reftest_screenshots have hashes.
+
+ Marionette internal reftest runner does not produce hashes.
+ """
+ log_data = extra.get("reftest_screenshots")
+ if not log_data:
+ return
+ for item in log_data:
+ if type(item) != dict:
+ # Skip relation strings.
+ continue
+ if "hash" not in item:
+ item["hash"] = hash_screenshots([item["screenshot"]])[0]
+
+
+def get_pages(ranges_value, total_pages):
+ """Get a set of page numbers to include in a print reftest.
+
+ :param ranges_value: Parsed page ranges as a list e.g. [[1,2], [4], [6,None]]
+ :param total_pages: Integer total number of pages in the paginated output.
+ :retval: Set containing integer page numbers to include in the comparison e.g.
+ for the example ranges value and 10 total pages this would be
+ {1,2,4,6,7,8,9,10}"""
+ if not ranges_value:
+ return set(range(1, total_pages + 1))
+
+ rv = set()
+
+ for range_limits in ranges_value:
+ if len(range_limits) == 1:
+ range_limits = [range_limits[0], range_limits[0]]
+
+ if range_limits[0] is None:
+ range_limits[0] = 1
+ if range_limits[1] is None:
+ range_limits[1] = total_pages
+
+ if range_limits[0] > total_pages:
+ continue
+ rv |= set(range(range_limits[0], range_limits[1] + 1))
+ return rv
+
+
+def reftest_result_converter(self, test, result):
+ extra = result.get("extra", {})
+ _ensure_hash_in_reftest_screenshots(extra)
+ return (test.result_cls(
+ result["status"],
+ result["message"],
+ extra=extra,
+ stack=result.get("stack")), [])
+
+
+def pytest_result_converter(self, test, data):
+ harness_data, subtest_data = data
+
+ if subtest_data is None:
+ subtest_data = []
+
+ harness_result = test.result_cls(*harness_data)
+ subtest_results = [test.subtest_result_cls(*item) for item in subtest_data]
+
+ return (harness_result, subtest_results)
+
+
+def crashtest_result_converter(self, test, result):
+ return test.result_cls(**result), []
+
+
+class ExecutorException(Exception):
+ def __init__(self, status, message):
+ self.status = status
+ self.message = message
+
+
+class TimedRunner:
+ def __init__(self, logger, func, protocol, url, timeout, extra_timeout):
+ self.func = func
+ self.logger = logger
+ self.result = None
+ self.protocol = protocol
+ self.url = url
+ self.timeout = timeout
+ self.extra_timeout = extra_timeout
+ self.result_flag = threading.Event()
+
+ def run(self):
+ for setup_fn in [self.set_timeout, self.before_run]:
+ err = setup_fn()
+ if err:
+ self.result = (False, err)
+ return self.result
+
+ executor = threading.Thread(target=self.run_func)
+ executor.start()
+
+ # Add twice the extra timeout since the called function is expected to
+ # wait at least self.timeout + self.extra_timeout and this gives some leeway
+ timeout = self.timeout + 2 * self.extra_timeout if self.timeout else None
+ finished = self.result_flag.wait(timeout)
+ if self.result is None:
+ if finished:
+ # flag is True unless we timeout; this *shouldn't* happen, but
+ # it can if self.run_func fails to set self.result due to raising
+ self.result = False, ("INTERNAL-ERROR", "%s.run_func didn't set a result" %
+ self.__class__.__name__)
+ else:
+ if self.protocol.is_alive():
+ message = "Executor hit external timeout (this may indicate a hang)\n"
+ # get a traceback for the current stack of the executor thread
+ message += "".join(traceback.format_stack(sys._current_frames()[executor.ident]))
+ self.result = False, ("EXTERNAL-TIMEOUT", message)
+ else:
+ self.logger.info("Browser not responding, setting status to CRASH")
+ self.result = False, ("CRASH", None)
+ elif self.result[1] is None:
+ # We didn't get any data back from the test, so check if the
+ # browser is still responsive
+ if self.protocol.is_alive():
+ self.result = False, ("INTERNAL-ERROR", None)
+ else:
+ self.logger.info("Browser not responding, setting status to CRASH")
+ self.result = False, ("CRASH", None)
+
+ return self.result
+
+ def set_timeout(self):
+ raise NotImplementedError
+
+ def before_run(self):
+ pass
+
+ def run_func(self):
+ raise NotImplementedError
+
+
+class TestExecutor:
+ """Abstract Base class for object that actually executes the tests in a
+ specific browser. Typically there will be a different TestExecutor
+ subclass for each test type and method of executing tests.
+
+ :param browser: ExecutorBrowser instance providing properties of the
+ browser that will be tested.
+ :param server_config: Dictionary of wptserve server configuration of the
+ form stored in TestEnvironment.config
+ :param timeout_multiplier: Multiplier relative to base timeout to use
+ when setting test timeout.
+ """
+ __metaclass__ = ABCMeta
+
+ test_type = None # type: ClassVar[str]
+ # convert_result is a class variable set to a callable converter
+ # (e.g. reftest_result_converter) converting from an instance of
+ # URLManifestItem (e.g. RefTest) + type-dependent results object +
+ # type-dependent extra data, returning a tuple of Result and list of
+ # SubtestResult. For now, any callable is accepted. TODO: Make this type
+ # stricter when more of the surrounding code is annotated.
+ convert_result = None # type: ClassVar[Callable[..., Any]]
+ supports_testdriver = False
+ supports_jsshell = False
+ # Extra timeout to use after internal test timeout at which the harness
+ # should force a timeout
+ extra_timeout = 5 # seconds
+
+
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ debug_info=None, **kwargs):
+ self.logger = logger
+ self.runner = None
+ self.browser = browser
+ self.server_config = server_config
+ self.timeout_multiplier = timeout_multiplier
+ self.debug_info = debug_info
+ self.last_environment = {"protocol": "http",
+ "prefs": {}}
+ self.protocol = None # This must be set in subclasses
+
+ def setup(self, runner):
+ """Run steps needed before tests can be started e.g. connecting to
+ browser instance
+
+ :param runner: TestRunner instance that is going to run the tests"""
+ self.runner = runner
+ if self.protocol is not None:
+ self.protocol.setup(runner)
+
+ def teardown(self):
+ """Run cleanup steps after tests have finished"""
+ if self.protocol is not None:
+ self.protocol.teardown()
+
+ def reset(self):
+ """Re-initialize internal state to facilitate repeated test execution
+ as implemented by the `--rerun` command-line argument."""
+ pass
+
+ def run_test(self, test):
+ """Run a particular test.
+
+ :param test: The test to run"""
+ try:
+ if test.environment != self.last_environment:
+ self.on_environment_change(test.environment)
+ result = self.do_test(test)
+ except Exception as e:
+ exception_string = traceback.format_exc()
+ self.logger.warning(exception_string)
+ result = self.result_from_exception(test, e, exception_string)
+
+ # log result of parent test
+ if result[0].status == "ERROR":
+ self.logger.debug(result[0].message)
+
+ self.last_environment = test.environment
+
+ self.runner.send_message("test_ended", test, result)
+
+ def server_url(self, protocol, subdomain=False):
+ scheme = "https" if protocol == "h2" else protocol
+ host = self.server_config["browser_host"]
+ if subdomain:
+ # The only supported subdomain filename flag is "www".
+ host = "{subdomain}.{host}".format(subdomain="www", host=host)
+ return "{scheme}://{host}:{port}".format(scheme=scheme, host=host,
+ port=self.server_config["ports"][protocol][0])
+
+ def test_url(self, test):
+ return urljoin(self.server_url(test.environment["protocol"],
+ test.subdomain), test.url)
+
+ @abstractmethod
+ def do_test(self, test):
+ """Test-type and protocol specific implementation of running a
+ specific test.
+
+ :param test: The test to run."""
+ pass
+
+ def on_environment_change(self, new_environment):
+ pass
+
+ def result_from_exception(self, test, e, exception_string):
+ if hasattr(e, "status") and e.status in test.result_cls.statuses:
+ status = e.status
+ else:
+ status = "INTERNAL-ERROR"
+ message = str(getattr(e, "message", ""))
+ if message:
+ message += "\n"
+ message += exception_string
+ return test.result_cls(status, message), []
+
+ def wait(self):
+ return self.protocol.base.wait()
+
+
+class TestharnessExecutor(TestExecutor):
+ convert_result = testharness_result_converter
+
+
+class RefTestExecutor(TestExecutor):
+ convert_result = reftest_result_converter
+ is_print = False
+
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1, screenshot_cache=None,
+ debug_info=None, reftest_screenshot="unexpected", **kwargs):
+ TestExecutor.__init__(self, logger, browser, server_config,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+
+ self.screenshot_cache = screenshot_cache
+ self.reftest_screenshot = reftest_screenshot
+
+
+class CrashtestExecutor(TestExecutor):
+ convert_result = crashtest_result_converter
+
+
+class PrintRefTestExecutor(TestExecutor):
+ convert_result = reftest_result_converter
+ is_print = True
+
+
+class RefTestImplementation:
+ def __init__(self, executor):
+ self.timeout_multiplier = executor.timeout_multiplier
+ self.executor = executor
+ # Cache of url:(screenshot hash, screenshot). Typically the
+ # screenshot is None, but we set this value if a test fails
+ # and the screenshot was taken from the cache so that we may
+ # retrieve the screenshot from the cache directly in the future
+ self.screenshot_cache = self.executor.screenshot_cache
+ self.message = None
+ self.reftest_screenshot = executor.reftest_screenshot
+
+ def setup(self):
+ pass
+
+ def teardown(self):
+ pass
+
+ @property
+ def logger(self):
+ return self.executor.logger
+
+ def get_hash(self, test, viewport_size, dpi, page_ranges):
+ key = (test.url, viewport_size, dpi)
+
+ if key not in self.screenshot_cache:
+ success, data = self.get_screenshot_list(test, viewport_size, dpi, page_ranges)
+
+ if not success:
+ return False, data
+
+ screenshots = data
+ hash_values = hash_screenshots(data)
+ self.screenshot_cache[key] = (hash_values, screenshots)
+
+ rv = (hash_values, screenshots)
+ else:
+ rv = self.screenshot_cache[key]
+
+ self.message.append(f"{test.url} {rv[0]}")
+ return True, rv
+
+ def reset(self):
+ self.screenshot_cache.clear()
+
+ def check_pass(self, hashes, screenshots, urls, relation, fuzzy):
+ """Check if a test passes, and return a tuple of (pass, page_idx),
+ where page_idx is the zero-based index of the first page on which a
+ difference occurs if any, or None if there are no differences"""
+
+ assert relation in ("==", "!=")
+ lhs_hashes, rhs_hashes = hashes
+ lhs_screenshots, rhs_screenshots = screenshots
+
+ if len(lhs_hashes) != len(rhs_hashes):
+ self.logger.info("Got different number of pages")
+ return relation == "!=", -1
+
+ assert len(lhs_screenshots) == len(lhs_hashes) == len(rhs_screenshots) == len(rhs_hashes)
+
+ for (page_idx, (lhs_hash,
+ rhs_hash,
+ lhs_screenshot,
+ rhs_screenshot)) in enumerate(zip(lhs_hashes,
+ rhs_hashes,
+ lhs_screenshots,
+ rhs_screenshots)):
+ comparison_screenshots = (lhs_screenshot, rhs_screenshot)
+ if not fuzzy or fuzzy == ((0, 0), (0, 0)):
+ equal = lhs_hash == rhs_hash
+ # sometimes images can have different hashes, but pixels can be identical.
+ if not equal:
+ self.logger.info("Image hashes didn't match%s, checking pixel differences" %
+ ("" if len(hashes) == 1 else " on page %i" % (page_idx + 1)))
+ max_per_channel, pixels_different = self.get_differences(comparison_screenshots,
+ urls)
+ equal = pixels_different == 0 and max_per_channel == 0
+ else:
+ max_per_channel, pixels_different = self.get_differences(comparison_screenshots,
+ urls,
+ page_idx if len(hashes) > 1 else None)
+ allowed_per_channel, allowed_different = fuzzy
+ self.logger.info("Allowed %s pixels different, maximum difference per channel %s" %
+ ("-".join(str(item) for item in allowed_different),
+ "-".join(str(item) for item in allowed_per_channel)))
+ equal = ((pixels_different == 0 and allowed_different[0] == 0) or
+ (max_per_channel == 0 and allowed_per_channel[0] == 0) or
+ (allowed_per_channel[0] <= max_per_channel <= allowed_per_channel[1] and
+ allowed_different[0] <= pixels_different <= allowed_different[1]))
+ if not equal:
+ return (False if relation == "==" else True, page_idx)
+ # All screenshots were equal within the fuzziness
+ return (True if relation == "==" else False, -1)
+
+ def get_differences(self, screenshots, urls, page_idx=None):
+ from PIL import Image, ImageChops, ImageStat
+
+ lhs = Image.open(io.BytesIO(base64.b64decode(screenshots[0]))).convert("RGB")
+ rhs = Image.open(io.BytesIO(base64.b64decode(screenshots[1]))).convert("RGB")
+ self.check_if_solid_color(lhs, urls[0])
+ self.check_if_solid_color(rhs, urls[1])
+ diff = ImageChops.difference(lhs, rhs)
+ minimal_diff = diff.crop(diff.getbbox())
+ mask = minimal_diff.convert("L", dither=None)
+ stat = ImageStat.Stat(minimal_diff, mask)
+ per_channel = max(item[1] for item in stat.extrema)
+ count = stat.count[0]
+ self.logger.info("Found %s pixels different, maximum difference per channel %s%s" %
+ (count,
+ per_channel,
+ "" if page_idx is None else " on page %i" % (page_idx + 1)))
+ return per_channel, count
+
+ def check_if_solid_color(self, image, url):
+ extrema = image.getextrema()
+ if all(min == max for min, max in extrema):
+ color = ''.join('%02X' % value for value, _ in extrema)
+ self.message.append(f"Screenshot is solid color 0x{color} for {url}\n")
+
+ def run_test(self, test):
+ viewport_size = test.viewport_size
+ dpi = test.dpi
+ page_ranges = test.page_ranges
+ self.message = []
+
+
+ # Depth-first search of reference tree, with the goal
+ # of reachings a leaf node with only pass results
+
+ stack = list(((test, item[0]), item[1]) for item in reversed(test.references))
+
+ while stack:
+ hashes = [None, None]
+ screenshots = [None, None]
+ urls = [None, None]
+
+ nodes, relation = stack.pop()
+ fuzzy = self.get_fuzzy(test, nodes, relation)
+
+ for i, node in enumerate(nodes):
+ success, data = self.get_hash(node, viewport_size, dpi, page_ranges)
+ if success is False:
+ return {"status": data[0], "message": data[1]}
+
+ hashes[i], screenshots[i] = data
+ urls[i] = node.url
+
+ is_pass, page_idx = self.check_pass(hashes, screenshots, urls, relation, fuzzy)
+ log_data = [
+ {"url": urls[0], "screenshot": screenshots[0][page_idx],
+ "hash": hashes[0][page_idx]},
+ relation,
+ {"url": urls[1], "screenshot": screenshots[1][page_idx],
+ "hash": hashes[1][page_idx]}
+ ]
+
+ if is_pass:
+ fuzzy = self.get_fuzzy(test, nodes, relation)
+ if nodes[1].references:
+ stack.extend(list(((nodes[1], item[0]), item[1])
+ for item in reversed(nodes[1].references)))
+ else:
+ test_result = {"status": "PASS", "message": None}
+ if (self.reftest_screenshot == "always" or
+ self.reftest_screenshot == "unexpected" and
+ test.expected() != "PASS"):
+ test_result["extra"] = {"reftest_screenshots": log_data}
+ # We passed
+ return test_result
+
+ # We failed, so construct a failure message
+
+ for i, (node, screenshot) in enumerate(zip(nodes, screenshots)):
+ if screenshot is None:
+ success, screenshot = self.retake_screenshot(node, viewport_size, dpi, page_ranges)
+ if success:
+ screenshots[i] = screenshot
+
+ test_result = {"status": "FAIL",
+ "message": "\n".join(self.message)}
+ if (self.reftest_screenshot in ("always", "fail") or
+ self.reftest_screenshot == "unexpected" and
+ test.expected() != "FAIL"):
+ test_result["extra"] = {"reftest_screenshots": log_data}
+ return test_result
+
+ def get_fuzzy(self, root_test, test_nodes, relation):
+ full_key = tuple([item.url for item in test_nodes] + [relation])
+ ref_only_key = test_nodes[1].url
+
+ fuzzy_override = root_test.fuzzy_override
+ fuzzy = test_nodes[0].fuzzy
+
+ sources = [fuzzy_override, fuzzy]
+ keys = [full_key, ref_only_key, None]
+ value = None
+ for source in sources:
+ for key in keys:
+ if key in source:
+ value = source[key]
+ break
+ if value:
+ break
+ return value
+
+ def retake_screenshot(self, node, viewport_size, dpi, page_ranges):
+ success, data = self.get_screenshot_list(node,
+ viewport_size,
+ dpi,
+ page_ranges)
+ if not success:
+ return False, data
+
+ key = (node.url, viewport_size, dpi)
+ hash_val, _ = self.screenshot_cache[key]
+ self.screenshot_cache[key] = hash_val, data
+ return True, data
+
+ def get_screenshot_list(self, node, viewport_size, dpi, page_ranges):
+ success, data = self.executor.screenshot(node, viewport_size, dpi, page_ranges)
+ if success and not isinstance(data, list):
+ return success, [data]
+ return success, data
+
+
+class WdspecExecutor(TestExecutor):
+ convert_result = pytest_result_converter
+ protocol_cls = WdspecProtocol # type: ClassVar[Type[Protocol]]
+
+ def __init__(self, logger, browser, server_config, webdriver_binary,
+ webdriver_args, timeout_multiplier=1, capabilities=None,
+ debug_info=None, **kwargs):
+ super().__init__(logger, browser, server_config,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+ self.webdriver_binary = webdriver_binary
+ self.webdriver_args = webdriver_args
+ self.timeout_multiplier = timeout_multiplier
+ self.capabilities = capabilities
+
+ def setup(self, runner):
+ self.protocol = self.protocol_cls(self, self.browser)
+ super().setup(runner)
+
+ def is_alive(self):
+ return self.protocol.is_alive()
+
+ def on_environment_change(self, new_environment):
+ pass
+
+ def do_test(self, test):
+ timeout = test.timeout * self.timeout_multiplier + self.extra_timeout
+
+ success, data = WdspecRun(self.do_wdspec,
+ test.abs_path,
+ timeout).run()
+
+ if success:
+ return self.convert_result(test, data)
+
+ return (test.result_cls(*data), [])
+
+ def do_wdspec(self, path, timeout):
+ session_config = {"host": self.browser.host,
+ "port": self.browser.port,
+ "capabilities": self.capabilities,
+ "webdriver": {
+ "binary": self.webdriver_binary,
+ "args": self.webdriver_args
+ }}
+
+ return pytestrunner.run(path,
+ self.server_config,
+ session_config,
+ timeout=timeout)
+
+
+class WdspecRun:
+ def __init__(self, func, path, timeout):
+ self.func = func
+ self.result = (None, None)
+ self.path = path
+ self.timeout = timeout
+ self.result_flag = threading.Event()
+
+ def run(self):
+ """Runs function in a thread and interrupts it if it exceeds the
+ given timeout. Returns (True, (Result, [SubtestResult ...])) in
+ case of success, or (False, (status, extra information)) in the
+ event of failure.
+ """
+
+ executor = threading.Thread(target=self._run)
+ executor.start()
+
+ self.result_flag.wait(self.timeout)
+ if self.result[1] is None:
+ self.result = False, ("EXTERNAL-TIMEOUT", None)
+
+ return self.result
+
+ def _run(self):
+ try:
+ self.result = True, self.func(self.path, self.timeout)
+ except (socket.timeout, OSError):
+ self.result = False, ("CRASH", None)
+ except Exception as e:
+ message = getattr(e, "message")
+ if message:
+ message += "\n"
+ message += traceback.format_exc()
+ self.result = False, ("INTERNAL-ERROR", message)
+ finally:
+ self.result_flag.set()
+
+
+class CallbackHandler:
+ """Handle callbacks from testdriver-using tests.
+
+ The default implementation here makes sense for things that are roughly like
+ WebDriver. Things that are more different to WebDriver may need to create a
+ fully custom implementation."""
+
+ unimplemented_exc = (NotImplementedError,) # type: ClassVar[Tuple[Type[Exception], ...]]
+
+ def __init__(self, logger, protocol, test_window):
+ self.protocol = protocol
+ self.test_window = test_window
+ self.logger = logger
+ self.callbacks = {
+ "action": self.process_action,
+ "complete": self.process_complete
+ }
+
+ self.actions = {cls.name: cls(self.logger, self.protocol) for cls in actions}
+
+ def __call__(self, result):
+ url, command, payload = result
+ self.logger.debug("Got async callback: %s" % result[1])
+ try:
+ callback = self.callbacks[command]
+ except KeyError:
+ raise ValueError("Unknown callback type %r" % result[1])
+ return callback(url, payload)
+
+ def process_complete(self, url, payload):
+ rv = [strip_server(url)] + payload
+ return True, rv
+
+ def process_action(self, url, payload):
+ action = payload["action"]
+ cmd_id = payload["id"]
+ self.logger.debug("Got action: %s" % action)
+ try:
+ action_handler = self.actions[action]
+ except KeyError:
+ raise ValueError("Unknown action %s" % action)
+ try:
+ with ActionContext(self.logger, self.protocol, payload.get("context")):
+ result = action_handler(payload)
+ except self.unimplemented_exc:
+ self.logger.warning("Action %s not implemented" % action)
+ self._send_message(cmd_id, "complete", "error", "Action %s not implemented" % action)
+ except Exception:
+ self.logger.warning("Action %s failed" % action)
+ self.logger.warning(traceback.format_exc())
+ self._send_message(cmd_id, "complete", "error")
+ raise
+ else:
+ self.logger.debug(f"Action {action} completed with result {result}")
+ return_message = {"result": result}
+ self._send_message(cmd_id, "complete", "success", json.dumps(return_message))
+
+ return False, None
+
+ def _send_message(self, cmd_id, message_type, status, message=None):
+ self.protocol.testdriver.send_message(cmd_id, message_type, status, message=message)
+
+
+class ActionContext:
+ def __init__(self, logger, protocol, context):
+ self.logger = logger
+ self.protocol = protocol
+ self.context = context
+ self.initial_window = None
+
+ def __enter__(self):
+ if self.context is None:
+ return
+
+ self.initial_window = self.protocol.base.current_window
+ self.logger.debug("Switching to window %s" % self.context)
+ self.protocol.testdriver.switch_to_window(self.context, self.initial_window)
+
+ def __exit__(self, *args):
+ if self.context is None:
+ return
+
+ self.logger.debug("Switching back to initial window")
+ self.protocol.base.set_window(self.initial_window)
+ self.initial_window = None
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorchrome.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorchrome.py
new file mode 100644
index 0000000000..e5f5615385
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorchrome.py
@@ -0,0 +1,114 @@
+# mypy: allow-untyped-defs
+
+import os
+import traceback
+
+from urllib.parse import urljoin
+
+from .base import get_pages
+from .executorwebdriver import WebDriverProtocol, WebDriverRefTestExecutor, WebDriverRun
+from .protocol import PrintProtocolPart
+
+here = os.path.dirname(__file__)
+
+
+class ChromeDriverPrintProtocolPart(PrintProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+ self.runner_handle = None
+
+ def load_runner(self):
+ url = urljoin(self.parent.executor.server_url("http"), "/print_reftest_runner.html")
+ self.logger.debug("Loading %s" % url)
+ try:
+ self.webdriver.url = url
+ except Exception as e:
+ self.logger.critical(
+ "Loading initial page %s failed. Ensure that the "
+ "there are no other programs bound to this port and "
+ "that your firewall rules or network setup does not "
+ "prevent access.\n%s" % (url, traceback.format_exc(e)))
+ raise
+ self.runner_handle = self.webdriver.window_handle
+
+ def render_as_pdf(self, width, height):
+ margin = 0.5
+ body = {
+ "cmd": "Page.printToPDF",
+ "params": {
+ # Chrome accepts dimensions in inches; we are using cm
+ "paperWidth": width / 2.54,
+ "paperHeight": height / 2.54,
+ "marginLeft": margin,
+ "marginRight": margin,
+ "marginTop": margin,
+ "marginBottom": margin,
+ "shrinkToFit": False,
+ "printBackground": True,
+ }
+ }
+ return self.webdriver.send_session_command("POST", "goog/cdp/execute", body=body)["data"]
+
+ def pdf_to_png(self, pdf_base64, ranges):
+ handle = self.webdriver.window_handle
+ self.webdriver.window_handle = self.runner_handle
+ try:
+ rv = self.webdriver.execute_async_script("""
+let callback = arguments[arguments.length - 1];
+render('%s').then(result => callback(result))""" % pdf_base64)
+ page_numbers = get_pages(ranges, len(rv))
+ rv = [item for i, item in enumerate(rv) if i + 1 in page_numbers]
+ return rv
+ finally:
+ self.webdriver.window_handle = handle
+
+
+class ChromeDriverProtocol(WebDriverProtocol):
+ implements = WebDriverProtocol.implements + [ChromeDriverPrintProtocolPart]
+
+
+class ChromeDriverPrintRefTestExecutor(WebDriverRefTestExecutor):
+ protocol_cls = ChromeDriverProtocol
+
+ def setup(self, runner):
+ super().setup(runner)
+ self.protocol.pdf_print.load_runner()
+ self.has_window = False
+ with open(os.path.join(here, "reftest.js")) as f:
+ self.script = f.read()
+
+ def screenshot(self, test, viewport_size, dpi, page_ranges):
+ # https://github.com/web-platform-tests/wpt/issues/7140
+ assert dpi is None
+
+ if not self.has_window:
+ self.protocol.base.execute_script(self.script)
+ self.protocol.base.set_window(self.protocol.webdriver.handles[-1])
+ self.has_window = True
+
+ self.viewport_size = viewport_size
+ self.page_ranges = page_ranges.get(test.url)
+ timeout = self.timeout_multiplier * test.timeout if self.debug_info is None else None
+
+ test_url = self.test_url(test)
+
+ return WebDriverRun(self.logger,
+ self._render,
+ self.protocol,
+ test_url,
+ timeout,
+ self.extra_timeout).run()
+
+ def _render(self, protocol, url, timeout):
+ protocol.webdriver.url = url
+
+ protocol.base.execute_script(self.wait_script, asynchronous=True)
+
+ pdf = protocol.pdf_print.render_as_pdf(*self.viewport_size)
+ screenshots = protocol.pdf_print.pdf_to_png(pdf, self.page_ranges)
+ for i, screenshot in enumerate(screenshots):
+ # strip off the data:img/png, part of the url
+ if screenshot.startswith("data:image/png;base64,"):
+ screenshots[i] = screenshot.split(",", 1)[1]
+
+ return screenshots
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorcontentshell.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorcontentshell.py
new file mode 100644
index 0000000000..474bb7168e
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorcontentshell.py
@@ -0,0 +1,269 @@
+# mypy: allow-untyped-defs
+
+from .base import RefTestExecutor, RefTestImplementation, CrashtestExecutor, TestharnessExecutor
+from .protocol import Protocol, ProtocolPart
+from time import time
+from queue import Empty
+from base64 import b64encode
+import json
+
+
+class CrashError(BaseException):
+ pass
+
+
+def _read_line(io_queue, deadline=None, encoding=None, errors="strict", raise_crash=True):
+ """Reads a single line from the io queue. The read must succeed before `deadline` or
+ a TimeoutError is raised. The line is returned as a bytestring or optionally with the
+ specified `encoding`. If `raise_crash` is set, a CrashError is raised if the line
+ happens to be a crash message.
+ """
+ current_time = time()
+
+ if deadline and current_time > deadline:
+ raise TimeoutError()
+
+ try:
+ line = io_queue.get(True, deadline - current_time if deadline else None)
+ if raise_crash and line.startswith(b"#CRASHED"):
+ raise CrashError()
+ except Empty:
+ raise TimeoutError()
+
+ return line.decode(encoding, errors) if encoding else line
+
+
+class ContentShellTestPart(ProtocolPart):
+ """This protocol part is responsible for running tests via content_shell's protocol mode.
+
+ For more details, see:
+ https://chromium.googlesource.com/chromium/src.git/+/HEAD/content/web_test/browser/test_info_extractor.h
+ """
+ name = "content_shell_test"
+ eof_marker = '#EOF\n' # Marker sent by content_shell after blocks.
+
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.stdout_queue = parent.browser.stdout_queue
+ self.stdin_queue = parent.browser.stdin_queue
+
+ def do_test(self, command, timeout=None):
+ """Send a command to content_shell and return the resulting outputs.
+
+ A command consists of a URL to navigate to, followed by an optional
+ expected image hash and 'print' mode specifier. The syntax looks like:
+ http://web-platform.test:8000/test.html['<hash>['print]]
+ """
+ self._send_command(command)
+
+ deadline = time() + timeout if timeout else None
+ # The first block can also contain audio data but not in WPT.
+ text = self._read_block(deadline)
+ image = self._read_block(deadline)
+
+ return text, image
+
+ def _send_command(self, command):
+ """Sends a single `command`, i.e. a URL to open, to content_shell.
+ """
+ self.stdin_queue.put((command + "\n").encode("utf-8"))
+
+ def _read_block(self, deadline=None):
+ """Tries to read a single block of content from stdout before the `deadline`.
+ """
+ while True:
+ line = _read_line(self.stdout_queue, deadline, "latin-1").rstrip()
+
+ if line == "Content-Type: text/plain":
+ return self._read_text_block(deadline)
+
+ if line == "Content-Type: image/png":
+ return self._read_image_block(deadline)
+
+ if line == "#EOF":
+ return None
+
+ def _read_text_block(self, deadline=None):
+ """Tries to read a plain text block in utf-8 encoding before the `deadline`.
+ """
+ result = ""
+
+ while True:
+ line = _read_line(self.stdout_queue, deadline, "utf-8", "replace", False)
+
+ if line.endswith(self.eof_marker):
+ result += line[:-len(self.eof_marker)]
+ break
+ elif line.endswith('#EOF\r\n'):
+ result += line[:-len('#EOF\r\n')]
+ self.logger.warning('Got a CRLF-terminated #EOF - this is a driver bug.')
+ break
+
+ result += line
+
+ return result
+
+ def _read_image_block(self, deadline=None):
+ """Tries to read an image block (as a binary png) before the `deadline`.
+ """
+ content_length_line = _read_line(self.stdout_queue, deadline, "utf-8")
+ assert content_length_line.startswith("Content-Length:")
+ content_length = int(content_length_line[15:])
+
+ result = bytearray()
+
+ while True:
+ line = _read_line(self.stdout_queue, deadline, raise_crash=False)
+ excess = len(line) + len(result) - content_length
+
+ if excess > 0:
+ # This is the line that contains the EOF marker.
+ assert excess == len(self.eof_marker)
+ result += line[:-excess]
+ break
+
+ result += line
+
+ return result
+
+
+class ContentShellErrorsPart(ProtocolPart):
+ """This protocol part is responsible for collecting the errors reported by content_shell.
+ """
+ name = "content_shell_errors"
+
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.stderr_queue = parent.browser.stderr_queue
+
+ def read_errors(self):
+ """Reads the entire content of the stderr queue as is available right now (no blocking).
+ """
+ result = ""
+
+ while not self.stderr_queue.empty():
+ # There is no potential for race conditions here because this is the only place
+ # where we read from the stderr queue.
+ result += _read_line(self.stderr_queue, None, "utf-8", "replace", False)
+
+ return result
+
+
+class ContentShellProtocol(Protocol):
+ implements = [ContentShellTestPart, ContentShellErrorsPart]
+ init_timeout = 10 # Timeout (seconds) to wait for #READY message.
+
+ def connect(self):
+ """Waits for content_shell to emit its "#READY" message which signals that it is fully
+ initialized. We wait for a maximum of self.init_timeout seconds.
+ """
+ deadline = time() + self.init_timeout
+
+ while True:
+ if _read_line(self.browser.stdout_queue, deadline).rstrip() == b"#READY":
+ break
+
+ def after_connect(self):
+ pass
+
+ def teardown(self):
+ # Close the queue properly to avoid broken pipe spam in the log.
+ self.browser.stdin_queue.close()
+ self.browser.stdin_queue.join_thread()
+
+ def is_alive(self):
+ """Checks if content_shell is alive by determining if the IO pipes are still
+ open. This does not guarantee that the process is responsive.
+ """
+ return self.browser.io_stopped.is_set()
+
+
+def _convert_exception(test, exception, errors):
+ """Converts our TimeoutError and CrashError exceptions into test results.
+ """
+ if isinstance(exception, TimeoutError):
+ return (test.result_cls("EXTERNAL-TIMEOUT", errors), [])
+ if isinstance(exception, CrashError):
+ return (test.result_cls("CRASH", errors), [])
+ raise exception
+
+
+class ContentShellRefTestExecutor(RefTestExecutor):
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1, screenshot_cache=None,
+ debug_info=None, reftest_screenshot="unexpected", **kwargs):
+ super().__init__(logger, browser, server_config, timeout_multiplier, screenshot_cache,
+ debug_info, reftest_screenshot, **kwargs)
+ self.implementation = RefTestImplementation(self)
+ self.protocol = ContentShellProtocol(self, browser)
+
+ def reset(self):
+ self.implementation.reset()
+
+ def do_test(self, test):
+ try:
+ result = self.implementation.run_test(test)
+ self.protocol.content_shell_errors.read_errors()
+ return self.convert_result(test, result)
+ except BaseException as exception:
+ return _convert_exception(test, exception, self.protocol.content_shell_errors.read_errors())
+
+ def screenshot(self, test, viewport_size, dpi, page_ranges):
+ # Currently, the page size and DPI are hardcoded for print-reftests:
+ # https://chromium.googlesource.com/chromium/src/+/4e1b7bc33d42b401d7d9ad1dcba72883add3e2af/content/web_test/renderer/test_runner.cc#100
+ # Content shell has an internal `window.testRunner.setPrintingSize(...)`
+ # API, but it's not callable with protocol mode.
+ assert dpi is None
+ command = self.test_url(test)
+ if self.is_print:
+ # Currently, `content_shell` uses the expected image hash to avoid
+ # dumping a matching image as an optimization. In Chromium, the
+ # hash can be computed from an expected screenshot checked into the
+ # source tree (i.e., without looking at a reference). This is not
+ # possible in `wpt`, so pass an empty hash here to force a dump.
+ command += "''print"
+ _, image = self.protocol.content_shell_test.do_test(
+ command, test.timeout * self.timeout_multiplier)
+
+ if not image:
+ return False, ("ERROR", self.protocol.content_shell_errors.read_errors())
+
+ return True, b64encode(image).decode()
+
+
+class ContentShellPrintRefTestExecutor(ContentShellRefTestExecutor):
+ is_print = True
+
+
+class ContentShellCrashtestExecutor(CrashtestExecutor):
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1, debug_info=None,
+ **kwargs):
+ super().__init__(logger, browser, server_config, timeout_multiplier, debug_info, **kwargs)
+ self.protocol = ContentShellProtocol(self, browser)
+
+ def do_test(self, test):
+ try:
+ _ = self.protocol.content_shell_test.do_test(self.test_url(test), test.timeout * self.timeout_multiplier)
+ self.protocol.content_shell_errors.read_errors()
+ return self.convert_result(test, {"status": "PASS", "message": None})
+ except BaseException as exception:
+ return _convert_exception(test, exception, self.protocol.content_shell_errors.read_errors())
+
+
+class ContentShellTestharnessExecutor(TestharnessExecutor):
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1, debug_info=None,
+ **kwargs):
+ super().__init__(logger, browser, server_config, timeout_multiplier, debug_info, **kwargs)
+ self.protocol = ContentShellProtocol(self, browser)
+
+ def do_test(self, test):
+ try:
+ text, _ = self.protocol.content_shell_test.do_test(self.test_url(test),
+ test.timeout * self.timeout_multiplier)
+
+ errors = self.protocol.content_shell_errors.read_errors()
+ if not text:
+ return (test.result_cls("ERROR", errors), [])
+
+ return self.convert_result(test, json.loads(text))
+ except BaseException as exception:
+ return _convert_exception(test, exception, self.protocol.content_shell_errors.read_errors())
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executormarionette.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executormarionette.py
new file mode 100644
index 0000000000..5cd18f2493
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executormarionette.py
@@ -0,0 +1,1323 @@
+# mypy: allow-untyped-defs
+
+import json
+import os
+import shutil
+import tempfile
+import threading
+import time
+import traceback
+import uuid
+
+from urllib.parse import urljoin
+
+errors = None
+marionette = None
+pytestrunner = None
+
+here = os.path.dirname(__file__)
+
+from .base import (CallbackHandler,
+ CrashtestExecutor,
+ RefTestExecutor,
+ RefTestImplementation,
+ TestharnessExecutor,
+ TimedRunner,
+ WdspecExecutor,
+ get_pages,
+ strip_server)
+from .protocol import (ActionSequenceProtocolPart,
+ AssertsProtocolPart,
+ BaseProtocolPart,
+ TestharnessProtocolPart,
+ PrefsProtocolPart,
+ Protocol,
+ StorageProtocolPart,
+ SelectorProtocolPart,
+ ClickProtocolPart,
+ CookiesProtocolPart,
+ SendKeysProtocolPart,
+ TestDriverProtocolPart,
+ CoverageProtocolPart,
+ GenerateTestReportProtocolPart,
+ VirtualAuthenticatorProtocolPart,
+ WindowProtocolPart,
+ SetPermissionProtocolPart,
+ PrintProtocolPart,
+ DebugProtocolPart,
+ merge_dicts)
+
+
+def do_delayed_imports():
+ global errors, marionette, Addons
+
+ from marionette_driver import marionette, errors
+ from marionette_driver.addons import Addons
+
+
+def _switch_to_window(marionette, handle):
+ """Switch to the specified window; subsequent commands will be
+ directed at the new window.
+
+ This is a workaround for issue 24924[0]; marionettedriver 3.1.0 dropped the
+ 'name' parameter from its switch_to_window command, but it is still needed
+ for at least Firefox 79.
+
+ [0]: https://github.com/web-platform-tests/wpt/issues/24924
+
+ :param marionette: The Marionette instance
+ :param handle: The id of the window to switch to.
+ """
+ marionette._send_message("WebDriver:SwitchToWindow",
+ {"handle": handle, "name": handle, "focus": True})
+ marionette.window = handle
+
+
+class MarionetteBaseProtocolPart(BaseProtocolPart):
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.timeout = None
+
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def execute_script(self, script, asynchronous=False):
+ method = self.marionette.execute_async_script if asynchronous else self.marionette.execute_script
+ return method(script, new_sandbox=False, sandbox=None)
+
+ def set_timeout(self, timeout):
+ """Set the Marionette script timeout.
+
+ :param timeout: Script timeout in seconds
+
+ """
+ if timeout != self.timeout:
+ self.marionette.timeout.script = timeout
+ self.timeout = timeout
+
+ @property
+ def current_window(self):
+ return self.marionette.current_window_handle
+
+ def set_window(self, handle):
+ _switch_to_window(self.marionette, handle)
+
+ def window_handles(self):
+ return self.marionette.window_handles
+
+ def load(self, url):
+ self.marionette.navigate(url)
+
+ def wait(self):
+ try:
+ socket_timeout = self.marionette.client.socket_timeout
+ except AttributeError:
+ # This can happen if there was a crash
+ return
+ if socket_timeout:
+ try:
+ self.marionette.timeout.script = socket_timeout / 2
+ except OSError:
+ self.logger.debug("Socket closed")
+ return
+
+ while True:
+ try:
+ return self.marionette.execute_async_script("""let callback = arguments[arguments.length - 1];
+addEventListener("__test_restart", e => {e.preventDefault(); callback(true)})""")
+ except errors.NoSuchWindowException:
+ # The window closed
+ break
+ except errors.ScriptTimeoutException:
+ self.logger.debug("Script timed out")
+ pass
+ except errors.JavascriptException as e:
+ # This can happen if we navigate, but just keep going
+ self.logger.debug(e)
+ pass
+ except OSError:
+ self.logger.debug("Socket closed")
+ break
+ except Exception:
+ self.logger.warning(traceback.format_exc())
+ break
+ return False
+
+
+class MarionetteTestharnessProtocolPart(TestharnessProtocolPart):
+ def __init__(self, parent):
+ super().__init__(parent)
+ self.runner_handle = None
+ with open(os.path.join(here, "runner.js")) as f:
+ self.runner_script = f.read()
+ with open(os.path.join(here, "window-loaded.js")) as f:
+ self.window_loaded_script = f.read()
+
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def load_runner(self, url_protocol):
+ # Check if we previously had a test window open, and if we did make sure it's closed
+ if self.runner_handle:
+ self._close_windows()
+ url = urljoin(self.parent.executor.server_url(url_protocol), "/testharness_runner.html")
+ self.logger.debug("Loading %s" % url)
+ try:
+ self.dismiss_alert(lambda: self.marionette.navigate(url))
+ except Exception:
+ self.logger.critical(
+ "Loading initial page %s failed. Ensure that the "
+ "there are no other programs bound to this port and "
+ "that your firewall rules or network setup does not "
+ "prevent access.\n%s" % (url, traceback.format_exc()))
+ raise
+ self.runner_handle = self.marionette.current_window_handle
+ format_map = {"title": threading.current_thread().name.replace("'", '"')}
+ self.parent.base.execute_script(self.runner_script % format_map)
+
+ def _close_windows(self):
+ handles = self.marionette.window_handles
+ runner_handle = None
+ try:
+ handles.remove(self.runner_handle)
+ runner_handle = self.runner_handle
+ except ValueError:
+ # The runner window probably changed id but we can restore it
+ # This isn't supposed to happen, but marionette ids are not yet stable
+ # We assume that the first handle returned corresponds to the runner,
+ # but it hopefully doesn't matter too much if that assumption is
+ # wrong since we reload the runner in that tab anyway.
+ runner_handle = handles.pop(0)
+ self.logger.info("Changing harness_window to %s" % runner_handle)
+
+ for handle in handles:
+ try:
+ self.logger.info("Closing window %s" % handle)
+ _switch_to_window(self.marionette, handle)
+ self.dismiss_alert(lambda: self.marionette.close())
+ except errors.NoSuchWindowException:
+ # We might have raced with the previous test to close this
+ # window, skip it.
+ pass
+ _switch_to_window(self.marionette, runner_handle)
+ return runner_handle
+
+ def close_old_windows(self, url_protocol):
+ runner_handle = self._close_windows()
+ if runner_handle != self.runner_handle:
+ self.load_runner(url_protocol)
+ return self.runner_handle
+
+ def dismiss_alert(self, f):
+ while True:
+ try:
+ f()
+ except errors.UnexpectedAlertOpen:
+ alert = self.marionette.switch_to_alert()
+ try:
+ alert.dismiss()
+ except errors.NoAlertPresentException:
+ pass
+ else:
+ break
+
+ def get_test_window(self, window_id, parent, timeout=5):
+ """Find the test window amongst all the open windows.
+ This is assumed to be either the named window or the one after the parent in the list of
+ window handles
+
+ :param window_id: The DOM name of the Window
+ :param parent: The handle of the runner window
+ :param timeout: The time in seconds to wait for the window to appear. This is because in
+ some implementations there's a race between calling window.open and the
+ window being added to the list of WebDriver accessible windows."""
+ test_window = None
+ end_time = time.time() + timeout
+ while time.time() < end_time:
+ if window_id:
+ try:
+ # Try this, it's in Level 1 but nothing supports it yet
+ win_s = self.parent.base.execute_script("return window['%s'];" % self.window_id)
+ win_obj = json.loads(win_s)
+ test_window = win_obj["window-fcc6-11e5-b4f8-330a88ab9d7f"]
+ except Exception:
+ pass
+
+ if test_window is None:
+ handles = self.marionette.window_handles
+ if len(handles) == 2:
+ test_window = next(iter(set(handles) - {parent}))
+ elif len(handles) > 2 and handles[0] == parent:
+ # Hope the first one here is the test window
+ test_window = handles[1]
+
+ if test_window is not None:
+ assert test_window != parent
+ return test_window
+
+ time.sleep(0.1)
+
+ raise Exception("unable to find test window")
+
+ def test_window_loaded(self):
+ """Wait until the page in the new window has been loaded.
+
+ Hereby ignore Javascript execptions that are thrown when
+ the document has been unloaded due to a process change.
+ """
+ while True:
+ try:
+ self.parent.base.execute_script(self.window_loaded_script, asynchronous=True)
+ break
+ except errors.JavascriptException:
+ pass
+
+
+class MarionettePrefsProtocolPart(PrefsProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def set(self, name, value):
+ if not isinstance(value, str):
+ value = str(value)
+
+ if value.lower() not in ("true", "false"):
+ try:
+ int(value)
+ except ValueError:
+ value = f"'{value}'"
+ else:
+ value = value.lower()
+
+ self.logger.info(f"Setting pref {name} to {value}")
+
+ script = """
+ let prefInterface = Components.classes["@mozilla.org/preferences-service;1"]
+ .getService(Components.interfaces.nsIPrefBranch);
+ let pref = '%s';
+ let type = prefInterface.getPrefType(pref);
+ let value = %s;
+ switch(type) {
+ case prefInterface.PREF_STRING:
+ prefInterface.setCharPref(pref, value);
+ break;
+ case prefInterface.PREF_BOOL:
+ prefInterface.setBoolPref(pref, value);
+ break;
+ case prefInterface.PREF_INT:
+ prefInterface.setIntPref(pref, value);
+ break;
+ case prefInterface.PREF_INVALID:
+ // Pref doesn't seem to be defined already; guess at the
+ // right way to set it based on the type of value we have.
+ switch (typeof value) {
+ case "boolean":
+ prefInterface.setBoolPref(pref, value);
+ break;
+ case "string":
+ prefInterface.setCharPref(pref, value);
+ break;
+ case "number":
+ prefInterface.setIntPref(pref, value);
+ break;
+ default:
+ throw new Error("Unknown pref value type: " + (typeof value));
+ }
+ break;
+ default:
+ throw new Error("Unknown pref type " + type);
+ }
+ """ % (name, value)
+ with self.marionette.using_context(self.marionette.CONTEXT_CHROME):
+ self.marionette.execute_script(script)
+
+ def clear(self, name):
+ self.logger.info(f"Clearing pref {name}")
+ script = """
+ let prefInterface = Components.classes["@mozilla.org/preferences-service;1"]
+ .getService(Components.interfaces.nsIPrefBranch);
+ let pref = '%s';
+ prefInterface.clearUserPref(pref);
+ """ % name
+ with self.marionette.using_context(self.marionette.CONTEXT_CHROME):
+ self.marionette.execute_script(script)
+
+ def get(self, name):
+ script = """
+ let prefInterface = Components.classes["@mozilla.org/preferences-service;1"]
+ .getService(Components.interfaces.nsIPrefBranch);
+ let pref = '%s';
+ let type = prefInterface.getPrefType(pref);
+ switch(type) {
+ case prefInterface.PREF_STRING:
+ return prefInterface.getCharPref(pref);
+ case prefInterface.PREF_BOOL:
+ return prefInterface.getBoolPref(pref);
+ case prefInterface.PREF_INT:
+ return prefInterface.getIntPref(pref);
+ case prefInterface.PREF_INVALID:
+ return null;
+ }
+ """ % name
+ with self.marionette.using_context(self.marionette.CONTEXT_CHROME):
+ rv = self.marionette.execute_script(script)
+ self.logger.debug(f"Got pref {name} with value {rv}")
+ return rv
+
+
+class MarionetteStorageProtocolPart(StorageProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def clear_origin(self, url):
+ self.logger.info("Clearing origin %s" % (url))
+ script = """
+ let url = '%s';
+ let uri = Components.classes["@mozilla.org/network/io-service;1"]
+ .getService(Ci.nsIIOService)
+ .newURI(url);
+ let ssm = Components.classes["@mozilla.org/scriptsecuritymanager;1"]
+ .getService(Ci.nsIScriptSecurityManager);
+ let principal = ssm.createContentPrincipal(uri, {});
+ let qms = Components.classes["@mozilla.org/dom/quota-manager-service;1"]
+ .getService(Components.interfaces.nsIQuotaManagerService);
+ qms.clearStoragesForPrincipal(principal, "default", null, true);
+ """ % url
+ with self.marionette.using_context(self.marionette.CONTEXT_CHROME):
+ self.marionette.execute_script(script)
+
+
+class MarionetteAssertsProtocolPart(AssertsProtocolPart):
+ def setup(self):
+ self.assert_count = {"chrome": 0, "content": 0}
+ self.chrome_assert_count = 0
+ self.marionette = self.parent.marionette
+
+ def get(self):
+ script = """
+ debug = Cc["@mozilla.org/xpcom/debug;1"].getService(Ci.nsIDebug2);
+ if (debug.isDebugBuild) {
+ return debug.assertionCount;
+ }
+ return 0;
+ """
+
+ def get_count(context, **kwargs):
+ try:
+ context_count = self.marionette.execute_script(script, **kwargs)
+ if context_count:
+ self.parent.logger.info("Got %s assert count %s" % (context, context_count))
+ test_count = context_count - self.assert_count[context]
+ self.assert_count[context] = context_count
+ return test_count
+ except errors.NoSuchWindowException:
+ # If the window was already closed
+ self.parent.logger.warning("Failed to get assertion count; window was closed")
+ except (errors.MarionetteException, OSError):
+ # This usually happens if the process crashed
+ pass
+
+ counts = []
+ with self.marionette.using_context(self.marionette.CONTEXT_CHROME):
+ counts.append(get_count("chrome"))
+ if self.parent.e10s:
+ counts.append(get_count("content", sandbox="system"))
+
+ counts = [item for item in counts if item is not None]
+
+ if not counts:
+ return None
+
+ return sum(counts)
+
+
+class MarionetteSelectorProtocolPart(SelectorProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def elements_by_selector(self, selector):
+ return self.marionette.find_elements("css selector", selector)
+
+
+class MarionetteClickProtocolPart(ClickProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def element(self, element):
+ return element.click()
+
+
+class MarionetteCookiesProtocolPart(CookiesProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def delete_all_cookies(self):
+ self.logger.info("Deleting all cookies")
+ return self.marionette.delete_all_cookies()
+
+ def get_all_cookies(self):
+ self.logger.info("Getting all cookies")
+ return self.marionette.get_cookies()
+
+ def get_named_cookie(self, name):
+ self.logger.info("Getting cookie named %s" % name)
+ try:
+ return self.marionette.get_cookie(name)
+ # When errors.NoSuchCookieException is supported,
+ # that should be used here instead.
+ except Exception:
+ return None
+
+
+class MarionetteSendKeysProtocolPart(SendKeysProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def send_keys(self, element, keys):
+ return element.send_keys(keys)
+
+class MarionetteWindowProtocolPart(WindowProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def minimize(self):
+ return self.marionette.minimize_window()
+
+ def set_rect(self, rect):
+ self.marionette.set_window_rect(rect["x"], rect["y"], rect["height"], rect["width"])
+
+
+class MarionetteActionSequenceProtocolPart(ActionSequenceProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def send_actions(self, actions):
+ actions = self.marionette._to_json(actions)
+ self.logger.info(actions)
+ self.marionette._send_message("WebDriver:PerformActions", actions)
+
+ def release(self):
+ self.marionette._send_message("WebDriver:ReleaseActions", {})
+
+
+class MarionetteTestDriverProtocolPart(TestDriverProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def send_message(self, cmd_id, message_type, status, message=None):
+ obj = {
+ "cmd_id": cmd_id,
+ "type": "testdriver-%s" % str(message_type),
+ "status": str(status)
+ }
+ if message:
+ obj["message"] = str(message)
+ self.parent.base.execute_script("window.postMessage(%s, '*')" % json.dumps(obj))
+
+ def _switch_to_frame(self, index_or_elem):
+ try:
+ self.marionette.switch_to_frame(index_or_elem)
+ except (errors.NoSuchFrameException,
+ errors.StaleElementException) as e:
+ raise ValueError from e
+
+ def _switch_to_parent_frame(self):
+ self.marionette.switch_to_parent_frame()
+
+
+class MarionetteCoverageProtocolPart(CoverageProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ if not self.parent.ccov:
+ self.is_enabled = False
+ return
+
+ script = """
+ const {PerTestCoverageUtils} = ChromeUtils.import("chrome://remote/content/marionette/PerTestCoverageUtils.jsm");
+ return PerTestCoverageUtils.enabled;
+ """
+ with self.marionette.using_context(self.marionette.CONTEXT_CHROME):
+ self.is_enabled = self.marionette.execute_script(script)
+
+ def reset(self):
+ script = """
+ var callback = arguments[arguments.length - 1];
+
+ const {PerTestCoverageUtils} = ChromeUtils.import("chrome://remote/content/marionette/PerTestCoverageUtils.jsm");
+ PerTestCoverageUtils.beforeTest().then(callback, callback);
+ """
+ with self.marionette.using_context(self.marionette.CONTEXT_CHROME):
+ try:
+ error = self.marionette.execute_async_script(script)
+ if error is not None:
+ raise Exception('Failure while resetting counters: %s' % json.dumps(error))
+ except (errors.MarionetteException, OSError):
+ # This usually happens if the process crashed
+ pass
+
+ def dump(self):
+ if len(self.marionette.window_handles):
+ handle = self.marionette.window_handles[0]
+ _switch_to_window(self.marionette, handle)
+
+ script = """
+ var callback = arguments[arguments.length - 1];
+
+ const {PerTestCoverageUtils} = ChromeUtils.import("chrome://remote/content/marionette/PerTestCoverageUtils.jsm");
+ PerTestCoverageUtils.afterTest().then(callback, callback);
+ """
+ with self.marionette.using_context(self.marionette.CONTEXT_CHROME):
+ try:
+ error = self.marionette.execute_async_script(script)
+ if error is not None:
+ raise Exception('Failure while dumping counters: %s' % json.dumps(error))
+ except (errors.MarionetteException, OSError):
+ # This usually happens if the process crashed
+ pass
+
+class MarionetteGenerateTestReportProtocolPart(GenerateTestReportProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def generate_test_report(self, config):
+ raise NotImplementedError("generate_test_report not yet implemented")
+
+class MarionetteVirtualAuthenticatorProtocolPart(VirtualAuthenticatorProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def add_virtual_authenticator(self, config):
+ raise NotImplementedError("add_virtual_authenticator not yet implemented")
+
+ def remove_virtual_authenticator(self, authenticator_id):
+ raise NotImplementedError("remove_virtual_authenticator not yet implemented")
+
+ def add_credential(self, authenticator_id, credential):
+ raise NotImplementedError("add_credential not yet implemented")
+
+ def get_credentials(self, authenticator_id):
+ raise NotImplementedError("get_credentials not yet implemented")
+
+ def remove_credential(self, authenticator_id, credential_id):
+ raise NotImplementedError("remove_credential not yet implemented")
+
+ def remove_all_credentials(self, authenticator_id):
+ raise NotImplementedError("remove_all_credentials not yet implemented")
+
+ def set_user_verified(self, authenticator_id, uv):
+ raise NotImplementedError("set_user_verified not yet implemented")
+
+
+class MarionetteSetPermissionProtocolPart(SetPermissionProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def set_permission(self, descriptor, state):
+ body = {
+ "descriptor": descriptor,
+ "state": state,
+ }
+ try:
+ self.marionette._send_message("WebDriver:SetPermission", body)
+ except errors.UnsupportedOperationException:
+ raise NotImplementedError("set_permission not yet implemented")
+
+
+class MarionettePrintProtocolPart(PrintProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+ self.runner_handle = None
+
+ def load_runner(self):
+ url = urljoin(self.parent.executor.server_url("http"), "/print_reftest_runner.html")
+ self.logger.debug("Loading %s" % url)
+ try:
+ self.marionette.navigate(url)
+ except Exception as e:
+ self.logger.critical(
+ "Loading initial page %s failed. Ensure that the "
+ "there are no other programs bound to this port and "
+ "that your firewall rules or network setup does not "
+ "prevent access.\n%s" % (url, traceback.format_exc(e)))
+ raise
+ self.runner_handle = self.marionette.current_window_handle
+
+ def render_as_pdf(self, width, height):
+ margin = 0.5 * 2.54
+ body = {
+ "page": {
+ "width": width,
+ "height": height
+ },
+ "margin": {
+ "left": margin,
+ "right": margin,
+ "top": margin,
+ "bottom": margin,
+ },
+ "shrinkToFit": False,
+ "printBackground": True,
+ }
+ return self.marionette._send_message("WebDriver:Print", body, key="value")
+
+ def pdf_to_png(self, pdf_base64, page_ranges):
+ handle = self.marionette.current_window_handle
+ _switch_to_window(self.marionette, self.runner_handle)
+ try:
+ rv = self.marionette.execute_async_script("""
+let callback = arguments[arguments.length - 1];
+render('%s').then(result => callback(result))""" % pdf_base64, new_sandbox=False, sandbox=None)
+ page_numbers = get_pages(page_ranges, len(rv))
+ rv = [item for i, item in enumerate(rv) if i + 1 in page_numbers]
+ return rv
+ finally:
+ _switch_to_window(self.marionette, handle)
+
+
+class MarionetteDebugProtocolPart(DebugProtocolPart):
+ def setup(self):
+ self.marionette = self.parent.marionette
+
+ def load_devtools(self):
+ with self.marionette.using_context(self.marionette.CONTEXT_CHROME):
+ # Once ESR is 107 is released, we can replace the ChromeUtils.import(DevToolsShim.jsm)
+ # with ChromeUtils.importESModule(DevToolsShim.sys.mjs) in this snippet:
+ self.parent.base.execute_script("""
+const { DevToolsShim } = ChromeUtils.import(
+ "chrome://devtools-startup/content/DevToolsShim.jsm"
+);
+
+const callback = arguments[arguments.length - 1];
+
+async function loadDevTools() {
+ const tab = window.gBrowser.selectedTab;
+ await DevToolsShim.showToolboxForTab(tab, {
+ toolId: "webconsole",
+ hostType: "window"
+ });
+}
+
+loadDevTools().catch((e) => console.error("Devtools failed to load", e))
+ .then(callback);
+""", asynchronous=True)
+
+
+class MarionetteProtocol(Protocol):
+ implements = [MarionetteBaseProtocolPart,
+ MarionetteTestharnessProtocolPart,
+ MarionettePrefsProtocolPart,
+ MarionetteStorageProtocolPart,
+ MarionetteSelectorProtocolPart,
+ MarionetteClickProtocolPart,
+ MarionetteCookiesProtocolPart,
+ MarionetteSendKeysProtocolPart,
+ MarionetteWindowProtocolPart,
+ MarionetteActionSequenceProtocolPart,
+ MarionetteTestDriverProtocolPart,
+ MarionetteAssertsProtocolPart,
+ MarionetteCoverageProtocolPart,
+ MarionetteGenerateTestReportProtocolPart,
+ MarionetteVirtualAuthenticatorProtocolPart,
+ MarionetteSetPermissionProtocolPart,
+ MarionettePrintProtocolPart,
+ MarionetteDebugProtocolPart]
+
+ def __init__(self, executor, browser, capabilities=None, timeout_multiplier=1, e10s=True, ccov=False):
+ do_delayed_imports()
+
+ super().__init__(executor, browser)
+ self.marionette = None
+ self.marionette_port = browser.marionette_port
+ self.capabilities = capabilities
+ if hasattr(browser, "capabilities"):
+ if self.capabilities is None:
+ self.capabilities = browser.capabilities
+ else:
+ merge_dicts(self.capabilities, browser.capabilities)
+ self.timeout_multiplier = timeout_multiplier
+ self.runner_handle = None
+ self.e10s = e10s
+ self.ccov = ccov
+
+ def connect(self):
+ self.logger.debug("Connecting to Marionette on port %i" % self.marionette_port)
+ startup_timeout = marionette.Marionette.DEFAULT_STARTUP_TIMEOUT * self.timeout_multiplier
+ self.marionette = marionette.Marionette(host='127.0.0.1',
+ port=self.marionette_port,
+ socket_timeout=None,
+ startup_timeout=startup_timeout)
+
+ self.logger.debug("Waiting for Marionette connection")
+ while True:
+ try:
+ self.marionette.raise_for_port()
+ break
+ except OSError:
+ # When running in a debugger wait indefinitely for Firefox to start
+ if self.executor.debug_info is None:
+ raise
+
+ self.logger.debug("Starting Marionette session")
+ self.marionette.start_session(self.capabilities)
+ self.logger.debug("Marionette session started")
+
+ def after_connect(self):
+ pass
+
+ def teardown(self):
+ if self.marionette and self.marionette.session_id:
+ try:
+ self.marionette._request_in_app_shutdown()
+ self.marionette.delete_session(send_request=False)
+ self.marionette.cleanup()
+ except Exception:
+ # This is typically because the session never started
+ pass
+ if self.marionette is not None:
+ self.marionette = None
+ super().teardown()
+
+ def is_alive(self):
+ try:
+ self.marionette.current_window_handle
+ except Exception:
+ return False
+ return True
+
+ def on_environment_change(self, old_environment, new_environment):
+ #Unset all the old prefs
+ for name in old_environment.get("prefs", {}).keys():
+ value = self.executor.original_pref_values[name]
+ if value is None:
+ self.prefs.clear(name)
+ else:
+ self.prefs.set(name, value)
+
+ for name, value in new_environment.get("prefs", {}).items():
+ 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):
+ timeout = self.timeout
+
+ try:
+ if timeout is not None:
+ self.protocol.base.set_timeout(timeout + self.extra_timeout)
+ else:
+ # We just want it to never time out, really, but marionette doesn't
+ # make that possible. It also seems to time out immediately if the
+ # timeout is set too high. This works at least.
+ self.protocol.base.set_timeout(2**28 - 1)
+ except OSError:
+ msg = "Lost marionette connection before starting test"
+ self.logger.error(msg)
+ return ("INTERNAL-ERROR", msg)
+
+ def before_run(self):
+ index = self.url.rfind("/storage/")
+ if index != -1:
+ # Clear storage
+ self.protocol.storage.clear_origin(self.url)
+
+ def run_func(self):
+ try:
+ self.result = True, self.func(self.protocol, self.url, self.timeout)
+ except errors.ScriptTimeoutException:
+ self.logger.debug("Got a marionette timeout")
+ self.result = False, ("EXTERNAL-TIMEOUT", None)
+ except OSError:
+ # This can happen on a crash
+ # Also, should check after the test if the firefox process is still running
+ # and otherwise ignore any other result and set it to crash
+ self.logger.info("IOError on command, setting status to CRASH")
+ self.result = False, ("CRASH", None)
+ except errors.NoSuchWindowException:
+ self.logger.info("NoSuchWindowException on command, setting status to CRASH")
+ self.result = False, ("CRASH", None)
+ except Exception as e:
+ if isinstance(e, errors.JavascriptException) and str(e).startswith("Document was unloaded"):
+ message = "Document unloaded; maybe test navigated the top-level-browsing context?"
+ else:
+ message = getattr(e, "message", "")
+ if message:
+ message += "\n"
+ message += traceback.format_exc()
+ self.logger.warning(traceback.format_exc())
+ self.result = False, ("INTERNAL-ERROR", message)
+ finally:
+ self.result_flag.set()
+
+
+class MarionetteTestharnessExecutor(TestharnessExecutor):
+ supports_testdriver = True
+
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ close_after_done=True, debug_info=None, capabilities=None,
+ debug=False, ccov=False, debug_test=False, **kwargs):
+ """Marionette-based executor for testharness.js tests"""
+ TestharnessExecutor.__init__(self, logger, browser, server_config,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+ self.protocol = MarionetteProtocol(self,
+ browser,
+ capabilities,
+ timeout_multiplier,
+ kwargs["e10s"],
+ ccov)
+ with open(os.path.join(here, "testharness_webdriver_resume.js")) as f:
+ self.script_resume = f.read()
+ self.close_after_done = close_after_done
+ self.window_id = str(uuid.uuid4())
+ self.debug = debug
+ self.debug_test = debug_test
+
+ self.install_extensions = browser.extensions
+
+ self.original_pref_values = {}
+
+ if marionette is None:
+ do_delayed_imports()
+
+ def setup(self, runner):
+ super().setup(runner)
+ for extension_path in self.install_extensions:
+ self.logger.info("Installing extension from %s" % extension_path)
+ addons = Addons(self.protocol.marionette)
+ addons.install(extension_path)
+
+ self.protocol.testharness.load_runner(self.last_environment["protocol"])
+
+ def is_alive(self):
+ return self.protocol.is_alive()
+
+ def on_environment_change(self, new_environment):
+ self.protocol.on_environment_change(self.last_environment, new_environment)
+
+ if new_environment["protocol"] != self.last_environment["protocol"]:
+ self.protocol.testharness.load_runner(new_environment["protocol"])
+
+ def do_test(self, test):
+ timeout = (test.timeout * self.timeout_multiplier if self.debug_info is None
+ else None)
+
+ success, data = ExecuteAsyncScriptRun(self.logger,
+ self.do_testharness,
+ self.protocol,
+ self.test_url(test),
+ timeout,
+ self.extra_timeout).run()
+ # The format of data depends on whether the test ran to completion or not
+ # For asserts we only care about the fact that if it didn't complete, the
+ # status is in the first field.
+ status = None
+ if not success:
+ status = data[0]
+
+ extra = None
+ if self.debug and (success or status not in ("CRASH", "INTERNAL-ERROR")):
+ assertion_count = self.protocol.asserts.get()
+ if assertion_count is not None:
+ extra = {"assertion_count": assertion_count}
+
+ if success:
+ return self.convert_result(test, data, extra=extra)
+
+ return (test.result_cls(extra=extra, *data), [])
+
+ def do_testharness(self, protocol, url, timeout):
+ parent_window = protocol.testharness.close_old_windows(self.last_environment["protocol"])
+
+ if self.protocol.coverage.is_enabled:
+ self.protocol.coverage.reset()
+
+ format_map = {"url": strip_server(url)}
+
+ protocol.base.execute_script("window.open('about:blank', '%s', 'noopener')" % self.window_id)
+ test_window = protocol.testharness.get_test_window(self.window_id, parent_window,
+ timeout=10 * self.timeout_multiplier)
+ self.protocol.base.set_window(test_window)
+ protocol.testharness.test_window_loaded()
+
+ if self.debug_test and self.browser.supports_devtools:
+ self.protocol.debug.load_devtools()
+
+ handler = CallbackHandler(self.logger, protocol, test_window)
+ protocol.marionette.navigate(url)
+ while True:
+ result = protocol.base.execute_script(
+ self.script_resume % format_map, asynchronous=True)
+ if result is None:
+ # This can happen if we get an content process crash
+ return None
+ done, rv = handler(result)
+ if done:
+ break
+
+ if self.protocol.coverage.is_enabled:
+ self.protocol.coverage.dump()
+
+ return rv
+
+
+class MarionetteRefTestExecutor(RefTestExecutor):
+ is_print = False
+
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ screenshot_cache=None, close_after_done=True,
+ debug_info=None, reftest_internal=False,
+ reftest_screenshot="unexpected", ccov=False,
+ group_metadata=None, capabilities=None, debug=False,
+ browser_version=None, debug_test=False, **kwargs):
+ """Marionette-based executor for reftests"""
+ RefTestExecutor.__init__(self,
+ logger,
+ browser,
+ server_config,
+ screenshot_cache=screenshot_cache,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+ self.protocol = MarionetteProtocol(self, browser, capabilities,
+ timeout_multiplier, kwargs["e10s"],
+ ccov)
+ self.implementation = self.get_implementation(reftest_internal)
+ self.implementation_kwargs = {}
+ if reftest_internal:
+ self.implementation_kwargs["screenshot"] = reftest_screenshot
+ self.implementation_kwargs["chrome_scope"] = (browser_version is not None and
+ int(browser_version.split(".")[0]) < 82)
+ self.close_after_done = close_after_done
+ self.has_window = False
+ self.original_pref_values = {}
+ self.group_metadata = group_metadata
+ self.debug = debug
+ self.debug_test = debug_test
+
+ self.install_extensions = browser.extensions
+
+ with open(os.path.join(here, "reftest.js")) as f:
+ self.script = f.read()
+ with open(os.path.join(here, "test-wait.js")) as f:
+ self.wait_script = f.read() % {"classname": "reftest-wait"}
+
+ def get_implementation(self, reftest_internal):
+ return (InternalRefTestImplementation if reftest_internal
+ else RefTestImplementation)(self)
+
+ def setup(self, runner):
+ super().setup(runner)
+ for extension_path in self.install_extensions:
+ self.logger.info("Installing extension from %s" % extension_path)
+ addons = Addons(self.protocol.marionette)
+ addons.install(extension_path)
+
+ self.implementation.setup(**self.implementation_kwargs)
+
+ def teardown(self):
+ try:
+ self.implementation.teardown()
+ if self.protocol.marionette and self.protocol.marionette.session_id:
+ handles = self.protocol.marionette.window_handles
+ if handles:
+ _switch_to_window(self.protocol.marionette, handles[0])
+ super().teardown()
+ except Exception:
+ # Ignore errors during teardown
+ self.logger.warning("Exception during reftest teardown:\n%s" %
+ traceback.format_exc())
+
+ def reset(self):
+ self.implementation.reset(**self.implementation_kwargs)
+
+ def is_alive(self):
+ return self.protocol.is_alive()
+
+ def on_environment_change(self, new_environment):
+ self.protocol.on_environment_change(self.last_environment, new_environment)
+
+ def do_test(self, test):
+ if not isinstance(self.implementation, InternalRefTestImplementation):
+ if self.close_after_done and self.has_window:
+ self.protocol.marionette.close()
+ _switch_to_window(self.protocol.marionette,
+ self.protocol.marionette.window_handles[-1])
+ self.has_window = False
+
+ if not self.has_window:
+ self.protocol.base.execute_script(self.script)
+ self.protocol.base.set_window(self.protocol.marionette.window_handles[-1])
+ self.has_window = True
+ self.protocol.testharness.test_window_loaded()
+
+ if self.protocol.coverage.is_enabled:
+ self.protocol.coverage.reset()
+
+ result = self.implementation.run_test(test)
+
+ if self.protocol.coverage.is_enabled:
+ self.protocol.coverage.dump()
+
+ if self.debug:
+ assertion_count = self.protocol.asserts.get()
+ if "extra" not in result:
+ result["extra"] = {}
+ if assertion_count is not None:
+ result["extra"]["assertion_count"] = assertion_count
+
+ if self.debug_test and result["status"] in ["PASS", "FAIL", "ERROR"] and "extra" in result:
+ self.protocol.base.set_window(self.protocol.base.window_handles()[0])
+ self.protocol.debug.load_reftest_analyzer(test, result)
+
+ return self.convert_result(test, result)
+
+ def screenshot(self, test, viewport_size, dpi, page_ranges):
+ # https://github.com/web-platform-tests/wpt/issues/7135
+ assert viewport_size is None
+ assert dpi is None
+
+ timeout = self.timeout_multiplier * test.timeout if self.debug_info is None else None
+
+ test_url = self.test_url(test)
+
+ return ExecuteAsyncScriptRun(self.logger,
+ self._screenshot,
+ self.protocol,
+ test_url,
+ timeout,
+ self.extra_timeout).run()
+
+ def _screenshot(self, protocol, url, timeout):
+ protocol.marionette.navigate(url)
+
+ protocol.base.execute_script(self.wait_script, asynchronous=True)
+
+ screenshot = protocol.marionette.screenshot(full=False)
+ # strip off the data:img/png, part of the url
+ if screenshot.startswith("data:image/png;base64,"):
+ screenshot = screenshot.split(",", 1)[1]
+
+ return screenshot
+
+
+class InternalRefTestImplementation(RefTestImplementation):
+ def __init__(self, executor):
+ self.timeout_multiplier = executor.timeout_multiplier
+ self.executor = executor
+ self.chrome_scope = False
+
+ @property
+ def logger(self):
+ return self.executor.logger
+
+ def setup(self, screenshot="unexpected", chrome_scope=False):
+ data = {"screenshot": screenshot, "isPrint": self.executor.is_print}
+ if self.executor.group_metadata is not None:
+ data["urlCount"] = {urljoin(self.executor.server_url(key[0]), key[1]):value
+ for key, value in self.executor.group_metadata.get("url_count", {}).items()
+ if value > 1}
+ self.chrome_scope = chrome_scope
+ if chrome_scope:
+ self.logger.debug("Using marionette Chrome scope for reftests")
+ self.executor.protocol.marionette.set_context(self.executor.protocol.marionette.CONTEXT_CHROME)
+ self.executor.protocol.marionette._send_message("reftest:setup", data)
+
+ def reset(self, **kwargs):
+ # this is obvious wrong; it shouldn't be a no-op
+ # see https://github.com/web-platform-tests/wpt/issues/15604
+ pass
+
+ def run_test(self, test):
+ references = self.get_references(test, test)
+ timeout = (test.timeout * 1000) * self.timeout_multiplier
+ rv = self.executor.protocol.marionette._send_message("reftest:run",
+ {"test": self.executor.test_url(test),
+ "references": references,
+ "expected": test.expected(),
+ "timeout": timeout,
+ "width": 800,
+ "height": 600,
+ "pageRanges": test.page_ranges})["value"]
+ return rv
+
+ def get_references(self, root_test, node):
+ rv = []
+ for item, relation in node.references:
+ rv.append([self.executor.test_url(item), self.get_references(root_test, item), relation,
+ {"fuzzy": self.get_fuzzy(root_test, [node, item], relation)}])
+ return rv
+
+ def teardown(self):
+ try:
+ if self.executor.protocol.marionette and self.executor.protocol.marionette.session_id:
+ self.executor.protocol.marionette._send_message("reftest:teardown", {})
+ if self.chrome_scope:
+ self.executor.protocol.marionette.set_context(
+ self.executor.protocol.marionette.CONTEXT_CONTENT)
+ # the reftest runner opens/closes a window with focus, so as
+ # with after closing a window we need to give a new window
+ # focus
+ handles = self.executor.protocol.marionette.window_handles
+ if handles:
+ _switch_to_window(self.executor.protocol.marionette, handles[0])
+ except Exception:
+ # Ignore errors during teardown
+ self.logger.warning(traceback.format_exc())
+
+
+class MarionetteCrashtestExecutor(CrashtestExecutor):
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ debug_info=None, capabilities=None, debug=False,
+ ccov=False, **kwargs):
+ """Marionette-based executor for testharness.js tests"""
+ CrashtestExecutor.__init__(self, logger, browser, server_config,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+ self.protocol = MarionetteProtocol(self,
+ browser,
+ capabilities,
+ timeout_multiplier,
+ kwargs["e10s"],
+ ccov)
+
+ self.original_pref_values = {}
+ self.debug = debug
+
+ with open(os.path.join(here, "test-wait.js")) as f:
+ self.wait_script = f.read() % {"classname": "test-wait"}
+
+ if marionette is None:
+ do_delayed_imports()
+
+ def is_alive(self):
+ return self.protocol.is_alive()
+
+ def on_environment_change(self, new_environment):
+ self.protocol.on_environment_change(self.last_environment, new_environment)
+
+ def do_test(self, test):
+ timeout = (test.timeout * self.timeout_multiplier if self.debug_info is None
+ else None)
+
+ success, data = ExecuteAsyncScriptRun(self.logger,
+ self.do_crashtest,
+ self.protocol,
+ self.test_url(test),
+ timeout,
+ self.extra_timeout).run()
+ status = None
+ if not success:
+ status = data[0]
+
+ extra = None
+ if self.debug and (success or status not in ("CRASH", "INTERNAL-ERROR")):
+ assertion_count = self.protocol.asserts.get()
+ if assertion_count is not None:
+ extra = {"assertion_count": assertion_count}
+
+ if success:
+ return self.convert_result(test, data)
+
+ return (test.result_cls(extra=extra, *data), [])
+
+ def do_crashtest(self, protocol, url, timeout):
+ if self.protocol.coverage.is_enabled:
+ self.protocol.coverage.reset()
+
+ protocol.base.load(url)
+ protocol.base.execute_script(self.wait_script, asynchronous=True)
+
+ if self.protocol.coverage.is_enabled:
+ self.protocol.coverage.dump()
+
+ return {"status": "PASS",
+ "message": None}
+
+
+class MarionettePrintRefTestExecutor(MarionetteRefTestExecutor):
+ is_print = True
+
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ screenshot_cache=None, close_after_done=True,
+ debug_info=None, reftest_screenshot="unexpected", ccov=False,
+ group_metadata=None, capabilities=None, debug=False,
+ reftest_internal=False, **kwargs):
+ """Marionette-based executor for reftests"""
+ MarionetteRefTestExecutor.__init__(self,
+ logger,
+ browser,
+ server_config,
+ timeout_multiplier=timeout_multiplier,
+ screenshot_cache=screenshot_cache,
+ close_after_done=close_after_done,
+ debug_info=debug_info,
+ reftest_screenshot=reftest_screenshot,
+ reftest_internal=reftest_internal,
+ ccov=ccov,
+ group_metadata=group_metadata,
+ capabilities=capabilities,
+ debug=debug,
+ **kwargs)
+
+ def setup(self, runner):
+ super().setup(runner)
+ if not isinstance(self.implementation, InternalRefTestImplementation):
+ self.protocol.pdf_print.load_runner()
+
+ def get_implementation(self, reftest_internal):
+ return (InternalRefTestImplementation if reftest_internal
+ else RefTestImplementation)(self)
+
+ def screenshot(self, test, viewport_size, dpi, page_ranges):
+ # https://github.com/web-platform-tests/wpt/issues/7140
+ assert dpi is None
+
+ self.viewport_size = viewport_size
+ timeout = self.timeout_multiplier * test.timeout if self.debug_info is None else None
+
+ test_url = self.test_url(test)
+ self.page_ranges = page_ranges.get(test.url)
+
+ return ExecuteAsyncScriptRun(self.logger,
+ self._render,
+ self.protocol,
+ test_url,
+ timeout,
+ self.extra_timeout).run()
+
+ def _render(self, protocol, url, timeout):
+ protocol.marionette.navigate(url)
+
+ protocol.base.execute_script(self.wait_script, asynchronous=True)
+
+ pdf = protocol.pdf_print.render_as_pdf(*self.viewport_size)
+ screenshots = protocol.pdf_print.pdf_to_png(pdf, self.page_ranges)
+ for i, screenshot in enumerate(screenshots):
+ # strip off the data:img/png, part of the url
+ if screenshot.startswith("data:image/png;base64,"):
+ screenshots[i] = screenshot.split(",", 1)[1]
+
+ return screenshots
+
+
+class MarionetteWdspecExecutor(WdspecExecutor):
+ def __init__(self, logger, browser, *args, **kwargs):
+ super().__init__(logger, browser, *args, **kwargs)
+
+ args = self.capabilities["moz:firefoxOptions"].setdefault("args", [])
+ args.extend(["--profile", self.browser.profile])
+
+ for option in ["androidPackage", "androidDeviceSerial", "env"]:
+ if hasattr(browser, option):
+ self.capabilities["moz:firefoxOptions"][option] = getattr(browser, option)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorselenium.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorselenium.py
new file mode 100644
index 0000000000..85076c877c
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorselenium.py
@@ -0,0 +1,485 @@
+# mypy: allow-untyped-defs
+
+import json
+import os
+import socket
+import threading
+import time
+import traceback
+import uuid
+from urllib.parse import urljoin
+
+from .base import (CallbackHandler,
+ RefTestExecutor,
+ RefTestImplementation,
+ TestharnessExecutor,
+ TimedRunner,
+ strip_server)
+from .protocol import (BaseProtocolPart,
+ TestharnessProtocolPart,
+ Protocol,
+ SelectorProtocolPart,
+ ClickProtocolPart,
+ CookiesProtocolPart,
+ SendKeysProtocolPart,
+ WindowProtocolPart,
+ ActionSequenceProtocolPart,
+ TestDriverProtocolPart)
+
+here = os.path.dirname(__file__)
+
+webdriver = None
+exceptions = None
+RemoteConnection = None
+Command = None
+
+
+def do_delayed_imports():
+ global webdriver
+ global exceptions
+ global RemoteConnection
+ global Command
+ from selenium import webdriver
+ from selenium.common import exceptions
+ from selenium.webdriver.remote.remote_connection import RemoteConnection
+ from selenium.webdriver.remote.command import Command
+
+
+class SeleniumBaseProtocolPart(BaseProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def execute_script(self, script, asynchronous=False):
+ method = self.webdriver.execute_async_script if asynchronous else self.webdriver.execute_script
+ return method(script)
+
+ def set_timeout(self, timeout):
+ self.webdriver.set_script_timeout(timeout * 1000)
+
+ @property
+ def current_window(self):
+ return self.webdriver.current_window_handle
+
+ def set_window(self, handle):
+ self.webdriver.switch_to_window(handle)
+
+ def window_handles(self):
+ return self.webdriver.window_handles
+
+ def load(self, url):
+ self.webdriver.get(url)
+
+ def wait(self):
+ while True:
+ try:
+ return self.webdriver.execute_async_script("""let callback = arguments[arguments.length - 1];
+addEventListener("__test_restart", e => {e.preventDefault(); callback(true)})""")
+ except exceptions.TimeoutException:
+ pass
+ except (socket.timeout, exceptions.NoSuchWindowException, exceptions.ErrorInResponseException, OSError):
+ break
+ except Exception:
+ self.logger.error(traceback.format_exc())
+ break
+ return False
+
+
+class SeleniumTestharnessProtocolPart(TestharnessProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+ self.runner_handle = None
+ with open(os.path.join(here, "runner.js")) as f:
+ self.runner_script = f.read()
+ with open(os.path.join(here, "window-loaded.js")) as f:
+ self.window_loaded_script = f.read()
+
+ def load_runner(self, url_protocol):
+ if self.runner_handle:
+ self.webdriver.switch_to_window(self.runner_handle)
+ url = urljoin(self.parent.executor.server_url(url_protocol),
+ "/testharness_runner.html")
+ self.logger.debug("Loading %s" % url)
+ self.webdriver.get(url)
+ self.runner_handle = self.webdriver.current_window_handle
+ format_map = {"title": threading.current_thread().name.replace("'", '"')}
+ self.parent.base.execute_script(self.runner_script % format_map)
+
+ def close_old_windows(self):
+ handles = [item for item in self.webdriver.window_handles if item != self.runner_handle]
+ for handle in handles:
+ try:
+ self.webdriver.switch_to_window(handle)
+ self.webdriver.close()
+ except exceptions.NoSuchWindowException:
+ pass
+ self.webdriver.switch_to_window(self.runner_handle)
+ return self.runner_handle
+
+ def get_test_window(self, window_id, parent, timeout=5):
+ """Find the test window amongst all the open windows.
+ This is assumed to be either the named window or the one after the parent in the list of
+ window handles
+
+ :param window_id: The DOM name of the Window
+ :param parent: The handle of the runner window
+ :param timeout: The time in seconds to wait for the window to appear. This is because in
+ some implementations there's a race between calling window.open and the
+ window being added to the list of WebDriver accessible windows."""
+ test_window = None
+ end_time = time.time() + timeout
+ while time.time() < end_time:
+ try:
+ # Try using the JSON serialization of the WindowProxy object,
+ # it's in Level 1 but nothing supports it yet
+ win_s = self.webdriver.execute_script("return window['%s'];" % window_id)
+ win_obj = json.loads(win_s)
+ test_window = win_obj["window-fcc6-11e5-b4f8-330a88ab9d7f"]
+ except Exception:
+ pass
+
+ if test_window is None:
+ after = self.webdriver.window_handles
+ if len(after) == 2:
+ test_window = next(iter(set(after) - {parent}))
+ elif after[0] == parent and len(after) > 2:
+ # Hope the first one here is the test window
+ test_window = after[1]
+
+ if test_window is not None:
+ assert test_window != parent
+ return test_window
+
+ time.sleep(0.1)
+
+ raise Exception("unable to find test window")
+
+ def test_window_loaded(self):
+ """Wait until the page in the new window has been loaded.
+
+ Hereby ignore Javascript execptions that are thrown when
+ the document has been unloaded due to a process change.
+ """
+ while True:
+ try:
+ self.webdriver.execute_async_script(self.window_loaded_script)
+ break
+ except exceptions.JavascriptException:
+ pass
+
+
+class SeleniumSelectorProtocolPart(SelectorProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def elements_by_selector(self, selector):
+ return self.webdriver.find_elements_by_css_selector(selector)
+
+ def elements_by_selector_and_frame(self, element_selector, frame):
+ return self.webdriver.find_elements_by_css_selector(element_selector)
+
+
+class SeleniumClickProtocolPart(ClickProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def element(self, element):
+ return element.click()
+
+
+class SeleniumCookiesProtocolPart(CookiesProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def delete_all_cookies(self):
+ self.logger.info("Deleting all cookies")
+ return self.webdriver.delete_all_cookies()
+
+ def get_all_cookies(self):
+ self.logger.info("Getting all cookies")
+ return self.webdriver.get_all_cookies()
+
+ def get_named_cookie(self, name):
+ self.logger.info("Getting cookie named %s" % name)
+ try:
+ return self.webdriver.get_named_cookie(name)
+ except exceptions.NoSuchCookieException:
+ return None
+
+class SeleniumWindowProtocolPart(WindowProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def minimize(self):
+ self.previous_rect = self.webdriver.window.rect
+ self.logger.info("Minimizing")
+ return self.webdriver.minimize()
+
+ def set_rect(self, rect):
+ self.logger.info("Setting window rect")
+ self.webdriver.window.rect = rect
+
+class SeleniumSendKeysProtocolPart(SendKeysProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def send_keys(self, element, keys):
+ return element.send_keys(keys)
+
+
+class SeleniumActionSequenceProtocolPart(ActionSequenceProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def send_actions(self, actions):
+ self.webdriver.execute(Command.W3C_ACTIONS, {"actions": actions})
+
+ def release(self):
+ self.webdriver.execute(Command.W3C_CLEAR_ACTIONS, {})
+
+
+class SeleniumTestDriverProtocolPart(TestDriverProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def send_message(self, cmd_id, message_type, status, message=None):
+ obj = {
+ "cmd_id": cmd_id,
+ "type": "testdriver-%s" % str(message_type),
+ "status": str(status)
+ }
+ if message:
+ obj["message"] = str(message)
+ self.webdriver.execute_script("window.postMessage(%s, '*')" % json.dumps(obj))
+
+
+class SeleniumProtocol(Protocol):
+ implements = [SeleniumBaseProtocolPart,
+ SeleniumTestharnessProtocolPart,
+ SeleniumSelectorProtocolPart,
+ SeleniumClickProtocolPart,
+ SeleniumCookiesProtocolPart,
+ SeleniumSendKeysProtocolPart,
+ SeleniumTestDriverProtocolPart,
+ SeleniumWindowProtocolPart,
+ SeleniumActionSequenceProtocolPart]
+
+ def __init__(self, executor, browser, capabilities, **kwargs):
+ do_delayed_imports()
+
+ super().__init__(executor, browser)
+ self.capabilities = capabilities
+ self.url = browser.webdriver_url
+ self.webdriver = None
+
+ def connect(self):
+ """Connect to browser via Selenium's WebDriver implementation."""
+ self.logger.debug("Connecting to Selenium on URL: %s" % self.url)
+
+ self.webdriver = webdriver.Remote(command_executor=RemoteConnection(self.url.strip("/"),
+ resolve_ip=False),
+ desired_capabilities=self.capabilities)
+
+ def teardown(self):
+ self.logger.debug("Hanging up on Selenium session")
+ try:
+ self.webdriver.quit()
+ except Exception:
+ pass
+ del self.webdriver
+
+ def is_alive(self):
+ try:
+ # Get a simple property over the connection
+ self.webdriver.current_window_handle
+ # TODO what exception?
+ except (socket.timeout, exceptions.ErrorInResponseException):
+ return False
+ return True
+
+ def after_connect(self):
+ self.testharness.load_runner(self.executor.last_environment["protocol"])
+
+
+class SeleniumRun(TimedRunner):
+ def set_timeout(self):
+ timeout = self.timeout
+
+ try:
+ self.protocol.base.set_timeout(timeout + self.extra_timeout)
+ except exceptions.ErrorInResponseException:
+ msg = "Lost WebDriver connection"
+ self.logger.error(msg)
+ return ("INTERNAL-ERROR", msg)
+
+ def run_func(self):
+ try:
+ self.result = True, self.func(self.protocol, self.url, self.timeout)
+ except exceptions.TimeoutException:
+ self.result = False, ("EXTERNAL-TIMEOUT", None)
+ except (socket.timeout, exceptions.ErrorInResponseException):
+ self.result = False, ("CRASH", None)
+ except Exception as e:
+ message = str(getattr(e, "message", ""))
+ if message:
+ message += "\n"
+ message += traceback.format_exc()
+ self.result = False, ("INTERNAL-ERROR", message)
+ finally:
+ self.result_flag.set()
+
+
+class SeleniumTestharnessExecutor(TestharnessExecutor):
+ supports_testdriver = True
+
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ close_after_done=True, capabilities=None, debug_info=None,
+ supports_eager_pageload=True, **kwargs):
+ """Selenium-based executor for testharness.js tests"""
+ TestharnessExecutor.__init__(self, logger, browser, server_config,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+ self.protocol = SeleniumProtocol(self, browser, capabilities)
+ with open(os.path.join(here, "testharness_webdriver_resume.js")) as f:
+ self.script_resume = f.read()
+ self.close_after_done = close_after_done
+ self.window_id = str(uuid.uuid4())
+ self.supports_eager_pageload = supports_eager_pageload
+
+ def is_alive(self):
+ return self.protocol.is_alive()
+
+ def on_environment_change(self, new_environment):
+ if new_environment["protocol"] != self.last_environment["protocol"]:
+ self.protocol.testharness.load_runner(new_environment["protocol"])
+
+ def do_test(self, test):
+ url = self.test_url(test)
+
+ success, data = SeleniumRun(self.logger,
+ self.do_testharness,
+ self.protocol,
+ url,
+ test.timeout * self.timeout_multiplier,
+ self.extra_timeout).run()
+
+ if success:
+ return self.convert_result(test, data)
+
+ return (test.result_cls(*data), [])
+
+ def do_testharness(self, protocol, url, timeout):
+ format_map = {"url": strip_server(url)}
+
+ parent_window = protocol.testharness.close_old_windows()
+ # Now start the test harness
+ protocol.base.execute_script("window.open('about:blank', '%s', 'noopener')" % self.window_id)
+ test_window = protocol.testharness.get_test_window(self.window_id,
+ parent_window,
+ timeout=5*self.timeout_multiplier)
+ self.protocol.base.set_window(test_window)
+ protocol.testharness.test_window_loaded()
+
+ protocol.base.load(url)
+
+ if not self.supports_eager_pageload:
+ self.wait_for_load(protocol)
+
+ handler = CallbackHandler(self.logger, protocol, test_window)
+ while True:
+ result = protocol.base.execute_script(
+ self.script_resume % format_map, asynchronous=True)
+ done, rv = handler(result)
+ if done:
+ break
+ return rv
+
+ def wait_for_load(self, protocol):
+ # pageLoadStrategy=eager doesn't work in Chrome so try to emulate in user script
+ loaded = False
+ seen_error = False
+ while not loaded:
+ try:
+ loaded = protocol.base.execute_script("""
+var callback = arguments[arguments.length - 1];
+if (location.href === "about:blank") {
+ callback(false);
+} else if (document.readyState !== "loading") {
+ callback(true);
+} else {
+ document.addEventListener("readystatechange", () => {if (document.readyState !== "loading") {callback(true)}});
+}""", asynchronous=True)
+ except Exception:
+ # We can get an error here if the script runs in the initial about:blank
+ # document before it has navigated, with the driver returning an error
+ # indicating that the document was unloaded
+ if seen_error:
+ raise
+ seen_error = True
+
+
+class SeleniumRefTestExecutor(RefTestExecutor):
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ screenshot_cache=None, close_after_done=True,
+ debug_info=None, capabilities=None, **kwargs):
+ """Selenium WebDriver-based executor for reftests"""
+ RefTestExecutor.__init__(self,
+ logger,
+ browser,
+ server_config,
+ screenshot_cache=screenshot_cache,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+ self.protocol = SeleniumProtocol(self, browser,
+ capabilities=capabilities)
+ self.implementation = RefTestImplementation(self)
+ self.close_after_done = close_after_done
+ self.has_window = False
+
+ with open(os.path.join(here, "test-wait.js")) as f:
+ self.wait_script = f.read() % {"classname": "reftest-wait"}
+
+ def reset(self):
+ self.implementation.reset()
+
+ def is_alive(self):
+ return self.protocol.is_alive()
+
+ def do_test(self, test):
+ self.logger.info("Test requires OS-level window focus")
+
+ width_offset, height_offset = self.protocol.webdriver.execute_script(
+ """return [window.outerWidth - window.innerWidth,
+ window.outerHeight - window.innerHeight];"""
+ )
+ self.protocol.webdriver.set_window_position(0, 0)
+ self.protocol.webdriver.set_window_size(800 + width_offset, 600 + height_offset)
+
+ result = self.implementation.run_test(test)
+
+ return self.convert_result(test, result)
+
+ def screenshot(self, test, viewport_size, dpi, page_ranges):
+ # https://github.com/web-platform-tests/wpt/issues/7135
+ assert viewport_size is None
+ assert dpi is None
+
+ return SeleniumRun(self.logger,
+ self._screenshot,
+ self.protocol,
+ self.test_url(test),
+ test.timeout,
+ self.extra_timeout).run()
+
+ def _screenshot(self, protocol, url, timeout):
+ webdriver = protocol.webdriver
+ webdriver.get(url)
+
+ webdriver.execute_async_script(self.wait_script)
+
+ screenshot = webdriver.get_screenshot_as_base64()
+
+ # strip off the data:img/png, part of the url
+ if screenshot.startswith("data:image/png;base64,"):
+ screenshot = screenshot.split(",", 1)[1]
+
+ return screenshot
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorservo.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorservo.py
new file mode 100644
index 0000000000..89aaf00352
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorservo.py
@@ -0,0 +1,363 @@
+# mypy: allow-untyped-defs
+
+import base64
+import json
+import os
+import subprocess
+import tempfile
+import threading
+import traceback
+import uuid
+
+from mozprocess import ProcessHandler
+
+from tools.serve.serve import make_hosts_file
+
+from .base import (RefTestImplementation,
+ crashtest_result_converter,
+ testharness_result_converter,
+ reftest_result_converter,
+ TimedRunner)
+from .process import ProcessTestExecutor
+from .protocol import ConnectionlessProtocol
+from ..browsers.base import browser_command
+
+
+pytestrunner = None
+webdriver = None
+
+
+def write_hosts_file(config):
+ hosts_fd, hosts_path = tempfile.mkstemp()
+ with os.fdopen(hosts_fd, "w") as f:
+ f.write(make_hosts_file(config, "127.0.0.1"))
+ return hosts_path
+
+
+def build_servo_command(test, test_url_func, browser, binary, pause_after_test, debug_info,
+ extra_args=None, debug_opts="replace-surrogates"):
+ args = [
+ "--hard-fail", "-u", "Servo/wptrunner",
+ "-z", test_url_func(test),
+ ]
+ if debug_opts:
+ args += ["-Z", debug_opts]
+ for stylesheet in browser.user_stylesheets:
+ args += ["--user-stylesheet", stylesheet]
+ for pref, value in test.environment.get('prefs', {}).items():
+ args += ["--pref", f"{pref}={value}"]
+ if browser.ca_certificate_path:
+ args += ["--certificate-path", browser.ca_certificate_path]
+ if extra_args:
+ args += extra_args
+ args += browser.binary_args
+ debug_args, command = browser_command(binary, args, debug_info)
+ if pause_after_test:
+ command.remove("-z")
+ return debug_args + command
+
+
+
+class ServoTestharnessExecutor(ProcessTestExecutor):
+ convert_result = testharness_result_converter
+
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1, debug_info=None,
+ pause_after_test=False, **kwargs):
+ ProcessTestExecutor.__init__(self, logger, browser, server_config,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+ self.pause_after_test = pause_after_test
+ self.result_data = None
+ self.result_flag = None
+ self.protocol = ConnectionlessProtocol(self, browser)
+ self.hosts_path = write_hosts_file(server_config)
+
+ def teardown(self):
+ try:
+ os.unlink(self.hosts_path)
+ except OSError:
+ pass
+ ProcessTestExecutor.teardown(self)
+
+ def do_test(self, test):
+ self.result_data = None
+ self.result_flag = threading.Event()
+
+ self.command = build_servo_command(test,
+ self.test_url,
+ self.browser,
+ self.binary,
+ self.pause_after_test,
+ self.debug_info)
+
+ env = os.environ.copy()
+ env["HOST_FILE"] = self.hosts_path
+ env["RUST_BACKTRACE"] = "1"
+
+
+ if not self.interactive:
+ self.proc = ProcessHandler(self.command,
+ processOutputLine=[self.on_output],
+ onFinish=self.on_finish,
+ env=env,
+ storeOutput=False)
+ self.proc.run()
+ else:
+ self.proc = subprocess.Popen(self.command, env=env)
+
+ try:
+ timeout = test.timeout * self.timeout_multiplier
+
+ # Now wait to get the output we expect, or until we reach the timeout
+ if not self.interactive and not self.pause_after_test:
+ wait_timeout = timeout + 5
+ self.result_flag.wait(wait_timeout)
+ else:
+ wait_timeout = None
+ self.proc.wait()
+
+ proc_is_running = True
+
+ if self.result_flag.is_set():
+ if self.result_data is not None:
+ result = self.convert_result(test, self.result_data)
+ else:
+ self.proc.wait()
+ result = (test.result_cls("CRASH", None), [])
+ proc_is_running = False
+ else:
+ result = (test.result_cls("TIMEOUT", None), [])
+
+
+ if proc_is_running:
+ if self.pause_after_test:
+ self.logger.info("Pausing until the browser exits")
+ self.proc.wait()
+ else:
+ self.proc.kill()
+ except: # noqa
+ self.proc.kill()
+ raise
+
+ return result
+
+ def on_output(self, line):
+ prefix = "ALERT: RESULT: "
+ line = line.decode("utf8", "replace")
+ if line.startswith(prefix):
+ self.result_data = json.loads(line[len(prefix):])
+ self.result_flag.set()
+ else:
+ if self.interactive:
+ print(line)
+ else:
+ self.logger.process_output(self.proc.pid,
+ line,
+ " ".join(self.command))
+
+ def on_finish(self):
+ self.result_flag.set()
+
+
+class TempFilename:
+ def __init__(self, directory):
+ self.directory = directory
+ self.path = None
+
+ def __enter__(self):
+ self.path = os.path.join(self.directory, str(uuid.uuid4()))
+ return self.path
+
+ def __exit__(self, *args, **kwargs):
+ try:
+ os.unlink(self.path)
+ except OSError:
+ pass
+
+
+class ServoRefTestExecutor(ProcessTestExecutor):
+ convert_result = reftest_result_converter
+
+ def __init__(self, logger, browser, server_config, binary=None, timeout_multiplier=1,
+ screenshot_cache=None, debug_info=None, pause_after_test=False,
+ reftest_screenshot="unexpected", **kwargs):
+ ProcessTestExecutor.__init__(self,
+ logger,
+ browser,
+ server_config,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info,
+ reftest_screenshot=reftest_screenshot)
+
+ self.protocol = ConnectionlessProtocol(self, browser)
+ self.screenshot_cache = screenshot_cache
+ self.reftest_screenshot = reftest_screenshot
+ self.implementation = RefTestImplementation(self)
+ self.tempdir = tempfile.mkdtemp()
+ self.hosts_path = write_hosts_file(server_config)
+
+ def reset(self):
+ self.implementation.reset()
+
+ def teardown(self):
+ try:
+ os.unlink(self.hosts_path)
+ except OSError:
+ pass
+ os.rmdir(self.tempdir)
+ ProcessTestExecutor.teardown(self)
+
+ def screenshot(self, test, viewport_size, dpi, page_ranges):
+ with TempFilename(self.tempdir) as output_path:
+ extra_args = ["--exit",
+ "--output=%s" % output_path,
+ "--resolution", viewport_size or "800x600"]
+ debug_opts = "disable-text-aa,load-webfonts-synchronously,replace-surrogates"
+
+ if dpi:
+ extra_args += ["--device-pixel-ratio", dpi]
+
+ self.command = build_servo_command(test,
+ self.test_url,
+ self.browser,
+ self.binary,
+ False,
+ self.debug_info,
+ extra_args,
+ debug_opts)
+
+ env = os.environ.copy()
+ env["HOST_FILE"] = self.hosts_path
+ env["RUST_BACKTRACE"] = "1"
+
+ if not self.interactive:
+ self.proc = ProcessHandler(self.command,
+ processOutputLine=[self.on_output],
+ env=env)
+
+
+ try:
+ self.proc.run()
+ timeout = test.timeout * self.timeout_multiplier + 5
+ rv = self.proc.wait(timeout=timeout)
+ except KeyboardInterrupt:
+ self.proc.kill()
+ raise
+ else:
+ self.proc = subprocess.Popen(self.command,
+ env=env)
+ try:
+ rv = self.proc.wait()
+ except KeyboardInterrupt:
+ self.proc.kill()
+ raise
+
+ if rv is None:
+ self.proc.kill()
+ return False, ("EXTERNAL-TIMEOUT", None)
+
+ if rv != 0 or not os.path.exists(output_path):
+ return False, ("CRASH", None)
+
+ with open(output_path, "rb") as f:
+ # Might need to strip variable headers or something here
+ data = f.read()
+ # Returning the screenshot as a string could potentially be avoided,
+ # see https://github.com/web-platform-tests/wpt/issues/28929.
+ return True, [base64.b64encode(data).decode()]
+
+ def do_test(self, test):
+ result = self.implementation.run_test(test)
+
+ return self.convert_result(test, result)
+
+ def on_output(self, line):
+ line = line.decode("utf8", "replace")
+ if self.interactive:
+ print(line)
+ else:
+ self.logger.process_output(self.proc.pid,
+ line,
+ " ".join(self.command))
+
+
+class ServoTimedRunner(TimedRunner):
+ def run_func(self):
+ try:
+ self.result = True, self.func(self.protocol, self.url, self.timeout)
+ except Exception as e:
+ message = getattr(e, "message", "")
+ if message:
+ message += "\n"
+ message += traceback.format_exc(e)
+ self.result = False, ("INTERNAL-ERROR", message)
+ finally:
+ self.result_flag.set()
+
+ def set_timeout(self):
+ pass
+
+
+class ServoCrashtestExecutor(ProcessTestExecutor):
+ convert_result = crashtest_result_converter
+
+ def __init__(self, logger, browser, server_config, binary=None, timeout_multiplier=1,
+ screenshot_cache=None, debug_info=None, pause_after_test=False,
+ **kwargs):
+ ProcessTestExecutor.__init__(self,
+ logger,
+ browser,
+ server_config,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+
+ self.pause_after_test = pause_after_test
+ self.protocol = ConnectionlessProtocol(self, browser)
+ self.tempdir = tempfile.mkdtemp()
+ self.hosts_path = write_hosts_file(server_config)
+
+ def do_test(self, test):
+ timeout = (test.timeout * self.timeout_multiplier if self.debug_info is None
+ else None)
+
+ test_url = self.test_url(test)
+ # We want to pass the full test object into build_servo_command,
+ # so stash it in the class
+ self.test = test
+ success, data = ServoTimedRunner(self.logger, self.do_crashtest, self.protocol,
+ test_url, timeout, self.extra_timeout).run()
+ # Ensure that no processes hang around if they timeout.
+ self.proc.kill()
+
+ if success:
+ return self.convert_result(test, data)
+
+ return (test.result_cls(*data), [])
+
+ def do_crashtest(self, protocol, url, timeout):
+ env = os.environ.copy()
+ env["HOST_FILE"] = self.hosts_path
+ env["RUST_BACKTRACE"] = "1"
+
+ command = build_servo_command(self.test,
+ self.test_url,
+ self.browser,
+ self.binary,
+ False,
+ self.debug_info,
+ extra_args=["-x"])
+
+ if not self.interactive:
+ self.proc = ProcessHandler(command,
+ env=env,
+ storeOutput=False)
+ self.proc.run()
+ else:
+ self.proc = subprocess.Popen(command, env=env)
+
+ self.proc.wait()
+
+ if self.proc.poll() >= 0:
+ return {"status": "PASS", "message": None}
+
+ return {"status": "CRASH", "message": None}
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorservodriver.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorservodriver.py
new file mode 100644
index 0000000000..0a939c5251
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorservodriver.py
@@ -0,0 +1,303 @@
+# mypy: allow-untyped-defs
+
+import json
+import os
+import socket
+import traceback
+
+from .base import (Protocol,
+ RefTestExecutor,
+ RefTestImplementation,
+ TestharnessExecutor,
+ TimedRunner,
+ strip_server)
+from .protocol import BaseProtocolPart
+from ..environment import wait_for_service
+
+webdriver = None
+ServoCommandExtensions = None
+
+here = os.path.dirname(__file__)
+
+
+def do_delayed_imports():
+ global webdriver
+ import webdriver
+
+ global ServoCommandExtensions
+
+ class ServoCommandExtensions:
+ def __init__(self, session):
+ self.session = session
+
+ @webdriver.client.command
+ def get_prefs(self, *prefs):
+ body = {"prefs": list(prefs)}
+ return self.session.send_session_command("POST", "servo/prefs/get", body)
+
+ @webdriver.client.command
+ def set_prefs(self, prefs):
+ body = {"prefs": prefs}
+ return self.session.send_session_command("POST", "servo/prefs/set", body)
+
+ @webdriver.client.command
+ def reset_prefs(self, *prefs):
+ body = {"prefs": list(prefs)}
+ return self.session.send_session_command("POST", "servo/prefs/reset", body)
+
+ def change_prefs(self, old_prefs, new_prefs):
+ # Servo interprets reset with an empty list as reset everything
+ if old_prefs:
+ self.reset_prefs(*old_prefs.keys())
+ self.set_prefs({k: parse_pref_value(v) for k, v in new_prefs.items()})
+
+
+# See parse_pref_from_command_line() in components/config/opts.rs
+def parse_pref_value(value):
+ if value == "true":
+ return True
+ if value == "false":
+ return False
+ try:
+ return float(value)
+ except ValueError:
+ return value
+
+
+class ServoBaseProtocolPart(BaseProtocolPart):
+ def execute_script(self, script, asynchronous=False):
+ pass
+
+ def set_timeout(self, timeout):
+ pass
+
+ def wait(self):
+ return False
+
+ def set_window(self, handle):
+ pass
+
+ def window_handles(self):
+ return []
+
+ def load(self, url):
+ pass
+
+
+class ServoWebDriverProtocol(Protocol):
+ implements = [ServoBaseProtocolPart]
+
+ def __init__(self, executor, browser, capabilities, **kwargs):
+ do_delayed_imports()
+ Protocol.__init__(self, executor, browser)
+ self.capabilities = capabilities
+ self.host = browser.webdriver_host
+ self.port = browser.webdriver_port
+ self.init_timeout = browser.init_timeout
+ self.session = None
+
+ def connect(self):
+ """Connect to browser via WebDriver."""
+ wait_for_service(self.logger, self.host, self.port, timeout=self.init_timeout)
+
+ self.session = webdriver.Session(self.host, self.port, extension=ServoCommandExtensions)
+ self.session.start()
+
+ def after_connect(self):
+ pass
+
+ def teardown(self):
+ self.logger.debug("Hanging up on WebDriver session")
+ try:
+ self.session.end()
+ except Exception:
+ pass
+
+ def is_alive(self):
+ try:
+ # Get a simple property over the connection
+ self.session.window_handle
+ # TODO what exception?
+ except Exception:
+ return False
+ return True
+
+ def wait(self):
+ while True:
+ try:
+ return self.session.execute_async_script("""let callback = arguments[arguments.length - 1];
+addEventListener("__test_restart", e => {e.preventDefault(); callback(true)})""")
+ except webdriver.TimeoutException:
+ pass
+ except (socket.timeout, OSError):
+ break
+ except Exception:
+ self.logger.error(traceback.format_exc())
+ break
+ return False
+
+
+class ServoWebDriverRun(TimedRunner):
+ def set_timeout(self):
+ pass
+
+ def run_func(self):
+ try:
+ self.result = True, self.func(self.protocol.session, self.url, self.timeout)
+ except webdriver.TimeoutException:
+ self.result = False, ("EXTERNAL-TIMEOUT", None)
+ except (socket.timeout, OSError):
+ self.result = False, ("CRASH", None)
+ except Exception as e:
+ message = getattr(e, "message", "")
+ if message:
+ message += "\n"
+ message += traceback.format_exc()
+ self.result = False, ("INTERNAL-ERROR", e)
+ finally:
+ self.result_flag.set()
+
+
+class ServoWebDriverTestharnessExecutor(TestharnessExecutor):
+ supports_testdriver = True
+
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ close_after_done=True, capabilities=None, debug_info=None,
+ **kwargs):
+ TestharnessExecutor.__init__(self, logger, browser, server_config, timeout_multiplier=1,
+ debug_info=None)
+ self.protocol = ServoWebDriverProtocol(self, browser, capabilities=capabilities)
+ with open(os.path.join(here, "testharness_servodriver.js")) as f:
+ self.script = f.read()
+ self.timeout = None
+
+ def on_protocol_change(self, new_protocol):
+ pass
+
+ def is_alive(self):
+ return self.protocol.is_alive()
+
+ def do_test(self, test):
+ url = self.test_url(test)
+
+ timeout = test.timeout * self.timeout_multiplier + self.extra_timeout
+
+ if timeout != self.timeout:
+ try:
+ self.protocol.session.timeouts.script = timeout
+ self.timeout = timeout
+ except OSError:
+ msg = "Lost WebDriver connection"
+ self.logger.error(msg)
+ return ("INTERNAL-ERROR", msg)
+
+ success, data = ServoWebDriverRun(self.logger,
+ self.do_testharness,
+ self.protocol,
+ url,
+ timeout,
+ self.extra_timeout).run()
+
+ if success:
+ return self.convert_result(test, data)
+
+ return (test.result_cls(*data), [])
+
+ def do_testharness(self, session, url, timeout):
+ session.url = url
+ result = json.loads(
+ session.execute_async_script(
+ self.script % {"abs_url": url,
+ "url": strip_server(url),
+ "timeout_multiplier": self.timeout_multiplier,
+ "timeout": timeout * 1000}))
+ # Prevent leaking every page in history until Servo develops a more sane
+ # page cache
+ session.back()
+ return result
+
+ def on_environment_change(self, new_environment):
+ self.protocol.session.extension.change_prefs(
+ self.last_environment.get("prefs", {}),
+ new_environment.get("prefs", {})
+ )
+
+
+class TimeoutError(Exception):
+ pass
+
+
+class ServoWebDriverRefTestExecutor(RefTestExecutor):
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ screenshot_cache=None, capabilities=None, debug_info=None,
+ **kwargs):
+ """Selenium WebDriver-based executor for reftests"""
+ RefTestExecutor.__init__(self,
+ logger,
+ browser,
+ server_config,
+ screenshot_cache=screenshot_cache,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+ self.protocol = ServoWebDriverProtocol(self, browser,
+ capabilities=capabilities)
+ self.implementation = RefTestImplementation(self)
+ self.timeout = None
+ with open(os.path.join(here, "test-wait.js")) as f:
+ self.wait_script = f.read() % {"classname": "reftest-wait"}
+
+ def reset(self):
+ self.implementation.reset()
+
+ def is_alive(self):
+ return self.protocol.is_alive()
+
+ def do_test(self, test):
+ try:
+ result = self.implementation.run_test(test)
+ return self.convert_result(test, result)
+ except OSError:
+ return test.result_cls("CRASH", None), []
+ except TimeoutError:
+ return test.result_cls("TIMEOUT", None), []
+ except Exception as e:
+ message = getattr(e, "message", "")
+ if message:
+ message += "\n"
+ message += traceback.format_exc()
+ return test.result_cls("INTERNAL-ERROR", message), []
+
+ def screenshot(self, test, viewport_size, dpi, page_ranges):
+ # https://github.com/web-platform-tests/wpt/issues/7135
+ assert viewport_size is None
+ assert dpi is None
+
+ timeout = (test.timeout * self.timeout_multiplier + self.extra_timeout
+ if self.debug_info is None else None)
+
+ if self.timeout != timeout:
+ try:
+ self.protocol.session.timeouts.script = timeout
+ self.timeout = timeout
+ except OSError:
+ msg = "Lost webdriver connection"
+ self.logger.error(msg)
+ return ("INTERNAL-ERROR", msg)
+
+ return ServoWebDriverRun(self.logger,
+ self._screenshot,
+ self.protocol,
+ self.test_url(test),
+ timeout,
+ self.extra_timeout).run()
+
+ def _screenshot(self, session, url, timeout):
+ session.url = url
+ session.execute_async_script(self.wait_script)
+ return session.screenshot()
+
+ def on_environment_change(self, new_environment):
+ self.protocol.session.extension.change_prefs(
+ self.last_environment.get("prefs", {}),
+ new_environment.get("prefs", {})
+ )
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorwebdriver.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorwebdriver.py
new file mode 100644
index 0000000000..54a5717999
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/executorwebdriver.py
@@ -0,0 +1,694 @@
+# mypy: allow-untyped-defs
+
+import json
+import os
+import socket
+import threading
+import time
+import traceback
+import uuid
+from urllib.parse import urljoin
+
+from .base import (CallbackHandler,
+ CrashtestExecutor,
+ RefTestExecutor,
+ RefTestImplementation,
+ TestharnessExecutor,
+ TimedRunner,
+ strip_server)
+from .protocol import (BaseProtocolPart,
+ TestharnessProtocolPart,
+ Protocol,
+ SelectorProtocolPart,
+ ClickProtocolPart,
+ CookiesProtocolPart,
+ SendKeysProtocolPart,
+ ActionSequenceProtocolPart,
+ TestDriverProtocolPart,
+ GenerateTestReportProtocolPart,
+ SetPermissionProtocolPart,
+ VirtualAuthenticatorProtocolPart,
+ WindowProtocolPart,
+ DebugProtocolPart,
+ SPCTransactionsProtocolPart,
+ merge_dicts)
+
+from webdriver.client import Session
+from webdriver import error
+
+here = os.path.dirname(__file__)
+
+
+class WebDriverCallbackHandler(CallbackHandler):
+ unimplemented_exc = (NotImplementedError, error.UnknownCommandException)
+
+
+class WebDriverBaseProtocolPart(BaseProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def execute_script(self, script, asynchronous=False):
+ method = self.webdriver.execute_async_script if asynchronous else self.webdriver.execute_script
+ return method(script)
+
+ def set_timeout(self, timeout):
+ try:
+ self.webdriver.timeouts.script = timeout
+ except error.WebDriverException:
+ # workaround https://bugs.chromium.org/p/chromedriver/issues/detail?id=2057
+ body = {"type": "script", "ms": timeout * 1000}
+ self.webdriver.send_session_command("POST", "timeouts", body)
+
+ @property
+ def current_window(self):
+ return self.webdriver.window_handle
+
+ def set_window(self, handle):
+ self.webdriver.window_handle = handle
+
+ def window_handles(self):
+ return self.webdriver.handles
+
+ def load(self, url):
+ self.webdriver.url = url
+
+ def wait(self):
+ while True:
+ try:
+ self.webdriver.execute_async_script("""let callback = arguments[arguments.length - 1];
+addEventListener("__test_restart", e => {e.preventDefault(); callback(true)})""")
+ self.webdriver.execute_async_script("")
+ except (error.TimeoutException,
+ error.ScriptTimeoutException,
+ error.JavascriptErrorException):
+ # A JavascriptErrorException will happen when we navigate;
+ # by ignoring it it's possible to reload the test whilst the
+ # harness remains paused
+ pass
+ except (socket.timeout, error.NoSuchWindowException, error.UnknownErrorException, OSError):
+ break
+ except Exception:
+ self.logger.error(traceback.format_exc())
+ break
+ return False
+
+
+class WebDriverTestharnessProtocolPart(TestharnessProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+ self.runner_handle = None
+ with open(os.path.join(here, "runner.js")) as f:
+ self.runner_script = f.read()
+ with open(os.path.join(here, "window-loaded.js")) as f:
+ self.window_loaded_script = f.read()
+
+ def load_runner(self, url_protocol):
+ if self.runner_handle:
+ self.webdriver.window_handle = self.runner_handle
+ url = urljoin(self.parent.executor.server_url(url_protocol),
+ "/testharness_runner.html")
+ self.logger.debug("Loading %s" % url)
+
+ self.webdriver.url = url
+ self.runner_handle = self.webdriver.window_handle
+ format_map = {"title": threading.current_thread().name.replace("'", '"')}
+ self.parent.base.execute_script(self.runner_script % format_map)
+
+ def close_old_windows(self):
+ self.webdriver.actions.release()
+ handles = [item for item in self.webdriver.handles if item != self.runner_handle]
+ for handle in handles:
+ try:
+ self.webdriver.window_handle = handle
+ self.webdriver.window.close()
+ except error.NoSuchWindowException:
+ pass
+ self.webdriver.window_handle = self.runner_handle
+ return self.runner_handle
+
+ def get_test_window(self, window_id, parent, timeout=5):
+ """Find the test window amongst all the open windows.
+ This is assumed to be either the named window or the one after the parent in the list of
+ window handles
+
+ :param window_id: The DOM name of the Window
+ :param parent: The handle of the runner window
+ :param timeout: The time in seconds to wait for the window to appear. This is because in
+ some implementations there's a race between calling window.open and the
+ window being added to the list of WebDriver accessible windows."""
+ test_window = None
+ end_time = time.time() + timeout
+ while time.time() < end_time:
+ try:
+ # Try using the JSON serialization of the WindowProxy object,
+ # it's in Level 1 but nothing supports it yet
+ win_s = self.webdriver.execute_script("return window['%s'];" % window_id)
+ win_obj = json.loads(win_s)
+ test_window = win_obj["window-fcc6-11e5-b4f8-330a88ab9d7f"]
+ except Exception:
+ pass
+
+ if test_window is None:
+ after = self.webdriver.handles
+ if len(after) == 2:
+ test_window = next(iter(set(after) - {parent}))
+ elif after[0] == parent and len(after) > 2:
+ # Hope the first one here is the test window
+ test_window = after[1]
+
+ if test_window is not None:
+ assert test_window != parent
+ return test_window
+
+ time.sleep(0.1)
+
+ raise Exception("unable to find test window")
+
+ def test_window_loaded(self):
+ """Wait until the page in the new window has been loaded.
+
+ Hereby ignore Javascript execptions that are thrown when
+ the document has been unloaded due to a process change.
+ """
+ while True:
+ try:
+ self.webdriver.execute_script(self.window_loaded_script, asynchronous=True)
+ break
+ except error.JavascriptErrorException:
+ pass
+
+
+class WebDriverSelectorProtocolPart(SelectorProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def elements_by_selector(self, selector):
+ return self.webdriver.find.css(selector)
+
+
+class WebDriverClickProtocolPart(ClickProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def element(self, element):
+ self.logger.info("click " + repr(element))
+ return element.click()
+
+
+class WebDriverCookiesProtocolPart(CookiesProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def delete_all_cookies(self):
+ self.logger.info("Deleting all cookies")
+ return self.webdriver.send_session_command("DELETE", "cookie")
+
+ def get_all_cookies(self):
+ self.logger.info("Getting all cookies")
+ return self.webdriver.send_session_command("GET", "cookie")
+
+ def get_named_cookie(self, name):
+ self.logger.info("Getting cookie named %s" % name)
+ try:
+ return self.webdriver.send_session_command("GET", "cookie/%s" % name)
+ except error.NoSuchCookieException:
+ return None
+
+class WebDriverWindowProtocolPart(WindowProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def minimize(self):
+ self.logger.info("Minimizing")
+ return self.webdriver.window.minimize()
+
+ def set_rect(self, rect):
+ self.logger.info("Restoring")
+ self.webdriver.window.rect = rect
+
+class WebDriverSendKeysProtocolPart(SendKeysProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def send_keys(self, element, keys):
+ try:
+ return element.send_keys(keys)
+ except error.UnknownErrorException as e:
+ # workaround https://bugs.chromium.org/p/chromedriver/issues/detail?id=1999
+ if (e.http_status != 500 or
+ e.status_code != "unknown error"):
+ raise
+ return element.send_element_command("POST", "value", {"value": list(keys)})
+
+
+class WebDriverActionSequenceProtocolPart(ActionSequenceProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def send_actions(self, actions):
+ self.webdriver.actions.perform(actions['actions'])
+
+ def release(self):
+ self.webdriver.actions.release()
+
+
+class WebDriverTestDriverProtocolPart(TestDriverProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def send_message(self, cmd_id, message_type, status, message=None):
+ obj = {
+ "cmd_id": cmd_id,
+ "type": "testdriver-%s" % str(message_type),
+ "status": str(status)
+ }
+ if message:
+ obj["message"] = str(message)
+ self.webdriver.execute_script("window.postMessage(%s, '*')" % json.dumps(obj))
+
+ def _switch_to_frame(self, index_or_elem):
+ try:
+ self.webdriver.switch_frame(index_or_elem)
+ except (error.StaleElementReferenceException,
+ error.NoSuchFrameException) as e:
+ raise ValueError from e
+
+ def _switch_to_parent_frame(self):
+ self.webdriver.switch_frame("parent")
+
+
+class WebDriverGenerateTestReportProtocolPart(GenerateTestReportProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def generate_test_report(self, message):
+ json_message = {"message": message}
+ self.webdriver.send_session_command("POST", "reporting/generate_test_report", json_message)
+
+
+class WebDriverSetPermissionProtocolPart(SetPermissionProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def set_permission(self, descriptor, state):
+ permission_params_dict = {
+ "descriptor": descriptor,
+ "state": state,
+ }
+ self.webdriver.send_session_command("POST", "permissions", permission_params_dict)
+
+
+class WebDriverVirtualAuthenticatorProtocolPart(VirtualAuthenticatorProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def add_virtual_authenticator(self, config):
+ return self.webdriver.send_session_command("POST", "webauthn/authenticator", config)
+
+ def remove_virtual_authenticator(self, authenticator_id):
+ return self.webdriver.send_session_command("DELETE", "webauthn/authenticator/%s" % authenticator_id)
+
+ def add_credential(self, authenticator_id, credential):
+ return self.webdriver.send_session_command("POST", "webauthn/authenticator/%s/credential" % authenticator_id, credential)
+
+ def get_credentials(self, authenticator_id):
+ return self.webdriver.send_session_command("GET", "webauthn/authenticator/%s/credentials" % authenticator_id)
+
+ def remove_credential(self, authenticator_id, credential_id):
+ return self.webdriver.send_session_command("DELETE", f"webauthn/authenticator/{authenticator_id}/credentials/{credential_id}")
+
+ def remove_all_credentials(self, authenticator_id):
+ return self.webdriver.send_session_command("DELETE", "webauthn/authenticator/%s/credentials" % authenticator_id)
+
+ def set_user_verified(self, authenticator_id, uv):
+ return self.webdriver.send_session_command("POST", "webauthn/authenticator/%s/uv" % authenticator_id, uv)
+
+
+class WebDriverSPCTransactionsProtocolPart(SPCTransactionsProtocolPart):
+ def setup(self):
+ self.webdriver = self.parent.webdriver
+
+ def set_spc_transaction_mode(self, mode):
+ body = {"mode": mode}
+ return self.webdriver.send_session_command("POST", "secure-payment-confirmation/set-mode", body)
+
+
+class WebDriverDebugProtocolPart(DebugProtocolPart):
+ def load_devtools(self):
+ raise NotImplementedError()
+
+
+class WebDriverProtocol(Protocol):
+ implements = [WebDriverBaseProtocolPart,
+ WebDriverTestharnessProtocolPart,
+ WebDriverSelectorProtocolPart,
+ WebDriverClickProtocolPart,
+ WebDriverCookiesProtocolPart,
+ WebDriverSendKeysProtocolPart,
+ WebDriverWindowProtocolPart,
+ WebDriverActionSequenceProtocolPart,
+ WebDriverTestDriverProtocolPart,
+ WebDriverGenerateTestReportProtocolPart,
+ WebDriverSetPermissionProtocolPart,
+ WebDriverVirtualAuthenticatorProtocolPart,
+ WebDriverSPCTransactionsProtocolPart,
+ WebDriverDebugProtocolPart]
+
+ def __init__(self, executor, browser, capabilities, **kwargs):
+ super().__init__(executor, browser)
+ self.capabilities = capabilities
+ if hasattr(browser, "capabilities"):
+ if self.capabilities is None:
+ 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
+
+ def connect(self):
+ """Connect to browser via WebDriver."""
+ self.logger.debug("Connecting to WebDriver on URL: %s" % self.url)
+
+ host, port = self.url.split(":")[1].strip("/"), self.url.split(':')[-1].strip("/")
+
+ capabilities = {"alwaysMatch": self.capabilities}
+ self.webdriver = Session(host, port, capabilities=capabilities)
+ self.webdriver.start()
+
+ def teardown(self):
+ self.logger.debug("Hanging up on WebDriver session")
+ try:
+ self.webdriver.end()
+ except Exception as e:
+ message = str(getattr(e, "message", ""))
+ if message:
+ message += "\n"
+ message += traceback.format_exc()
+ self.logger.debug(message)
+ self.webdriver = None
+
+ def is_alive(self):
+ try:
+ # Get a simple property over the connection, with 2 seconds of timeout
+ # that should be more than enough to check if the WebDriver its
+ # still alive, and allows to complete the check within the testrunner
+ # 5 seconds of extra_timeout we have as maximum to end the test before
+ # the external timeout from testrunner triggers.
+ self.webdriver.send_session_command("GET", "window", timeout=2)
+ except (socket.timeout, error.UnknownErrorException, error.InvalidSessionIdException):
+ return False
+ return True
+
+ def after_connect(self):
+ self.testharness.load_runner(self.executor.last_environment["protocol"])
+
+
+class WebDriverRun(TimedRunner):
+ def set_timeout(self):
+ try:
+ self.protocol.base.set_timeout(self.timeout + self.extra_timeout)
+ except error.UnknownErrorException:
+ msg = "Lost WebDriver connection"
+ self.logger.error(msg)
+ return ("INTERNAL-ERROR", msg)
+
+ def run_func(self):
+ try:
+ self.result = True, self.func(self.protocol, self.url, self.timeout)
+ except (error.TimeoutException, error.ScriptTimeoutException):
+ self.result = False, ("EXTERNAL-TIMEOUT", None)
+ except (socket.timeout, error.UnknownErrorException):
+ self.result = False, ("CRASH", None)
+ except Exception as e:
+ if (isinstance(e, error.WebDriverException) and
+ e.http_status == 408 and
+ e.status_code == "asynchronous script timeout"):
+ # workaround for https://bugs.chromium.org/p/chromedriver/issues/detail?id=2001
+ self.result = False, ("EXTERNAL-TIMEOUT", None)
+ else:
+ message = str(getattr(e, "message", ""))
+ if message:
+ message += "\n"
+ message += traceback.format_exc()
+ self.result = False, ("INTERNAL-ERROR", message)
+ finally:
+ self.result_flag.set()
+
+
+class WebDriverTestharnessExecutor(TestharnessExecutor):
+ supports_testdriver = True
+ protocol_cls = WebDriverProtocol
+
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ close_after_done=True, capabilities=None, debug_info=None,
+ supports_eager_pageload=True, cleanup_after_test=True,
+ **kwargs):
+ """WebDriver-based executor for testharness.js tests"""
+ TestharnessExecutor.__init__(self, logger, browser, server_config,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+ self.protocol = self.protocol_cls(self, browser, capabilities)
+ with open(os.path.join(here, "testharness_webdriver_resume.js")) as f:
+ self.script_resume = f.read()
+ with open(os.path.join(here, "window-loaded.js")) as f:
+ self.window_loaded_script = f.read()
+
+ self.close_after_done = close_after_done
+ self.window_id = str(uuid.uuid4())
+ self.supports_eager_pageload = supports_eager_pageload
+ self.cleanup_after_test = cleanup_after_test
+
+ def is_alive(self):
+ return self.protocol.is_alive()
+
+ def on_environment_change(self, new_environment):
+ if new_environment["protocol"] != self.last_environment["protocol"]:
+ self.protocol.testharness.load_runner(new_environment["protocol"])
+
+ def do_test(self, test):
+ url = self.test_url(test)
+
+ success, data = WebDriverRun(self.logger,
+ self.do_testharness,
+ self.protocol,
+ url,
+ test.timeout * self.timeout_multiplier,
+ self.extra_timeout).run()
+
+ if success:
+ return self.convert_result(test, data)
+
+ return (test.result_cls(*data), [])
+
+ def do_testharness(self, protocol, url, timeout):
+ format_map = {"url": strip_server(url)}
+
+ # The previous test may not have closed its old windows (if something
+ # went wrong or if cleanup_after_test was False), so clean up here.
+ parent_window = protocol.testharness.close_old_windows()
+
+ # Now start the test harness
+ protocol.base.execute_script("window.open('about:blank', '%s', 'noopener')" % self.window_id)
+ test_window = protocol.testharness.get_test_window(self.window_id,
+ parent_window,
+ timeout=5*self.timeout_multiplier)
+ self.protocol.base.set_window(test_window)
+
+ # Wait until about:blank has been loaded
+ protocol.base.execute_script(self.window_loaded_script, asynchronous=True)
+
+ handler = WebDriverCallbackHandler(self.logger, protocol, test_window)
+ protocol.webdriver.url = url
+
+ if not self.supports_eager_pageload:
+ self.wait_for_load(protocol)
+
+ while True:
+ result = protocol.base.execute_script(
+ self.script_resume % format_map, asynchronous=True)
+
+ # As of 2019-03-29, WebDriver does not define expected behavior for
+ # cases where the browser crashes during script execution:
+ #
+ # https://github.com/w3c/webdriver/issues/1308
+ if not isinstance(result, list) or len(result) != 2:
+ try:
+ is_alive = self.is_alive()
+ except error.WebDriverException:
+ is_alive = False
+
+ if not is_alive:
+ raise Exception("Browser crashed during script execution.")
+
+ done, rv = handler(result)
+ if done:
+ break
+
+ # Attempt to cleanup any leftover windows, if allowed. This is
+ # preferable as it will blame the correct test if something goes wrong
+ # closing windows, but if the user wants to see the test results we
+ # have to leave the window(s) open.
+ if self.cleanup_after_test:
+ protocol.testharness.close_old_windows()
+
+ return rv
+
+ def wait_for_load(self, protocol):
+ # pageLoadStrategy=eager doesn't work in Chrome so try to emulate in user script
+ loaded = False
+ seen_error = False
+ while not loaded:
+ try:
+ loaded = protocol.base.execute_script("""
+var callback = arguments[arguments.length - 1];
+if (location.href === "about:blank") {
+ callback(false);
+} else if (document.readyState !== "loading") {
+ callback(true);
+} else {
+ document.addEventListener("readystatechange", () => {if (document.readyState !== "loading") {callback(true)}});
+}""", asynchronous=True)
+ except error.JavascriptErrorException:
+ # We can get an error here if the script runs in the initial about:blank
+ # document before it has navigated, with the driver returning an error
+ # indicating that the document was unloaded
+ if seen_error:
+ raise
+ seen_error = True
+
+
+class WebDriverRefTestExecutor(RefTestExecutor):
+ protocol_cls = WebDriverProtocol
+
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ screenshot_cache=None, close_after_done=True,
+ debug_info=None, capabilities=None, debug_test=False,
+ reftest_screenshot="unexpected", **kwargs):
+ """WebDriver-based executor for reftests"""
+ RefTestExecutor.__init__(self,
+ logger,
+ browser,
+ server_config,
+ screenshot_cache=screenshot_cache,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info,
+ reftest_screenshot=reftest_screenshot)
+ self.protocol = self.protocol_cls(self,
+ browser,
+ capabilities=capabilities)
+ self.implementation = RefTestImplementation(self)
+ self.close_after_done = close_after_done
+ self.has_window = False
+ self.debug_test = debug_test
+
+ with open(os.path.join(here, "test-wait.js")) as f:
+ self.wait_script = f.read() % {"classname": "reftest-wait"}
+
+ def reset(self):
+ self.implementation.reset()
+
+ def is_alive(self):
+ return self.protocol.is_alive()
+
+ def do_test(self, test):
+ width_offset, height_offset = self.protocol.webdriver.execute_script(
+ """return [window.outerWidth - window.innerWidth,
+ window.outerHeight - window.innerHeight];"""
+ )
+ try:
+ self.protocol.webdriver.window.position = (0, 0)
+ except error.InvalidArgumentException:
+ # Safari 12 throws with 0 or 1, treating them as bools; fixed in STP
+ self.protocol.webdriver.window.position = (2, 2)
+ self.protocol.webdriver.window.size = (800 + width_offset, 600 + height_offset)
+
+ result = self.implementation.run_test(test)
+
+ if self.debug_test and result["status"] in ["PASS", "FAIL", "ERROR"] and "extra" in result:
+ self.protocol.debug.load_reftest_analyzer(test, result)
+
+ return self.convert_result(test, result)
+
+ def screenshot(self, test, viewport_size, dpi, page_ranges):
+ # https://github.com/web-platform-tests/wpt/issues/7135
+ assert viewport_size is None
+ assert dpi is None
+
+ return WebDriverRun(self.logger,
+ self._screenshot,
+ self.protocol,
+ self.test_url(test),
+ test.timeout,
+ self.extra_timeout).run()
+
+ def _screenshot(self, protocol, url, timeout):
+ self.protocol.base.load(url)
+
+ self.protocol.base.execute_script(self.wait_script, True)
+
+ screenshot = self.protocol.webdriver.screenshot()
+ if screenshot is None:
+ raise ValueError('screenshot is None')
+
+ # strip off the data:img/png, part of the url
+ if screenshot.startswith("data:image/png;base64,"):
+ screenshot = screenshot.split(",", 1)[1]
+
+ return screenshot
+
+
+class WebDriverCrashtestExecutor(CrashtestExecutor):
+ protocol_cls = WebDriverProtocol
+
+ def __init__(self, logger, browser, server_config, timeout_multiplier=1,
+ screenshot_cache=None, close_after_done=True,
+ debug_info=None, capabilities=None, **kwargs):
+ """WebDriver-based executor for reftests"""
+ CrashtestExecutor.__init__(self,
+ logger,
+ browser,
+ server_config,
+ screenshot_cache=screenshot_cache,
+ timeout_multiplier=timeout_multiplier,
+ debug_info=debug_info)
+ self.protocol = self.protocol_cls(self,
+ browser,
+ capabilities=capabilities)
+
+ with open(os.path.join(here, "test-wait.js")) as f:
+ self.wait_script = f.read() % {"classname": "test-wait"}
+
+ def do_test(self, test):
+ timeout = (test.timeout * self.timeout_multiplier if self.debug_info is None
+ else None)
+
+ success, data = WebDriverRun(self.logger,
+ self.do_crashtest,
+ self.protocol,
+ self.test_url(test),
+ timeout,
+ self.extra_timeout).run()
+
+ if success:
+ return self.convert_result(test, data)
+
+ return (test.result_cls(*data), [])
+
+ def do_crashtest(self, protocol, url, timeout):
+ protocol.base.load(url)
+ protocol.base.execute_script(self.wait_script, asynchronous=True)
+
+ return {"status": "PASS",
+ "message": None}
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/process.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/process.py
new file mode 100644
index 0000000000..4a2c01372e
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/process.py
@@ -0,0 +1,22 @@
+# mypy: allow-untyped-defs
+
+from .base import TestExecutor
+
+
+class ProcessTestExecutor(TestExecutor):
+ def __init__(self, *args, **kwargs):
+ TestExecutor.__init__(self, *args, **kwargs)
+ self.binary = self.browser.binary
+ self.interactive = (False if self.debug_info is None
+ else self.debug_info.interactive)
+
+ def setup(self, runner):
+ self.runner = runner
+ self.runner.send_message("init_succeeded")
+ return True
+
+ def is_alive(self):
+ return True
+
+ def do_test(self, test):
+ raise NotImplementedError
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/protocol.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/protocol.py
new file mode 100644
index 0000000000..75e113c71d
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/protocol.py
@@ -0,0 +1,689 @@
+# mypy: allow-untyped-defs
+
+import traceback
+from http.client import HTTPConnection
+
+from abc import ABCMeta, abstractmethod
+from typing import ClassVar, List, Type
+
+
+def merge_dicts(target, source):
+ if not (isinstance(target, dict) and isinstance(source, dict)):
+ raise TypeError
+ for (key, source_value) in source.items():
+ if key not in target:
+ target[key] = source_value
+ else:
+ if isinstance(source_value, dict) and isinstance(target[key], dict):
+ merge_dicts(target[key], source_value)
+ else:
+ target[key] = source_value
+
+class Protocol:
+ """Backend for a specific browser-control protocol.
+
+ Each Protocol is composed of a set of ProtocolParts that implement
+ the APIs required for specific interactions. This reflects the fact
+ that not all implementaions will support exactly the same feature set.
+ Each ProtocolPart is exposed directly on the protocol through an accessor
+ attribute with a name given by its `name` property.
+
+ :param Executor executor: The Executor instance that's using this Protocol
+ :param Browser browser: The Browser using this protocol"""
+ __metaclass__ = ABCMeta
+
+ implements = [] # type: ClassVar[List[Type[ProtocolPart]]]
+
+ def __init__(self, executor, browser):
+ self.executor = executor
+ self.browser = browser
+
+ for cls in self.implements:
+ name = cls.name
+ assert not hasattr(self, name)
+ setattr(self, name, cls(self))
+
+ @property
+ def logger(self):
+ """:returns: Current logger"""
+ return self.executor.logger
+
+ def is_alive(self):
+ """Is the browser connection still active
+
+ :returns: A boolean indicating whether the connection is still active."""
+ return True
+
+ def setup(self, runner):
+ """Handle protocol setup, and send a message to the runner to indicate
+ success or failure."""
+ msg = None
+ try:
+ msg = "Failed to start protocol connection"
+ self.connect()
+
+ msg = None
+
+ for cls in self.implements:
+ getattr(self, cls.name).setup()
+
+ msg = "Post-connection steps failed"
+ self.after_connect()
+ except Exception:
+ if msg is not None:
+ self.logger.warning(msg)
+ self.logger.warning(traceback.format_exc())
+ raise
+
+ @abstractmethod
+ def connect(self):
+ """Make a connection to the remote browser"""
+ pass
+
+ @abstractmethod
+ def after_connect(self):
+ """Run any post-connection steps. This happens after the ProtocolParts are
+ initalized so can depend on a fully-populated object."""
+ pass
+
+ def teardown(self):
+ """Run cleanup steps after the tests are finished."""
+ for cls in self.implements:
+ getattr(self, cls.name).teardown()
+
+
+class ProtocolPart:
+ """Base class for all ProtocolParts.
+
+ :param Protocol parent: The parent protocol"""
+ __metaclass__ = ABCMeta
+
+ name = None # type: ClassVar[str]
+
+ def __init__(self, parent):
+ self.parent = parent
+
+ @property
+ def logger(self):
+ """:returns: Current logger"""
+ return self.parent.logger
+
+ def setup(self):
+ """Run any setup steps required for the ProtocolPart."""
+ pass
+
+ def teardown(self):
+ """Run any teardown steps required for the ProtocolPart."""
+ pass
+
+
+class BaseProtocolPart(ProtocolPart):
+ """Generic bits of protocol that are required for multiple test types"""
+ __metaclass__ = ABCMeta
+
+ name = "base"
+
+ @abstractmethod
+ def execute_script(self, script, asynchronous=False):
+ """Execute javascript in the current Window.
+
+ :param str script: The js source to execute. This is implicitly wrapped in a function.
+ :param bool asynchronous: Whether the script is asynchronous in the webdriver
+ sense i.e. whether the return value is the result of
+ the initial function call or if it waits for some callback.
+ :returns: The result of the script execution.
+ """
+ pass
+
+ @abstractmethod
+ def set_timeout(self, timeout):
+ """Set the timeout for script execution.
+
+ :param timeout: Script timeout in seconds"""
+ pass
+
+ @abstractmethod
+ def wait(self):
+ """Wait indefinitely for the browser to close.
+
+ :returns: True to re-run the test, or False to continue with the next test"""
+ pass
+
+ @property
+ def current_window(self):
+ """Return a handle identifying the current top level browsing context
+
+ :returns: A protocol-specific handle"""
+ pass
+
+ @abstractmethod
+ def set_window(self, handle):
+ """Set the top level browsing context to one specified by a given handle.
+
+ :param handle: A protocol-specific handle identifying a top level browsing
+ context."""
+ pass
+
+ @abstractmethod
+ def window_handles(self):
+ """Get a list of handles to top-level browsing contexts"""
+ pass
+
+ @abstractmethod
+ def load(self, url):
+ """Load a url in the current browsing context
+
+ :param url: The url to load"""
+ pass
+
+
+class TestharnessProtocolPart(ProtocolPart):
+ """Protocol part required to run testharness tests."""
+ __metaclass__ = ABCMeta
+
+ name = "testharness"
+
+ @abstractmethod
+ def load_runner(self, url_protocol):
+ """Load the initial page used to control the tests.
+
+ :param str url_protocol: "https" or "http" depending on the test metadata.
+ """
+ pass
+
+ @abstractmethod
+ def close_old_windows(self, url_protocol):
+ """Close existing windows except for the initial runner window.
+ After calling this method there must be exactly one open window that
+ contains the initial runner page.
+
+ :param str url_protocol: "https" or "http" depending on the test metadata.
+ """
+ pass
+
+ @abstractmethod
+ def get_test_window(self, window_id, parent):
+ """Get the window handle dorresponding to the window containing the
+ currently active test.
+
+ :param window_id: A string containing the DOM name of the Window that
+ contains the test, or None.
+ :param parent: The handle of the runner window.
+ :returns: A protocol-specific window handle.
+ """
+ pass
+
+ @abstractmethod
+ def test_window_loaded(self):
+ """Wait until the newly opened test window has been loaded."""
+
+
+class PrefsProtocolPart(ProtocolPart):
+ """Protocol part that allows getting and setting browser prefs."""
+ __metaclass__ = ABCMeta
+
+ name = "prefs"
+
+ @abstractmethod
+ def set(self, name, value):
+ """Set the named pref to value.
+
+ :param name: A pref name of browser-specific type
+ :param value: A pref value of browser-specific type"""
+ pass
+
+ @abstractmethod
+ def get(self, name):
+ """Get the current value of a named pref
+
+ :param name: A pref name of browser-specific type
+ :returns: A pref value of browser-specific type"""
+ pass
+
+ @abstractmethod
+ def clear(self, name):
+ """Reset the value of a named pref back to the default.
+
+ :param name: A pref name of browser-specific type"""
+ pass
+
+
+class StorageProtocolPart(ProtocolPart):
+ """Protocol part for manipulating browser storage."""
+ __metaclass__ = ABCMeta
+
+ name = "storage"
+
+ @abstractmethod
+ def clear_origin(self, url):
+ """Clear all the storage for a specified origin.
+
+ :param url: A url belonging to the origin"""
+ pass
+
+
+class SelectorProtocolPart(ProtocolPart):
+ """Protocol part for selecting elements on the page."""
+ __metaclass__ = ABCMeta
+
+ name = "select"
+
+ def element_by_selector(self, element_selector):
+ elements = self.elements_by_selector(element_selector)
+ if len(elements) == 0:
+ raise ValueError(f"Selector '{element_selector}' matches no elements")
+ elif len(elements) > 1:
+ raise ValueError(f"Selector '{element_selector}' matches multiple elements")
+ return elements[0]
+
+ @abstractmethod
+ def elements_by_selector(self, selector):
+ """Select elements matching a CSS selector
+
+ :param str selector: The CSS selector
+ :returns: A list of protocol-specific handles to elements"""
+ pass
+
+
+class ClickProtocolPart(ProtocolPart):
+ """Protocol part for performing trusted clicks"""
+ __metaclass__ = ABCMeta
+
+ name = "click"
+
+ @abstractmethod
+ def element(self, element):
+ """Perform a trusted click somewhere on a specific element.
+
+ :param element: A protocol-specific handle to an element."""
+ pass
+
+
+class CookiesProtocolPart(ProtocolPart):
+ """Protocol part for managing cookies"""
+ __metaclass__ = ABCMeta
+
+ name = "cookies"
+
+ @abstractmethod
+ def delete_all_cookies(self):
+ """Delete all cookies."""
+ pass
+
+ @abstractmethod
+ def get_all_cookies(self):
+ """Get all cookies."""
+ pass
+
+ @abstractmethod
+ def get_named_cookie(self, name):
+ """Get named cookie.
+
+ :param name: The name of the cookie to get."""
+ pass
+
+
+class SendKeysProtocolPart(ProtocolPart):
+ """Protocol part for performing trusted clicks"""
+ __metaclass__ = ABCMeta
+
+ name = "send_keys"
+
+ @abstractmethod
+ def send_keys(self, element, keys):
+ """Send keys to a specific element.
+
+ :param element: A protocol-specific handle to an element.
+ :param keys: A protocol-specific handle to a string of input keys."""
+ pass
+
+class WindowProtocolPart(ProtocolPart):
+ """Protocol part for manipulating the window"""
+ __metaclass__ = ABCMeta
+
+ name = "window"
+
+ @abstractmethod
+ def set_rect(self, rect):
+ """Restores the window to the given rect."""
+ pass
+
+ @abstractmethod
+ def minimize(self):
+ """Minimizes the window and returns the previous rect."""
+ pass
+
+class GenerateTestReportProtocolPart(ProtocolPart):
+ """Protocol part for generating test reports"""
+ __metaclass__ = ABCMeta
+
+ name = "generate_test_report"
+
+ @abstractmethod
+ def generate_test_report(self, message):
+ """Generate a test report.
+
+ :param message: The message to be contained in the report."""
+ pass
+
+
+class SetPermissionProtocolPart(ProtocolPart):
+ """Protocol part for setting permissions"""
+ __metaclass__ = ABCMeta
+
+ name = "set_permission"
+
+ @abstractmethod
+ def set_permission(self, descriptor, state):
+ """Set permission state.
+
+ :param descriptor: A PermissionDescriptor object.
+ :param state: The state to set the permission to."""
+ pass
+
+
+class ActionSequenceProtocolPart(ProtocolPart):
+ """Protocol part for performing trusted clicks"""
+ __metaclass__ = ABCMeta
+
+ name = "action_sequence"
+
+ @abstractmethod
+ def send_actions(self, actions):
+ """Send a sequence of actions to the window.
+
+ :param actions: A protocol-specific handle to an array of actions."""
+ pass
+
+ def release(self):
+ pass
+
+
+class TestDriverProtocolPart(ProtocolPart):
+ """Protocol part that implements the basic functionality required for
+ all testdriver-based tests."""
+ __metaclass__ = ABCMeta
+
+ name = "testdriver"
+
+ @abstractmethod
+ def send_message(self, cmd_id, message_type, status, message=None):
+ """Send a testdriver message to the browser.
+
+ :param int cmd_id: The id of the command to which we're responding
+ :param str message_type: The kind of the message.
+ :param str status: Either "failure" or "success" depending on whether the
+ previous command succeeded.
+ :param str message: Additional data to add to the message."""
+ pass
+
+ def switch_to_window(self, wptrunner_id, initial_window=None):
+ """Switch to a window given a wptrunner window id
+
+ :param str wptrunner_id: Testdriver-specific id for the target window
+ :param str initial_window: WebDriver window id for the test window"""
+ if wptrunner_id is None:
+ return
+
+ if initial_window is None:
+ initial_window = self.parent.base.current_window
+
+ stack = [str(item) for item in self.parent.base.window_handles()]
+ first = True
+ while stack:
+ item = stack.pop()
+
+ if item is None:
+ assert first is False
+ self._switch_to_parent_frame()
+ continue
+
+ if isinstance(item, str):
+ if not first or item != initial_window:
+ self.parent.base.set_window(item)
+ first = False
+ else:
+ assert first is False
+ try:
+ self._switch_to_frame(item)
+ except ValueError:
+ # The frame no longer exists, or doesn't have a nested browsing context, so continue
+ continue
+
+ try:
+ # Get the window id and a list of elements containing nested browsing contexts.
+ # For embed we can't tell fpr sure if there's a nested browsing context, so always return it
+ # and fail later if there isn't
+ result = self.parent.base.execute_script("""
+ let contextParents = Array.from(document.querySelectorAll("frame, iframe, embed, object"))
+ .filter(elem => elem.localName !== "embed" ? (elem.contentWindow !== null) : true);
+ return [window.__wptrunner_id, contextParents]""")
+ except Exception:
+ continue
+
+ if result is None:
+ # With marionette at least this is possible if the content process crashed. Not quite
+ # sure how we want to handle that case.
+ continue
+
+ handle_window_id, nested_context_containers = result
+
+ if handle_window_id and str(handle_window_id) == wptrunner_id:
+ return
+
+ for elem in reversed(nested_context_containers):
+ # None here makes us switch back to the parent after we've processed the frame
+ stack.append(None)
+ stack.append(elem)
+
+ raise Exception("Window with id %s not found" % wptrunner_id)
+
+ @abstractmethod
+ def _switch_to_frame(self, index_or_elem):
+ """Switch to a frame in the current window
+
+ :param int index_or_elem: Frame id or container element"""
+ pass
+
+ @abstractmethod
+ def _switch_to_parent_frame(self):
+ """Switch to the parent of the current frame"""
+ pass
+
+
+class AssertsProtocolPart(ProtocolPart):
+ """ProtocolPart that implements the functionality required to get a count of non-fatal
+ assertions triggered"""
+ __metaclass__ = ABCMeta
+
+ name = "asserts"
+
+ @abstractmethod
+ def get(self):
+ """Get a count of assertions since the last browser start"""
+ pass
+
+
+class CoverageProtocolPart(ProtocolPart):
+ """Protocol part for collecting per-test coverage data."""
+ __metaclass__ = ABCMeta
+
+ name = "coverage"
+
+ @abstractmethod
+ def reset(self):
+ """Reset coverage counters"""
+ pass
+
+ @abstractmethod
+ def dump(self):
+ """Dump coverage counters"""
+ pass
+
+
+class VirtualAuthenticatorProtocolPart(ProtocolPart):
+ """Protocol part for creating and manipulating virtual authenticators"""
+ __metaclass__ = ABCMeta
+
+ name = "virtual_authenticator"
+
+ @abstractmethod
+ def add_virtual_authenticator(self, config):
+ """Add a virtual authenticator
+
+ :param config: The Authenticator Configuration"""
+ pass
+
+ @abstractmethod
+ def remove_virtual_authenticator(self, authenticator_id):
+ """Remove a virtual authenticator
+
+ :param str authenticator_id: The ID of the authenticator to remove"""
+ pass
+
+ @abstractmethod
+ def add_credential(self, authenticator_id, credential):
+ """Inject a credential onto an authenticator
+
+ :param str authenticator_id: The ID of the authenticator to add the credential to
+ :param credential: The credential to inject"""
+ pass
+
+ @abstractmethod
+ def get_credentials(self, authenticator_id):
+ """Get the credentials stored in an authenticator
+
+ :param str authenticator_id: The ID of the authenticator
+ :returns: An array with the credentials stored on the authenticator"""
+ pass
+
+ @abstractmethod
+ def remove_credential(self, authenticator_id, credential_id):
+ """Remove a credential stored in an authenticator
+
+ :param str authenticator_id: The ID of the authenticator
+ :param str credential_id: The ID of the credential"""
+ pass
+
+ @abstractmethod
+ def remove_all_credentials(self, authenticator_id):
+ """Remove all the credentials stored in an authenticator
+
+ :param str authenticator_id: The ID of the authenticator"""
+ pass
+
+ @abstractmethod
+ def set_user_verified(self, authenticator_id, uv):
+ """Sets the user verified flag on an authenticator
+
+ :param str authenticator_id: The ID of the authenticator
+ :param bool uv: the user verified flag"""
+ pass
+
+
+class SPCTransactionsProtocolPart(ProtocolPart):
+ """Protocol part for Secure Payment Confirmation transactions"""
+ __metaclass__ = ABCMeta
+
+ name = "spc_transactions"
+
+ @abstractmethod
+ def set_spc_transaction_mode(self, mode):
+ """Set the SPC transaction automation mode
+
+ :param str mode: The automation mode to set"""
+ pass
+
+
+class PrintProtocolPart(ProtocolPart):
+ """Protocol part for rendering to a PDF."""
+ __metaclass__ = ABCMeta
+
+ name = "pdf_print"
+
+ @abstractmethod
+ def render_as_pdf(self, width, height):
+ """Output document as PDF"""
+ pass
+
+
+class DebugProtocolPart(ProtocolPart):
+ """Protocol part for debugging test failures."""
+ __metaclass__ = ABCMeta
+
+ name = "debug"
+
+ @abstractmethod
+ def load_devtools(self):
+ """Load devtools in the current window"""
+ pass
+
+ def load_reftest_analyzer(self, test, result):
+ import io
+ import mozlog
+ from urllib.parse import quote, urljoin
+
+ debug_test_logger = mozlog.structuredlog.StructuredLogger("debug_test")
+ output = io.StringIO()
+ debug_test_logger.suite_start([])
+ debug_test_logger.add_handler(mozlog.handlers.StreamHandler(output, formatter=mozlog.formatters.TbplFormatter()))
+ debug_test_logger.test_start(test.id)
+ # Always use PASS as the expected value so we get output even for expected failures
+ debug_test_logger.test_end(test.id, result["status"], "PASS", extra=result.get("extra"))
+
+ self.parent.base.load(urljoin(self.parent.executor.server_url("https"),
+ "/common/third_party/reftest-analyzer.xhtml#log=%s" %
+ quote(output.getvalue())))
+
+
+class ConnectionlessBaseProtocolPart(BaseProtocolPart):
+ def load(self, url):
+ pass
+
+ def execute_script(self, script, asynchronous=False):
+ pass
+
+ def set_timeout(self, timeout):
+ pass
+
+ def wait(self):
+ return False
+
+ def set_window(self, handle):
+ pass
+
+ def window_handles(self):
+ return []
+
+
+class ConnectionlessProtocol(Protocol):
+ implements = [ConnectionlessBaseProtocolPart]
+
+ def connect(self):
+ pass
+
+ def after_connect(self):
+ pass
+
+
+class WdspecProtocol(ConnectionlessProtocol):
+ implements = [ConnectionlessBaseProtocolPart]
+
+ def __init__(self, executor, browser):
+ super().__init__(executor, browser)
+
+ def is_alive(self):
+ """Test that the connection is still alive.
+
+ Because the remote communication happens over HTTP we need to
+ make an explicit request to the remote. It is allowed for
+ WebDriver spec tests to not have a WebDriver session, since this
+ may be what is tested.
+
+ An HTTP request to an invalid path that results in a 404 is
+ proof enough to us that the server is alive and kicking.
+ """
+ conn = HTTPConnection(self.browser.host, self.browser.port)
+ conn.request("HEAD", "/invalid")
+ res = conn.getresponse()
+ return res.status == 404
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/pytestrunner/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/pytestrunner/__init__.py
new file mode 100644
index 0000000000..1baaf9573a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/pytestrunner/__init__.py
@@ -0,0 +1 @@
+from .runner import run # noqa: F401
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/pytestrunner/runner.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/pytestrunner/runner.py
new file mode 100644
index 0000000000..f520e095e8
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/pytestrunner/runner.py
@@ -0,0 +1,171 @@
+# mypy: allow-untyped-defs
+
+"""
+Provides interface to deal with pytest.
+
+Usage::
+
+ session = webdriver.client.Session("127.0.0.1", "4444", "/")
+ harness_result = ("OK", None)
+ subtest_results = pytestrunner.run("/path/to/test", session.url)
+ return (harness_result, subtest_results)
+"""
+
+import errno
+import json
+import os
+import shutil
+import tempfile
+from collections import OrderedDict
+
+
+pytest = None
+
+
+def do_delayed_imports():
+ global pytest
+ import pytest
+
+
+def run(path, server_config, session_config, timeout=0, environ=None):
+ """
+ Run Python test at ``path`` in pytest. The provided ``session``
+ is exposed as a fixture available in the scope of the test functions.
+
+ :param path: Path to the test file.
+ :param session_config: dictionary of host, port,capabilities parameters
+ to pass through to the webdriver session
+ :param timeout: Duration before interrupting potentially hanging
+ tests. If 0, there is no timeout.
+
+ :returns: (<harness result>, [<subtest result>, ...]),
+ where <subtest result> is (test id, status, message, stacktrace).
+ """
+ if pytest is None:
+ do_delayed_imports()
+
+ old_environ = os.environ.copy()
+ try:
+ with TemporaryDirectory() as cache:
+ config_path = os.path.join(cache, "wd_config.json")
+ os.environ["WDSPEC_CONFIG_FILE"] = config_path
+
+ config = session_config.copy()
+ config["wptserve"] = server_config.as_dict()
+
+ with open(config_path, "w") as f:
+ json.dump(config, f)
+
+ if environ:
+ os.environ.update(environ)
+
+ harness = HarnessResultRecorder()
+ subtests = SubtestResultRecorder()
+
+ try:
+ basetemp = os.path.join(cache, "pytest")
+ pytest.main(["--strict-markers", # turn function marker warnings into errors
+ "-vv", # show each individual subtest and full failure logs
+ "--capture", "no", # enable stdout/stderr from tests
+ "--basetemp", basetemp, # temporary directory
+ "--showlocals", # display contents of variables in local scope
+ "-p", "no:mozlog", # use the WPT result recorder
+ "-p", "no:cacheprovider", # disable state preservation across invocations
+ "-o=console_output_style=classic", # disable test progress bar
+ path],
+ plugins=[harness, subtests])
+ except Exception as e:
+ harness.outcome = ("INTERNAL-ERROR", str(e))
+
+ finally:
+ os.environ = old_environ
+
+ subtests_results = [(key,) + value for (key, value) in subtests.results.items()]
+ return (harness.outcome, subtests_results)
+
+
+class HarnessResultRecorder:
+ outcomes = {
+ "failed": "ERROR",
+ "passed": "OK",
+ "skipped": "SKIP",
+ }
+
+ def __init__(self):
+ # we are ok unless told otherwise
+ self.outcome = ("OK", None)
+
+ def pytest_collectreport(self, report):
+ harness_result = self.outcomes[report.outcome]
+ self.outcome = (harness_result, None)
+
+
+class SubtestResultRecorder:
+ def __init__(self):
+ self.results = OrderedDict()
+
+ def pytest_runtest_logreport(self, report):
+ if report.passed and report.when == "call":
+ self.record_pass(report)
+ elif report.failed:
+ # pytest outputs the stacktrace followed by an error message prefixed
+ # with "E ", e.g.
+ #
+ # def test_example():
+ # > assert "fuu" in "foobar"
+ # > E AssertionError: assert 'fuu' in 'foobar'
+ message = ""
+ for line in report.longreprtext.splitlines():
+ if line.startswith("E "):
+ message = line[1:].strip()
+ break
+
+ if report.when != "call":
+ self.record_error(report, message)
+ else:
+ self.record_fail(report, message)
+ elif report.skipped:
+ self.record_skip(report)
+
+ def record_pass(self, report):
+ self.record(report.nodeid, "PASS")
+
+ def record_fail(self, report, message):
+ self.record(report.nodeid, "FAIL", message=message, stack=report.longrepr)
+
+ def record_error(self, report, message):
+ # error in setup/teardown
+ message = f"{report.when} error: {message}"
+ self.record(report.nodeid, "ERROR", message, report.longrepr)
+
+ def record_skip(self, report):
+ self.record(report.nodeid, "ERROR",
+ "In-test skip decorators are disallowed, "
+ "please use WPT metadata to ignore tests.")
+
+ def record(self, test, status, message=None, stack=None):
+ if stack is not None:
+ stack = str(stack)
+ # Ensure we get a single result per subtest; pytest will sometimes
+ # call pytest_runtest_logreport more than once per test e.g. if
+ # it fails and then there's an error during teardown.
+ subtest_id = test.split("::")[-1]
+ if subtest_id in self.results and status == "PASS":
+ # This shouldn't happen, but never overwrite an existing result with PASS
+ return
+ new_result = (status, message, stack)
+ self.results[subtest_id] = new_result
+
+
+class TemporaryDirectory:
+ def __enter__(self):
+ self.path = tempfile.mkdtemp(prefix="wdspec-")
+ return self.path
+
+ def __exit__(self, *args):
+ try:
+ shutil.rmtree(self.path)
+ except OSError as e:
+ # no such file or directory
+ if e.errno != errno.ENOENT:
+ raise
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/reftest.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/reftest.js
new file mode 100644
index 0000000000..1ba98c686f
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/reftest.js
@@ -0,0 +1 @@
+var win = window.open("about:blank", "test", "left=0,top=0,width=800,height=600");
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/runner.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/runner.js
new file mode 100644
index 0000000000..171e6febd9
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/runner.js
@@ -0,0 +1 @@
+document.title = '%(title)s';
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/test-wait.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/test-wait.js
new file mode 100644
index 0000000000..ad08ad7d76
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/test-wait.js
@@ -0,0 +1,55 @@
+var callback = arguments[arguments.length - 1];
+var observer = null;
+var root = document.documentElement;
+
+function wait_load() {
+ if (Document.prototype.hasOwnProperty("fonts")) {
+ document.fonts.ready.then(wait_paints);
+ } else {
+ // This might take the screenshot too early, depending on whether the
+ // load event is blocked on fonts being loaded. See:
+ // https://github.com/w3c/csswg-drafts/issues/1088
+ wait_paints();
+ }
+}
+
+
+function wait_paints() {
+ // As of 2017-04-05, the Chromium web browser exhibits a rendering bug
+ // (https://bugs.chromium.org/p/chromium/issues/detail?id=708757) that
+ // produces instability during screen capture. The following use of
+ // `requestAnimationFrame` is intended as a short-term workaround, though
+ // it is not guaranteed to resolve the issue.
+ //
+ // For further detail, see:
+ // https://github.com/jugglinmike/chrome-screenshot-race/issues/1
+
+ requestAnimationFrame(function() {
+ requestAnimationFrame(function() {
+ screenshot_if_ready();
+ });
+ });
+}
+
+function screenshot_if_ready() {
+ if (root &&
+ root.classList.contains("%(classname)s") &&
+ observer === null) {
+ observer = new MutationObserver(wait_paints);
+ observer.observe(root, {attributes: true});
+ var event = new Event("TestRendered", {bubbles: true});
+ root.dispatchEvent(event);
+ return;
+ }
+ if (observer !== null) {
+ observer.disconnect();
+ }
+ callback();
+}
+
+
+if (document.readyState != "complete") {
+ addEventListener('load', wait_load);
+} else {
+ wait_load();
+}
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/testharness_servodriver.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/testharness_servodriver.js
new file mode 100644
index 0000000000..d731cc04d7
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/testharness_servodriver.js
@@ -0,0 +1,2 @@
+window.__wd_results_callback__ = arguments[arguments.length - 1];
+window.__wd_results_timer__ = setTimeout(timeout, %(timeout)s);
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/testharness_webdriver_resume.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/testharness_webdriver_resume.js
new file mode 100644
index 0000000000..36d086c974
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/testharness_webdriver_resume.js
@@ -0,0 +1,5 @@
+// We have to set the url here to ensure we get the same escaping as in the harness
+// and also to handle the case where the test changes the fragment
+window.__wptrunner_url = "%(url)s";
+window.__wptrunner_testdriver_callback = arguments[arguments.length - 1];
+window.__wptrunner_process_next_event();
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/window-loaded.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/window-loaded.js
new file mode 100644
index 0000000000..78d73285a4
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/executors/window-loaded.js
@@ -0,0 +1,9 @@
+const [resolve] = arguments;
+
+if (document.readyState != "complete") {
+ window.addEventListener("load", () => {
+ resolve();
+ }, { once: true });
+} else {
+ resolve();
+}
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/expected.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/expected.py
new file mode 100644
index 0000000000..72607ea25f
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/expected.py
@@ -0,0 +1,16 @@
+# mypy: allow-untyped-defs
+
+import os
+
+
+def expected_path(metadata_path, test_path):
+ """Path to the expectation data file for a given test path.
+
+ This is defined as metadata_path + relative_test_path + .ini
+
+ :param metadata_path: Path to the root of the metadata directory
+ :param test_path: Relative path to the test file from the test root
+ """
+ args = list(test_path.split("/"))
+ args[-1] += ".ini"
+ return os.path.join(metadata_path, *args)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/expectedtree.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/expectedtree.py
new file mode 100644
index 0000000000..88cf40ad94
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/expectedtree.py
@@ -0,0 +1,132 @@
+# mypy: allow-untyped-defs
+
+from math import log
+from collections import defaultdict
+
+class Node:
+ def __init__(self, prop, value):
+ self.prop = prop
+ self.value = value
+ self.parent = None
+
+ self.children = set()
+
+ # Populated for leaf nodes
+ self.run_info = set()
+ self.result_values = defaultdict(int)
+
+ def add(self, node):
+ self.children.add(node)
+ node.parent = self
+
+ def __iter__(self):
+ yield self
+ for node in self.children:
+ yield from node
+
+ def __len__(self):
+ return 1 + sum(len(item) for item in self.children)
+
+
+def entropy(results):
+ """This is basically a measure of the uniformity of the values in results
+ based on the shannon entropy"""
+
+ result_counts = defaultdict(int)
+ total = float(len(results))
+ for values in results.values():
+ # Not sure this is right, possibly want to treat multiple values as
+ # distinct from multiple of the same value?
+ for value in values:
+ result_counts[value] += 1
+
+ entropy_sum = 0
+
+ for count in result_counts.values():
+ prop = float(count) / total
+ entropy_sum -= prop * log(prop, 2)
+
+ return entropy_sum
+
+
+def split_results(prop, results):
+ """Split a dictionary of results into a dictionary of dictionaries where
+ each sub-dictionary has a specific value of the given property"""
+ by_prop = defaultdict(dict)
+ for run_info, value in results.items():
+ by_prop[run_info[prop]][run_info] = value
+
+ return by_prop
+
+
+def build_tree(properties, dependent_props, results, tree=None):
+ """Build a decision tree mapping properties to results
+
+ :param properties: - A list of run_info properties to consider
+ in the tree
+ :param dependent_props: - A dictionary mapping property name
+ to properties that should only be considered
+ after the properties in the key. For example
+ {"os": ["version"]} means that "version" won't
+ be used until after os.
+ :param results: Dictionary mapping run_info to set of results
+ :tree: A Node object to use as the root of the (sub)tree"""
+
+ if tree is None:
+ tree = Node(None, None)
+
+ prop_index = {prop: i for i, prop in enumerate(properties)}
+
+ all_results = defaultdict(int)
+ for result_values in results.values():
+ for result_value, count in result_values.items():
+ all_results[result_value] += count
+
+ # If there is only one result we are done
+ if not properties or len(all_results) == 1:
+ for value, count in all_results.items():
+ tree.result_values[value] += count
+ tree.run_info |= set(results.keys())
+ return tree
+
+ results_partitions = []
+ remove_properties = set()
+ for prop in properties:
+ result_sets = split_results(prop, results)
+ if len(result_sets) == 1:
+ # If this property doesn't partition the space then just remove it
+ # from the set to consider
+ remove_properties.add(prop)
+ continue
+ new_entropy = 0.
+ results_sets_entropy = []
+ for prop_value, result_set in result_sets.items():
+ results_sets_entropy.append((entropy(result_set), prop_value, result_set))
+ new_entropy += (float(len(result_set)) / len(results)) * results_sets_entropy[-1][0]
+
+ results_partitions.append((new_entropy,
+ prop,
+ results_sets_entropy))
+
+ # In the case that no properties partition the space
+ if not results_partitions:
+ for value, count in all_results.items():
+ tree.result_values[value] += count
+ tree.run_info |= set(results.keys())
+ return tree
+
+ # split by the property with the highest entropy
+ results_partitions.sort(key=lambda x: (x[0], prop_index[x[1]]))
+ _, best_prop, sub_results = results_partitions[0]
+
+ # Create a new set of properties that can be used
+ new_props = properties[:prop_index[best_prop]] + properties[prop_index[best_prop] + 1:]
+ new_props.extend(dependent_props.get(best_prop, []))
+ if remove_properties:
+ new_props = [item for item in new_props if item not in remove_properties]
+
+ for _, prop_value, results_sets in sub_results:
+ node = Node(best_prop, prop_value)
+ tree.add(node)
+ build_tree(new_props, dependent_props, results_sets, node)
+ return tree
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/font.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/font.py
new file mode 100644
index 0000000000..c533d70df7
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/font.py
@@ -0,0 +1,144 @@
+# mypy: allow-untyped-defs
+
+import ctypes
+import os
+import platform
+import plistlib
+
+from shutil import copy2, rmtree
+from subprocess import call, check_output
+
+HERE = os.path.dirname(__file__)
+SYSTEM = platform.system().lower()
+
+
+class FontInstaller:
+ def __init__(self, logger, font_dir=None, **fonts):
+ self.logger = logger
+ self.font_dir = font_dir
+ self.installed_fonts = False
+ self.created_dir = False
+ self.fonts = fonts
+
+ def __call__(self, env_options=None, env_config=None):
+ return self
+
+ def __enter__(self):
+ for _, font_path in self.fonts.items():
+ font_name = font_path.split('/')[-1]
+ install = getattr(self, 'install_%s_font' % SYSTEM, None)
+ if not install:
+ self.logger.warning('Font installation not supported on %s' % SYSTEM)
+ return False
+ if install(font_name, font_path):
+ self.installed_fonts = True
+ self.logger.info('Installed font: %s' % font_name)
+ else:
+ self.logger.warning('Unable to install font: %s' % font_name)
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ if not self.installed_fonts:
+ return False
+
+ for _, font_path in self.fonts.items():
+ font_name = font_path.split('/')[-1]
+ remove = getattr(self, 'remove_%s_font' % SYSTEM, None)
+ if not remove:
+ self.logger.warning('Font removal not supported on %s' % SYSTEM)
+ return False
+ if remove(font_name, font_path):
+ self.logger.info('Removed font: %s' % font_name)
+ else:
+ self.logger.warning('Unable to remove font: %s' % font_name)
+
+ def install_linux_font(self, font_name, font_path):
+ if not self.font_dir:
+ self.font_dir = os.path.join(os.path.expanduser('~'), '.fonts')
+ if not os.path.exists(self.font_dir):
+ os.makedirs(self.font_dir)
+ self.created_dir = True
+ if not os.path.exists(os.path.join(self.font_dir, font_name)):
+ copy2(font_path, self.font_dir)
+ try:
+ fc_cache_returncode = call('fc-cache')
+ return not fc_cache_returncode
+ except OSError: # If fontconfig doesn't exist, return False
+ self.logger.error('fontconfig not available on this Linux system.')
+ return False
+
+ def install_darwin_font(self, font_name, font_path):
+ if not self.font_dir:
+ self.font_dir = os.path.join(os.path.expanduser('~'),
+ 'Library/Fonts')
+ if not os.path.exists(self.font_dir):
+ os.makedirs(self.font_dir)
+ self.created_dir = True
+ installed_font_path = os.path.join(self.font_dir, font_name)
+ if not os.path.exists(installed_font_path):
+ copy2(font_path, self.font_dir)
+
+ # Per https://github.com/web-platform-tests/results-collection/issues/218
+ # installing Ahem on macOS is flaky, so check if it actually installed
+ with open(os.devnull, 'w') as f:
+ fonts = check_output(['/usr/sbin/system_profiler', '-xml', 'SPFontsDataType'], stderr=f)
+
+ try:
+ # if py3
+ load_plist = plistlib.loads
+ except AttributeError:
+ load_plist = plistlib.readPlistFromString
+ fonts = load_plist(fonts)
+ assert len(fonts) == 1
+ for font in fonts[0]['_items']:
+ if font['path'] == installed_font_path:
+ return True
+ return False
+
+ def install_windows_font(self, _, font_path):
+ hwnd_broadcast = 0xFFFF
+ wm_fontchange = 0x001D
+
+ gdi32 = ctypes.WinDLL('gdi32')
+ if gdi32.AddFontResourceW(font_path):
+ from ctypes import wintypes
+ wparam = 0
+ lparam = 0
+ SendNotifyMessageW = ctypes.windll.user32.SendNotifyMessageW
+ SendNotifyMessageW.argtypes = [wintypes.HANDLE, wintypes.UINT,
+ wintypes.WPARAM, wintypes.LPARAM]
+ return bool(SendNotifyMessageW(hwnd_broadcast, wm_fontchange,
+ wparam, lparam))
+
+ def remove_linux_font(self, font_name, _):
+ if self.created_dir:
+ rmtree(self.font_dir)
+ else:
+ os.remove(f'{self.font_dir}/{font_name}')
+ try:
+ fc_cache_returncode = call('fc-cache')
+ return not fc_cache_returncode
+ except OSError: # If fontconfig doesn't exist, return False
+ self.logger.error('fontconfig not available on this Linux system.')
+ return False
+
+ def remove_darwin_font(self, font_name, _):
+ if self.created_dir:
+ rmtree(self.font_dir)
+ else:
+ os.remove(os.path.join(self.font_dir, font_name))
+ return True
+
+ def remove_windows_font(self, _, font_path):
+ hwnd_broadcast = 0xFFFF
+ wm_fontchange = 0x001D
+
+ gdi32 = ctypes.WinDLL('gdi32')
+ if gdi32.RemoveFontResourceW(font_path):
+ from ctypes import wintypes
+ wparam = 0
+ lparam = 0
+ SendNotifyMessageW = ctypes.windll.user32.SendNotifyMessageW
+ SendNotifyMessageW.argtypes = [wintypes.HANDLE, wintypes.UINT,
+ wintypes.WPARAM, wintypes.LPARAM]
+ return bool(SendNotifyMessageW(hwnd_broadcast, wm_fontchange,
+ wparam, lparam))
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/__init__.py
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/chromium.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/chromium.py
new file mode 100644
index 0000000000..eca63d136b
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/chromium.py
@@ -0,0 +1,335 @@
+# mypy: allow-untyped-defs
+
+import functools
+import json
+import time
+
+from collections import defaultdict
+from mozlog.formatters import base
+
+from wptrunner.wptmanifest import serializer
+
+_escape_heading = functools.partial(serializer.escape, extras="]")
+
+
+class ChromiumFormatter(base.BaseFormatter): # type: ignore
+ """Formatter to produce results matching the Chromium JSON Test Results format.
+ https://chromium.googlesource.com/chromium/src/+/master/docs/testing/json_test_results_format.md
+
+ Notably, each test has an "artifacts" field that is a dict consisting of
+ "log": a list of strings (one per subtest + one for harness status, see
+ _append_test_message for the format)
+ "screenshots": a list of strings in the format of "url: base64"
+
+ """
+
+ def __init__(self):
+ # Whether the run was interrupted, either by the test runner or user.
+ self.interrupted = False
+
+ # A map of test status to the number of tests that had that status.
+ self.num_failures_by_status = defaultdict(int)
+
+ # Start time, expressed as offset since UNIX epoch in seconds. Measured
+ # from the first `suite_start` event.
+ self.start_timestamp_seconds = None
+
+ # A map of test names to test start timestamps, expressed in seconds
+ # since UNIX epoch. Only contains tests that are currently running
+ # (i.e., have not received the `test_end` event).
+ self.test_starts = {}
+
+ # Trie of test results. Each directory in the test name is a node in
+ # the trie and the leaf contains the dict of per-test data.
+ self.tests = {}
+
+ # Two dictionaries keyed by test name. Values are lists of strings:
+ # actual metadata content and other messages, respectively.
+ # See _append_test_message for examples.
+ self.actual_metadata = defaultdict(list)
+ self.messages = defaultdict(list)
+
+ # List of tests that have failing subtests.
+ self.tests_with_subtest_fails = set()
+
+ # Browser log for the current test under execution.
+ # These logs are from ChromeDriver's stdout/err, so we cannot say for
+ # sure which test a message is from, but instead we correlate them based
+ # on timing.
+ self.browser_log = []
+
+ def _append_test_message(self, test, subtest, wpt_actual_status, message):
+ r"""
+ Appends the message data for a test or subtest.
+
+ :param str test: the name of the test
+ :param str subtest: the name of the subtest with the message. Will be
+ None if this is called for a test.
+ :param str wpt_actual_status: the test status as reported by WPT
+ :param str message: the string to append to the message for this test
+
+ Example actual_metadata of a test with a subtest:
+ "[test_name]\n expected: OK\n"
+ " [subtest_name]\n expected: FAIL\n"
+
+ NOTE: throughout this function we output a key called "expected" but
+ fill it in with the actual status. This is by design. The goal of this
+ output is to look exactly like WPT's expectation metadata so that it
+ can be easily diff-ed.
+
+ Messages are appended verbatim to self.messages[test].
+ """
+ if subtest:
+ result = " [%s]\n expected: %s\n" % (_escape_heading(subtest),
+ wpt_actual_status)
+ self.actual_metadata[test].append(result)
+ if message:
+ self.messages[test].append("%s: %s\n" % (subtest, message))
+ else:
+ # No subtest, so this is the top-level test. The result must be
+ # prepended to the list, so that it comes before any subtest.
+ test_name_last_part = test.split("/")[-1]
+ result = "[%s]\n expected: %s\n" % (
+ _escape_heading(test_name_last_part), wpt_actual_status)
+ self.actual_metadata[test].insert(0, result)
+ if message:
+ self.messages[test].insert(0, "Harness: %s\n" % message)
+
+ def _append_artifact(self, cur_dict, artifact_name, artifact_value):
+ """
+ Appends artifacts to the specified dictionary.
+ :param dict cur_dict: the test leaf dictionary to append to
+ :param str artifact_name: the name of the artifact
+ :param str artifact_value: the value of the artifact
+ """
+ assert isinstance(artifact_value, str), "artifact_value must be a str"
+ if "artifacts" not in cur_dict.keys():
+ cur_dict["artifacts"] = defaultdict(list)
+ cur_dict["artifacts"][artifact_name].append(artifact_value)
+
+ def _store_test_result(self, name, actual, expected, actual_metadata,
+ messages, wpt_actual, subtest_failure,
+ duration=None, reftest_screenshots=None):
+ """
+ Stores the result of a single test in |self.tests|
+
+ :param str name: name of the test.
+ :param str actual: actual status of the test.
+ :param str expected: expected statuses of the test.
+ :param list actual_metadata: a list of metadata items.
+ :param list messages: a list of test messages.
+ :param str wpt_actual: actual status reported by wpt, may differ from |actual|.
+ :param bool subtest_failure: whether this test failed because of subtests.
+ :param Optional[float] duration: time it took in seconds to run this test.
+ :param Optional[list] reftest_screenshots: see executors/base.py for definition.
+ """
+ # The test name can contain a leading / which will produce an empty
+ # string in the first position of the list returned by split. We use
+ # filter(None) to remove such entries.
+ name_parts = filter(None, name.split("/"))
+ cur_dict = self.tests
+ for name_part in name_parts:
+ cur_dict = cur_dict.setdefault(name_part, {})
+ # Splitting and joining the list of statuses here avoids the need for
+ # recursively postprocessing the |tests| trie at shutdown. We assume the
+ # number of repetitions is typically small enough for the quadratic
+ # runtime to not matter.
+ statuses = cur_dict.get("actual", "").split()
+ statuses.append(actual)
+ cur_dict["actual"] = " ".join(statuses)
+ cur_dict["expected"] = expected
+ if duration is not None:
+ # Record the time to run the first invocation only.
+ cur_dict.setdefault("time", duration)
+ durations = cur_dict.setdefault("times", [])
+ durations.append(duration)
+ if subtest_failure:
+ self._append_artifact(cur_dict, "wpt_subtest_failure", "true")
+ if wpt_actual != actual:
+ self._append_artifact(cur_dict, "wpt_actual_status", wpt_actual)
+ if wpt_actual == 'CRASH':
+ for line in self.browser_log:
+ self._append_artifact(cur_dict, "wpt_crash_log", line)
+ for metadata in actual_metadata:
+ self._append_artifact(cur_dict, "wpt_actual_metadata", metadata)
+ for message in messages:
+ self._append_artifact(cur_dict, "wpt_log", message)
+
+ # Store screenshots (if any).
+ for item in reftest_screenshots or []:
+ if not isinstance(item, dict):
+ # Skip the relation string.
+ continue
+ data = "%s: %s" % (item["url"], item["screenshot"])
+ self._append_artifact(cur_dict, "screenshots", data)
+
+ # Figure out if there was a regression, unexpected status, or flake.
+ # This only happens for tests that were run
+ if actual != "SKIP":
+ if actual not in expected:
+ cur_dict["is_unexpected"] = True
+ if actual != "PASS":
+ cur_dict["is_regression"] = True
+ if len(set(statuses)) > 1:
+ cur_dict["is_flaky"] = True
+
+ # Update the count of how many tests ran with each status. Only includes
+ # the first invocation's result in the totals.
+ if len(statuses) == 1:
+ self.num_failures_by_status[actual] += 1
+
+ def _map_status_name(self, status):
+ """
+ Maps a WPT status to a Chromium status.
+
+ Chromium has five main statuses that we have to map to:
+ CRASH: the test harness crashed
+ FAIL: the test did not run as expected
+ PASS: the test ran as expected
+ SKIP: the test was not run
+ TIMEOUT: the did not finish in time and was aborted
+
+ :param str status: the string status of a test from WPT
+ :return: a corresponding string status for Chromium
+ """
+ if status == "OK":
+ return "PASS"
+ if status == "NOTRUN":
+ return "SKIP"
+ if status == "EXTERNAL-TIMEOUT":
+ return "TIMEOUT"
+ if status in ("ERROR", "PRECONDITION_FAILED"):
+ return "FAIL"
+ if status == "INTERNAL-ERROR":
+ return "CRASH"
+ # Any other status just gets returned as-is.
+ return status
+
+ def _get_expected_status_from_data(self, actual_status, data):
+ """
+ Gets the expected statuses from a |data| dictionary.
+
+ If there is no expected status in data, the actual status is returned.
+ This is because mozlog will delete "expected" from |data| if it is the
+ same as "status". So the presence of "expected" implies that "status" is
+ unexpected. Conversely, the absence of "expected" implies the "status"
+ is expected. So we use the "expected" status if it's there or fall back
+ to the actual status if it's not.
+
+ If the test has multiple statuses, it will have other statuses listed as
+ "known_intermittent" in |data|. If these exist, they will be added to
+ the returned status with spaced in between.
+
+ :param str actual_status: the actual status of the test
+ :param data: a data dictionary to extract expected status from
+ :return str: the expected statuses as a string
+ """
+ expected_statuses = self._map_status_name(data["expected"]) if "expected" in data else actual_status
+ if data.get("known_intermittent"):
+ all_statsues = {self._map_status_name(other_status) for other_status in data["known_intermittent"]}
+ all_statsues.add(expected_statuses)
+ expected_statuses = " ".join(sorted(all_statsues))
+ return expected_statuses
+
+ def _get_time(self, data):
+ """Get the timestamp of a message in seconds since the UNIX epoch."""
+ maybe_timestamp_millis = data.get("time")
+ if maybe_timestamp_millis is not None:
+ return float(maybe_timestamp_millis) / 1000
+ return time.time()
+
+ def _time_test(self, test_name, data):
+ """Time how long a test took to run.
+
+ :param str test_name: the name of the test to time
+ :param data: a data dictionary to extract the test end timestamp from
+ :return Optional[float]: a nonnegative duration in seconds or None if
+ the measurement is unavailable or invalid
+ """
+ test_start = self.test_starts.pop(test_name, None)
+ if test_start is not None:
+ # The |data| dictionary only provides millisecond resolution
+ # anyway, so further nonzero digits are unlikely to be meaningful.
+ duration = round(self._get_time(data) - test_start, 3)
+ if duration >= 0:
+ return duration
+ return None
+
+ def suite_start(self, data):
+ if self.start_timestamp_seconds is None:
+ self.start_timestamp_seconds = self._get_time(data)
+
+ def test_start(self, data):
+ test_name = data["test"]
+ self.test_starts[test_name] = self._get_time(data)
+
+ def test_status(self, data):
+ test_name = data["test"]
+ wpt_actual_status = data["status"]
+ actual_status = self._map_status_name(wpt_actual_status)
+ expected_statuses = self._get_expected_status_from_data(actual_status, data)
+
+ is_unexpected = actual_status not in expected_statuses
+ if is_unexpected and test_name not in self.tests_with_subtest_fails:
+ self.tests_with_subtest_fails.add(test_name)
+ # We should always get a subtest in the data dict, but it's technically
+ # possible that it's missing. Be resilient here.
+ subtest_name = data.get("subtest", "UNKNOWN SUBTEST")
+ self._append_test_message(test_name, subtest_name,
+ wpt_actual_status, data.get("message", ""))
+
+ def test_end(self, data):
+ test_name = data["test"]
+ # Save the status reported by WPT since we might change it when
+ # reporting to Chromium.
+ wpt_actual_status = data["status"]
+ actual_status = self._map_status_name(wpt_actual_status)
+ expected_statuses = self._get_expected_status_from_data(actual_status, data)
+ duration = self._time_test(test_name, data)
+ subtest_failure = False
+ if test_name in self.tests_with_subtest_fails:
+ subtest_failure = True
+ # Clean up the test list to avoid accumulating too many.
+ self.tests_with_subtest_fails.remove(test_name)
+ # This test passed but it has failing subtests. Since we can only
+ # report a single status to Chromium, we choose FAIL to indicate
+ # that something about this test did not run correctly.
+ if actual_status == "PASS":
+ actual_status = "FAIL"
+
+ self._append_test_message(test_name, None, wpt_actual_status,
+ data.get("message", ""))
+ self._store_test_result(test_name,
+ actual_status,
+ expected_statuses,
+ self.actual_metadata[test_name],
+ self.messages[test_name],
+ wpt_actual_status,
+ subtest_failure,
+ duration,
+ data.get("extra", {}).get("reftest_screenshots"))
+
+ # Remove the test from dicts to avoid accumulating too many.
+ self.actual_metadata.pop(test_name)
+ self.messages.pop(test_name)
+
+ # New test, new browser logs.
+ self.browser_log = []
+
+ def shutdown(self, data):
+ # Create the final result dictionary
+ final_result = {
+ # There are some required fields that we just hard-code.
+ "interrupted": False,
+ "path_delimiter": "/",
+ "version": 3,
+ "seconds_since_epoch": self.start_timestamp_seconds,
+ "num_failures_by_type": self.num_failures_by_status,
+ "tests": self.tests
+ }
+ return json.dumps(final_result)
+
+ def process_output(self, data):
+ cmd = data.get("command", "")
+ if any(c in cmd for c in ["chromedriver", "logcat"]):
+ self.browser_log.append(data['data'])
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/tests/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/tests/__init__.py
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/tests/test_chromium.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/tests/test_chromium.py
new file mode 100644
index 0000000000..bf815d5dc7
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/tests/test_chromium.py
@@ -0,0 +1,828 @@
+# mypy: ignore-errors
+
+import json
+import sys
+from os.path import dirname, join
+from io import StringIO
+
+from mozlog import handlers, structuredlog
+import pytest
+
+sys.path.insert(0, join(dirname(__file__), "..", ".."))
+from formatters.chromium import ChromiumFormatter
+
+
+@pytest.fixture
+def logger():
+ test_logger = structuredlog.StructuredLogger("test_a")
+ try:
+ yield test_logger
+ finally:
+ # Loggers of the same name share state globally:
+ # https://searchfox.org/mozilla-central/rev/1c54648c082efdeb08cf6a5e3a8187e83f7549b9/testing/mozbase/mozlog/mozlog/structuredlog.py#195-196
+ #
+ # Resetting the state here ensures the logger will not be shut down in
+ # the next test.
+ test_logger.reset_state()
+
+
+def test_chromium_required_fields(logger, capfd):
+ # Test that the test results contain a handful of required fields.
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # output a bunch of stuff
+ logger.suite_start(["test-id-1"], run_info={}, time=123)
+ logger.test_start("test-id-1")
+ logger.test_end("test-id-1", status="PASS", expected="PASS")
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_obj = json.load(output)
+
+ # Check for existence of required fields
+ assert "interrupted" in output_obj
+ assert "path_delimiter" in output_obj
+ assert "version" in output_obj
+ assert "num_failures_by_type" in output_obj
+ assert "tests" in output_obj
+
+ test_obj = output_obj["tests"]["test-id-1"]
+ assert "actual" in test_obj
+ assert "expected" in test_obj
+
+
+def test_time_per_test(logger, capfd):
+ # Test that the formatter measures time per test correctly.
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ logger.suite_start(["test-id-1", "test-id-2"], run_info={}, time=50)
+ logger.test_start("test-id-1", time=100)
+ logger.test_start("test-id-2", time=200)
+ logger.test_end("test-id-1", status="PASS", expected="PASS", time=300)
+ logger.test_end("test-id-2", status="PASS", expected="PASS", time=199)
+ logger.suite_end()
+
+ logger.suite_start(["test-id-1"], run_info={}, time=400)
+ logger.test_start("test-id-1", time=500)
+ logger.test_end("test-id-1", status="PASS", expected="PASS", time=600)
+ logger.suite_end()
+
+ # Write the final results.
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_obj = json.load(output)
+
+ test1_obj = output_obj["tests"]["test-id-1"]
+ test2_obj = output_obj["tests"]["test-id-2"]
+ # Test 1 run 1: 300ms - 100ms = 0.2s
+ # Test 1 run 2: 600ms - 500ms = 0.1s
+ assert test1_obj["time"] == pytest.approx(0.2)
+ assert len(test1_obj["times"]) == 2
+ assert test1_obj["times"][0] == pytest.approx(0.2)
+ assert test1_obj["times"][1] == pytest.approx(0.1)
+ assert "time" not in test2_obj
+ assert "times" not in test2_obj
+
+
+def test_chromium_test_name_trie(logger, capfd):
+ # Ensure test names are broken into directories and stored in a trie with
+ # test results at the leaves.
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # output a bunch of stuff
+ logger.suite_start(["/foo/bar/test-id-1", "/foo/test-id-2"], run_info={},
+ time=123)
+ logger.test_start("/foo/bar/test-id-1")
+ logger.test_end("/foo/bar/test-id-1", status="TIMEOUT", expected="FAIL")
+ logger.test_start("/foo/test-id-2")
+ logger.test_end("/foo/test-id-2", status="ERROR", expected="TIMEOUT")
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_obj = json.load(output)
+
+ # Ensure that the test names are broken up by directory name and that the
+ # results are stored at the leaves.
+ test_obj = output_obj["tests"]["foo"]["bar"]["test-id-1"]
+ assert test_obj["actual"] == "TIMEOUT"
+ assert test_obj["expected"] == "FAIL"
+
+ test_obj = output_obj["tests"]["foo"]["test-id-2"]
+ # The ERROR status is mapped to FAIL for Chromium
+ assert test_obj["actual"] == "FAIL"
+ assert test_obj["expected"] == "TIMEOUT"
+
+
+def test_num_failures_by_type(logger, capfd):
+ # Test that the number of failures by status type is correctly calculated.
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # Run some tests with different statuses: 3 passes, 1 timeout
+ logger.suite_start(["t1", "t2", "t3", "t4"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_end("t1", status="PASS", expected="PASS")
+ logger.test_start("t2")
+ logger.test_end("t2", status="PASS", expected="PASS")
+ logger.test_start("t3")
+ logger.test_end("t3", status="PASS", expected="FAIL")
+ logger.test_start("t4")
+ logger.test_end("t4", status="TIMEOUT", expected="CRASH")
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ num_failures_by_type = json.load(output)["num_failures_by_type"]
+
+ # We expect 3 passes and 1 timeout, nothing else.
+ assert sorted(num_failures_by_type.keys()) == ["PASS", "TIMEOUT"]
+ assert num_failures_by_type["PASS"] == 3
+ assert num_failures_by_type["TIMEOUT"] == 1
+
+
+def test_subtest_messages(logger, capfd):
+ # Tests accumulation of test output
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # Run two tests with subtest messages. The subtest name should be included
+ # in the output. We should also tolerate missing messages and subtest names
+ # with unusual characters.
+ logger.suite_start(["t1", "t2"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_status("t1", status="FAIL", subtest="t1_a",
+ message="t1_a_message")
+ # Subtest name includes a backslash and two closing square brackets.
+ logger.test_status("t1", status="PASS", subtest=r"t1_\[]]b",
+ message="t1_b_message")
+ logger.test_end("t1", status="PASS", expected="PASS")
+ logger.test_start("t2")
+ # Subtests with empty messages should not be ignored.
+ logger.test_status("t2", status="PASS", subtest="t2_a")
+ # A test-level message will also be appended
+ logger.test_end("t2", status="TIMEOUT", expected="PASS",
+ message="t2_message")
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ t1_artifacts = output_json["tests"]["t1"]["artifacts"]
+ assert t1_artifacts["wpt_actual_metadata"] == [
+ "[t1]\n expected: PASS\n",
+ " [t1_a]\n expected: FAIL\n",
+ " [t1_\\\\[\\]\\]b]\n expected: PASS\n",
+ ]
+ assert t1_artifacts["wpt_log"] == [
+ "t1_a: t1_a_message\n",
+ # Only humans will read the log, so there's no need to escape
+ # characters here.
+ "t1_\\[]]b: t1_b_message\n",
+ ]
+ assert t1_artifacts["wpt_subtest_failure"] == ["true"]
+ t2_artifacts = output_json["tests"]["t2"]["artifacts"]
+ assert t2_artifacts["wpt_actual_metadata"] == [
+ "[t2]\n expected: TIMEOUT\n",
+ " [t2_a]\n expected: PASS\n",
+ ]
+ assert t2_artifacts["wpt_log"] == [
+ "Harness: t2_message\n"
+ ]
+ assert "wpt_subtest_failure" not in t2_artifacts.keys()
+
+
+def test_subtest_failure(logger, capfd):
+ # Tests that a test fails if a subtest fails
+
+ # Set up the handler.
+ output = StringIO()
+ formatter = ChromiumFormatter()
+ logger.add_handler(handlers.StreamHandler(output, formatter))
+
+ # Run a test with some subtest failures.
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_status("t1", status="FAIL", subtest="t1_a",
+ message="t1_a_message")
+ logger.test_status("t1", status="PASS", subtest="t1_b",
+ message="t1_b_message")
+ logger.test_status("t1", status="TIMEOUT", subtest="t1_c",
+ message="t1_c_message")
+
+ # Make sure the test name was added to the set of tests with subtest fails
+ assert "t1" in formatter.tests_with_subtest_fails
+
+ # The test status is reported as a pass here because the harness was able to
+ # run the test to completion.
+ logger.test_end("t1", status="PASS", expected="PASS", message="top_message")
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ t1_artifacts = test_obj["artifacts"]
+ assert t1_artifacts["wpt_actual_metadata"] == [
+ "[t1]\n expected: PASS\n",
+ " [t1_a]\n expected: FAIL\n",
+ " [t1_b]\n expected: PASS\n",
+ " [t1_c]\n expected: TIMEOUT\n",
+ ]
+ assert t1_artifacts["wpt_log"] == [
+ "Harness: top_message\n",
+ "t1_a: t1_a_message\n",
+ "t1_b: t1_b_message\n",
+ "t1_c: t1_c_message\n",
+ ]
+ assert t1_artifacts["wpt_subtest_failure"] == ["true"]
+ # The status of the test in the output is a failure because subtests failed,
+ # despite the harness reporting that the test passed. But the harness status
+ # is logged as an artifact.
+ assert t1_artifacts["wpt_actual_status"] == ["PASS"]
+ assert test_obj["actual"] == "FAIL"
+ assert test_obj["expected"] == "PASS"
+ # Also ensure that the formatter cleaned up its internal state
+ assert "t1" not in formatter.tests_with_subtest_fails
+
+
+def test_expected_subtest_failure(logger, capfd):
+ # Tests that an expected subtest failure does not cause the test to fail
+
+ # Set up the handler.
+ output = StringIO()
+ formatter = ChromiumFormatter()
+ logger.add_handler(handlers.StreamHandler(output, formatter))
+
+ # Run a test with some expected subtest failures.
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_status("t1", status="FAIL", expected="FAIL", subtest="t1_a",
+ message="t1_a_message")
+ logger.test_status("t1", status="PASS", subtest="t1_b",
+ message="t1_b_message")
+ logger.test_status("t1", status="TIMEOUT", expected="TIMEOUT", subtest="t1_c",
+ message="t1_c_message")
+
+ # The subtest failures are all expected so this test should not be added to
+ # the set of tests with subtest failures.
+ assert "t1" not in formatter.tests_with_subtest_fails
+
+ # The test status is reported as a pass here because the harness was able to
+ # run the test to completion.
+ logger.test_end("t1", status="OK", expected="OK")
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ assert test_obj["artifacts"]["wpt_actual_metadata"] == [
+ "[t1]\n expected: OK\n",
+ " [t1_a]\n expected: FAIL\n",
+ " [t1_b]\n expected: PASS\n",
+ " [t1_c]\n expected: TIMEOUT\n",
+ ]
+ assert test_obj["artifacts"]["wpt_log"] == [
+ "t1_a: t1_a_message\n",
+ "t1_b: t1_b_message\n",
+ "t1_c: t1_c_message\n",
+ ]
+ # The status of the test in the output is a pass because the subtest
+ # failures were all expected.
+ assert test_obj["actual"] == "PASS"
+ assert test_obj["expected"] == "PASS"
+
+
+def test_unexpected_subtest_pass(logger, capfd):
+ # A subtest that unexpectedly passes is considered a failure condition.
+
+ # Set up the handler.
+ output = StringIO()
+ formatter = ChromiumFormatter()
+ logger.add_handler(handlers.StreamHandler(output, formatter))
+
+ # Run a test with a subtest that is expected to fail but passes.
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_status("t1", status="PASS", expected="FAIL", subtest="t1_a",
+ message="t1_a_message")
+
+ # Since the subtest behaviour is unexpected, it's considered a failure, so
+ # the test should be added to the set of tests with subtest failures.
+ assert "t1" in formatter.tests_with_subtest_fails
+
+ # The test status is reported as a pass here because the harness was able to
+ # run the test to completion.
+ logger.test_end("t1", status="PASS", expected="PASS")
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ t1_artifacts = test_obj["artifacts"]
+ assert t1_artifacts["wpt_actual_metadata"] == [
+ "[t1]\n expected: PASS\n",
+ " [t1_a]\n expected: PASS\n",
+ ]
+ assert t1_artifacts["wpt_log"] == [
+ "t1_a: t1_a_message\n",
+ ]
+ assert t1_artifacts["wpt_subtest_failure"] == ["true"]
+ # Since the subtest status is unexpected, we fail the test. But we report
+ # wpt_actual_status as an artifact
+ assert t1_artifacts["wpt_actual_status"] == ["PASS"]
+ assert test_obj["actual"] == "FAIL"
+ assert test_obj["expected"] == "PASS"
+ # Also ensure that the formatter cleaned up its internal state
+ assert "t1" not in formatter.tests_with_subtest_fails
+
+
+def test_expected_test_fail(logger, capfd):
+ # Check that an expected test-level failure is treated as a Pass
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # Run some tests with different statuses: 3 passes, 1 timeout
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_end("t1", status="ERROR", expected="ERROR")
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ # The test's actual and expected status should map from "ERROR" to "FAIL"
+ assert test_obj["actual"] == "FAIL"
+ assert test_obj["expected"] == "FAIL"
+ # ..and this test should not be a regression nor unexpected
+ assert "is_regression" not in test_obj
+ assert "is_unexpected" not in test_obj
+
+
+def test_unexpected_test_fail(logger, capfd):
+ # Check that an unexpected test-level failure is marked as unexpected and
+ # as a regression.
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # Run some tests with different statuses: 3 passes, 1 timeout
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_end("t1", status="ERROR", expected="OK")
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ # The test's actual and expected status should be mapped, ERROR->FAIL and
+ # OK->PASS
+ assert test_obj["actual"] == "FAIL"
+ assert test_obj["expected"] == "PASS"
+ # ..and this test should be a regression and unexpected
+ assert test_obj["is_regression"] is True
+ assert test_obj["is_unexpected"] is True
+
+
+def test_flaky_test_expected(logger, capfd):
+ # Check that a flaky test with multiple possible statuses is seen as
+ # expected if its actual status is one of the possible ones.
+
+ # set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # Run a test that is known to be flaky
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_end("t1", status="ERROR", expected="OK", known_intermittent=["ERROR", "TIMEOUT"])
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ # The test's statuses are all mapped, changing ERROR->FAIL and OK->PASS
+ assert test_obj["actual"] == "FAIL"
+ # All the possible statuses are merged and sorted together into expected.
+ assert test_obj["expected"] == "FAIL PASS TIMEOUT"
+ # ...this is not a regression or unexpected because the actual status is one
+ # of the expected ones
+ assert "is_regression" not in test_obj
+ assert "is_unexpected" not in test_obj
+
+
+def test_flaky_test_unexpected(logger, capfd):
+ # Check that a flaky test with multiple possible statuses is seen as
+ # unexpected if its actual status is NOT one of the possible ones.
+
+ # set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # Run a test that is known to be flaky
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_end("t1", status="ERROR", expected="OK", known_intermittent=["TIMEOUT"])
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ # The test's statuses are all mapped, changing ERROR->FAIL and OK->PASS
+ assert test_obj["actual"] == "FAIL"
+ # All the possible statuses are merged and sorted together into expected.
+ assert test_obj["expected"] == "PASS TIMEOUT"
+ # ...this is a regression and unexpected because the actual status is not
+ # one of the expected ones
+ assert test_obj["is_regression"] is True
+ assert test_obj["is_unexpected"] is True
+
+
+def test_precondition_failed(logger, capfd):
+ # Check that a failed precondition gets properly handled.
+
+ # set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # Run a test with a precondition failure
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_end("t1", status="PRECONDITION_FAILED", expected="OK")
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ # The precondition failure should map to FAIL status, but we should also
+ # have an artifact containing the original PRECONDITION_FAILED status.
+ assert test_obj["actual"] == "FAIL"
+ assert test_obj["artifacts"]["wpt_actual_status"] == ["PRECONDITION_FAILED"]
+ # ...this is an unexpected regression because we expected a pass but failed
+ assert test_obj["is_regression"] is True
+ assert test_obj["is_unexpected"] is True
+
+
+def test_repeated_test_statuses(logger, capfd):
+ # Check that the logger outputs all statuses from multiple runs of a test.
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # Run a test suite for the first time.
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_end("t1", status="PASS", expected="PASS", known_intermittent=[])
+ logger.suite_end()
+
+ # Run the test suite for the second time.
+ logger.suite_start(["t1"], run_info={}, time=456)
+ logger.test_start("t1")
+ logger.test_end("t1", status="FAIL", expected="PASS", known_intermittent=[])
+ logger.suite_end()
+
+ # Write the final results.
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ status_totals = output_json["num_failures_by_type"]
+ assert status_totals["PASS"] == 1
+ # A missing result type is the same as being present and set to zero (0).
+ assert status_totals.get("FAIL", 0) == 0
+
+ # The actual statuses are accumulated in a ordered space-separated list.
+ test_obj = output_json["tests"]["t1"]
+ assert test_obj["actual"] == "PASS FAIL"
+ assert test_obj["expected"] == "PASS"
+
+
+def test_flaky_test_detection(logger, capfd):
+ # Check that the logger detects flakiness for a test run multiple times.
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ logger.suite_start(["t1", "t2"], run_info={})
+ logger.test_start("t1")
+ logger.test_start("t2")
+ logger.test_end("t1", status="FAIL", expected="PASS")
+ logger.test_end("t2", status="FAIL", expected="FAIL")
+ logger.suite_end()
+
+ logger.suite_start(["t1", "t2"], run_info={})
+ logger.test_start("t1")
+ logger.test_start("t2")
+ logger.test_end("t1", status="PASS", expected="PASS")
+ logger.test_end("t2", status="FAIL", expected="FAIL")
+ logger.suite_end()
+
+ # Write the final results.
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ # We consider a test flaky if it runs multiple times and produces more than
+ # one kind of result.
+ test1_obj = output_json["tests"]["t1"]
+ test2_obj = output_json["tests"]["t2"]
+ assert test1_obj["is_flaky"] is True
+ assert "is_flaky" not in test2_obj
+
+
+def test_known_intermittent_empty(logger, capfd):
+ # If the known_intermittent list is empty, we want to ensure we don't append
+ # any extraneous characters to the output.
+
+ # set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # Run a test and include an empty known_intermittent list
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_end("t1", status="OK", expected="OK", known_intermittent=[])
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ # Both actual and expected statuses get mapped to Pass. No extra whitespace
+ # anywhere.
+ assert test_obj["actual"] == "PASS"
+ assert test_obj["expected"] == "PASS"
+
+
+def test_known_intermittent_duplicate(logger, capfd):
+ # We don't want to have duplicate statuses in the final "expected" field.
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # There are two duplications in this input:
+ # 1. known_intermittent already contains expected;
+ # 2. both statuses in known_intermittent map to FAIL in Chromium.
+ # In the end, we should only get one FAIL in Chromium "expected".
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_end("t1", status="ERROR", expected="ERROR", known_intermittent=["FAIL", "ERROR"])
+ logger.suite_end()
+ logger.shutdown()
+
+ # Check nothing got output to stdout/stderr.
+ # (Note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # Check the actual output of the formatter.
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ assert test_obj["actual"] == "FAIL"
+ # No duplicate "FAIL" in "expected".
+ assert test_obj["expected"] == "FAIL"
+
+
+def test_reftest_screenshots(logger, capfd):
+ # reftest_screenshots, if present, should be plumbed into artifacts.
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ # Run a reftest with reftest_screenshots.
+ logger.suite_start(["t1"], run_info={}, time=123)
+ logger.test_start("t1")
+ logger.test_end("t1", status="FAIL", expected="PASS", extra={
+ "reftest_screenshots": [
+ {"url": "foo.html", "hash": "HASH1", "screenshot": "DATA1"},
+ "!=",
+ {"url": "foo-ref.html", "hash": "HASH2", "screenshot": "DATA2"},
+ ]
+ })
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ assert test_obj["artifacts"]["screenshots"] == [
+ "foo.html: DATA1",
+ "foo-ref.html: DATA2",
+ ]
+
+
+def test_process_output_crashing_test(logger, capfd):
+ """Test that chromedriver logs are preserved for crashing tests"""
+
+ # Set up the handler.
+ output = StringIO()
+ logger.add_handler(handlers.StreamHandler(output, ChromiumFormatter()))
+
+ logger.suite_start(["t1", "t2", "t3"], run_info={}, time=123)
+
+ logger.test_start("t1")
+ logger.process_output(100, "This message should be recorded", "/some/path/to/chromedriver --some-flag")
+ logger.process_output(101, "This message should not be recorded", "/some/other/process --another-flag")
+ logger.process_output(100, "This message should also be recorded", "/some/path/to/chromedriver --some-flag")
+ logger.test_end("t1", status="CRASH", expected="CRASH")
+
+ logger.test_start("t2")
+ logger.process_output(100, "Another message for the second test", "/some/path/to/chromedriver --some-flag")
+ logger.test_end("t2", status="CRASH", expected="PASS")
+
+ logger.test_start("t3")
+ logger.process_output(100, "This test fails", "/some/path/to/chromedriver --some-flag")
+ logger.process_output(100, "But the output should not be captured", "/some/path/to/chromedriver --some-flag")
+ logger.process_output(100, "Because it does not crash", "/some/path/to/chromedriver --some-flag")
+ logger.test_end("t3", status="FAIL", expected="PASS")
+
+ logger.suite_end()
+ logger.shutdown()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_json = json.load(output)
+
+ test_obj = output_json["tests"]["t1"]
+ assert test_obj["artifacts"]["wpt_crash_log"] == [
+ "This message should be recorded",
+ "This message should also be recorded"
+ ]
+
+ test_obj = output_json["tests"]["t2"]
+ assert test_obj["artifacts"]["wpt_crash_log"] == [
+ "Another message for the second test"
+ ]
+
+ test_obj = output_json["tests"]["t3"]
+ assert "wpt_crash_log" not in test_obj["artifacts"]
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/wptreport.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/wptreport.py
new file mode 100644
index 0000000000..be6cca2afc
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/wptreport.py
@@ -0,0 +1,137 @@
+# mypy: allow-untyped-defs
+
+import json
+import re
+
+from mozlog.structured.formatters.base import BaseFormatter
+from ..executors.base import strip_server
+
+
+LONE_SURROGATE_RE = re.compile("[\uD800-\uDFFF]")
+
+
+def surrogate_replacement(match):
+ return "U+" + hex(ord(match.group()))[2:]
+
+
+def replace_lone_surrogate(data):
+ return LONE_SURROGATE_RE.subn(surrogate_replacement, data)[0]
+
+
+class WptreportFormatter(BaseFormatter): # type: ignore
+ """Formatter that produces results in the format that wptreport expects."""
+
+ def __init__(self):
+ self.raw_results = {}
+ self.results = {}
+
+ def suite_start(self, data):
+ if 'run_info' in data:
+ self.results['run_info'] = data['run_info']
+ self.results['time_start'] = data['time']
+ self.results["results"] = []
+
+ def suite_end(self, data):
+ self.results['time_end'] = data['time']
+ for test_name in self.raw_results:
+ result = {"test": test_name}
+ result.update(self.raw_results[test_name])
+ self.results["results"].append(result)
+ return json.dumps(self.results) + "\n"
+
+ def find_or_create_test(self, data):
+ test_name = data["test"]
+ if test_name not in self.raw_results:
+ self.raw_results[test_name] = {
+ "subtests": [],
+ "status": "",
+ "message": None
+ }
+ return self.raw_results[test_name]
+
+ def test_start(self, data):
+ test = self.find_or_create_test(data)
+ test["start_time"] = data["time"]
+
+ def create_subtest(self, data):
+ test = self.find_or_create_test(data)
+ subtest_name = replace_lone_surrogate(data["subtest"])
+
+ subtest = {
+ "name": subtest_name,
+ "status": "",
+ "message": None
+ }
+ test["subtests"].append(subtest)
+
+ return subtest
+
+ def test_status(self, data):
+ subtest = self.create_subtest(data)
+ subtest["status"] = data["status"]
+ if "expected" in data:
+ subtest["expected"] = data["expected"]
+ if "known_intermittent" in data:
+ subtest["known_intermittent"] = data["known_intermittent"]
+ if "message" in data:
+ subtest["message"] = replace_lone_surrogate(data["message"])
+
+ def test_end(self, data):
+ test = self.find_or_create_test(data)
+ start_time = test.pop("start_time")
+ test["duration"] = data["time"] - start_time
+ test["status"] = data["status"]
+ if "expected" in data:
+ test["expected"] = data["expected"]
+ if "known_intermittent" in data:
+ test["known_intermittent"] = data["known_intermittent"]
+ if "message" in data:
+ test["message"] = replace_lone_surrogate(data["message"])
+ if "reftest_screenshots" in data.get("extra", {}):
+ test["screenshots"] = {
+ strip_server(item["url"]): "sha1:" + item["hash"]
+ for item in data["extra"]["reftest_screenshots"]
+ if type(item) == dict
+ }
+ test_name = data["test"]
+ result = {"test": data["test"]}
+ result.update(self.raw_results[test_name])
+ self.results["results"].append(result)
+ self.raw_results.pop(test_name)
+
+ def assertion_count(self, data):
+ test = self.find_or_create_test(data)
+ test["asserts"] = {
+ "count": data["count"],
+ "min": data["min_expected"],
+ "max": data["max_expected"]
+ }
+
+ def lsan_leak(self, data):
+ if "lsan_leaks" not in self.results:
+ self.results["lsan_leaks"] = []
+ lsan_leaks = self.results["lsan_leaks"]
+ lsan_leaks.append({"frames": data["frames"],
+ "scope": data["scope"],
+ "allowed_match": data.get("allowed_match")})
+
+ def find_or_create_mozleak(self, data):
+ if "mozleak" not in self.results:
+ self.results["mozleak"] = {}
+ scope = data["scope"]
+ if scope not in self.results["mozleak"]:
+ self.results["mozleak"][scope] = {"objects": [], "total": []}
+ return self.results["mozleak"][scope]
+
+ def mozleak_object(self, data):
+ scope_data = self.find_or_create_mozleak(data)
+ scope_data["objects"].append({"process": data["process"],
+ "name": data["name"],
+ "allowed": data.get("allowed", False),
+ "bytes": data["bytes"]})
+
+ def mozleak_total(self, data):
+ scope_data = self.find_or_create_mozleak(data)
+ scope_data["total"].append({"bytes": data["bytes"],
+ "threshold": data.get("threshold", 0),
+ "process": data["process"]})
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/wptscreenshot.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/wptscreenshot.py
new file mode 100644
index 0000000000..2b2d1ad49d
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/formatters/wptscreenshot.py
@@ -0,0 +1,49 @@
+# mypy: allow-untyped-defs
+
+import requests
+from mozlog.structured.formatters.base import BaseFormatter
+
+DEFAULT_API = "https://wpt.fyi/api/screenshots/hashes"
+
+
+class WptscreenshotFormatter(BaseFormatter): # type: ignore
+ """Formatter that outputs screenshots in the format expected by wpt.fyi."""
+
+ def __init__(self, api=None):
+ self.api = api or DEFAULT_API
+ self.cache = set()
+
+ def suite_start(self, data):
+ # TODO(Hexcles): We might want to move the request into a different
+ # place, make it non-blocking, and handle errors better.
+ params = {}
+ run_info = data.get("run_info", {})
+ if "product" in run_info:
+ params["browser"] = run_info["product"]
+ if "browser_version" in run_info:
+ params["browser_version"] = run_info["browser_version"]
+ if "os" in run_info:
+ params["os"] = run_info["os"]
+ if "os_version" in run_info:
+ params["os_version"] = run_info["os_version"]
+ try:
+ r = requests.get(self.api, params=params)
+ r.raise_for_status()
+ self.cache = set(r.json())
+ except (requests.exceptions.RequestException, ValueError):
+ pass
+
+ def test_end(self, data):
+ if "reftest_screenshots" not in data.get("extra", {}):
+ return
+ output = ""
+ for item in data["extra"]["reftest_screenshots"]:
+ if type(item) != dict:
+ # Skip the relation string.
+ continue
+ checksum = "sha1:" + item["hash"]
+ if checksum in self.cache:
+ continue
+ self.cache.add(checksum)
+ output += "data:image/png;base64,{}\n".format(item["screenshot"])
+ return output if output else None
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/instruments.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/instruments.py
new file mode 100644
index 0000000000..26df5fa29b
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/instruments.py
@@ -0,0 +1,121 @@
+# mypy: allow-untyped-defs
+
+import time
+import threading
+
+from . import mpcontext
+
+"""Instrumentation for measuring high-level time spent on various tasks inside the runner.
+
+This is lower fidelity than an actual profile, but allows custom data to be considered,
+so that we can see the time spent in specific tests and test directories.
+
+
+Instruments are intended to be used as context managers with the return value of __enter__
+containing the user-facing API e.g.
+
+with Instrument(*args) as recording:
+ recording.set(["init"])
+ do_init()
+ recording.pause()
+ for thread in test_threads:
+ thread.start(recording, *args)
+ for thread in test_threads:
+ thread.join()
+ recording.set(["teardown"]) # un-pauses the Instrument
+ do_teardown()
+"""
+
+class NullInstrument:
+ def set(self, stack):
+ """Set the current task to stack
+
+ :param stack: A list of strings defining the current task.
+ These are interpreted like a stack trace so that ["foo"] and
+ ["foo", "bar"] both show up as descendants of "foo"
+ """
+ pass
+
+ def pause(self):
+ """Stop recording a task on the current thread. This is useful if the thread
+ is purely waiting on the results of other threads"""
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args, **kwargs):
+ return
+
+
+class InstrumentWriter:
+ def __init__(self, queue):
+ self.queue = queue
+
+ def set(self, stack):
+ stack.insert(0, threading.current_thread().name)
+ stack = self._check_stack(stack)
+ self.queue.put(("set", threading.current_thread().ident, time.time(), stack))
+
+ def pause(self):
+ self.queue.put(("pause", threading.current_thread().ident, time.time(), None))
+
+ def _check_stack(self, stack):
+ assert isinstance(stack, (tuple, list))
+ return [item.replace(" ", "_") for item in stack]
+
+
+class Instrument:
+ def __init__(self, file_path):
+ """Instrument that collects data from multiple threads and sums the time in each
+ thread. The output is in the format required by flamegraph.pl to enable visualisation
+ of the time spent in each task.
+
+ :param file_path: - The path on which to write instrument output. Any existing file
+ at the path will be overwritten
+ """
+ self.path = file_path
+ self.queue = None
+ self.current = None
+ self.start_time = None
+ self.instrument_proc = None
+
+ def __enter__(self):
+ assert self.instrument_proc is None
+ assert self.queue is None
+ mp = mpcontext.get_context()
+ self.queue = mp.Queue()
+ self.instrument_proc = mp.Process(target=self.run)
+ self.instrument_proc.start()
+ return InstrumentWriter(self.queue)
+
+ def __exit__(self, *args, **kwargs):
+ self.queue.put(("stop", None, time.time(), None))
+ self.instrument_proc.join()
+ self.instrument_proc = None
+ self.queue = None
+
+ def run(self):
+ known_commands = {"stop", "pause", "set"}
+ with open(self.path, "w") as f:
+ thread_data = {}
+ while True:
+ command, thread, time_stamp, stack = self.queue.get()
+ assert command in known_commands
+
+ # If we are done recording, dump the information from all threads to the file
+ # before exiting. Otherwise for either 'set' or 'pause' we only need to dump
+ # information from the current stack (if any) that was recording on the reporting
+ # thread (as that stack is no longer active).
+ items = []
+ if command == "stop":
+ items = thread_data.values()
+ elif thread in thread_data:
+ items.append(thread_data.pop(thread))
+ for output_stack, start_time in items:
+ f.write("%s %d\n" % (";".join(output_stack), int(1000 * (time_stamp - start_time))))
+
+ if command == "set":
+ thread_data[thread] = (stack, time_stamp)
+ elif command == "stop":
+ break
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/manifestexpected.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/manifestexpected.py
new file mode 100644
index 0000000000..0d92a48689
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/manifestexpected.py
@@ -0,0 +1,542 @@
+# mypy: allow-untyped-defs
+
+import os
+from collections import deque
+from urllib.parse import urljoin
+
+from .wptmanifest.backends import static
+from .wptmanifest.backends.base import ManifestItem
+
+from . import expected
+
+"""Manifest structure used to store expected results of a test.
+
+Each manifest file is represented by an ExpectedManifest that
+has one or more TestNode children, one per test in the manifest.
+Each TestNode has zero or more SubtestNode children, one for each
+known subtest of the test.
+"""
+
+
+def data_cls_getter(output_node, visited_node):
+ # visited_node is intentionally unused
+ if output_node is None:
+ return ExpectedManifest
+ if isinstance(output_node, ExpectedManifest):
+ return TestNode
+ if isinstance(output_node, TestNode):
+ return SubtestNode
+ raise ValueError
+
+
+def bool_prop(name, node):
+ """Boolean property"""
+ try:
+ return bool(node.get(name))
+ except KeyError:
+ return None
+
+
+def int_prop(name, node):
+ """Boolean property"""
+ try:
+ return int(node.get(name))
+ except KeyError:
+ return None
+
+
+def list_prop(name, node):
+ """List property"""
+ try:
+ list_prop = node.get(name)
+ if isinstance(list_prop, str):
+ return [list_prop]
+ return list(list_prop)
+ except KeyError:
+ return []
+
+
+def str_prop(name, node):
+ try:
+ prop = node.get(name)
+ if not isinstance(prop, str):
+ raise ValueError
+ return prop
+ except KeyError:
+ return None
+
+
+def tags(node):
+ """Set of tags that have been applied to the test"""
+ try:
+ value = node.get("tags")
+ if isinstance(value, str):
+ return {value}
+ return set(value)
+ except KeyError:
+ return set()
+
+
+def prefs(node):
+ def value(ini_value):
+ if isinstance(ini_value, str):
+ return tuple(pref_piece.strip() for pref_piece in ini_value.split(':', 1))
+ else:
+ # this should be things like @Reset, which are apparently type 'object'
+ return (ini_value, None)
+
+ try:
+ node_prefs = node.get("prefs")
+ if isinstance(node_prefs, str):
+ rv = dict(value(node_prefs))
+ else:
+ rv = dict(value(item) for item in node_prefs)
+ except KeyError:
+ rv = {}
+ return rv
+
+
+def set_prop(name, node):
+ try:
+ node_items = node.get(name)
+ if isinstance(node_items, str):
+ rv = {node_items}
+ else:
+ rv = set(node_items)
+ except KeyError:
+ rv = set()
+ return rv
+
+
+def leak_threshold(node):
+ rv = {}
+ try:
+ node_items = node.get("leak-threshold")
+ if isinstance(node_items, str):
+ node_items = [node_items]
+ for item in node_items:
+ process, value = item.rsplit(":", 1)
+ rv[process.strip()] = int(value.strip())
+ except KeyError:
+ pass
+ return rv
+
+
+def fuzzy_prop(node):
+ """Fuzzy reftest match
+
+ This can either be a list of strings or a single string. When a list is
+ supplied, the format of each item matches the description below.
+
+ The general format is
+ fuzzy = [key ":"] <prop> ";" <prop>
+ key = <test name> [reftype <reference name>]
+ reftype = "==" | "!="
+ prop = [propName "=" ] range
+ propName = "maxDifferences" | "totalPixels"
+ range = <digits> ["-" <digits>]
+
+ So for example:
+ maxDifferences=10;totalPixels=10-20
+
+ specifies that for any test/ref pair for which no other rule is supplied,
+ there must be a maximum pixel difference of exactly 10, and between 10 and
+ 20 total pixels different.
+
+ test.html==ref.htm:10;20
+
+ specifies that for a equality comparison between test.html and ref.htm,
+ resolved relative to the test path, there can be a maximum difference
+ of 10 in the pixel value for any channel and 20 pixels total difference.
+
+ ref.html:10;20
+
+ is just like the above but applies to any comparison involving ref.html
+ on the right hand side.
+
+ The return format is [(key, (maxDifferenceRange, totalPixelsRange))], where
+ the key is either None where no specific reference is specified, the reference
+ name where there is only one component or a tuple (test, ref, reftype) when the
+ exact comparison is specified. maxDifferenceRange and totalPixelsRange are tuples
+ of integers indicating the inclusive range of allowed values.
+"""
+ rv = []
+ args = ["maxDifference", "totalPixels"]
+ try:
+ value = node.get("fuzzy")
+ except KeyError:
+ return rv
+ if not isinstance(value, list):
+ value = [value]
+ for item in value:
+ if not isinstance(item, str):
+ rv.append(item)
+ continue
+ parts = item.rsplit(":", 1)
+ if len(parts) == 1:
+ key = None
+ fuzzy_values = parts[0]
+ else:
+ key, fuzzy_values = parts
+ for reftype in ["==", "!="]:
+ if reftype in key:
+ key = key.split(reftype)
+ key.append(reftype)
+ key = tuple(key)
+ ranges = fuzzy_values.split(";")
+ if len(ranges) != 2:
+ raise ValueError("Malformed fuzzy value %s" % item)
+ arg_values = {None: deque()}
+ for range_str_value in ranges:
+ if "=" in range_str_value:
+ name, range_str_value = (part.strip()
+ for part in range_str_value.split("=", 1))
+ if name not in args:
+ raise ValueError("%s is not a valid fuzzy property" % name)
+ if arg_values.get(name):
+ raise ValueError("Got multiple values for argument %s" % name)
+ else:
+ name = None
+ if "-" in range_str_value:
+ range_min, range_max = range_str_value.split("-")
+ else:
+ range_min = range_str_value
+ range_max = range_str_value
+ try:
+ range_value = tuple(int(item.strip()) for item in (range_min, range_max))
+ except ValueError:
+ raise ValueError("Fuzzy value %s must be a range of integers" % range_str_value)
+ if name is None:
+ arg_values[None].append(range_value)
+ else:
+ arg_values[name] = range_value
+ range_values = []
+ for arg_name in args:
+ if arg_values.get(arg_name):
+ value = arg_values.pop(arg_name)
+ else:
+ value = arg_values[None].popleft()
+ range_values.append(value)
+ rv.append((key, tuple(range_values)))
+ return rv
+
+
+class ExpectedManifest(ManifestItem):
+ def __init__(self, node, test_path, url_base):
+ """Object representing all the tests in a particular manifest
+
+ :param name: Name of the AST Node associated with this object.
+ Should always be None since this should always be associated with
+ the root node of the AST.
+ :param test_path: Path of the test file associated with this manifest.
+ :param url_base: Base url for serving the tests in this manifest
+ """
+ name = node.data
+ if name is not None:
+ raise ValueError("ExpectedManifest should represent the root node")
+ if test_path is None:
+ raise ValueError("ExpectedManifest requires a test path")
+ if url_base is None:
+ raise ValueError("ExpectedManifest requires a base url")
+ ManifestItem.__init__(self, node)
+ self.child_map = {}
+ self.test_path = test_path
+ self.url_base = url_base
+
+ def append(self, child):
+ """Add a test to the manifest"""
+ ManifestItem.append(self, child)
+ self.child_map[child.id] = child
+
+ def _remove_child(self, child):
+ del self.child_map[child.id]
+ ManifestItem.remove_child(self, child)
+ assert len(self.child_map) == len(self.children)
+
+ def get_test(self, test_id):
+ """Get a test from the manifest by ID
+
+ :param test_id: ID of the test to return."""
+ return self.child_map.get(test_id)
+
+ @property
+ def url(self):
+ return urljoin(self.url_base,
+ "/".join(self.test_path.split(os.path.sep)))
+
+ @property
+ def disabled(self):
+ return bool_prop("disabled", self)
+
+ @property
+ def restart_after(self):
+ return bool_prop("restart-after", self)
+
+ @property
+ def leaks(self):
+ return bool_prop("leaks", self)
+
+ @property
+ def min_assertion_count(self):
+ return int_prop("min-asserts", self)
+
+ @property
+ def max_assertion_count(self):
+ return int_prop("max-asserts", self)
+
+ @property
+ def tags(self):
+ return tags(self)
+
+ @property
+ def prefs(self):
+ return prefs(self)
+
+ @property
+ def lsan_disabled(self):
+ return bool_prop("lsan-disabled", self)
+
+ @property
+ def lsan_allowed(self):
+ return set_prop("lsan-allowed", self)
+
+ @property
+ def leak_allowed(self):
+ return set_prop("leak-allowed", self)
+
+ @property
+ def leak_threshold(self):
+ return leak_threshold(self)
+
+ @property
+ def lsan_max_stack_depth(self):
+ return int_prop("lsan-max-stack-depth", self)
+
+ @property
+ def fuzzy(self):
+ return fuzzy_prop(self)
+
+ @property
+ def expected(self):
+ return list_prop("expected", self)[0]
+
+ @property
+ def known_intermittent(self):
+ return list_prop("expected", self)[1:]
+
+ @property
+ def implementation_status(self):
+ return str_prop("implementation-status", self)
+
+
+class DirectoryManifest(ManifestItem):
+ @property
+ def disabled(self):
+ return bool_prop("disabled", self)
+
+ @property
+ def restart_after(self):
+ return bool_prop("restart-after", self)
+
+ @property
+ def leaks(self):
+ return bool_prop("leaks", self)
+
+ @property
+ def min_assertion_count(self):
+ return int_prop("min-asserts", self)
+
+ @property
+ def max_assertion_count(self):
+ return int_prop("max-asserts", self)
+
+ @property
+ def tags(self):
+ return tags(self)
+
+ @property
+ def prefs(self):
+ return prefs(self)
+
+ @property
+ def lsan_disabled(self):
+ return bool_prop("lsan-disabled", self)
+
+ @property
+ def lsan_allowed(self):
+ return set_prop("lsan-allowed", self)
+
+ @property
+ def leak_allowed(self):
+ return set_prop("leak-allowed", self)
+
+ @property
+ def leak_threshold(self):
+ return leak_threshold(self)
+
+ @property
+ def lsan_max_stack_depth(self):
+ return int_prop("lsan-max-stack-depth", self)
+
+ @property
+ def fuzzy(self):
+ return fuzzy_prop(self)
+
+ @property
+ def implementation_status(self):
+ return str_prop("implementation-status", self)
+
+
+class TestNode(ManifestItem):
+ def __init__(self, node, **kwargs):
+ """Tree node associated with a particular test in a manifest
+
+ :param name: name of the test"""
+ assert node.data is not None
+ ManifestItem.__init__(self, node, **kwargs)
+ self.updated_expected = []
+ self.new_expected = []
+ self.subtests = {}
+ self.default_status = None
+ self._from_file = True
+
+ @property
+ def is_empty(self):
+ required_keys = {"type"}
+ if set(self._data.keys()) != required_keys:
+ return False
+ return all(child.is_empty for child in self.children)
+
+ @property
+ def test_type(self):
+ return self.get("type")
+
+ @property
+ def id(self):
+ return urljoin(self.parent.url, self.name)
+
+ @property
+ def disabled(self):
+ return bool_prop("disabled", self)
+
+ @property
+ def restart_after(self):
+ return bool_prop("restart-after", self)
+
+ @property
+ def leaks(self):
+ return bool_prop("leaks", self)
+
+ @property
+ def min_assertion_count(self):
+ return int_prop("min-asserts", self)
+
+ @property
+ def max_assertion_count(self):
+ return int_prop("max-asserts", self)
+
+ @property
+ def tags(self):
+ return tags(self)
+
+ @property
+ def prefs(self):
+ return prefs(self)
+
+ @property
+ def lsan_disabled(self):
+ return bool_prop("lsan-disabled", self)
+
+ @property
+ def lsan_allowed(self):
+ return set_prop("lsan-allowed", self)
+
+ @property
+ def leak_allowed(self):
+ return set_prop("leak-allowed", self)
+
+ @property
+ def leak_threshold(self):
+ return leak_threshold(self)
+
+ @property
+ def lsan_max_stack_depth(self):
+ return int_prop("lsan-max-stack-depth", self)
+
+ @property
+ def fuzzy(self):
+ return fuzzy_prop(self)
+
+ @property
+ def expected(self):
+ return list_prop("expected", self)[0]
+
+ @property
+ def known_intermittent(self):
+ return list_prop("expected", self)[1:]
+
+ @property
+ def implementation_status(self):
+ return str_prop("implementation-status", self)
+
+ def append(self, node):
+ """Add a subtest to the current test
+
+ :param node: AST Node associated with the subtest"""
+ child = ManifestItem.append(self, node)
+ self.subtests[child.name] = child
+
+ def get_subtest(self, name):
+ """Get the SubtestNode corresponding to a particular subtest, by name
+
+ :param name: Name of the node to return"""
+ if name in self.subtests:
+ return self.subtests[name]
+ return None
+
+
+class SubtestNode(TestNode):
+ @property
+ def is_empty(self):
+ if self._data:
+ return False
+ return True
+
+
+def get_manifest(metadata_root, test_path, url_base, run_info):
+ """Get the ExpectedManifest for a particular test path, or None if there is no
+ metadata stored for that test path.
+
+ :param metadata_root: Absolute path to the root of the metadata directory
+ :param test_path: Path to the test(s) relative to the test root
+ :param url_base: Base url for serving the tests in this manifest
+ :param run_info: Dictionary of properties of the test run for which the expectation
+ values should be computed.
+ """
+ manifest_path = expected.expected_path(metadata_root, test_path)
+ try:
+ with open(manifest_path, "rb") as f:
+ return static.compile(f,
+ run_info,
+ data_cls_getter=data_cls_getter,
+ test_path=test_path,
+ url_base=url_base)
+ except OSError:
+ return None
+
+
+def get_dir_manifest(path, run_info):
+ """Get the ExpectedManifest for a particular test path, or None if there is no
+ metadata stored for that test path.
+
+ :param path: Full path to the ini file
+ :param run_info: Dictionary of properties of the test run for which the expectation
+ values should be computed.
+ """
+ try:
+ with open(path, "rb") as f:
+ return static.compile(f,
+ run_info,
+ data_cls_getter=lambda x,y: DirectoryManifest)
+ except OSError:
+ return None
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/manifestinclude.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/manifestinclude.py
new file mode 100644
index 0000000000..89031d8fb0
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/manifestinclude.py
@@ -0,0 +1,156 @@
+# mypy: allow-untyped-defs
+
+"""Manifest structure used to store paths that should be included in a test run.
+
+The manifest is represented by a tree of IncludeManifest objects, the root
+representing the file and each subnode representing a subdirectory that should
+be included or excluded.
+"""
+import glob
+import os
+from urllib.parse import urlparse, urlsplit
+
+from .wptmanifest.node import DataNode
+from .wptmanifest.backends import conditional
+from .wptmanifest.backends.conditional import ManifestItem
+
+
+class IncludeManifest(ManifestItem):
+ def __init__(self, node):
+ """Node in a tree structure representing the paths
+ that should be included or excluded from the test run.
+
+ :param node: AST Node corresponding to this Node.
+ """
+ ManifestItem.__init__(self, node)
+ self.child_map = {}
+
+ @classmethod
+ def create(cls):
+ """Create an empty IncludeManifest tree"""
+ node = DataNode(None)
+ return cls(node)
+
+ def set_defaults(self):
+ if not self.has_key("skip"):
+ self.set("skip", "False")
+
+ def append(self, child):
+ ManifestItem.append(self, child)
+ self.child_map[child.name] = child
+ assert len(self.child_map) == len(self.children)
+
+ def include(self, test):
+ """Return a boolean indicating whether a particular test should be
+ included in a test run, based on the IncludeManifest tree rooted on
+ this object.
+
+ :param test: The test object"""
+ path_components = self._get_components(test.url)
+ return self._include(test, path_components)
+
+ def _include(self, test, path_components):
+ if path_components:
+ next_path_part = path_components.pop()
+ if next_path_part in self.child_map:
+ return self.child_map[next_path_part]._include(test, path_components)
+
+ node = self
+ while node:
+ try:
+ skip_value = self.get("skip", {"test_type": test.item_type}).lower()
+ assert skip_value in ("true", "false")
+ return skip_value != "true"
+ except KeyError:
+ if node.parent is not None:
+ node = node.parent
+ else:
+ # Include by default
+ return True
+
+ def _get_components(self, url):
+ rv = []
+ url_parts = urlsplit(url)
+ variant = ""
+ if url_parts.query:
+ variant += "?" + url_parts.query
+ if url_parts.fragment:
+ variant += "#" + url_parts.fragment
+ if variant:
+ rv.append(variant)
+ rv.extend([item for item in reversed(url_parts.path.split("/")) if item])
+ return rv
+
+ def _add_rule(self, test_manifests, url, direction):
+ maybe_path = os.path.join(os.path.abspath(os.curdir), url)
+ rest, last = os.path.split(maybe_path)
+ fragment = query = None
+ if "#" in last:
+ last, fragment = last.rsplit("#", 1)
+ if "?" in last:
+ last, query = last.rsplit("?", 1)
+
+ maybe_path = os.path.join(rest, last)
+ paths = glob.glob(maybe_path)
+
+ if paths:
+ urls = []
+ for path in paths:
+ for manifest, data in test_manifests.items():
+ found = False
+ rel_path = os.path.relpath(path, data["tests_path"])
+ iterator = manifest.iterpath if os.path.isfile(path) else manifest.iterdir
+ for test in iterator(rel_path):
+ if not hasattr(test, "url"):
+ continue
+ url = test.url
+ if query or fragment:
+ parsed = urlparse(url)
+ if ((query and query != parsed.query) or
+ (fragment and fragment != parsed.fragment)):
+ continue
+ urls.append(url)
+ found = True
+ if found:
+ break
+ else:
+ urls = [url]
+
+ assert direction in ("include", "exclude")
+
+ for url in urls:
+ components = self._get_components(url)
+
+ node = self
+ while components:
+ component = components.pop()
+ if component not in node.child_map:
+ new_node = IncludeManifest(DataNode(component))
+ node.append(new_node)
+ new_node.set("skip", node.get("skip", {}))
+
+ node = node.child_map[component]
+
+ skip = False if direction == "include" else True
+ node.set("skip", str(skip))
+
+ def add_include(self, test_manifests, url_prefix):
+ """Add a rule indicating that tests under a url path
+ should be included in test runs
+
+ :param url_prefix: The url prefix to include
+ """
+ return self._add_rule(test_manifests, url_prefix, "include")
+
+ def add_exclude(self, test_manifests, url_prefix):
+ """Add a rule indicating that tests under a url path
+ should be excluded from test runs
+
+ :param url_prefix: The url prefix to exclude
+ """
+ return self._add_rule(test_manifests, url_prefix, "exclude")
+
+
+def get_manifest(manifest_path):
+ with open(manifest_path, "rb") as f:
+ return conditional.compile(f, data_cls_getter=lambda x, y: IncludeManifest)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/manifestupdate.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/manifestupdate.py
new file mode 100644
index 0000000000..ce12bc3370
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/manifestupdate.py
@@ -0,0 +1,967 @@
+# mypy: allow-untyped-defs
+
+import os
+from urllib.parse import urljoin, urlsplit
+from collections import namedtuple, defaultdict, deque
+from math import ceil
+from typing import Any, Callable, ClassVar, Dict, List
+
+from .wptmanifest import serialize
+from .wptmanifest.node import (DataNode, ConditionalNode, BinaryExpressionNode,
+ BinaryOperatorNode, NumberNode, StringNode, VariableNode,
+ ValueNode, UnaryExpressionNode, UnaryOperatorNode,
+ ListNode)
+from .wptmanifest.backends import conditional
+from .wptmanifest.backends.conditional import ManifestItem
+
+from . import expected
+from . import expectedtree
+
+"""Manifest structure used to update the expected results of a test
+
+Each manifest file is represented by an ExpectedManifest that has one
+or more TestNode children, one per test in the manifest. Each
+TestNode has zero or more SubtestNode children, one for each known
+subtest of the test.
+
+In these representations, conditionals expressions in the manifest are
+not evaluated upfront but stored as python functions to be evaluated
+at runtime.
+
+When a result for a test is to be updated set_result on the
+[Sub]TestNode is called to store the new result, alongside the
+existing conditional that result's run info matched, if any. Once all
+new results are known, update is called to compute the new
+set of results and conditionals. The AST of the underlying parsed manifest
+is updated with the changes, and the result is serialised to a file.
+"""
+
+
+class ConditionError(Exception):
+ def __init__(self, cond=None):
+ self.cond = cond
+
+
+class UpdateError(Exception):
+ pass
+
+
+Value = namedtuple("Value", ["run_info", "value"])
+
+
+def data_cls_getter(output_node, visited_node):
+ # visited_node is intentionally unused
+ if output_node is None:
+ return ExpectedManifest
+ elif isinstance(output_node, ExpectedManifest):
+ return TestNode
+ elif isinstance(output_node, TestNode):
+ return SubtestNode
+ else:
+ raise ValueError
+
+
+class UpdateProperties:
+ def __init__(self, manifest, **kwargs):
+ self._manifest = manifest
+ self._classes = kwargs
+
+ def __getattr__(self, name):
+ if name in self._classes:
+ rv = self._classes[name](self._manifest)
+ setattr(self, name, rv)
+ return rv
+ raise AttributeError
+
+ def __contains__(self, name):
+ return name in self._classes
+
+ def __iter__(self):
+ for name in self._classes.keys():
+ yield getattr(self, name)
+
+
+class ExpectedManifest(ManifestItem):
+ def __init__(self, node, test_path, url_base, run_info_properties,
+ update_intermittent=False, remove_intermittent=False):
+ """Object representing all the tests in a particular manifest
+
+ :param node: AST Node associated with this object. If this is None,
+ a new AST is created to associate with this manifest.
+ :param test_path: Path of the test file associated with this manifest.
+ :param url_base: Base url for serving the tests in this manifest.
+ :param run_info_properties: Tuple of ([property name],
+ {property_name: [dependent property]})
+ The first part lists run_info properties
+ that are always used in the update, the second
+ maps property names to additional properties that
+ can be considered if we already have a condition on
+ the key property e.g. {"foo": ["bar"]} means that
+ we consider making conditions on bar only after we
+ already made one on foo.
+ :param update_intermittent: When True, intermittent statuses will be recorded
+ as `expected` in the test metadata.
+ :param: remove_intermittent: When True, old intermittent statuses will be removed
+ if no longer intermittent. This is only relevant if
+ `update_intermittent` is also True, because if False,
+ the metadata will simply update one `expected`status.
+ """
+ if node is None:
+ node = DataNode(None)
+ ManifestItem.__init__(self, node)
+ self.child_map = {}
+ self.test_path = test_path
+ self.url_base = url_base
+ assert self.url_base is not None
+ self._modified = False
+ self.run_info_properties = run_info_properties
+ self.update_intermittent = update_intermittent
+ self.remove_intermittent = remove_intermittent
+ self.update_properties = UpdateProperties(self, **{
+ "lsan": LsanUpdate,
+ "leak_object": LeakObjectUpdate,
+ "leak_threshold": LeakThresholdUpdate,
+ })
+
+ @property
+ def modified(self):
+ if self._modified:
+ return True
+ return any(item.modified for item in self.children)
+
+ @modified.setter
+ def modified(self, value):
+ self._modified = value
+
+ def append(self, child):
+ ManifestItem.append(self, child)
+ if child.id in self.child_map:
+ print("Warning: Duplicate heading %s" % child.id)
+ self.child_map[child.id] = child
+
+ def _remove_child(self, child):
+ del self.child_map[child.id]
+ ManifestItem._remove_child(self, child)
+
+ def get_test(self, test_id):
+ """Return a TestNode by test id, or None if no test matches
+
+ :param test_id: The id of the test to look up"""
+
+ return self.child_map.get(test_id)
+
+ def has_test(self, test_id):
+ """Boolean indicating whether the current test has a known child test
+ with id test id
+
+ :param test_id: The id of the test to look up"""
+
+ return test_id in self.child_map
+
+ @property
+ def url(self):
+ return urljoin(self.url_base,
+ "/".join(self.test_path.split(os.path.sep)))
+
+ def set_lsan(self, run_info, result):
+ """Set the result of the test in a particular run
+
+ :param run_info: Dictionary of run_info parameters corresponding
+ to this run
+ :param result: Lsan violations detected"""
+ self.update_properties.lsan.set(run_info, result)
+
+ def set_leak_object(self, run_info, result):
+ """Set the result of the test in a particular run
+
+ :param run_info: Dictionary of run_info parameters corresponding
+ to this run
+ :param result: Leaked objects deletec"""
+ self.update_properties.leak_object.set(run_info, result)
+
+ def set_leak_threshold(self, run_info, result):
+ """Set the result of the test in a particular run
+
+ :param run_info: Dictionary of run_info parameters corresponding
+ to this run
+ :param result: Total number of bytes leaked"""
+ self.update_properties.leak_threshold.set(run_info, result)
+
+ def update(self, full_update, disable_intermittent):
+ for prop_update in self.update_properties:
+ prop_update.update(full_update,
+ disable_intermittent)
+
+
+class TestNode(ManifestItem):
+ def __init__(self, node):
+ """Tree node associated with a particular test in a manifest
+
+ :param node: AST node associated with the test"""
+
+ ManifestItem.__init__(self, node)
+ self.subtests = {}
+ self._from_file = True
+ self.new_disabled = False
+ self.has_result = False
+ self._modified = False
+ self.update_properties = UpdateProperties(
+ self,
+ expected=ExpectedUpdate,
+ max_asserts=MaxAssertsUpdate,
+ min_asserts=MinAssertsUpdate
+ )
+
+ @classmethod
+ def create(cls, test_id):
+ """Create a TestNode corresponding to a given test
+
+ :param test_type: The type of the test
+ :param test_id: The id of the test"""
+ name = test_id[len(urlsplit(test_id).path.rsplit("/", 1)[0]) + 1:]
+ node = DataNode(name)
+ self = cls(node)
+
+ self._from_file = False
+ return self
+
+ @property
+ def is_empty(self):
+ ignore_keys = {"type"}
+ if set(self._data.keys()) - ignore_keys:
+ return False
+ return all(child.is_empty for child in self.children)
+
+ @property
+ def test_type(self):
+ """The type of the test represented by this TestNode"""
+ return self.get("type", None)
+
+ @property
+ def id(self):
+ """The id of the test represented by this TestNode"""
+ return urljoin(self.parent.url, self.name)
+
+ @property
+ def modified(self):
+ if self._modified:
+ return self._modified
+ return any(child.modified for child in self.children)
+
+ @modified.setter
+ def modified(self, value):
+ self._modified = value
+
+ def disabled(self, run_info):
+ """Boolean indicating whether this test is disabled when run in an
+ environment with the given run_info
+
+ :param run_info: Dictionary of run_info parameters"""
+
+ return self.get("disabled", run_info) is not None
+
+ def set_result(self, run_info, result):
+ """Set the result of the test in a particular run
+
+ :param run_info: Dictionary of run_info parameters corresponding
+ to this run
+ :param result: Status of the test in this run"""
+ self.update_properties.expected.set(run_info, result)
+
+ def set_asserts(self, run_info, count):
+ """Set the assert count of a test
+
+ """
+ self.update_properties.min_asserts.set(run_info, count)
+ self.update_properties.max_asserts.set(run_info, count)
+
+ def append(self, node):
+ child = ManifestItem.append(self, node)
+ self.subtests[child.name] = child
+
+ def get_subtest(self, name):
+ """Return a SubtestNode corresponding to a particular subtest of
+ the current test, creating a new one if no subtest with that name
+ already exists.
+
+ :param name: Name of the subtest"""
+
+ if name in self.subtests:
+ return self.subtests[name]
+ else:
+ subtest = SubtestNode.create(name)
+ self.append(subtest)
+ return subtest
+
+ def update(self, full_update, disable_intermittent):
+ for prop_update in self.update_properties:
+ prop_update.update(full_update,
+ disable_intermittent)
+
+
+class SubtestNode(TestNode):
+ def __init__(self, node):
+ assert isinstance(node, DataNode)
+ TestNode.__init__(self, node)
+
+ @classmethod
+ def create(cls, name):
+ node = DataNode(name)
+ self = cls(node)
+ return self
+
+ @property
+ def is_empty(self):
+ if self._data:
+ return False
+ return True
+
+
+def build_conditional_tree(_, run_info_properties, results):
+ properties, dependent_props = run_info_properties
+ return expectedtree.build_tree(properties, dependent_props, results)
+
+
+def build_unconditional_tree(_, run_info_properties, results):
+ root = expectedtree.Node(None, None)
+ for run_info, values in results.items():
+ for value, count in values.items():
+ root.result_values[value] += count
+ root.run_info.add(run_info)
+ return root
+
+
+class PropertyUpdate:
+ property_name = None # type: ClassVar[str]
+ cls_default_value = None # type: ClassVar[Any]
+ value_type = None # type: ClassVar[type]
+ # property_builder is a class variable set to either build_conditional_tree
+ # or build_unconditional_tree. TODO: Make this type stricter when those
+ # methods are annotated.
+ property_builder = None # type: ClassVar[Callable[..., Any]]
+
+ def __init__(self, node):
+ self.node = node
+ self.default_value = self.cls_default_value
+ self.has_result = False
+ self.results = defaultdict(lambda: defaultdict(int))
+ self.update_intermittent = self.node.root.update_intermittent
+ self.remove_intermittent = self.node.root.remove_intermittent
+
+ def run_info_by_condition(self, run_info_index, conditions):
+ run_info_by_condition = defaultdict(list)
+ # A condition might match 0 or more run_info values
+ run_infos = run_info_index.keys()
+ for cond in conditions:
+ for run_info in run_infos:
+ if cond(run_info):
+ run_info_by_condition[cond].append(run_info)
+
+ return run_info_by_condition
+
+ def set(self, run_info, value):
+ self.has_result = True
+ self.node.has_result = True
+ self.check_default(value)
+ value = self.from_result_value(value)
+ self.results[run_info][value] += 1
+
+ def check_default(self, result):
+ return
+
+ def from_result_value(self, value):
+ """Convert a value from a test result into the internal format"""
+ return value
+
+ def from_ini_value(self, value):
+ """Convert a value from an ini file into the internal format"""
+ if self.value_type:
+ return self.value_type(value)
+ return value
+
+ def to_ini_value(self, value):
+ """Convert a value from the internal format to the ini file format"""
+ return str(value)
+
+ def updated_value(self, current, new):
+ """Given a single current value and a set of observed new values,
+ compute an updated value for the property"""
+ return new
+
+ @property
+ def unconditional_value(self):
+ try:
+ unconditional_value = self.from_ini_value(
+ self.node.get(self.property_name))
+ except KeyError:
+ unconditional_value = self.default_value
+ return unconditional_value
+
+ def update(self,
+ full_update=False,
+ disable_intermittent=None):
+ """Update the underlying manifest AST for this test based on all the
+ added results.
+
+ This will update existing conditionals if they got the same result in
+ all matching runs in the updated results, will delete existing conditionals
+ that get more than one different result in the updated run, and add new
+ conditionals for anything that doesn't match an existing conditional.
+
+ Conditionals not matched by any added result are not changed.
+
+ When `disable_intermittent` is not None, disable any test that shows multiple
+ unexpected results for the same set of parameters.
+ """
+ if not self.has_result:
+ return
+
+ property_tree = self.property_builder(self.node.root.run_info_properties,
+ self.results)
+
+ conditions, errors = self.update_conditions(property_tree,
+ full_update)
+
+ for e in errors:
+ if disable_intermittent:
+ condition = e.cond.children[0] if e.cond else None
+ msg = disable_intermittent if isinstance(disable_intermittent, str) else "unstable"
+ self.node.set("disabled", msg, condition)
+ self.node.new_disabled = True
+ else:
+ msg = "Conflicting metadata values for %s" % (
+ self.node.root.test_path)
+ if e.cond:
+ msg += ": %s" % serialize(e.cond).strip()
+ print(msg)
+
+ # If all the values match remove all conditionals
+ # This handles the case where we update a number of existing conditions and they
+ # all end up looking like the post-update default.
+ new_default = self.default_value
+ if conditions and conditions[-1][0] is None:
+ new_default = conditions[-1][1]
+ if all(condition[1] == new_default for condition in conditions):
+ conditions = [(None, new_default)]
+
+ # Don't set the default to the class default
+ if (conditions and
+ conditions[-1][0] is None and
+ conditions[-1][1] == self.default_value):
+ self.node.modified = True
+ conditions = conditions[:-1]
+
+ if self.node.modified:
+ self.node.clear(self.property_name)
+
+ for condition, value in conditions:
+ self.node.set(self.property_name,
+ self.to_ini_value(value),
+ condition)
+
+ def update_conditions(self,
+ property_tree,
+ full_update):
+ # This is complicated because the expected behaviour is complex
+ # The complexity arises from the fact that there are two ways of running
+ # the tool, with a full set of runs (full_update=True) or with partial metadata
+ # (full_update=False). In the case of a full update things are relatively simple:
+ # * All existing conditionals are ignored, with the exception of conditionals that
+ # depend on variables not used by the updater, which are retained as-is
+ # * All created conditionals are independent of each other (i.e. order isn't
+ # important in the created conditionals)
+ # In the case where we don't have a full set of runs, the expected behaviour
+ # is much less clear. This is of course the common case for when a developer
+ # runs the test on their own machine. In this case the assumptions above are untrue
+ # * The existing conditions may be required to handle other platforms
+ # * The order of the conditions may be important, since we don't know if they overlap
+ # e.g. `if os == linux and version == 18.04` overlaps with `if (os != win)`.
+ # So in the case we have a full set of runs, the process is pretty simple:
+ # * Generate the conditionals for the property_tree
+ # * Pick the most common value as the default and add only those conditions
+ # not matching the default
+ # In the case where we have a partial set of runs, things are more complex
+ # and more best-effort
+ # * For each existing conditional, see if it matches any of the run info we
+ # have. In cases where it does match, record the new results
+ # * Where all the new results match, update the right hand side of that
+ # conditional, otherwise remove it
+ # * If this leaves nothing existing, then proceed as with the full update
+ # * Otherwise add conditionals for the run_info that doesn't match any
+ # remaining conditions
+ prev_default = None
+
+ current_conditions = self.node.get_conditions(self.property_name)
+
+ # Ignore the current default value
+ if current_conditions and current_conditions[-1].condition_node is None:
+ self.node.modified = True
+ prev_default = current_conditions[-1].value
+ current_conditions = current_conditions[:-1]
+
+ # If there aren't any current conditions, or there is just a default
+ # value for all run_info, proceed as for a full update
+ if not current_conditions:
+ return self._update_conditions_full(property_tree,
+ prev_default=prev_default)
+
+ conditions = []
+ errors = []
+
+ run_info_index = {run_info: node
+ for node in property_tree
+ for run_info in node.run_info}
+
+ node_by_run_info = {run_info: node
+ for (run_info, node) in run_info_index.items()
+ if node.result_values}
+
+ run_info_by_condition = self.run_info_by_condition(run_info_index,
+ current_conditions)
+
+ run_info_with_condition = set()
+
+ if full_update:
+ # Even for a full update we need to keep hand-written conditions not
+ # using the properties we've specified and not matching any run_info
+ top_level_props, dependent_props = self.node.root.run_info_properties
+ update_properties = set(top_level_props)
+ for item in dependent_props.values():
+ update_properties |= set(item)
+ for condition in current_conditions:
+ if (not condition.variables.issubset(update_properties) and
+ not run_info_by_condition[condition]):
+ conditions.append((condition.condition_node,
+ self.from_ini_value(condition.value)))
+
+ new_conditions, errors = self._update_conditions_full(property_tree,
+ prev_default=prev_default)
+ conditions.extend(new_conditions)
+ return conditions, errors
+
+ # Retain existing conditions if they match the updated values
+ for condition in current_conditions:
+ # All run_info that isn't handled by some previous condition
+ all_run_infos_condition = run_info_by_condition[condition]
+ run_infos = {item for item in all_run_infos_condition
+ if item not in run_info_with_condition}
+
+ if not run_infos:
+ # Retain existing conditions that don't match anything in the update
+ conditions.append((condition.condition_node,
+ self.from_ini_value(condition.value)))
+ continue
+
+ # Set of nodes in the updated tree that match the same run_info values as the
+ # current existing node
+ nodes = [node_by_run_info[run_info] for run_info in run_infos
+ if run_info in node_by_run_info]
+ # If all the values are the same, update the value
+ if nodes and all(set(node.result_values.keys()) == set(nodes[0].result_values.keys()) for node in nodes):
+ current_value = self.from_ini_value(condition.value)
+ try:
+ new_value = self.updated_value(current_value,
+ nodes[0].result_values)
+ except ConditionError as e:
+ errors.append(e)
+ continue
+ if new_value != current_value:
+ self.node.modified = True
+ conditions.append((condition.condition_node, new_value))
+ run_info_with_condition |= set(run_infos)
+ else:
+ # Don't append this condition
+ self.node.modified = True
+
+ new_conditions, new_errors = self.build_tree_conditions(property_tree,
+ run_info_with_condition,
+ prev_default)
+ if new_conditions:
+ self.node.modified = True
+
+ conditions.extend(new_conditions)
+ errors.extend(new_errors)
+
+ return conditions, errors
+
+ def _update_conditions_full(self,
+ property_tree,
+ prev_default=None):
+ self.node.modified = True
+ conditions, errors = self.build_tree_conditions(property_tree,
+ set(),
+ prev_default)
+
+ return conditions, errors
+
+ def build_tree_conditions(self,
+ property_tree,
+ run_info_with_condition,
+ prev_default=None):
+ conditions = []
+ errors = []
+
+ value_count = defaultdict(int)
+
+ def to_count_value(v):
+ if v is None:
+ return v
+ # Need to count the values in a hashable type
+ count_value = self.to_ini_value(v)
+ if isinstance(count_value, list):
+ count_value = tuple(count_value)
+ return count_value
+
+
+ queue = deque([(property_tree, [])])
+ while queue:
+ node, parents = queue.popleft()
+ parents_and_self = parents + [node]
+ if node.result_values and any(run_info not in run_info_with_condition
+ for run_info in node.run_info):
+ prop_set = [(item.prop, item.value) for item in parents_and_self if item.prop]
+ value = node.result_values
+ error = None
+ if parents:
+ try:
+ value = self.updated_value(None, value)
+ except ConditionError:
+ expr = make_expr(prop_set, value)
+ error = ConditionError(expr)
+ else:
+ expr = make_expr(prop_set, value)
+ else:
+ # The root node needs special handling
+ expr = None
+ try:
+ value = self.updated_value(self.unconditional_value,
+ value)
+ except ConditionError:
+ error = ConditionError(expr)
+ # If we got an error for the root node, re-add the previous
+ # default value
+ if prev_default:
+ conditions.append((None, prev_default))
+ if error is None:
+ count_value = to_count_value(value)
+ value_count[count_value] += len(node.run_info)
+
+ if error is None:
+ conditions.append((expr, value))
+ else:
+ errors.append(error)
+
+ for child in node.children:
+ queue.append((child, parents_and_self))
+
+ conditions = conditions[::-1]
+
+ # If we haven't set a default condition, add one and remove all the conditions
+ # with the same value
+ if value_count and (not conditions or conditions[-1][0] is not None):
+ # Sort in order of occurence, prioritising values that match the class default
+ # or the previous default
+ cls_default = to_count_value(self.default_value)
+ prev_default = to_count_value(prev_default)
+ commonest_value = max(value_count, key=lambda x:(value_count.get(x),
+ x == cls_default,
+ x == prev_default))
+ if isinstance(commonest_value, tuple):
+ commonest_value = list(commonest_value)
+ commonest_value = self.from_ini_value(commonest_value)
+ conditions = [item for item in conditions if item[1] != commonest_value]
+ conditions.append((None, commonest_value))
+
+ return conditions, errors
+
+
+class ExpectedUpdate(PropertyUpdate):
+ property_name = "expected"
+ property_builder = build_conditional_tree
+
+ def check_default(self, result):
+ if self.default_value is not None:
+ assert self.default_value == result.default_expected
+ else:
+ self.default_value = result.default_expected
+
+ def from_result_value(self, result):
+ # When we are updating intermittents, we need to keep a record of any existing
+ # intermittents to pass on when building the property tree and matching statuses and
+ # intermittents to the correct run info - this is so we can add them back into the
+ # metadata aligned with the right conditions, unless specified not to with
+ # self.remove_intermittent.
+ # The (status, known_intermittent) tuple is counted when the property tree is built, but
+ # the count value only applies to the first item in the tuple, the status from that run,
+ # when passed to `updated_value`.
+ if (not self.update_intermittent or
+ self.remove_intermittent or
+ not result.known_intermittent):
+ return result.status
+ return result.status + result.known_intermittent
+
+ def to_ini_value(self, value):
+ if isinstance(value, (list, tuple)):
+ return [str(item) for item in value]
+ return str(value)
+
+ def updated_value(self, current, new):
+ if len(new) > 1 and not self.update_intermittent and not isinstance(current, list):
+ raise ConditionError
+
+ counts = {}
+ for status, count in new.items():
+ if isinstance(status, tuple):
+ counts[status[0]] = count
+ counts.update({intermittent: 0 for intermittent in status[1:] if intermittent not in counts})
+ else:
+ counts[status] = count
+
+ if not (self.update_intermittent or isinstance(current, list)):
+ return list(counts)[0]
+
+ # Reorder statuses first based on counts, then based on status priority if there are ties.
+ # Counts with 0 are considered intermittent.
+ statuses = ["OK", "PASS", "FAIL", "ERROR", "TIMEOUT", "CRASH"]
+ status_priority = {value: i for i, value in enumerate(statuses)}
+ sorted_new = sorted(counts.items(), key=lambda x:(-1 * x[1],
+ status_priority.get(x[0],
+ len(status_priority))))
+ expected = []
+ for status, count in sorted_new:
+ # If we are not removing existing recorded intermittents, with a count of 0,
+ # add them in to expected.
+ if count > 0 or not self.remove_intermittent:
+ expected.append(status)
+
+ # If the new intermittent is a subset of the existing one, just use the existing one
+ # This prevents frequent flip-flopping of results between e.g. [OK, TIMEOUT] and
+ # [TIMEOUT, OK]
+ if current and set(expected).issubset(set(current)):
+ return current
+
+ if self.update_intermittent:
+ if len(expected) == 1:
+ return expected[0]
+ return expected
+
+ # If we are not updating intermittents, return the status with the highest occurence.
+ return expected[0]
+
+
+class MaxAssertsUpdate(PropertyUpdate):
+ """For asserts we always update the default value and never add new conditionals.
+ The value we set as the default is the maximum the current default or one more than the
+ number of asserts we saw in any configuration."""
+
+ property_name = "max-asserts"
+ cls_default_value = 0
+ value_type = int
+ property_builder = build_unconditional_tree
+
+ def updated_value(self, current, new):
+ if any(item > current for item in new):
+ return max(new) + 1
+ return current
+
+
+class MinAssertsUpdate(PropertyUpdate):
+ property_name = "min-asserts"
+ cls_default_value = 0
+ value_type = int
+ property_builder = build_unconditional_tree
+
+ def updated_value(self, current, new):
+ if any(item < current for item in new):
+ rv = min(new) - 1
+ else:
+ rv = current
+ return max(rv, 0)
+
+
+class AppendOnlyListUpdate(PropertyUpdate):
+ cls_default_value = [] # type: ClassVar[List[str]]
+ property_builder = build_unconditional_tree
+
+ def updated_value(self, current, new):
+ if current is None:
+ rv = set()
+ else:
+ rv = set(current)
+
+ for item in new:
+ if item is None:
+ continue
+ elif isinstance(item, str):
+ rv.add(item)
+ else:
+ rv |= item
+
+ return sorted(rv)
+
+
+class LsanUpdate(AppendOnlyListUpdate):
+ property_name = "lsan-allowed"
+ property_builder = build_unconditional_tree
+
+ def from_result_value(self, result):
+ # If we have an allowed_match that matched, return None
+ # This value is ignored later (because it matches the default)
+ # We do that because then if we allow a failure in foo/__dir__.ini
+ # we don't want to update foo/bar/__dir__.ini with the same rule
+ if result[1]:
+ return None
+ # Otherwise return the topmost stack frame
+ # TODO: there is probably some improvement to be made by looking for a "better" stack frame
+ return result[0][0]
+
+ def to_ini_value(self, value):
+ return value
+
+
+class LeakObjectUpdate(AppendOnlyListUpdate):
+ property_name = "leak-allowed"
+ property_builder = build_unconditional_tree
+
+ def from_result_value(self, result):
+ # If we have an allowed_match that matched, return None
+ if result[1]:
+ return None
+ # Otherwise return the process/object name
+ return result[0]
+
+
+class LeakThresholdUpdate(PropertyUpdate):
+ property_name = "leak-threshold"
+ cls_default_value = {} # type: ClassVar[Dict[str, int]]
+ property_builder = build_unconditional_tree
+
+ def from_result_value(self, result):
+ return result
+
+ def to_ini_value(self, data):
+ return ["%s:%s" % item for item in sorted(data.items())]
+
+ def from_ini_value(self, data):
+ rv = {}
+ for item in data:
+ key, value = item.split(":", 1)
+ rv[key] = int(float(value))
+ return rv
+
+ def updated_value(self, current, new):
+ if current:
+ rv = current.copy()
+ else:
+ rv = {}
+ for process, leaked_bytes, threshold in new:
+ # If the value is less than the threshold but there isn't
+ # an old value we must have inherited the threshold from
+ # a parent ini file so don't any anything to this one
+ if process not in rv and leaked_bytes < threshold:
+ continue
+ if leaked_bytes > rv.get(process, 0):
+ # Round up to nearest 50 kb
+ boundary = 50 * 1024
+ rv[process] = int(boundary * ceil(float(leaked_bytes) / boundary))
+ return rv
+
+
+def make_expr(prop_set, rhs):
+ """Create an AST that returns the value ``status`` given all the
+ properties in prop_set match.
+
+ :param prop_set: tuple of (property name, value) pairs for each
+ property in this expression and the value it must match
+ :param status: Status on RHS when all the given properties match
+ """
+ root = ConditionalNode()
+
+ assert len(prop_set) > 0
+
+ expressions = []
+ for prop, value in prop_set:
+ if value not in (True, False):
+ expressions.append(
+ BinaryExpressionNode(
+ BinaryOperatorNode("=="),
+ VariableNode(prop),
+ make_node(value)))
+ else:
+ if value:
+ expressions.append(VariableNode(prop))
+ else:
+ expressions.append(
+ UnaryExpressionNode(
+ UnaryOperatorNode("not"),
+ VariableNode(prop)
+ ))
+ if len(expressions) > 1:
+ prev = expressions[-1]
+ for curr in reversed(expressions[:-1]):
+ node = BinaryExpressionNode(
+ BinaryOperatorNode("and"),
+ curr,
+ prev)
+ prev = node
+ else:
+ node = expressions[0]
+
+ root.append(node)
+ rhs_node = make_value_node(rhs)
+ root.append(rhs_node)
+
+ return root
+
+
+def make_node(value):
+ if isinstance(value, (int, float,)):
+ node = NumberNode(value)
+ elif isinstance(value, str):
+ node = StringNode(str(value))
+ elif hasattr(value, "__iter__"):
+ node = ListNode()
+ for item in value:
+ node.append(make_node(item))
+ return node
+
+
+def make_value_node(value):
+ if isinstance(value, (int, float,)):
+ node = ValueNode(value)
+ elif isinstance(value, str):
+ node = ValueNode(str(value))
+ elif hasattr(value, "__iter__"):
+ node = ListNode()
+ for item in value:
+ node.append(make_value_node(item))
+ else:
+ raise ValueError("Don't know how to convert %s into node" % type(value))
+ return node
+
+
+def get_manifest(metadata_root, test_path, url_base, run_info_properties, update_intermittent, remove_intermittent):
+ """Get the ExpectedManifest for a particular test path, or None if there is no
+ metadata stored for that test path.
+
+ :param metadata_root: Absolute path to the root of the metadata directory
+ :param test_path: Path to the test(s) relative to the test root
+ :param url_base: Base url for serving the tests in this manifest"""
+ manifest_path = expected.expected_path(metadata_root, test_path)
+ try:
+ with open(manifest_path, "rb") as f:
+ rv = compile(f, test_path, url_base,
+ run_info_properties, update_intermittent, remove_intermittent)
+ except OSError:
+ return None
+ return rv
+
+
+def compile(manifest_file, test_path, url_base, run_info_properties, update_intermittent, remove_intermittent):
+ return conditional.compile(manifest_file,
+ data_cls_getter=data_cls_getter,
+ test_path=test_path,
+ url_base=url_base,
+ run_info_properties=run_info_properties,
+ update_intermittent=update_intermittent,
+ remove_intermittent=remove_intermittent)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/metadata.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/metadata.py
new file mode 100644
index 0000000000..3ae97114f8
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/metadata.py
@@ -0,0 +1,836 @@
+# mypy: allow-untyped-defs
+
+import array
+import os
+from collections import defaultdict, namedtuple
+from typing import Dict, List, Tuple
+
+from mozlog import structuredlog
+from six import ensure_str, ensure_text
+from sys import intern
+
+from . import manifestupdate
+from . import products
+from . import testloader
+from . import wptmanifest
+from . import wpttest
+from .expected import expected_path
+manifest = None # Module that will be imported relative to test_root
+manifestitem = None
+
+logger = structuredlog.StructuredLogger("web-platform-tests")
+
+try:
+ import ujson as json
+except ImportError:
+ import json # type: ignore
+
+
+class RunInfo:
+ """A wrapper around RunInfo dicts so that they can be hashed by identity"""
+
+ def __init__(self, dict_value):
+ self.data = dict_value
+ self.canonical_repr = tuple(tuple(item) for item in sorted(dict_value.items()))
+
+ def __getitem__(self, key):
+ return self.data[key]
+
+ def __setitem__(self, key, value):
+ raise TypeError
+
+ def __hash__(self):
+ return hash(self.canonical_repr)
+
+ def __eq__(self, other):
+ return self.canonical_repr == other.canonical_repr
+
+ def iteritems(self):
+ yield from self.data.items()
+
+ def items(self):
+ return list(self.items())
+
+
+def get_properties(properties_file=None, extra_properties=None, config=None, product=None):
+ """Read the list of properties to use for updating metadata.
+
+ :param properties_file: Path to a JSON file containing properties.
+ :param extra_properties: List of extra properties to use
+ :param config: (deprecated) wptrunner config
+ :param Product: (deprecated) product name (requires a config argument to be used)
+ """
+ properties = []
+ dependents = {}
+
+ if properties_file is not None:
+ logger.debug(f"Reading update properties from {properties_file}")
+ try:
+ with open(properties_file) as f:
+ data = json.load(f)
+ msg = None
+ if "properties" not in data:
+ msg = "Properties file missing 'properties' key"
+ elif not isinstance(data["properties"], list):
+ msg = "Properties file 'properties' value must be a list"
+ elif not all(isinstance(item, str) for item in data["properties"]):
+ msg = "Properties file 'properties' value must be a list of strings"
+ elif "dependents" in data:
+ dependents = data["dependents"]
+ if not isinstance(dependents, dict):
+ msg = "Properties file 'dependent_properties' value must be an object"
+ elif (not all(isinstance(dependents[item], list) and
+ all(isinstance(item_value, str)
+ for item_value in dependents[item])
+ for item in dependents)):
+ msg = ("Properties file 'dependent_properties' values must be lists of" +
+ " strings")
+ if msg is not None:
+ logger.error(msg)
+ raise ValueError(msg)
+
+ properties = data["properties"]
+ except OSError:
+ logger.critical(f"Error opening properties file {properties_file}")
+ raise
+ except ValueError:
+ logger.critical(f"Error parsing properties file {properties_file}")
+ raise
+ elif product is not None:
+ logger.warning("Falling back to getting metadata update properties from wptrunner browser "
+ "product file, this will be removed")
+ if config is None:
+ msg = "Must provide a config together with a product"
+ logger.critical(msg)
+ raise ValueError(msg)
+
+ properties, dependents = products.load_product_update(config, product)
+
+ if extra_properties is not None:
+ properties.extend(extra_properties)
+
+ properties_set = set(properties)
+ if any(item not in properties_set for item in dependents.keys()):
+ msg = "All 'dependent' keys must be in 'properties'"
+ logger.critical(msg)
+ raise ValueError(msg)
+
+ return properties, dependents
+
+
+def update_expected(test_paths, log_file_names,
+ update_properties, full_update=False, disable_intermittent=None,
+ update_intermittent=False, remove_intermittent=False, **kwargs):
+ """Update the metadata files for web-platform-tests based on
+ the results obtained in a previous run or runs
+
+ If `disable_intermittent` is not None, assume log_file_names refers to logs from repeated
+ test jobs, disable tests that don't behave as expected on all runs
+
+ If `update_intermittent` is True, intermittent statuses will be recorded as `expected` in
+ the metadata.
+
+ If `remove_intermittent` is True and used in conjunction with `update_intermittent`, any
+ intermittent statuses which are not present in the current run will be removed from the
+ metadata, else they are left in."""
+
+ do_delayed_imports()
+
+ id_test_map = load_test_data(test_paths)
+
+ msg = f"Updating metadata using properties: {','.join(update_properties[0])}"
+ if update_properties[1]:
+ dependent_strs = [f"{item}: {','.join(values)}"
+ for item, values in update_properties[1].items()]
+ msg += f", and dependent properties: {' '.join(dependent_strs)}"
+ logger.info(msg)
+
+ for metadata_path, updated_ini in update_from_logs(id_test_map,
+ update_properties,
+ disable_intermittent,
+ update_intermittent,
+ remove_intermittent,
+ full_update,
+ *log_file_names):
+
+ write_new_expected(metadata_path, updated_ini)
+ if disable_intermittent:
+ for test in updated_ini.iterchildren():
+ for subtest in test.iterchildren():
+ if subtest.new_disabled:
+ logger.info("disabled: %s" % os.path.dirname(subtest.root.test_path) + "/" + subtest.name)
+ if test.new_disabled:
+ logger.info("disabled: %s" % test.root.test_path)
+
+
+def do_delayed_imports():
+ global manifest, manifestitem
+ from manifest import manifest, item as manifestitem # type: ignore
+
+
+# For each testrun
+# Load all files and scan for the suite_start entry
+# Build a hash of filename: properties
+# For each different set of properties, gather all chunks
+# For each chunk in the set of chunks, go through all tests
+# for each test, make a map of {conditionals: [(platform, new_value)]}
+# Repeat for each platform
+# For each test in the list of tests:
+# for each conditional:
+# If all the new values match (or there aren't any) retain that conditional
+# If any new values mismatch:
+# If disable_intermittent and any repeated values don't match, disable the test
+# else mark the test as needing human attention
+# Check if all the RHS values are the same; if so collapse the conditionals
+
+
+class InternedData:
+ """Class for interning data of any (hashable) type.
+
+ This class is intended for building a mapping of int <=> value, such
+ that the integer may be stored as a proxy for the real value, and then
+ the real value obtained later from the proxy value.
+
+ In order to support the use case of packing the integer value as binary,
+ it is possible to specify a maximum bitsize of the data; adding more items
+ than this allowed will result in a ValueError exception.
+
+ The zero value is reserved to use as a sentinal."""
+
+ type_conv = None
+ rev_type_conv = None
+
+ def __init__(self, max_bits: int = 8):
+ self.max_idx = 2**max_bits - 2
+ # Reserve 0 as a sentinal
+ self._data: Tuple[List[object], Dict[int, object]]
+ self._data = [None], {}
+
+ def clear(self):
+ self.__init__()
+
+ def store(self, obj):
+ if self.type_conv is not None:
+ obj = self.type_conv(obj)
+
+ objs, obj_to_idx = self._data
+ if obj not in obj_to_idx:
+ value = len(objs)
+ objs.append(obj)
+ obj_to_idx[obj] = value
+ if value > self.max_idx:
+ raise ValueError
+ else:
+ value = obj_to_idx[obj]
+ return value
+
+ def get(self, idx):
+ obj = self._data[0][idx]
+ if self.rev_type_conv is not None:
+ obj = self.rev_type_conv(obj)
+ return obj
+
+ def __iter__(self):
+ for i in range(1, len(self._data[0])):
+ yield self.get(i)
+
+
+class RunInfoInterned(InternedData):
+ def type_conv(self, value):
+ return tuple(value.items())
+
+ def rev_type_conv(self, value):
+ return dict(value)
+
+
+prop_intern = InternedData(4)
+run_info_intern = InternedData(8)
+status_intern = InternedData(4)
+
+
+def pack_result(data):
+ # As `status_intern` normally handles one status, if `known_intermittent` is present in
+ # the test logs, intern and store this with the `status` in an array until needed.
+ if not data.get("known_intermittent"):
+ return status_intern.store(data.get("status"))
+ result = array.array("B")
+ expected = data.get("expected")
+ if expected is None:
+ expected = data["status"]
+ result_parts = [data["status"], expected] + data["known_intermittent"]
+ for i, part in enumerate(result_parts):
+ value = status_intern.store(part)
+ if i % 2 == 0:
+ assert value < 16
+ result.append(value << 4)
+ else:
+ result[-1] += value
+ return result
+
+
+def unpack_result(data):
+ if isinstance(data, int):
+ return (status_intern.get(data), None)
+ if isinstance(data, str):
+ return (data, None)
+ # Unpack multiple statuses into a tuple to be used in the Results named tuple below,
+ # separating `status` and `known_intermittent`.
+ results = []
+ for packed_value in data:
+ first = status_intern.get(packed_value >> 4)
+ second = status_intern.get(packed_value & 0x0F)
+ results.append(first)
+ if second:
+ results.append(second)
+ return ((results[0],), tuple(results[1:]))
+
+
+def load_test_data(test_paths):
+ manifest_loader = testloader.ManifestLoader(test_paths, False)
+ manifests = manifest_loader.load()
+
+ id_test_map = {}
+ for test_manifest, paths in manifests.items():
+ id_test_map.update(create_test_tree(paths["metadata_path"],
+ test_manifest))
+ return id_test_map
+
+
+def update_from_logs(id_test_map, update_properties, disable_intermittent, update_intermittent,
+ remove_intermittent, full_update, *log_filenames):
+
+ updater = ExpectedUpdater(id_test_map)
+
+ for i, log_filename in enumerate(log_filenames):
+ logger.info("Processing log %d/%d" % (i + 1, len(log_filenames)))
+ with open(log_filename) as f:
+ updater.update_from_log(f)
+
+ yield from update_results(id_test_map, update_properties, full_update,
+ disable_intermittent, update_intermittent=update_intermittent,
+ remove_intermittent=remove_intermittent)
+
+
+def update_results(id_test_map,
+ update_properties,
+ full_update,
+ disable_intermittent,
+ update_intermittent,
+ remove_intermittent):
+ test_file_items = set(id_test_map.values())
+
+ default_expected_by_type = {}
+ for test_type, test_cls in wpttest.manifest_test_cls.items():
+ if test_cls.result_cls:
+ default_expected_by_type[(test_type, False)] = test_cls.result_cls.default_expected
+ if test_cls.subtest_result_cls:
+ default_expected_by_type[(test_type, True)] = test_cls.subtest_result_cls.default_expected
+
+ for test_file in test_file_items:
+ updated_expected = test_file.update(default_expected_by_type, update_properties,
+ full_update, disable_intermittent, update_intermittent,
+ remove_intermittent)
+ if updated_expected is not None and updated_expected.modified:
+ yield test_file.metadata_path, updated_expected
+
+
+def directory_manifests(metadata_path):
+ rv = []
+ for dirpath, dirname, filenames in os.walk(metadata_path):
+ if "__dir__.ini" in filenames:
+ rel_path = os.path.relpath(dirpath, metadata_path)
+ rv.append(os.path.join(rel_path, "__dir__.ini"))
+ return rv
+
+
+def write_new_expected(metadata_path, expected):
+ # Serialize the data back to a file
+ path = expected_path(metadata_path, expected.test_path)
+ if not expected.is_empty:
+ manifest_str = wptmanifest.serialize(expected.node,
+ skip_empty_data=True)
+ assert manifest_str != ""
+ dir = os.path.dirname(path)
+ if not os.path.exists(dir):
+ os.makedirs(dir)
+ tmp_path = path + ".tmp"
+ try:
+ with open(tmp_path, "wb") as f:
+ f.write(manifest_str.encode("utf8"))
+ os.replace(tmp_path, path)
+ except (Exception, KeyboardInterrupt):
+ try:
+ os.unlink(tmp_path)
+ except OSError:
+ pass
+ else:
+ try:
+ os.unlink(path)
+ except OSError:
+ pass
+
+
+class ExpectedUpdater:
+ def __init__(self, id_test_map):
+ self.id_test_map = id_test_map
+ self.run_info = None
+ self.action_map = {"suite_start": self.suite_start,
+ "test_start": self.test_start,
+ "test_status": self.test_status,
+ "test_end": self.test_end,
+ "assertion_count": self.assertion_count,
+ "lsan_leak": self.lsan_leak,
+ "mozleak_object": self.mozleak_object,
+ "mozleak_total": self.mozleak_total}
+ self.tests_visited = {}
+
+ def update_from_log(self, log_file):
+ # We support three possible formats:
+ # * wptreport format; one json object in the file, possibly pretty-printed
+ # * wptreport format; one run per line
+ # * raw log format
+
+ # Try reading a single json object in wptreport format
+ self.run_info = None
+ success = self.get_wptreport_data(log_file.read())
+
+ if success:
+ return
+
+ # Try line-separated json objects in wptreport format
+ log_file.seek(0)
+ for line in log_file:
+ success = self.get_wptreport_data(line)
+ if not success:
+ break
+ else:
+ return
+
+ # Assume the file is a raw log
+ log_file.seek(0)
+ self.update_from_raw_log(log_file)
+
+ def get_wptreport_data(self, input_str):
+ try:
+ data = json.loads(input_str)
+ except Exception:
+ pass
+ else:
+ if "action" not in data and "results" in data:
+ self.update_from_wptreport_log(data)
+ return True
+ return False
+
+ def update_from_raw_log(self, log_file):
+ action_map = self.action_map
+ for line in log_file:
+ try:
+ data = json.loads(line)
+ except ValueError:
+ # Just skip lines that aren't json
+ continue
+ action = data["action"]
+ if action in action_map:
+ action_map[action](data)
+
+ def update_from_wptreport_log(self, data):
+ action_map = self.action_map
+ action_map["suite_start"]({"run_info": data["run_info"]})
+ for test in data["results"]:
+ action_map["test_start"]({"test": test["test"]})
+ for subtest in test["subtests"]:
+ action_map["test_status"]({"test": test["test"],
+ "subtest": subtest["name"],
+ "status": subtest["status"],
+ "expected": subtest.get("expected"),
+ "known_intermittent": subtest.get("known_intermittent", [])})
+ action_map["test_end"]({"test": test["test"],
+ "status": test["status"],
+ "expected": test.get("expected"),
+ "known_intermittent": test.get("known_intermittent", [])})
+ if "asserts" in test:
+ asserts = test["asserts"]
+ action_map["assertion_count"]({"test": test["test"],
+ "count": asserts["count"],
+ "min_expected": asserts["min"],
+ "max_expected": asserts["max"]})
+ for item in data.get("lsan_leaks", []):
+ action_map["lsan_leak"](item)
+
+ mozleak_data = data.get("mozleak", {})
+ for scope, scope_data in mozleak_data.items():
+ for key, action in [("objects", "mozleak_object"),
+ ("total", "mozleak_total")]:
+ for item in scope_data.get(key, []):
+ item_data = {"scope": scope}
+ item_data.update(item)
+ action_map[action](item_data)
+
+ def suite_start(self, data):
+ self.run_info = run_info_intern.store(RunInfo(data["run_info"]))
+
+ def test_start(self, data):
+ test_id = intern(ensure_str(data["test"]))
+ try:
+ self.id_test_map[test_id]
+ except KeyError:
+ logger.warning("Test not found %s, skipping" % test_id)
+ return
+
+ self.tests_visited[test_id] = set()
+
+ def test_status(self, data):
+ test_id = intern(ensure_str(data["test"]))
+ subtest = intern(ensure_str(data["subtest"]))
+ test_data = self.id_test_map.get(test_id)
+ if test_data is None:
+ return
+
+ self.tests_visited[test_id].add(subtest)
+
+ result = pack_result(data)
+
+ test_data.set(test_id, subtest, "status", self.run_info, result)
+ if data.get("expected") and data["expected"] != data["status"]:
+ test_data.set_requires_update()
+
+ def test_end(self, data):
+ if data["status"] == "SKIP":
+ return
+
+ test_id = intern(ensure_str(data["test"]))
+ test_data = self.id_test_map.get(test_id)
+ if test_data is None:
+ return
+
+ result = pack_result(data)
+
+ test_data.set(test_id, None, "status", self.run_info, result)
+ if data.get("expected") and data["expected"] != data["status"]:
+ test_data.set_requires_update()
+ del self.tests_visited[test_id]
+
+ def assertion_count(self, data):
+ test_id = intern(ensure_str(data["test"]))
+ test_data = self.id_test_map.get(test_id)
+ if test_data is None:
+ return
+
+ test_data.set(test_id, None, "asserts", self.run_info, data["count"])
+ if data["count"] < data["min_expected"] or data["count"] > data["max_expected"]:
+ test_data.set_requires_update()
+
+ def test_for_scope(self, data):
+ dir_path = data.get("scope", "/")
+ dir_id = intern(ensure_str(os.path.join(dir_path, "__dir__").replace(os.path.sep, "/")))
+ if dir_id.startswith("/"):
+ dir_id = dir_id[1:]
+ return dir_id, self.id_test_map[dir_id]
+
+ def lsan_leak(self, data):
+ if data["scope"] == "/":
+ logger.warning("Not updating lsan annotations for root scope")
+ return
+ dir_id, test_data = self.test_for_scope(data)
+ test_data.set(dir_id, None, "lsan",
+ self.run_info, (data["frames"], data.get("allowed_match")))
+ if not data.get("allowed_match"):
+ test_data.set_requires_update()
+
+ def mozleak_object(self, data):
+ if data["scope"] == "/":
+ logger.warning("Not updating mozleak annotations for root scope")
+ return
+ dir_id, test_data = self.test_for_scope(data)
+ test_data.set(dir_id, None, "leak-object",
+ self.run_info, ("%s:%s", (data["process"], data["name"]),
+ data.get("allowed")))
+ if not data.get("allowed"):
+ test_data.set_requires_update()
+
+ def mozleak_total(self, data):
+ if data["scope"] == "/":
+ logger.warning("Not updating mozleak annotations for root scope")
+ return
+ if data["bytes"]:
+ dir_id, test_data = self.test_for_scope(data)
+ test_data.set(dir_id, None, "leak-threshold",
+ self.run_info, (data["process"], data["bytes"], data["threshold"]))
+ if data["bytes"] > data["threshold"] or data["bytes"] < 0:
+ test_data.set_requires_update()
+
+
+def create_test_tree(metadata_path, test_manifest):
+ """Create a map of test_id to TestFileData for that test.
+ """
+ do_delayed_imports()
+ id_test_map = {}
+ exclude_types = frozenset(["manual", "support", "conformancechecker"])
+ all_types = set(manifestitem.item_types.keys())
+ assert all_types > exclude_types
+ include_types = all_types - exclude_types
+ for item_type, test_path, tests in test_manifest.itertypes(*include_types):
+ test_file_data = TestFileData(intern(ensure_str(test_manifest.url_base)),
+ intern(ensure_str(item_type)),
+ metadata_path,
+ test_path,
+ tests)
+ for test in tests:
+ id_test_map[intern(ensure_str(test.id))] = test_file_data
+
+ dir_path = os.path.dirname(test_path)
+ while True:
+ dir_meta_path = os.path.join(dir_path, "__dir__")
+ dir_id = (test_manifest.url_base + dir_meta_path.replace(os.path.sep, "/")).lstrip("/")
+ if dir_id in id_test_map:
+ break
+
+ test_file_data = TestFileData(intern(ensure_str(test_manifest.url_base)),
+ None,
+ metadata_path,
+ dir_meta_path,
+ [])
+ id_test_map[dir_id] = test_file_data
+ dir_path = os.path.dirname(dir_path)
+ if not dir_path:
+ break
+
+ return id_test_map
+
+
+class PackedResultList:
+ """Class for storing test results.
+
+ Results are stored as an array of 2-byte integers for compactness.
+ The first 4 bits represent the property name, the second 4 bits
+ represent the test status (if it's a result with a status code), and
+ the final 8 bits represent the run_info. If the result doesn't have a
+ simple status code but instead a richer type, we place that richer type
+ in a dictionary and set the status part of the result type to 0.
+
+ This class depends on the global prop_intern, run_info_intern and
+ status_intern InteredData objects to convert between the bit values
+ and corresponding Python objects."""
+
+ def __init__(self):
+ self.data = array.array("H")
+
+ __slots__ = ("data", "raw_data")
+
+ def append(self, prop, run_info, value):
+ out_val = (prop << 12) + run_info
+ if prop == prop_intern.store("status") and isinstance(value, int):
+ out_val += value << 8
+ else:
+ if not hasattr(self, "raw_data"):
+ self.raw_data = {}
+ self.raw_data[len(self.data)] = value
+ self.data.append(out_val)
+
+ def unpack(self, idx, packed):
+ prop = prop_intern.get((packed & 0xF000) >> 12)
+
+ value_idx = (packed & 0x0F00) >> 8
+ if value_idx == 0:
+ value = self.raw_data[idx]
+ else:
+ value = status_intern.get(value_idx)
+
+ run_info = run_info_intern.get(packed & 0x00FF)
+
+ return prop, run_info, value
+
+ def __iter__(self):
+ for i, item in enumerate(self.data):
+ yield self.unpack(i, item)
+
+
+class TestFileData:
+ __slots__ = ("url_base", "item_type", "test_path", "metadata_path", "tests",
+ "_requires_update", "data")
+
+ def __init__(self, url_base, item_type, metadata_path, test_path, tests):
+ self.url_base = url_base
+ self.item_type = item_type
+ self.test_path = test_path
+ self.metadata_path = metadata_path
+ self.tests = {intern(ensure_str(item.id)) for item in tests}
+ self._requires_update = False
+ self.data = defaultdict(lambda: defaultdict(PackedResultList))
+
+ def set_requires_update(self):
+ self._requires_update = True
+
+ @property
+ def requires_update(self):
+ return self._requires_update
+
+ def set(self, test_id, subtest_id, prop, run_info, value):
+ self.data[test_id][subtest_id].append(prop_intern.store(prop),
+ run_info,
+ value)
+
+ def expected(self, update_properties, update_intermittent, remove_intermittent):
+ expected_data = load_expected(self.url_base,
+ self.metadata_path,
+ self.test_path,
+ self.tests,
+ update_properties,
+ update_intermittent,
+ remove_intermittent)
+ if expected_data is None:
+ expected_data = create_expected(self.url_base,
+ self.test_path,
+ update_properties,
+ update_intermittent,
+ remove_intermittent)
+ return expected_data
+
+ def is_disabled(self, test):
+ # This conservatively assumes that anything that was disabled remains disabled
+ # we could probably do better by checking if it's in the full set of run infos
+ return test.has_key("disabled")
+
+ def orphan_subtests(self, expected):
+ # Return subtest nodes present in the expected file, but missing from the data
+ rv = []
+
+ for test_id, subtests in self.data.items():
+ test = expected.get_test(ensure_text(test_id))
+ if not test:
+ continue
+ seen_subtests = {ensure_text(item) for item in subtests.keys() if item is not None}
+ missing_subtests = set(test.subtests.keys()) - seen_subtests
+ for item in missing_subtests:
+ expected_subtest = test.get_subtest(item)
+ if not self.is_disabled(expected_subtest):
+ rv.append(expected_subtest)
+ for name in seen_subtests:
+ subtest = test.get_subtest(name)
+ # If any of the items have children (ie subsubtests) we want to prune thes
+ if subtest.children:
+ rv.extend(subtest.children)
+
+ return rv
+
+ def filter_unknown_props(self, update_properties, subtests):
+ # Remove subtests which have some conditions that aren't in update_properties
+ # since removing these may be inappropriate
+ top_level_props, dependent_props = update_properties
+ all_properties = set(top_level_props)
+ for item in dependent_props.values():
+ all_properties |= set(item)
+
+ filtered = []
+ for subtest in subtests:
+ include = True
+ for key, _ in subtest.iter_properties():
+ conditions = subtest.get_conditions(key)
+ for condition in conditions:
+ if not condition.variables.issubset(all_properties):
+ include = False
+ break
+ if not include:
+ break
+ if include:
+ filtered.append(subtest)
+ return filtered
+
+ def update(self, default_expected_by_type, update_properties,
+ full_update=False, disable_intermittent=None, update_intermittent=False,
+ remove_intermittent=False):
+ # If we are doing a full update, we may need to prune missing nodes
+ # even if the expectations didn't change
+ if not self.requires_update and not full_update:
+ return
+
+ logger.debug("Updating %s", self.metadata_path)
+
+ expected = self.expected(update_properties,
+ update_intermittent=update_intermittent,
+ remove_intermittent=remove_intermittent)
+
+ if full_update:
+ orphans = self.orphan_subtests(expected)
+ orphans = self.filter_unknown_props(update_properties, orphans)
+
+ if not self.requires_update and not orphans:
+ return
+
+ if orphans:
+ expected.modified = True
+ for item in orphans:
+ item.remove()
+
+ expected_by_test = {}
+
+ for test_id in self.tests:
+ if not expected.has_test(test_id):
+ expected.append(manifestupdate.TestNode.create(test_id))
+ test_expected = expected.get_test(test_id)
+ expected_by_test[test_id] = test_expected
+
+ for test_id, test_data in self.data.items():
+ test_id = ensure_str(test_id)
+ for subtest_id, results_list in test_data.items():
+ for prop, run_info, value in results_list:
+ # Special case directory metadata
+ if subtest_id is None and test_id.endswith("__dir__"):
+ if prop == "lsan":
+ expected.set_lsan(run_info, value)
+ elif prop == "leak-object":
+ expected.set_leak_object(run_info, value)
+ elif prop == "leak-threshold":
+ expected.set_leak_threshold(run_info, value)
+ continue
+
+ test_expected = expected_by_test[test_id]
+ if subtest_id is None:
+ item_expected = test_expected
+ else:
+ subtest_id = ensure_text(subtest_id)
+ item_expected = test_expected.get_subtest(subtest_id)
+
+ if prop == "status":
+ status, known_intermittent = unpack_result(value)
+ value = Result(status,
+ known_intermittent,
+ default_expected_by_type[self.item_type,
+ subtest_id is not None])
+ item_expected.set_result(run_info, value)
+ elif prop == "asserts":
+ item_expected.set_asserts(run_info, value)
+
+ expected.update(full_update=full_update,
+ disable_intermittent=disable_intermittent)
+ for test in expected.iterchildren():
+ for subtest in test.iterchildren():
+ subtest.update(full_update=full_update,
+ disable_intermittent=disable_intermittent)
+ test.update(full_update=full_update,
+ disable_intermittent=disable_intermittent)
+
+ return expected
+
+
+Result = namedtuple("Result", ["status", "known_intermittent", "default_expected"])
+
+
+def create_expected(url_base, test_path, run_info_properties, update_intermittent, remove_intermittent):
+ expected = manifestupdate.ExpectedManifest(None,
+ test_path,
+ url_base,
+ run_info_properties,
+ update_intermittent,
+ remove_intermittent)
+ return expected
+
+
+def load_expected(url_base, metadata_path, test_path, tests, run_info_properties, update_intermittent, remove_intermittent):
+ expected_manifest = manifestupdate.get_manifest(metadata_path,
+ test_path,
+ url_base,
+ run_info_properties,
+ update_intermittent,
+ remove_intermittent)
+ return expected_manifest
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/mpcontext.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/mpcontext.py
new file mode 100644
index 0000000000..d423d9b9a1
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/mpcontext.py
@@ -0,0 +1,13 @@
+# mypy: allow-untyped-defs
+
+import multiprocessing
+
+_context = None
+
+
+def get_context():
+ global _context
+
+ if _context is None:
+ _context = multiprocessing.get_context("spawn")
+ return _context
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/print_reftest_runner.html b/testing/web-platform/tests/tools/wptrunner/wptrunner/print_reftest_runner.html
new file mode 100644
index 0000000000..3ce18d4dd8
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/print_reftest_runner.html
@@ -0,0 +1,33 @@
+<!doctype html>
+<title></title>
+<script src="/_pdf_js/pdf.js"></script>
+<canvas></canvas>
+<script>
+function render(pdfData) {
+ return _render(pdfData);
+}
+
+async function _render(pdfData) {
+ let loadingTask = pdfjsLib.getDocument({data: atob(pdfData)});
+ let pdf = await loadingTask.promise;
+ let rendered = [];
+ for (let pageNumber=1; pageNumber<=pdf.numPages; pageNumber++) {
+ let page = await pdf.getPage(pageNumber);
+ var viewport = page.getViewport({scale: 96./72.});
+ // Prepare canvas using PDF page dimensions
+ var canvas = document.getElementsByTagName('canvas')[0];
+ var context = canvas.getContext('2d');
+ canvas.height = viewport.height;
+ canvas.width = viewport.width;
+
+ // Render PDF page into canvas context
+ var renderContext = {
+ canvasContext: context,
+ viewport: viewport
+ };
+ await page.render(renderContext).promise;
+ rendered.push(canvas.toDataURL());
+ }
+ return rendered;
+}
+</script>
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/products.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/products.py
new file mode 100644
index 0000000000..0706a2b5c8
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/products.py
@@ -0,0 +1,67 @@
+# mypy: allow-untyped-defs
+
+import importlib
+import imp
+
+from .browsers import product_list
+
+
+def product_module(config, product):
+ if product not in product_list:
+ raise ValueError("Unknown product %s" % product)
+
+ path = config.get("products", {}).get(product, None)
+ if path:
+ module = imp.load_source('wptrunner.browsers.' + product, path)
+ else:
+ module = importlib.import_module("wptrunner.browsers." + product)
+
+ if not hasattr(module, "__wptrunner__"):
+ raise ValueError("Product module does not define __wptrunner__ variable")
+
+ return module
+
+
+class Product:
+ def __init__(self, config, product):
+ module = product_module(config, product)
+ data = module.__wptrunner__
+ self.name = product
+ if isinstance(data["browser"], str):
+ self._browser_cls = {None: getattr(module, data["browser"])}
+ else:
+ self._browser_cls = {key: getattr(module, value)
+ for key, value in data["browser"].items()}
+ self.check_args = getattr(module, data["check_args"])
+ self.get_browser_kwargs = getattr(module, data["browser_kwargs"])
+ self.get_executor_kwargs = getattr(module, data["executor_kwargs"])
+ self.env_options = getattr(module, data["env_options"])()
+ self.get_env_extras = getattr(module, data["env_extras"])
+ self.run_info_extras = (getattr(module, data["run_info_extras"])
+ if "run_info_extras" in data else lambda **kwargs:{})
+ self.get_timeout_multiplier = getattr(module, data["timeout_multiplier"])
+
+ self.executor_classes = {}
+ for test_type, cls_name in data["executor"].items():
+ cls = getattr(module, cls_name)
+ self.executor_classes[test_type] = cls
+
+ def get_browser_cls(self, test_type):
+ if test_type in self._browser_cls:
+ return self._browser_cls[test_type]
+ return self._browser_cls[None]
+
+
+def load_product_update(config, product):
+ """Return tuple of (property_order, boolean_properties) indicating the
+ run_info properties to use when constructing the expectation data for
+ this product. None for either key indicates that the default keys
+ appropriate for distinguishing based on platform will be used."""
+
+ module = product_module(config, product)
+ data = module.__wptrunner__
+
+ update_properties = (getattr(module, data["update_properties"])()
+ if "update_properties" in data else (["product"], {}))
+
+ return update_properties
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/stability.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/stability.py
new file mode 100644
index 0000000000..9ac6249c44
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/stability.py
@@ -0,0 +1,417 @@
+# mypy: allow-untyped-defs
+
+import copy
+import functools
+import imp
+import io
+import os
+from collections import OrderedDict, defaultdict
+from datetime import datetime
+
+from mozlog import reader
+from mozlog.formatters import JSONFormatter
+from mozlog.handlers import BaseHandler, StreamHandler, LogLevelFilter
+
+from . import wptrunner
+
+here = os.path.dirname(__file__)
+localpaths = imp.load_source("localpaths", os.path.abspath(os.path.join(here, os.pardir, os.pardir, "localpaths.py")))
+from ci.tc.github_checks_output import get_gh_checks_outputter # type: ignore
+from wpt.markdown import markdown_adjust, table # type: ignore
+
+
+# If a test takes more than (FLAKY_THRESHOLD*timeout) and does not consistently
+# time out, it is considered slow (potentially flaky).
+FLAKY_THRESHOLD = 0.8
+
+
+class LogActionFilter(BaseHandler): # type: ignore
+
+ """Handler that filters out messages not of a given set of actions.
+
+ Subclasses BaseHandler.
+
+ :param inner: Handler to use for messages that pass this filter
+ :param actions: List of actions for which to fire the handler
+ """
+
+ def __init__(self, inner, actions):
+ """Extend BaseHandler and set inner and actions props on self."""
+ BaseHandler.__init__(self, inner)
+ self.inner = inner
+ self.actions = actions
+
+ def __call__(self, item):
+ """Invoke handler if action is in list passed as constructor param."""
+ if item["action"] in self.actions:
+ return self.inner(item)
+
+
+class LogHandler(reader.LogHandler): # type: ignore
+
+ """Handle updating test and subtest status in log.
+
+ Subclasses reader.LogHandler.
+ """
+ def __init__(self):
+ self.results = OrderedDict()
+
+ def find_or_create_test(self, data):
+ test_name = data["test"]
+ if self.results.get(test_name):
+ return self.results[test_name]
+
+ test = {
+ "subtests": OrderedDict(),
+ "status": defaultdict(int),
+ "longest_duration": defaultdict(float),
+ }
+ self.results[test_name] = test
+ return test
+
+ def find_or_create_subtest(self, data):
+ test = self.find_or_create_test(data)
+ subtest_name = data["subtest"]
+
+ if test["subtests"].get(subtest_name):
+ return test["subtests"][subtest_name]
+
+ subtest = {
+ "status": defaultdict(int),
+ "messages": set()
+ }
+ test["subtests"][subtest_name] = subtest
+
+ return subtest
+
+ def test_start(self, data):
+ test = self.find_or_create_test(data)
+ test["start_time"] = data["time"]
+
+ def test_status(self, data):
+ subtest = self.find_or_create_subtest(data)
+ subtest["status"][data["status"]] += 1
+ if data.get("message"):
+ subtest["messages"].add(data["message"])
+
+ def test_end(self, data):
+ test = self.find_or_create_test(data)
+ test["status"][data["status"]] += 1
+ # Timestamps are in ms since epoch.
+ duration = data["time"] - test.pop("start_time")
+ test["longest_duration"][data["status"]] = max(
+ duration, test["longest_duration"][data["status"]])
+ try:
+ # test_timeout is in seconds; convert it to ms.
+ test["timeout"] = data["extra"]["test_timeout"] * 1000
+ except KeyError:
+ # If a test is skipped, it won't have extra info.
+ pass
+
+
+def is_inconsistent(results_dict, iterations):
+ """Return whether or not a single test is inconsistent."""
+ if 'SKIP' in results_dict:
+ return False
+ return len(results_dict) > 1 or sum(results_dict.values()) != iterations
+
+
+def find_slow_status(test):
+ """Check if a single test almost times out.
+
+ We are interested in tests that almost time out (i.e. likely to be flaky).
+ Therefore, timeout statuses are ignored, including (EXTERNAL-)TIMEOUT.
+ CRASH & ERROR are also ignored because the they override TIMEOUT; a test
+ that both crashes and times out is marked as CRASH, so it won't be flaky.
+
+ Returns:
+ A result status produced by a run that almost times out; None, if no
+ runs almost time out.
+ """
+ if "timeout" not in test:
+ return None
+ threshold = test["timeout"] * FLAKY_THRESHOLD
+ for status in ['PASS', 'FAIL', 'OK']:
+ if (status in test["longest_duration"] and
+ test["longest_duration"][status] > threshold):
+ return status
+ return None
+
+
+def process_results(log, iterations):
+ """Process test log and return overall results and list of inconsistent tests."""
+ inconsistent = []
+ slow = []
+ handler = LogHandler()
+ reader.handle_log(reader.read(log), handler)
+ results = handler.results
+ for test_name, test in results.items():
+ if is_inconsistent(test["status"], iterations):
+ inconsistent.append((test_name, None, test["status"], []))
+ for subtest_name, subtest in test["subtests"].items():
+ if is_inconsistent(subtest["status"], iterations):
+ inconsistent.append((test_name, subtest_name, subtest["status"], subtest["messages"]))
+
+ slow_status = find_slow_status(test)
+ if slow_status is not None:
+ slow.append((
+ test_name,
+ slow_status,
+ test["longest_duration"][slow_status],
+ test["timeout"]
+ ))
+
+ return results, inconsistent, slow
+
+
+def err_string(results_dict, iterations):
+ """Create and return string with errors from test run."""
+ rv = []
+ total_results = sum(results_dict.values())
+ if total_results > iterations:
+ rv.append("Duplicate subtest name")
+ else:
+ for key, value in sorted(results_dict.items()):
+ rv.append("%s%s" %
+ (key, ": %s/%s" % (value, iterations) if value != iterations else ""))
+ if total_results < iterations:
+ rv.append("MISSING: %s/%s" % (iterations - total_results, iterations))
+ rv = ", ".join(rv)
+ if is_inconsistent(results_dict, iterations):
+ rv = "**%s**" % rv
+ return rv
+
+
+def write_github_checks_summary_inconsistent(log, inconsistent, iterations):
+ """Outputs a summary of inconsistent tests for GitHub Checks."""
+ log("Some affected tests had inconsistent (flaky) results:\n")
+ write_inconsistent(log, inconsistent, iterations)
+ log("\n")
+ log("These may be pre-existing or new flakes. Please try to reproduce (see "
+ "the above WPT command, though some flags may not be needed when "
+ "running locally) and determine if your change introduced the flake. "
+ "If you are unable to reproduce the problem, please tag "
+ "`@web-platform-tests/wpt-core-team` in a comment for help.\n")
+
+
+def write_github_checks_summary_slow_tests(log, slow):
+ """Outputs a summary of slow tests for GitHub Checks."""
+ log("Some affected tests had slow results:\n")
+ write_slow_tests(log, slow)
+ log("\n")
+ log("These may be pre-existing or newly slow tests. Slow tests indicate "
+ "that a test ran very close to the test timeout limit and so may "
+ "become TIMEOUT-flaky in the future. Consider speeding up the test or "
+ "breaking it into multiple tests. For help, please tag "
+ "`@web-platform-tests/wpt-core-team` in a comment.\n")
+
+
+def write_inconsistent(log, inconsistent, iterations):
+ """Output inconsistent tests to the passed in logging function."""
+ log("## Unstable results ##\n")
+ strings = [(
+ "`%s`" % markdown_adjust(test),
+ ("`%s`" % markdown_adjust(subtest)) if subtest else "",
+ err_string(results, iterations),
+ ("`%s`" % markdown_adjust(";".join(messages))) if len(messages) else "")
+ for test, subtest, results, messages in inconsistent]
+ table(["Test", "Subtest", "Results", "Messages"], strings, log)
+
+
+def write_slow_tests(log, slow):
+ """Output slow tests to the passed in logging function."""
+ log("## Slow tests ##\n")
+ strings = [(
+ "`%s`" % markdown_adjust(test),
+ "`%s`" % status,
+ "`%.0f`" % duration,
+ "`%.0f`" % timeout)
+ for test, status, duration, timeout in slow]
+ table(["Test", "Result", "Longest duration (ms)", "Timeout (ms)"], strings, log)
+
+
+def write_results(log, results, iterations, pr_number=None, use_details=False):
+ log("## All results ##\n")
+ if use_details:
+ log("<details>\n")
+ log("<summary>%i %s ran</summary>\n\n" % (len(results),
+ "tests" if len(results) > 1
+ else "test"))
+
+ for test_name, test in results.items():
+ baseurl = "http://w3c-test.org/submissions"
+ if "https" in os.path.splitext(test_name)[0].split(".")[1:]:
+ baseurl = "https://w3c-test.org/submissions"
+ title = test_name
+ if use_details:
+ log("<details>\n")
+ if pr_number:
+ title = "<a href=\"%s/%s%s\">%s</a>" % (baseurl, pr_number, test_name, title)
+ log('<summary>%s</summary>\n\n' % title)
+ else:
+ log("### %s ###" % title)
+ strings = [("", err_string(test["status"], iterations), "")]
+
+ strings.extend(((
+ ("`%s`" % markdown_adjust(subtest_name)) if subtest else "",
+ err_string(subtest["status"], iterations),
+ ("`%s`" % markdown_adjust(';'.join(subtest["messages"]))) if len(subtest["messages"]) else "")
+ for subtest_name, subtest in test["subtests"].items()))
+ table(["Subtest", "Results", "Messages"], strings, log)
+ if use_details:
+ log("</details>\n")
+
+ if use_details:
+ log("</details>\n")
+
+
+def run_step(logger, iterations, restart_after_iteration, kwargs_extras, **kwargs):
+ kwargs = copy.deepcopy(kwargs)
+
+ if restart_after_iteration:
+ kwargs["repeat"] = iterations
+ else:
+ kwargs["rerun"] = iterations
+
+ kwargs["pause_after_test"] = False
+ kwargs.update(kwargs_extras)
+
+ def wrap_handler(x):
+ if not kwargs.get("verify_log_full", False):
+ x = LogLevelFilter(x, "WARNING")
+ x = LogActionFilter(x, ["log", "process_output"])
+ return x
+
+ initial_handlers = logger._state.handlers
+ logger._state.handlers = [wrap_handler(handler)
+ for handler in initial_handlers]
+
+ log = io.BytesIO()
+ # Setup logging for wptrunner that keeps process output and
+ # warning+ level logs only
+ logger.add_handler(StreamHandler(log, JSONFormatter()))
+
+ _, test_status = wptrunner.run_tests(**kwargs)
+
+ logger._state.handlers = initial_handlers
+ logger._state.running_tests = set()
+ logger._state.suite_started = False
+
+ log.seek(0)
+ total_iterations = test_status.repeated_runs * kwargs.get("rerun", 1)
+ all_skipped = test_status.all_skipped
+ results, inconsistent, slow = process_results(log, total_iterations)
+ return total_iterations, all_skipped, results, inconsistent, slow
+
+
+def get_steps(logger, repeat_loop, repeat_restart, kwargs_extras):
+ steps = []
+ for kwargs_extra in kwargs_extras:
+ if kwargs_extra:
+ flags_string = " with flags %s" % " ".join(
+ "%s=%s" % item for item in kwargs_extra.items())
+ else:
+ flags_string = ""
+
+ if repeat_loop:
+ desc = "Running tests in a loop %d times%s" % (repeat_loop,
+ flags_string)
+ steps.append((desc,
+ functools.partial(run_step,
+ logger,
+ repeat_loop,
+ False,
+ kwargs_extra),
+ repeat_loop))
+
+ if repeat_restart:
+ desc = "Running tests in a loop with restarts %s times%s" % (repeat_restart,
+ flags_string)
+ steps.append((desc,
+ functools.partial(run_step,
+ logger,
+ repeat_restart,
+ True,
+ kwargs_extra),
+ repeat_restart))
+
+ return steps
+
+
+def write_summary(logger, step_results, final_result):
+ for desc, result in step_results:
+ logger.info('::: %s : %s' % (desc, result))
+ logger.info(':::')
+ if final_result == "PASS":
+ log = logger.info
+ elif final_result == "TIMEOUT":
+ log = logger.warning
+ else:
+ log = logger.error
+ log('::: Test verification %s' % final_result)
+
+ logger.info(':::')
+
+
+def check_stability(logger, repeat_loop=10, repeat_restart=5, chaos_mode=True, max_time=None,
+ output_results=True, **kwargs):
+ kwargs_extras = [{}]
+ if chaos_mode and kwargs["product"] == "firefox":
+ kwargs_extras.append({"chaos_mode_flags": int("0xfb", base=16)})
+
+ steps = get_steps(logger, repeat_loop, repeat_restart, kwargs_extras)
+
+ start_time = datetime.now()
+ step_results = []
+
+ github_checks_outputter = get_gh_checks_outputter(kwargs.get("github_checks_text_file"))
+
+ for desc, step_func, expected_iterations in steps:
+ if max_time and datetime.now() - start_time > max_time:
+ logger.info("::: Test verification is taking too long: Giving up!")
+ logger.info("::: So far, all checks passed, but not all checks were run.")
+ write_summary(logger, step_results, "TIMEOUT")
+ return 2
+
+ logger.info(':::')
+ logger.info('::: Running test verification step "%s"...' % desc)
+ logger.info(':::')
+ total_iterations, all_skipped, results, inconsistent, slow = step_func(**kwargs)
+
+ logger.info(f"::: Ran {total_iterations} of expected {expected_iterations} iterations.")
+ if total_iterations <= 1 and expected_iterations > 1 and not all_skipped:
+ step_results.append((desc, "FAIL"))
+ logger.info("::: Reached iteration timeout before finishing 2 or more repeat runs.")
+ logger.info("::: At least 2 successful repeat runs are required to validate stability.")
+ write_summary(logger, step_results, "TIMEOUT")
+ return 1
+
+ if output_results:
+ write_results(logger.info, results, total_iterations)
+
+ if inconsistent:
+ step_results.append((desc, "FAIL"))
+ if github_checks_outputter:
+ write_github_checks_summary_inconsistent(github_checks_outputter.output,
+ inconsistent, total_iterations)
+ write_inconsistent(logger.info, inconsistent, total_iterations)
+ write_summary(logger, step_results, "FAIL")
+ return 1
+
+ if slow:
+ step_results.append((desc, "FAIL"))
+ if github_checks_outputter:
+ write_github_checks_summary_slow_tests(github_checks_outputter.output, slow)
+ write_slow_tests(logger.info, slow)
+ write_summary(logger, step_results, "FAIL")
+ return 1
+
+ # If the tests passed but the number of iterations didn't match the number expected to run,
+ # it is likely that the runs were stopped early to avoid a timeout.
+ if total_iterations != expected_iterations:
+ result = f"PASS * {total_iterations}/{expected_iterations} repeats completed"
+ step_results.append((desc, result))
+ else:
+ step_results.append((desc, "PASS"))
+
+ write_summary(logger, step_results, "PASS")
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-extra.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-extra.js
new file mode 100644
index 0000000000..94a9a97125
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-extra.js
@@ -0,0 +1,259 @@
+"use strict";
+
+(function() {
+ const is_test_context = window.__wptrunner_message_queue !== undefined;
+ const pending = new Map();
+
+ let result = null;
+ let ctx_cmd_id = 0;
+ let testharness_context = null;
+
+ window.addEventListener("message", function(event) {
+ const data = event.data;
+
+ if (typeof data !== "object" && data !== null) {
+ return;
+ }
+
+ if (is_test_context && data.type === "testdriver-command") {
+ const command = data.message;
+ const ctx_id = command.cmd_id;
+ delete command.cmd_id;
+ const cmd_id = window.__wptrunner_message_queue.push(command);
+ let on_success = (data) => {
+ data.type = "testdriver-complete";
+ data.cmd_id = ctx_id;
+ event.source.postMessage(data, "*");
+ };
+ let on_failure = (data) => {
+ data.type = "testdriver-complete";
+ data.cmd_id = ctx_id;
+ event.source.postMessage(data, "*");
+ };
+ pending.set(cmd_id, [on_success, on_failure]);
+ } else if (data.type === "testdriver-complete") {
+ const cmd_id = data.cmd_id;
+ const [on_success, on_failure] = pending.get(cmd_id);
+ pending.delete(cmd_id);
+ const resolver = data.status === "success" ? on_success : on_failure;
+ resolver(data);
+ if (is_test_context) {
+ window.__wptrunner_process_next_event();
+ }
+ }
+ });
+
+ // Code copied from /common/utils.js
+ function rand_int(bits) {
+ if (bits < 1 || bits > 53) {
+ throw new TypeError();
+ } else {
+ if (bits >= 1 && bits <= 30) {
+ return 0 | ((1 << bits) * Math.random());
+ } else {
+ var high = (0 | ((1 << (bits - 30)) * Math.random())) * (1 << 30);
+ var low = 0 | ((1 << 30) * Math.random());
+ return high + low;
+ }
+ }
+ }
+
+ function to_hex(x, length) {
+ var rv = x.toString(16);
+ while (rv.length < length) {
+ rv = "0" + rv;
+ }
+ return rv;
+ }
+
+ function get_window_id(win) {
+ if (win == window && is_test_context) {
+ return null;
+ }
+ if (!win.__wptrunner_id) {
+ // generate a uuid
+ win.__wptrunner_id = [to_hex(rand_int(32), 8),
+ to_hex(rand_int(16), 4),
+ to_hex(0x4000 | rand_int(12), 4),
+ to_hex(0x8000 | rand_int(14), 4),
+ to_hex(rand_int(48), 12)].join("-");
+ }
+ return win.__wptrunner_id;
+ }
+
+ const get_context = function(element) {
+ if (!element) {
+ return null;
+ }
+ let elementWindow = element.ownerDocument.defaultView;
+ if (!elementWindow) {
+ throw new Error("Browsing context for element was detached");
+ }
+ return elementWindow;
+ };
+
+ const get_selector = function(element) {
+ let selector;
+
+ if (element.id) {
+ const id = element.id;
+
+ selector = "#";
+ // escape everything, because it's easy to implement
+ for (let i = 0, len = id.length; i < len; i++) {
+ selector += '\\' + id.charCodeAt(i).toString(16) + ' ';
+ }
+ } else {
+ // push and then reverse to avoid O(n) unshift in the loop
+ let segments = [];
+ for (let node = element;
+ node.parentElement;
+ node = node.parentElement) {
+ let segment = "*|" + node.localName;
+ let nth = Array.prototype.indexOf.call(node.parentElement.children, node) + 1;
+ segments.push(segment + ":nth-child(" + nth + ")");
+ }
+ segments.push(":root");
+ segments.reverse();
+
+ selector = segments.join(" > ");
+ }
+
+ return selector;
+ };
+
+ const create_action = function(name, props) {
+ let cmd_id;
+ const action_msg = {type: "action",
+ action: name,
+ ...props};
+ if (action_msg.context) {
+ action_msg.context = get_window_id(action_msg.context);
+ }
+ if (is_test_context) {
+ cmd_id = window.__wptrunner_message_queue.push(action_msg);
+ } else {
+ if (testharness_context === null) {
+ throw new Error("Tried to run in a non-testharness window without a call to set_test_context");
+ }
+ if (action_msg.context === null) {
+ action_msg.context = get_window_id(window);
+ }
+ cmd_id = ctx_cmd_id++;
+ action_msg.cmd_id = cmd_id;
+ window.test_driver.message_test({type: "testdriver-command",
+ message: action_msg});
+ }
+ const pending_promise = new Promise(function(resolve, reject) {
+ const on_success = data => {
+ result = JSON.parse(data.message).result;
+ resolve(result);
+ };
+ const on_failure = data => {
+ reject(`${data.status}: ${data.message}`);
+ };
+ pending.set(cmd_id, [on_success, on_failure]);
+ });
+ return pending_promise;
+ };
+
+ window.test_driver_internal.in_automation = true;
+
+ window.test_driver_internal.set_test_context = function(context) {
+ if (window.__wptrunner_message_queue) {
+ throw new Error("Tried to set testharness context in a window containing testharness.js");
+ }
+ testharness_context = context;
+ };
+
+ window.test_driver_internal.click = function(element) {
+ const selector = get_selector(element);
+ const context = get_context(element);
+ return create_action("click", {selector, context});
+ };
+
+ window.test_driver_internal.delete_all_cookies = function(context=null) {
+ return create_action("delete_all_cookies", {context});
+ };
+
+ window.test_driver_internal.get_all_cookies = function(context=null) {
+ return create_action("get_all_cookies", {context});
+ };
+
+ window.test_driver_internal.get_named_cookie = function(name, context=null) {
+ return create_action("get_named_cookie", {name, context});
+ };
+
+ window.test_driver_internal.minimize_window = function(context=null) {
+ return create_action("minimize_window", {context});
+ };
+
+ window.test_driver_internal.set_window_rect = function(rect, context=null) {
+ return create_action("set_window_rect", {rect, context});
+ };
+
+ window.test_driver_internal.send_keys = function(element, keys) {
+ const selector = get_selector(element);
+ const context = get_context(element);
+ return create_action("send_keys", {selector, keys, context});
+ };
+
+ window.test_driver_internal.action_sequence = function(actions, context=null) {
+ for (let actionSequence of actions) {
+ if (actionSequence.type == "pointer") {
+ for (let action of actionSequence.actions) {
+ // The origin of each action can only be an element or a string of a value "viewport" or "pointer".
+ if (action.type == "pointerMove" && typeof(action.origin) != 'string') {
+ let action_context = get_context(action.origin);
+ action.origin = {selector: get_selector(action.origin)};
+ if (context !== null && action_context !== context) {
+ throw new Error("Actions must be in a single context");
+ }
+ context = action_context;
+ }
+ }
+ }
+ }
+ return create_action("action_sequence", {actions, context});
+ };
+
+ window.test_driver_internal.generate_test_report = function(message, context=null) {
+ return create_action("generate_test_report", {message, context});
+ };
+
+ window.test_driver_internal.set_permission = function(permission_params, context=null) {
+ return create_action("set_permission", {permission_params, context});
+ };
+
+ window.test_driver_internal.add_virtual_authenticator = function(config, context=null) {
+ return create_action("add_virtual_authenticator", {config, context});
+ };
+
+ window.test_driver_internal.remove_virtual_authenticator = function(authenticator_id, context=null) {
+ return create_action("remove_virtual_authenticator", {authenticator_id, context});
+ };
+
+ window.test_driver_internal.add_credential = function(authenticator_id, credential, context=null) {
+ return create_action("add_credential", {authenticator_id, credential, context});
+ };
+
+ window.test_driver_internal.get_credentials = function(authenticator_id, context=null) {
+ return create_action("get_credentials", {authenticator_id, context});
+ };
+
+ window.test_driver_internal.remove_credential = function(authenticator_id, credential_id, context=null) {
+ return create_action("remove_credential", {authenticator_id, credential_id, context});
+ };
+
+ window.test_driver_internal.remove_all_credentials = function(authenticator_id, context=null) {
+ return create_action("remove_all_credentials", {authenticator_id, context});
+ };
+
+ window.test_driver_internal.set_user_verified = function(authenticator_id, uv, context=null) {
+ return create_action("set_user_verified", {authenticator_id, uv, context});
+ };
+
+ window.test_driver_internal.set_spc_transaction_mode = function(mode, context = null) {
+ return create_action("set_spc_transaction_mode", {mode, context});
+ };
+})();
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-vendor.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-vendor.js
new file mode 100644
index 0000000000..3e88403636
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/testdriver-vendor.js
@@ -0,0 +1 @@
+// This file intentionally left blank
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/testharness_runner.html b/testing/web-platform/tests/tools/wptrunner/wptrunner/testharness_runner.html
new file mode 100644
index 0000000000..1cc80a270e
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/testharness_runner.html
@@ -0,0 +1,6 @@
+<!doctype html>
+<title></title>
+<script>
+var timeout_multiplier = 1;
+var win = null;
+</script>
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-content-shell.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-content-shell.js
new file mode 100644
index 0000000000..e4693f9bc2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-content-shell.js
@@ -0,0 +1,25 @@
+var props = {output:%(output)d, debug: %(debug)s};
+var start_loc = document.createElement('a');
+start_loc.href = location.href;
+setup(props);
+
+testRunner.dumpAsText();
+testRunner.waitUntilDone();
+testRunner.setPopupBlockingEnabled(false);
+testRunner.setDumpJavaScriptDialogs(false);
+
+add_completion_callback(function (tests, harness_status) {
+ var id = decodeURIComponent(start_loc.pathname) + decodeURIComponent(start_loc.search) + decodeURIComponent(start_loc.hash);
+ var result_string = JSON.stringify([
+ id,
+ harness_status.status,
+ harness_status.message,
+ harness_status.stack,
+ tests.map(function(t) {
+ return [t.name, t.status, t.message, t.stack]
+ }),
+ ]);
+
+ testRunner.setCustomTextOutput(result_string);
+ testRunner.notifyDone();
+});
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-servo.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-servo.js
new file mode 100644
index 0000000000..4a27dc27ef
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-servo.js
@@ -0,0 +1,17 @@
+var props = {output:%(output)d, debug: %(debug)s};
+var start_loc = document.createElement('a');
+start_loc.href = location.href;
+setup(props);
+
+add_completion_callback(function (tests, harness_status) {
+ var id = decodeURIComponent(start_loc.pathname) + decodeURIComponent(start_loc.search) + decodeURIComponent(start_loc.hash);
+ console.log("ALERT: RESULT: " + JSON.stringify([
+ id,
+ harness_status.status,
+ harness_status.message,
+ harness_status.stack,
+ tests.map(function(t) {
+ return [t.name, t.status, t.message, t.stack]
+ }),
+ ]));
+});
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-servodriver.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-servodriver.js
new file mode 100644
index 0000000000..7819538dbb
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport-servodriver.js
@@ -0,0 +1,23 @@
+setup({output:%(output)d, debug: %(debug)s});
+
+add_completion_callback(function() {
+ add_completion_callback(function (tests, status) {
+ var subtest_results = tests.map(function(x) {
+ return [x.name, x.status, x.message, x.stack]
+ });
+ var id = location.pathname + location.search + location.hash;
+ var results = JSON.stringify([id,
+ status.status,
+ status.message,
+ status.stack,
+ subtest_results]);
+ (function done() {
+ if (window.__wd_results_callback__) {
+ clearTimeout(__wd_results_timer__);
+ __wd_results_callback__(results)
+ } else {
+ setTimeout(done, 20);
+ }
+ })()
+ })
+});
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport.js b/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport.js
new file mode 100644
index 0000000000..d385692445
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/testharnessreport.js
@@ -0,0 +1,88 @@
+class MessageQueue {
+ constructor() {
+ this.item_id = 0;
+ this._queue = [];
+ }
+
+ push(item) {
+ let cmd_id = this.item_id++;
+ item.id = cmd_id;
+ this._queue.push(item);
+ __wptrunner_process_next_event();
+ return cmd_id;
+ }
+
+ shift() {
+ return this._queue.shift();
+ }
+}
+
+window.__wptrunner_testdriver_callback = null;
+window.__wptrunner_message_queue = new MessageQueue();
+window.__wptrunner_url = null;
+
+window.__wptrunner_process_next_event = function() {
+ /* This function handles the next testdriver event. The presence of
+ window.testdriver_callback is used as a switch; when that function
+ is present we are able to handle the next event and when is is not
+ present we must wait. Therefore to drive the event processing, this
+ function must be called in two circumstances:
+ * Every time there is a new event that we may be able to handle
+ * Every time we set the callback function
+ This function unsets the callback, so no further testdriver actions
+ will be run until it is reset, which wptrunner does after it has
+ completed handling the current action.
+ */
+
+ if (!window.__wptrunner_testdriver_callback) {
+ return;
+ }
+ var data = window.__wptrunner_message_queue.shift();
+ if (!data) {
+ return;
+ }
+
+ var payload = undefined;
+
+ switch(data.type) {
+ case "complete":
+ var tests = data.tests;
+ var status = data.status;
+
+ var subtest_results = tests.map(function(x) {
+ return [x.name, x.status, x.message, x.stack];
+ });
+ payload = [status.status,
+ status.message,
+ status.stack,
+ subtest_results];
+ clearTimeout(window.__wptrunner_timer);
+ break;
+ case "action":
+ payload = data;
+ break;
+ default:
+ return;
+ }
+ var callback = window.__wptrunner_testdriver_callback;
+ window.__wptrunner_testdriver_callback = null;
+ callback([__wptrunner_url, data.type, payload]);
+};
+
+(function() {
+ var props = {output: %(output)d,
+ timeout_multiplier: %(timeout_multiplier)s,
+ explicit_timeout: %(explicit_timeout)s,
+ debug: %(debug)s,
+ message_events: ["completion"]};
+
+ add_completion_callback(function(tests, harness_status) {
+ __wptrunner_message_queue.push({
+ "type": "complete",
+ "tests": tests,
+ "status": harness_status});
+ __wptrunner_process_next_event();
+ });
+ setup(props);
+})();
+
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/testloader.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/testloader.py
new file mode 100644
index 0000000000..0cb5f499a9
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/testloader.py
@@ -0,0 +1,534 @@
+# mypy: allow-untyped-defs
+
+import hashlib
+import itertools
+import json
+import os
+from urllib.parse import urlsplit
+from abc import ABCMeta, abstractmethod
+from queue import Empty
+from collections import defaultdict, deque, namedtuple
+
+from . import manifestinclude
+from . import manifestexpected
+from . import mpcontext
+from . import wpttest
+from mozlog import structured
+
+manifest = None
+manifest_update = None
+download_from_github = None
+
+
+def do_delayed_imports():
+ # This relies on an already loaded module having set the sys.path correctly :(
+ global manifest, manifest_update, download_from_github
+ from manifest import manifest # type: ignore
+ from manifest import update as manifest_update
+ from manifest.download import download_from_github # type: ignore
+
+
+class TestGroupsFile:
+ """
+ Mapping object representing {group name: [test ids]}
+ """
+
+ def __init__(self, logger, path):
+ try:
+ with open(path) as f:
+ self._data = json.load(f)
+ except ValueError:
+ logger.critical("test groups file %s not valid json" % path)
+ raise
+
+ self.group_by_test = {}
+ for group, test_ids in self._data.items():
+ for test_id in test_ids:
+ self.group_by_test[test_id] = group
+
+ def __contains__(self, key):
+ return key in self._data
+
+ def __getitem__(self, key):
+ return self._data[key]
+
+def read_include_from_file(file):
+ new_include = []
+ with open(file) as f:
+ for line in f:
+ line = line.strip()
+ # Allow whole-line comments;
+ # fragments mean we can't have partial line #-based comments
+ if len(line) > 0 and not line.startswith("#"):
+ new_include.append(line)
+ return new_include
+
+def update_include_for_groups(test_groups, include):
+ if include is None:
+ # We're just running everything
+ return
+
+ new_include = []
+ for item in include:
+ if item in test_groups:
+ new_include.extend(test_groups[item])
+ else:
+ new_include.append(item)
+ return new_include
+
+
+class TestChunker:
+ def __init__(self, total_chunks, chunk_number, **kwargs):
+ self.total_chunks = total_chunks
+ self.chunk_number = chunk_number
+ assert self.chunk_number <= self.total_chunks
+ self.logger = structured.get_default_logger()
+ assert self.logger
+ self.kwargs = kwargs
+
+ def __call__(self, manifest):
+ raise NotImplementedError
+
+
+class Unchunked(TestChunker):
+ def __init__(self, *args, **kwargs):
+ TestChunker.__init__(self, *args, **kwargs)
+ assert self.total_chunks == 1
+
+ def __call__(self, manifest, **kwargs):
+ yield from manifest
+
+
+class HashChunker(TestChunker):
+ def __call__(self, manifest):
+ chunk_index = self.chunk_number - 1
+ for test_type, test_path, tests in manifest:
+ h = int(hashlib.md5(test_path.encode()).hexdigest(), 16)
+ if h % self.total_chunks == chunk_index:
+ yield test_type, test_path, tests
+
+
+class DirectoryHashChunker(TestChunker):
+ """Like HashChunker except the directory is hashed.
+
+ This ensures that all tests in the same directory end up in the same
+ chunk.
+ """
+ def __call__(self, manifest):
+ chunk_index = self.chunk_number - 1
+ depth = self.kwargs.get("depth")
+ for test_type, test_path, tests in manifest:
+ if depth:
+ hash_path = os.path.sep.join(os.path.dirname(test_path).split(os.path.sep, depth)[:depth])
+ else:
+ hash_path = os.path.dirname(test_path)
+ h = int(hashlib.md5(hash_path.encode()).hexdigest(), 16)
+ if h % self.total_chunks == chunk_index:
+ yield test_type, test_path, tests
+
+
+class TestFilter:
+ """Callable that restricts the set of tests in a given manifest according
+ to initial criteria"""
+ def __init__(self, test_manifests, include=None, exclude=None, manifest_path=None, explicit=False):
+ if manifest_path is None or include or explicit:
+ self.manifest = manifestinclude.IncludeManifest.create()
+ self.manifest.set_defaults()
+ else:
+ self.manifest = manifestinclude.get_manifest(manifest_path)
+
+ if include or explicit:
+ self.manifest.set("skip", "true")
+
+ if include:
+ for item in include:
+ self.manifest.add_include(test_manifests, item)
+
+ if exclude:
+ for item in exclude:
+ self.manifest.add_exclude(test_manifests, item)
+
+ def __call__(self, manifest_iter):
+ for test_type, test_path, tests in manifest_iter:
+ include_tests = set()
+ for test in tests:
+ if self.manifest.include(test):
+ include_tests.add(test)
+
+ if include_tests:
+ yield test_type, test_path, include_tests
+
+
+class TagFilter:
+ def __init__(self, tags):
+ self.tags = set(tags)
+
+ def __call__(self, test_iter):
+ for test in test_iter:
+ if test.tags & self.tags:
+ yield test
+
+
+class ManifestLoader:
+ def __init__(self, test_paths, force_manifest_update=False, manifest_download=False,
+ types=None):
+ do_delayed_imports()
+ self.test_paths = test_paths
+ self.force_manifest_update = force_manifest_update
+ self.manifest_download = manifest_download
+ self.types = types
+ self.logger = structured.get_default_logger()
+ if self.logger is None:
+ self.logger = structured.structuredlog.StructuredLogger("ManifestLoader")
+
+ def load(self):
+ rv = {}
+ for url_base, paths in self.test_paths.items():
+ manifest_file = self.load_manifest(url_base=url_base,
+ **paths)
+ path_data = {"url_base": url_base}
+ path_data.update(paths)
+ rv[manifest_file] = path_data
+ return rv
+
+ def load_manifest(self, tests_path, manifest_path, metadata_path, url_base="/", **kwargs):
+ cache_root = os.path.join(metadata_path, ".cache")
+ if self.manifest_download:
+ download_from_github(manifest_path, tests_path)
+ return manifest.load_and_update(tests_path, manifest_path, url_base,
+ cache_root=cache_root, update=self.force_manifest_update,
+ types=self.types)
+
+
+def iterfilter(filters, iter):
+ for f in filters:
+ iter = f(iter)
+ yield from iter
+
+
+class TestLoader:
+ """Loads tests according to a WPT manifest and any associated expectation files"""
+ def __init__(self,
+ test_manifests,
+ test_types,
+ run_info,
+ manifest_filters=None,
+ chunk_type="none",
+ total_chunks=1,
+ chunk_number=1,
+ include_https=True,
+ include_h2=True,
+ include_webtransport_h3=False,
+ skip_timeout=False,
+ skip_implementation_status=None,
+ chunker_kwargs=None):
+
+ self.test_types = test_types
+ self.run_info = run_info
+
+ self.manifest_filters = manifest_filters if manifest_filters is not None else []
+
+ self.manifests = test_manifests
+ self.tests = None
+ self.disabled_tests = None
+ self.include_https = include_https
+ self.include_h2 = include_h2
+ self.include_webtransport_h3 = include_webtransport_h3
+ self.skip_timeout = skip_timeout
+ self.skip_implementation_status = skip_implementation_status
+
+ self.chunk_type = chunk_type
+ self.total_chunks = total_chunks
+ self.chunk_number = chunk_number
+
+ if chunker_kwargs is None:
+ chunker_kwargs = {}
+ self.chunker = {"none": Unchunked,
+ "hash": HashChunker,
+ "dir_hash": DirectoryHashChunker}[chunk_type](total_chunks,
+ chunk_number,
+ **chunker_kwargs)
+
+ self._test_ids = None
+
+ self.directory_manifests = {}
+
+ self._load_tests()
+
+ @property
+ def test_ids(self):
+ if self._test_ids is None:
+ self._test_ids = []
+ for test_dict in [self.disabled_tests, self.tests]:
+ for test_type in self.test_types:
+ self._test_ids += [item.id for item in test_dict[test_type]]
+ return self._test_ids
+
+ def get_test(self, manifest_file, manifest_test, inherit_metadata, test_metadata):
+ if test_metadata is not None:
+ inherit_metadata.append(test_metadata)
+ test_metadata = test_metadata.get_test(manifest_test.id)
+
+ return wpttest.from_manifest(manifest_file, manifest_test, inherit_metadata, test_metadata)
+
+ def load_dir_metadata(self, test_manifest, metadata_path, test_path):
+ rv = []
+ path_parts = os.path.dirname(test_path).split(os.path.sep)
+ for i in range(len(path_parts) + 1):
+ path = os.path.join(metadata_path, os.path.sep.join(path_parts[:i]), "__dir__.ini")
+ if path not in self.directory_manifests:
+ self.directory_manifests[path] = manifestexpected.get_dir_manifest(path,
+ self.run_info)
+ manifest = self.directory_manifests[path]
+ if manifest is not None:
+ rv.append(manifest)
+ return rv
+
+ def load_metadata(self, test_manifest, metadata_path, test_path):
+ inherit_metadata = self.load_dir_metadata(test_manifest, metadata_path, test_path)
+ test_metadata = manifestexpected.get_manifest(
+ metadata_path, test_path, test_manifest.url_base, self.run_info)
+ return inherit_metadata, test_metadata
+
+ def iter_tests(self):
+ manifest_items = []
+ manifests_by_url_base = {}
+
+ for manifest in sorted(self.manifests.keys(), key=lambda x:x.url_base):
+ manifest_iter = iterfilter(self.manifest_filters,
+ manifest.itertypes(*self.test_types))
+ manifest_items.extend(manifest_iter)
+ manifests_by_url_base[manifest.url_base] = manifest
+
+ if self.chunker is not None:
+ manifest_items = self.chunker(manifest_items)
+
+ for test_type, test_path, tests in manifest_items:
+ manifest_file = manifests_by_url_base[next(iter(tests)).url_base]
+ metadata_path = self.manifests[manifest_file]["metadata_path"]
+
+ inherit_metadata, test_metadata = self.load_metadata(manifest_file, metadata_path, test_path)
+ for test in tests:
+ yield test_path, test_type, self.get_test(manifest_file, test, inherit_metadata, test_metadata)
+
+ def _load_tests(self):
+ """Read in the tests from the manifest file and add them to a queue"""
+ tests = {"enabled":defaultdict(list),
+ "disabled":defaultdict(list)}
+
+ for test_path, test_type, test in self.iter_tests():
+ enabled = not test.disabled()
+ if not self.include_https and test.environment["protocol"] == "https":
+ enabled = False
+ if not self.include_h2 and test.environment["protocol"] == "h2":
+ enabled = False
+ if self.skip_timeout and test.expected() == "TIMEOUT":
+ enabled = False
+ if self.skip_implementation_status and test.implementation_status() in self.skip_implementation_status:
+ enabled = False
+ key = "enabled" if enabled else "disabled"
+ tests[key][test_type].append(test)
+
+ self.tests = tests["enabled"]
+ self.disabled_tests = tests["disabled"]
+
+ def groups(self, test_types, chunk_type="none", total_chunks=1, chunk_number=1):
+ groups = set()
+
+ for test_type in test_types:
+ for test in self.tests[test_type]:
+ group = test.url.split("/")[1]
+ groups.add(group)
+
+ return groups
+
+
+def get_test_src(**kwargs):
+ test_source_kwargs = {"processes": kwargs["processes"],
+ "logger": kwargs["logger"]}
+ chunker_kwargs = {}
+ if kwargs["run_by_dir"] is not False:
+ # A value of None indicates infinite depth
+ test_source_cls = PathGroupedSource
+ test_source_kwargs["depth"] = kwargs["run_by_dir"]
+ chunker_kwargs["depth"] = kwargs["run_by_dir"]
+ elif kwargs["test_groups"]:
+ test_source_cls = GroupFileTestSource
+ test_source_kwargs["test_groups"] = kwargs["test_groups"]
+ else:
+ test_source_cls = SingleTestSource
+ return test_source_cls, test_source_kwargs, chunker_kwargs
+
+
+TestGroup = namedtuple("TestGroup", ["group", "test_type", "metadata"])
+
+
+class TestSource:
+ __metaclass__ = ABCMeta
+
+ def __init__(self, test_queue):
+ self.test_queue = test_queue
+ self.current_group = TestGroup(None, None, None)
+ self.logger = structured.get_default_logger()
+ if self.logger is None:
+ self.logger = structured.structuredlog.StructuredLogger("TestSource")
+
+ @abstractmethod
+ #@classmethod (doesn't compose with @abstractmethod in < 3.3)
+ def make_queue(cls, tests_by_type, **kwargs): # noqa: N805
+ pass
+
+ @abstractmethod
+ def tests_by_group(cls, tests_by_type, **kwargs): # noqa: N805
+ pass
+
+ @classmethod
+ def group_metadata(cls, state):
+ return {"scope": "/"}
+
+ def group(self):
+ if not self.current_group.group or len(self.current_group.group) == 0:
+ try:
+ self.current_group = self.test_queue.get(block=True, timeout=5)
+ except Empty:
+ self.logger.warning("Timed out getting test group from queue")
+ return TestGroup(None, None, None)
+ return self.current_group
+
+ @classmethod
+ def add_sentinal(cls, test_queue, num_of_workers):
+ # add one sentinal for each worker
+ for _ in range(num_of_workers):
+ test_queue.put(TestGroup(None, None, None))
+
+
+class GroupedSource(TestSource):
+ @classmethod
+ def new_group(cls, state, test_type, test, **kwargs):
+ raise NotImplementedError
+
+ @classmethod
+ def make_queue(cls, tests_by_type, **kwargs):
+ mp = mpcontext.get_context()
+ test_queue = mp.Queue()
+ groups = []
+
+ state = {}
+
+ for test_type, tests in tests_by_type.items():
+ for test in tests:
+ if cls.new_group(state, test_type, test, **kwargs):
+ group_metadata = cls.group_metadata(state)
+ groups.append(TestGroup(deque(), test_type, group_metadata))
+
+ group, _, metadata = groups[-1]
+ group.append(test)
+ test.update_metadata(metadata)
+
+ for item in groups:
+ test_queue.put(item)
+ cls.add_sentinal(test_queue, kwargs["processes"])
+ return test_queue
+
+ @classmethod
+ def tests_by_group(cls, tests_by_type, **kwargs):
+ groups = defaultdict(list)
+ state = {}
+ current = None
+ for test_type, tests in tests_by_type.items():
+ for test in tests:
+ if cls.new_group(state, test_type, test, **kwargs):
+ current = cls.group_metadata(state)['scope']
+ groups[current].append(test.id)
+ return groups
+
+
+class SingleTestSource(TestSource):
+ @classmethod
+ def make_queue(cls, tests_by_type, **kwargs):
+ mp = mpcontext.get_context()
+ test_queue = mp.Queue()
+ for test_type, tests in tests_by_type.items():
+ processes = kwargs["processes"]
+ queues = [deque([]) for _ in range(processes)]
+ metadatas = [cls.group_metadata(None) for _ in range(processes)]
+ for test in tests:
+ idx = hash(test.id) % processes
+ group = queues[idx]
+ metadata = metadatas[idx]
+ group.append(test)
+ test.update_metadata(metadata)
+
+ for item in zip(queues, itertools.repeat(test_type), metadatas):
+ if len(item[0]) > 0:
+ test_queue.put(TestGroup(*item))
+ cls.add_sentinal(test_queue, kwargs["processes"])
+
+ return test_queue
+
+ @classmethod
+ def tests_by_group(cls, tests_by_type, **kwargs):
+ return {cls.group_metadata(None)['scope']:
+ [t.id for t in itertools.chain.from_iterable(tests_by_type.values())]}
+
+
+class PathGroupedSource(GroupedSource):
+ @classmethod
+ def new_group(cls, state, test_type, test, **kwargs):
+ depth = kwargs.get("depth")
+ if depth is True or depth == 0:
+ depth = None
+ path = urlsplit(test.url).path.split("/")[1:-1][:depth]
+ rv = (test_type != state.get("prev_test_type") or
+ path != state.get("prev_path"))
+ state["prev_test_type"] = test_type
+ state["prev_path"] = path
+ return rv
+
+ @classmethod
+ def group_metadata(cls, state):
+ return {"scope": "/%s" % "/".join(state["prev_path"])}
+
+
+class GroupFileTestSource(TestSource):
+ @classmethod
+ def make_queue(cls, tests_by_type, **kwargs):
+ mp = mpcontext.get_context()
+ test_queue = mp.Queue()
+
+ for test_type, tests in tests_by_type.items():
+ tests_by_group = cls.tests_by_group({test_type: tests},
+ **kwargs)
+
+ ids_to_tests = {test.id: test for test in tests}
+
+ for group_name, test_ids in tests_by_group.items():
+ group_metadata = {"scope": group_name}
+ group = deque()
+
+ for test_id in test_ids:
+ test = ids_to_tests[test_id]
+ group.append(test)
+ test.update_metadata(group_metadata)
+
+ test_queue.put(TestGroup(group, test_type, group_metadata))
+
+ cls.add_sentinal(test_queue, kwargs["processes"])
+
+ return test_queue
+
+ @classmethod
+ def tests_by_group(cls, tests_by_type, **kwargs):
+ logger = kwargs["logger"]
+ test_groups = kwargs["test_groups"]
+
+ tests_by_group = defaultdict(list)
+ for test in itertools.chain.from_iterable(tests_by_type.values()):
+ try:
+ group = test_groups.group_by_test[test.id]
+ except KeyError:
+ logger.error("%s is missing from test groups file" % test.id)
+ raise
+ tests_by_group[group].append(test.id)
+
+ return tests_by_group
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/testrunner.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/testrunner.py
new file mode 100644
index 0000000000..82ffc9b84c
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/testrunner.py
@@ -0,0 +1,984 @@
+# mypy: allow-untyped-defs
+
+import threading
+import traceback
+from queue import Empty
+from collections import namedtuple
+
+from mozlog import structuredlog, capture
+
+from . import mpcontext
+
+# Special value used as a sentinal in various commands
+Stop = object()
+
+
+def release_mozlog_lock():
+ try:
+ from mozlog.structuredlog import StructuredLogger
+ try:
+ StructuredLogger._lock.release()
+ except threading.ThreadError:
+ pass
+ except ImportError:
+ pass
+
+
+TestImplementation = namedtuple('TestImplementation',
+ ['executor_cls', 'executor_kwargs',
+ 'browser_cls', 'browser_kwargs'])
+
+
+class LogMessageHandler:
+ def __init__(self, send_message):
+ self.send_message = send_message
+
+ def __call__(self, data):
+ self.send_message("log", data)
+
+
+class TestRunner:
+ """Class implementing the main loop for running tests.
+
+ This class delegates the job of actually running a test to the executor
+ that is passed in.
+
+ :param logger: Structured logger
+ :param command_queue: subprocess.Queue used to send commands to the
+ process
+ :param result_queue: subprocess.Queue used to send results to the
+ parent TestRunnerManager process
+ :param executor: TestExecutor object that will actually run a test.
+ """
+ def __init__(self, logger, command_queue, result_queue, executor, recording):
+ self.command_queue = command_queue
+ self.result_queue = result_queue
+
+ self.executor = executor
+ self.name = mpcontext.get_context().current_process().name
+ self.logger = logger
+ self.recording = recording
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ self.teardown()
+
+ def setup(self):
+ self.logger.debug("Executor setup")
+ try:
+ self.executor.setup(self)
+ except Exception:
+ # The caller is responsible for logging the exception if required
+ self.send_message("init_failed")
+ else:
+ self.send_message("init_succeeded")
+ self.logger.debug("Executor setup done")
+
+ def teardown(self):
+ self.executor.teardown()
+ self.send_message("runner_teardown")
+ self.result_queue = None
+ self.command_queue = None
+ self.browser = None
+
+ def run(self):
+ """Main loop accepting commands over the pipe and triggering
+ the associated methods"""
+ try:
+ self.setup()
+ except Exception:
+ self.logger.warning("An error occured during executor setup:\n%s" %
+ traceback.format_exc())
+ raise
+ commands = {"run_test": self.run_test,
+ "reset": self.reset,
+ "stop": self.stop,
+ "wait": self.wait}
+ while True:
+ command, args = self.command_queue.get()
+ try:
+ rv = commands[command](*args)
+ except Exception:
+ self.send_message("error",
+ "Error running command %s with arguments %r:\n%s" %
+ (command, args, traceback.format_exc()))
+ else:
+ if rv is Stop:
+ break
+
+ def stop(self):
+ return Stop
+
+ def reset(self):
+ self.executor.reset()
+
+ def run_test(self, test):
+ try:
+ return self.executor.run_test(test)
+ except Exception:
+ self.logger.error(traceback.format_exc())
+ raise
+
+ def wait(self):
+ rerun = self.executor.wait()
+ self.send_message("wait_finished", rerun)
+
+ def send_message(self, command, *args):
+ self.result_queue.put((command, args))
+
+
+def start_runner(runner_command_queue, runner_result_queue,
+ executor_cls, executor_kwargs,
+ executor_browser_cls, executor_browser_kwargs,
+ capture_stdio, stop_flag, recording):
+ """Launch a TestRunner in a new process"""
+
+ def send_message(command, *args):
+ runner_result_queue.put((command, args))
+
+ def handle_error(e):
+ logger.critical(traceback.format_exc())
+ stop_flag.set()
+
+ # Ensure that when we start this in a new process we have the global lock
+ # in the logging module unlocked
+ release_mozlog_lock()
+
+ proc_name = mpcontext.get_context().current_process().name
+ logger = structuredlog.StructuredLogger(proc_name)
+ logger.add_handler(LogMessageHandler(send_message))
+
+ with capture.CaptureIO(logger, capture_stdio):
+ try:
+ browser = executor_browser_cls(**executor_browser_kwargs)
+ executor = executor_cls(logger, browser, **executor_kwargs)
+ with TestRunner(logger, runner_command_queue, runner_result_queue, executor, recording) as runner:
+ try:
+ runner.run()
+ except KeyboardInterrupt:
+ stop_flag.set()
+ except Exception as e:
+ handle_error(e)
+ except Exception as e:
+ handle_error(e)
+
+
+class BrowserManager:
+ def __init__(self, logger, browser, command_queue, no_timeout=False):
+ self.logger = logger
+ self.browser = browser
+ self.no_timeout = no_timeout
+ self.browser_settings = None
+ self.last_test = None
+
+ self.started = False
+
+ self.init_timer = None
+ self.command_queue = command_queue
+
+ def update_settings(self, test):
+ browser_settings = self.browser.settings(test)
+ restart_required = ((self.browser_settings is not None and
+ self.browser_settings != browser_settings) or
+ (self.last_test != test and test.expected() == "CRASH"))
+ self.browser_settings = browser_settings
+ self.last_test = test
+ return restart_required
+
+ def init(self, group_metadata):
+ """Launch the browser that is being tested,
+ and the TestRunner process that will run the tests."""
+ # It seems that this lock is helpful to prevent some race that otherwise
+ # sometimes stops the spawned processes initialising correctly, and
+ # leaves this thread hung
+ if self.init_timer is not None:
+ self.init_timer.cancel()
+
+ self.logger.debug("Init called, starting browser and runner")
+
+ if not self.no_timeout:
+ self.init_timer = threading.Timer(self.browser.init_timeout,
+ self.init_timeout)
+ try:
+ if self.init_timer is not None:
+ self.init_timer.start()
+ self.logger.debug("Starting browser with settings %r" % self.browser_settings)
+ self.browser.start(group_metadata=group_metadata, **self.browser_settings)
+ self.browser_pid = self.browser.pid
+ except Exception:
+ self.logger.warning("Failure during init %s" % traceback.format_exc())
+ if self.init_timer is not None:
+ self.init_timer.cancel()
+ self.logger.error(traceback.format_exc())
+ succeeded = False
+ else:
+ succeeded = True
+ self.started = True
+
+ return succeeded
+
+ def send_message(self, command, *args):
+ self.command_queue.put((command, args))
+
+ def init_timeout(self):
+ # This is called from a separate thread, so we send a message to the
+ # main loop so we get back onto the manager thread
+ self.logger.debug("init_failed called from timer")
+ self.send_message("init_failed")
+
+ def after_init(self):
+ """Callback when we have started the browser, started the remote
+ control connection, and we are ready to start testing."""
+ if self.init_timer is not None:
+ self.init_timer.cancel()
+
+ def stop(self, force=False):
+ self.browser.stop(force=force)
+ self.started = False
+
+ def cleanup(self):
+ if self.init_timer is not None:
+ self.init_timer.cancel()
+
+ def check_crash(self, test_id):
+ return self.browser.check_crash(process=self.browser_pid, test=test_id)
+
+ def is_alive(self):
+ return self.browser.is_alive()
+
+
+class _RunnerManagerState:
+ before_init = namedtuple("before_init", [])
+ initializing = namedtuple("initializing",
+ ["test_type", "test", "test_group",
+ "group_metadata", "failure_count"])
+ running = namedtuple("running", ["test_type", "test", "test_group", "group_metadata"])
+ restarting = namedtuple("restarting", ["test_type", "test", "test_group",
+ "group_metadata", "force_stop"])
+ error = namedtuple("error", [])
+ stop = namedtuple("stop", ["force_stop"])
+
+
+RunnerManagerState = _RunnerManagerState()
+
+
+class TestRunnerManager(threading.Thread):
+ def __init__(self, suite_name, index, test_queue, test_source_cls,
+ test_implementation_by_type, stop_flag, rerun=1,
+ pause_after_test=False, pause_on_unexpected=False,
+ restart_on_unexpected=True, debug_info=None,
+ capture_stdio=True, restart_on_new_group=True, recording=None):
+ """Thread that owns a single TestRunner process and any processes required
+ by the TestRunner (e.g. the Firefox binary).
+
+ TestRunnerManagers are responsible for launching the browser process and the
+ runner process, and for logging the test progress. The actual test running
+ is done by the TestRunner. In particular they:
+
+ * Start the binary of the program under test
+ * Start the TestRunner
+ * Tell the TestRunner to start a test, if any
+ * Log that the test started
+ * Log the test results
+ * Take any remedial action required e.g. restart crashed or hung
+ processes
+ """
+ self.suite_name = suite_name
+
+ self.test_source = test_source_cls(test_queue)
+
+ self.manager_number = index
+ self.test_type = None
+
+ self.test_implementation_by_type = {}
+ for test_type, test_implementation in test_implementation_by_type.items():
+ kwargs = test_implementation.browser_kwargs
+ if kwargs.get("device_serial"):
+ kwargs = kwargs.copy()
+ # Assign Android device to runner according to current manager index
+ kwargs["device_serial"] = kwargs["device_serial"][index]
+ self.test_implementation_by_type[test_type] = TestImplementation(
+ test_implementation.executor_cls,
+ test_implementation.executor_kwargs,
+ test_implementation.browser_cls,
+ kwargs)
+ else:
+ self.test_implementation_by_type[test_type] = test_implementation
+
+ mp = mpcontext.get_context()
+
+ # Flags used to shut down this thread if we get a sigint
+ self.parent_stop_flag = stop_flag
+ self.child_stop_flag = mp.Event()
+
+ self.rerun = rerun
+ self.run_count = 0
+ self.pause_after_test = pause_after_test
+ self.pause_on_unexpected = pause_on_unexpected
+ self.restart_on_unexpected = restart_on_unexpected
+ self.debug_info = debug_info
+
+ assert recording is not None
+ self.recording = recording
+
+ self.command_queue = mp.Queue()
+ self.remote_queue = mp.Queue()
+
+ self.test_runner_proc = None
+
+ threading.Thread.__init__(self, name="TestRunnerManager-%s-%i" % (test_type, index))
+ # This is started in the actual new thread
+ self.logger = None
+
+ self.test_count = 0
+ self.unexpected_tests = set()
+ self.unexpected_pass_tests = set()
+
+ # This may not really be what we want
+ self.daemon = True
+
+ self.timer = None
+
+ self.max_restarts = 5
+
+ self.browser = None
+
+ self.capture_stdio = capture_stdio
+ self.restart_on_new_group = restart_on_new_group
+
+ def run(self):
+ """Main loop for the TestRunnerManager.
+
+ TestRunnerManagers generally receive commands from their
+ TestRunner updating them on the status of a test. They
+ may also have a stop flag set by the main thread indicating
+ that the manager should shut down the next time the event loop
+ spins."""
+ self.recording.set(["testrunner", "startup"])
+ self.logger = structuredlog.StructuredLogger(self.suite_name)
+ dispatch = {
+ RunnerManagerState.before_init: self.start_init,
+ RunnerManagerState.initializing: self.init,
+ RunnerManagerState.running: self.run_test,
+ RunnerManagerState.restarting: self.restart_runner,
+ }
+
+ self.state = RunnerManagerState.before_init()
+ end_states = (RunnerManagerState.stop,
+ RunnerManagerState.error)
+
+ try:
+ while not isinstance(self.state, end_states):
+ f = dispatch.get(self.state.__class__)
+ while f:
+ self.logger.debug(f"Dispatch {f.__name__}")
+ if self.should_stop():
+ return
+ new_state = f()
+ if new_state is None:
+ break
+ self.state = new_state
+ self.logger.debug(f"new state: {self.state.__class__.__name__}")
+ if isinstance(self.state, end_states):
+ return
+ f = dispatch.get(self.state.__class__)
+
+ new_state = None
+ while new_state is None:
+ new_state = self.wait_event()
+ if self.should_stop():
+ return
+ self.state = new_state
+ self.logger.debug(f"new state: {self.state.__class__.__name__}")
+ except Exception:
+ self.logger.error(traceback.format_exc())
+ raise
+ finally:
+ self.logger.debug("TestRunnerManager main loop terminating, starting cleanup")
+ force_stop = (not isinstance(self.state, RunnerManagerState.stop) or
+ self.state.force_stop)
+ self.stop_runner(force=force_stop)
+ self.teardown()
+ if self.browser is not None:
+ assert self.browser.browser is not None
+ self.browser.browser.cleanup()
+ self.logger.debug("TestRunnerManager main loop terminated")
+
+ def wait_event(self):
+ dispatch = {
+ RunnerManagerState.before_init: {},
+ RunnerManagerState.initializing:
+ {
+ "init_succeeded": self.init_succeeded,
+ "init_failed": self.init_failed,
+ },
+ RunnerManagerState.running:
+ {
+ "test_ended": self.test_ended,
+ "wait_finished": self.wait_finished,
+ },
+ RunnerManagerState.restarting: {},
+ RunnerManagerState.error: {},
+ RunnerManagerState.stop: {},
+ None: {
+ "runner_teardown": self.runner_teardown,
+ "log": self.log,
+ "error": self.error
+ }
+ }
+ try:
+ command, data = self.command_queue.get(True, 1)
+ self.logger.debug("Got command: %r" % command)
+ except OSError:
+ self.logger.error("Got IOError from poll")
+ return RunnerManagerState.restarting(self.state.test_type,
+ self.state.test,
+ self.state.test_group,
+ self.state.group_metadata,
+ False)
+ except Empty:
+ if (self.debug_info and self.debug_info.interactive and
+ self.browser.started and not self.browser.is_alive()):
+ self.logger.debug("Debugger exited")
+ return RunnerManagerState.stop(False)
+
+ if (isinstance(self.state, RunnerManagerState.running) and
+ not self.test_runner_proc.is_alive()):
+ if not self.command_queue.empty():
+ # We got a new message so process that
+ return
+
+ # If we got to here the runner presumably shut down
+ # unexpectedly
+ self.logger.info("Test runner process shut down")
+
+ if self.state.test is not None:
+ # This could happen if the test runner crashed for some other
+ # reason
+ # Need to consider the unlikely case where one test causes the
+ # runner process to repeatedly die
+ self.logger.critical("Last test did not complete")
+ return RunnerManagerState.error()
+ self.logger.warning("More tests found, but runner process died, restarting")
+ return RunnerManagerState.restarting(self.state.test_type,
+ self.state.test,
+ self.state.test_group,
+ self.state.group_metadata,
+ False)
+ else:
+ f = (dispatch.get(self.state.__class__, {}).get(command) or
+ dispatch.get(None, {}).get(command))
+ if not f:
+ self.logger.warning("Got command %s in state %s" %
+ (command, self.state.__class__.__name__))
+ return
+ return f(*data)
+
+ def should_stop(self):
+ return self.child_stop_flag.is_set() or self.parent_stop_flag.is_set()
+
+ def start_init(self):
+ test_type, test, test_group, group_metadata = self.get_next_test()
+ self.recording.set(["testrunner", "init"])
+ if test is None:
+ return RunnerManagerState.stop(True)
+ else:
+ return RunnerManagerState.initializing(test_type, test, test_group, group_metadata, 0)
+
+ def init(self):
+ assert isinstance(self.state, RunnerManagerState.initializing)
+ if self.state.failure_count > self.max_restarts:
+ self.logger.critical("Max restarts exceeded")
+ return RunnerManagerState.error()
+
+ if self.state.test_type != self.test_type:
+ if self.browser is not None:
+ assert self.browser.browser is not None
+ self.browser.browser.cleanup()
+ impl = self.test_implementation_by_type[self.state.test_type]
+ browser = impl.browser_cls(self.logger, remote_queue=self.command_queue,
+ **impl.browser_kwargs)
+ browser.setup()
+ self.browser = BrowserManager(self.logger,
+ browser,
+ self.command_queue,
+ no_timeout=self.debug_info is not None)
+ self.test_type = self.state.test_type
+
+ assert self.browser is not None
+ self.browser.update_settings(self.state.test)
+
+ result = self.browser.init(self.state.group_metadata)
+ if result is Stop:
+ return RunnerManagerState.error()
+ elif not result:
+ return RunnerManagerState.initializing(self.state.test_type,
+ self.state.test,
+ self.state.test_group,
+ self.state.group_metadata,
+ self.state.failure_count + 1)
+ else:
+ self.start_test_runner()
+
+ def start_test_runner(self):
+ # Note that we need to be careful to start the browser before the
+ # test runner to ensure that any state set when the browser is started
+ # can be passed in to the test runner.
+ assert isinstance(self.state, RunnerManagerState.initializing)
+ assert self.command_queue is not None
+ assert self.remote_queue is not None
+ self.logger.info("Starting runner")
+ impl = self.test_implementation_by_type[self.state.test_type]
+ self.executor_cls = impl.executor_cls
+ self.executor_kwargs = impl.executor_kwargs
+ self.executor_kwargs["group_metadata"] = self.state.group_metadata
+ self.executor_kwargs["browser_settings"] = self.browser.browser_settings
+ executor_browser_cls, executor_browser_kwargs = self.browser.browser.executor_browser()
+
+ args = (self.remote_queue,
+ self.command_queue,
+ self.executor_cls,
+ self.executor_kwargs,
+ executor_browser_cls,
+ executor_browser_kwargs,
+ self.capture_stdio,
+ self.child_stop_flag,
+ self.recording)
+
+ mp = mpcontext.get_context()
+ self.test_runner_proc = mp.Process(target=start_runner,
+ args=args,
+ name="TestRunner-%s-%i" % (
+ self.test_type, self.manager_number))
+ self.test_runner_proc.start()
+ self.logger.debug("Test runner started")
+ # Now we wait for either an init_succeeded event or an init_failed event
+
+ def init_succeeded(self):
+ assert isinstance(self.state, RunnerManagerState.initializing)
+ self.browser.after_init()
+ return RunnerManagerState.running(self.state.test_type,
+ self.state.test,
+ self.state.test_group,
+ self.state.group_metadata)
+
+ def init_failed(self):
+ assert isinstance(self.state, RunnerManagerState.initializing)
+ self.browser.check_crash(None)
+ self.browser.after_init()
+ self.stop_runner(force=True)
+ return RunnerManagerState.initializing(self.state.test_type,
+ self.state.test,
+ self.state.test_group,
+ self.state.group_metadata,
+ self.state.failure_count + 1)
+
+ def get_next_test(self):
+ # returns test_type, test, test_group, group_metadata
+ test = None
+ test_group = None
+ while test is None:
+ while test_group is None or len(test_group) == 0:
+ test_group, test_type, group_metadata = self.test_source.group()
+ if test_group is None:
+ self.logger.info("No more tests")
+ return None, None, None, None
+ test = test_group.popleft()
+ self.run_count = 0
+ return test_type, test, test_group, group_metadata
+
+ def run_test(self):
+ assert isinstance(self.state, RunnerManagerState.running)
+ assert self.state.test is not None
+
+ if self.browser.update_settings(self.state.test):
+ self.logger.info("Restarting browser for new test environment")
+ return RunnerManagerState.restarting(self.state.test_type,
+ self.state.test,
+ self.state.test_group,
+ self.state.group_metadata,
+ False)
+
+ self.recording.set(["testrunner", "test"] + self.state.test.id.split("/")[1:])
+ self.logger.test_start(self.state.test.id)
+ if self.rerun > 1:
+ self.logger.info("Run %d/%d" % (self.run_count, self.rerun))
+ self.send_message("reset")
+ self.run_count += 1
+ if self.debug_info is None:
+ # Factor of 3 on the extra timeout here is based on allowing the executor
+ # at least test.timeout + 2 * extra_timeout to complete,
+ # which in turn is based on having several layers of timeout inside the executor
+ wait_timeout = (self.state.test.timeout * self.executor_kwargs['timeout_multiplier'] +
+ 3 * self.executor_cls.extra_timeout)
+ self.timer = threading.Timer(wait_timeout, self._timeout)
+
+ self.send_message("run_test", self.state.test)
+ if self.timer:
+ self.timer.start()
+
+ def _timeout(self):
+ # This is executed in a different thread (threading.Timer).
+ self.logger.info("Got timeout in harness")
+ test = self.state.test
+ self.inject_message(
+ "test_ended",
+ test,
+ (test.result_cls("EXTERNAL-TIMEOUT",
+ "TestRunner hit external timeout "
+ "(this may indicate a hang)"), []),
+ )
+
+ def test_ended(self, test, results):
+ """Handle the end of a test.
+
+ Output the result of each subtest, and the result of the overall
+ harness to the logs.
+ """
+ if ((not isinstance(self.state, RunnerManagerState.running)) or
+ (test != self.state.test)):
+ # Due to inherent race conditions in EXTERNAL-TIMEOUT, we might
+ # receive multiple test_ended for a test (e.g. from both Executor
+ # and TestRunner), in which case we ignore the duplicate message.
+ self.logger.error("Received unexpected test_ended for %s" % test)
+ return
+ if self.timer is not None:
+ self.timer.cancel()
+
+ # Write the result of each subtest
+ file_result, test_results = results
+ subtest_unexpected = False
+ subtest_all_pass_or_expected = True
+ for result in test_results:
+ if test.disabled(result.name):
+ continue
+ expected = test.expected(result.name)
+ known_intermittent = test.known_intermittent(result.name)
+ is_unexpected = expected != result.status and result.status not in known_intermittent
+ is_expected_notrun = (expected == "NOTRUN" or "NOTRUN" in known_intermittent)
+
+ if is_unexpected:
+ subtest_unexpected = True
+
+ if result.status != "PASS" and not is_expected_notrun:
+ # Any result against an expected "NOTRUN" should be treated
+ # as unexpected pass.
+ subtest_all_pass_or_expected = False
+
+ self.logger.test_status(test.id,
+ result.name,
+ result.status,
+ message=result.message,
+ expected=expected,
+ known_intermittent=known_intermittent,
+ stack=result.stack)
+
+ expected = test.expected()
+ known_intermittent = test.known_intermittent()
+ status = file_result.status
+
+ if self.browser.check_crash(test.id) and status != "CRASH":
+ if test.test_type == "crashtest" or status == "EXTERNAL-TIMEOUT":
+ self.logger.info("Found a crash dump file; changing status to CRASH")
+ status = "CRASH"
+ else:
+ self.logger.warning(f"Found a crash dump; should change status from {status} to CRASH but this causes instability")
+
+ # We have a couple of status codes that are used internally, but not exposed to the
+ # user. These are used to indicate that some possibly-broken state was reached
+ # and we should restart the runner before the next test.
+ # INTERNAL-ERROR indicates a Python exception was caught in the harness
+ # EXTERNAL-TIMEOUT indicates we had to forcibly kill the browser from the harness
+ # because the test didn't return a result after reaching the test-internal timeout
+ status_subns = {"INTERNAL-ERROR": "ERROR",
+ "EXTERNAL-TIMEOUT": "TIMEOUT"}
+ status = status_subns.get(status, status)
+
+ self.test_count += 1
+ is_unexpected = expected != status and status not in known_intermittent
+
+ if is_unexpected or subtest_unexpected:
+ self.unexpected_tests.add(test.id)
+
+ # A result is unexpected pass if the test or any subtest run
+ # unexpectedly, and the overall status is OK (for test harness test), or
+ # PASS (for reftest), and all unexpected results for subtests (if any) are
+ # unexpected pass.
+ is_unexpected_pass = ((is_unexpected or subtest_unexpected) and
+ status in ["OK", "PASS"] and subtest_all_pass_or_expected)
+ if is_unexpected_pass:
+ self.unexpected_pass_tests.add(test.id)
+
+ if "assertion_count" in file_result.extra:
+ assertion_count = file_result.extra["assertion_count"]
+ if assertion_count is not None and assertion_count > 0:
+ self.logger.assertion_count(test.id,
+ int(assertion_count),
+ test.min_assertion_count,
+ test.max_assertion_count)
+
+ file_result.extra["test_timeout"] = test.timeout * self.executor_kwargs['timeout_multiplier']
+
+ self.logger.test_end(test.id,
+ status,
+ message=file_result.message,
+ expected=expected,
+ known_intermittent=known_intermittent,
+ extra=file_result.extra,
+ stack=file_result.stack)
+
+ restart_before_next = (test.restart_after or
+ file_result.status in ("CRASH", "EXTERNAL-TIMEOUT", "INTERNAL-ERROR") or
+ ((subtest_unexpected or is_unexpected) and
+ self.restart_on_unexpected))
+ force_stop = test.test_type == "wdspec" and file_result.status == "EXTERNAL-TIMEOUT"
+
+ self.recording.set(["testrunner", "after-test"])
+ if (not file_result.status == "CRASH" and
+ self.pause_after_test or
+ (self.pause_on_unexpected and (subtest_unexpected or is_unexpected))):
+ self.logger.info("Pausing until the browser exits")
+ self.send_message("wait")
+ else:
+ return self.after_test_end(test, restart_before_next, force_stop=force_stop)
+
+ def wait_finished(self, rerun=False):
+ assert isinstance(self.state, RunnerManagerState.running)
+ self.logger.debug("Wait finished")
+
+ # The browser should be stopped already, but this ensures we do any
+ # post-stop processing
+ return self.after_test_end(self.state.test, not rerun, force_rerun=rerun)
+
+ def after_test_end(self, test, restart, force_rerun=False, force_stop=False):
+ assert isinstance(self.state, RunnerManagerState.running)
+ # Mixing manual reruns and automatic reruns is confusing; we currently assume
+ # that as long as we've done at least the automatic run count in total we can
+ # continue with the next test.
+ if not force_rerun and self.run_count >= self.rerun:
+ test_type, test, test_group, group_metadata = self.get_next_test()
+ if test is None:
+ return RunnerManagerState.stop(force_stop)
+ if test_type != self.state.test_type:
+ self.logger.info(f"Restarting browser for new test type:{test_type}")
+ restart = True
+ elif self.restart_on_new_group and test_group is not self.state.test_group:
+ self.logger.info("Restarting browser for new test group")
+ restart = True
+ else:
+ test_type = self.state.test_type
+ test_group = self.state.test_group
+ group_metadata = self.state.group_metadata
+
+ if restart:
+ return RunnerManagerState.restarting(
+ test_type, test, test_group, group_metadata, force_stop)
+ else:
+ return RunnerManagerState.running(
+ test_type, test, test_group, group_metadata)
+
+ def restart_runner(self):
+ """Stop and restart the TestRunner"""
+ assert isinstance(self.state, RunnerManagerState.restarting)
+ self.stop_runner(force=self.state.force_stop)
+ return RunnerManagerState.initializing(
+ self.state.test_type, self.state.test,
+ self.state.test_group, self.state.group_metadata, 0)
+
+ def log(self, data):
+ self.logger.log_raw(data)
+
+ def error(self, message):
+ self.logger.error(message)
+ self.restart_runner()
+
+ def stop_runner(self, force=False):
+ """Stop the TestRunner and the browser binary."""
+ self.recording.set(["testrunner", "stop_runner"])
+ if self.test_runner_proc is None:
+ return
+
+ if self.test_runner_proc.is_alive():
+ self.send_message("stop")
+ try:
+ self.browser.stop(force=force)
+ self.ensure_runner_stopped()
+ finally:
+ self.cleanup()
+
+ def teardown(self):
+ self.logger.debug("TestRunnerManager teardown")
+ self.test_runner_proc = None
+ self.command_queue.close()
+ self.remote_queue.close()
+ self.command_queue = None
+ self.remote_queue = None
+ self.recording.pause()
+
+ def ensure_runner_stopped(self):
+ self.logger.debug("ensure_runner_stopped")
+ if self.test_runner_proc is None:
+ return
+
+ self.browser.stop(force=True)
+ self.logger.debug("waiting for runner process to end")
+ self.test_runner_proc.join(10)
+ self.logger.debug("After join")
+ mp = mpcontext.get_context()
+ if self.test_runner_proc.is_alive():
+ # This might leak a file handle from the queue
+ self.logger.warning("Forcibly terminating runner process")
+ self.test_runner_proc.terminate()
+ self.logger.debug("After terminating runner process")
+
+ # Multiprocessing queues are backed by operating system pipes. If
+ # the pipe in the child process had buffered data at the time of
+ # forced termination, the queue is no longer in a usable state
+ # (subsequent attempts to retrieve items may block indefinitely).
+ # Discard the potentially-corrupted queue and create a new one.
+ self.logger.debug("Recreating command queue")
+ self.command_queue.cancel_join_thread()
+ self.command_queue.close()
+ self.command_queue = mp.Queue()
+ self.logger.debug("Recreating remote queue")
+ self.remote_queue.cancel_join_thread()
+ self.remote_queue.close()
+ self.remote_queue = mp.Queue()
+ else:
+ self.logger.debug("Runner process exited with code %i" % self.test_runner_proc.exitcode)
+
+ def runner_teardown(self):
+ self.ensure_runner_stopped()
+ return RunnerManagerState.stop(False)
+
+ def send_message(self, command, *args):
+ """Send a message to the remote queue (to Executor)."""
+ self.remote_queue.put((command, args))
+
+ def inject_message(self, command, *args):
+ """Inject a message to the command queue (from Executor)."""
+ self.command_queue.put((command, args))
+
+ def cleanup(self):
+ self.logger.debug("TestRunnerManager cleanup")
+ if self.browser:
+ self.browser.cleanup()
+ while True:
+ try:
+ cmd, data = self.command_queue.get_nowait()
+ except Empty:
+ break
+ else:
+ if cmd == "log":
+ self.log(*data)
+ elif cmd == "runner_teardown":
+ # It's OK for the "runner_teardown" message to be left in
+ # the queue during cleanup, as we will already have tried
+ # to stop the TestRunner in `stop_runner`.
+ pass
+ else:
+ self.logger.warning(f"Command left in command_queue during cleanup: {cmd!r}, {data!r}")
+ while True:
+ try:
+ cmd, data = self.remote_queue.get_nowait()
+ self.logger.warning(f"Command left in remote_queue during cleanup: {cmd!r}, {data!r}")
+ except Empty:
+ break
+
+
+def make_test_queue(tests, test_source_cls, **test_source_kwargs):
+ queue = test_source_cls.make_queue(tests, **test_source_kwargs)
+
+ # There is a race condition that means sometimes we continue
+ # before the tests have been written to the underlying pipe.
+ # Polling the pipe for data here avoids that
+ queue._reader.poll(10)
+ assert not queue.empty()
+ return queue
+
+
+class ManagerGroup:
+ """Main thread object that owns all the TestRunnerManager threads."""
+ def __init__(self, suite_name, size, test_source_cls, test_source_kwargs,
+ test_implementation_by_type,
+ rerun=1,
+ pause_after_test=False,
+ pause_on_unexpected=False,
+ restart_on_unexpected=True,
+ debug_info=None,
+ capture_stdio=True,
+ restart_on_new_group=True,
+ recording=None):
+ self.suite_name = suite_name
+ self.size = size
+ self.test_source_cls = test_source_cls
+ self.test_source_kwargs = test_source_kwargs
+ self.test_implementation_by_type = test_implementation_by_type
+ self.pause_after_test = pause_after_test
+ self.pause_on_unexpected = pause_on_unexpected
+ self.restart_on_unexpected = restart_on_unexpected
+ self.debug_info = debug_info
+ self.rerun = rerun
+ self.capture_stdio = capture_stdio
+ self.restart_on_new_group = restart_on_new_group
+ self.recording = recording
+ assert recording is not None
+
+ self.pool = set()
+ # Event that is polled by threads so that they can gracefully exit in the face
+ # of sigint
+ self.stop_flag = threading.Event()
+ self.logger = structuredlog.StructuredLogger(suite_name)
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.stop()
+
+ def run(self, tests):
+ """Start all managers in the group"""
+ self.logger.debug("Using %i processes" % self.size)
+
+ test_queue = make_test_queue(tests, self.test_source_cls, **self.test_source_kwargs)
+
+ for idx in range(self.size):
+ manager = TestRunnerManager(self.suite_name,
+ idx,
+ test_queue,
+ self.test_source_cls,
+ self.test_implementation_by_type,
+ self.stop_flag,
+ self.rerun,
+ self.pause_after_test,
+ self.pause_on_unexpected,
+ self.restart_on_unexpected,
+ self.debug_info,
+ self.capture_stdio,
+ self.restart_on_new_group,
+ recording=self.recording)
+ manager.start()
+ self.pool.add(manager)
+ self.wait()
+
+ def wait(self):
+ """Wait for all the managers in the group to finish"""
+ for manager in self.pool:
+ manager.join()
+
+ def stop(self):
+ """Set the stop flag so that all managers in the group stop as soon
+ as possible"""
+ self.stop_flag.set()
+ self.logger.debug("Stop flag set in ManagerGroup")
+
+ def test_count(self):
+ return sum(manager.test_count for manager in self.pool)
+
+ def unexpected_tests(self):
+ return set().union(*(manager.unexpected_tests for manager in self.pool))
+
+ def unexpected_pass_tests(self):
+ return set().union(*(manager.unexpected_pass_tests for manager in self.pool))
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/__init__.py
new file mode 100644
index 0000000000..b4a26cee9b
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/__init__.py
@@ -0,0 +1,9 @@
+# mypy: ignore-errors
+
+import os
+import sys
+
+here = os.path.abspath(os.path.dirname(__file__))
+sys.path.insert(0, os.path.join(here, os.pardir, os.pardir, os.pardir))
+
+import localpaths as _localpaths # noqa: F401
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/base.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/base.py
new file mode 100644
index 0000000000..176eef6a42
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/base.py
@@ -0,0 +1,63 @@
+# mypy: allow-untyped-defs
+
+import os
+import sys
+
+from os.path import dirname, join
+
+import pytest
+
+sys.path.insert(0, join(dirname(__file__), "..", ".."))
+
+from .. import browsers
+
+
+_products = browsers.product_list
+_active_products = set()
+
+if "CURRENT_TOX_ENV" in os.environ:
+ current_tox_env_split = os.environ["CURRENT_TOX_ENV"].split("-")
+
+ tox_env_extra_browsers = {
+ "chrome": {"chrome_android"},
+ "edge": {"edge_webdriver"},
+ "servo": {"servodriver"},
+ }
+
+ _active_products = set(_products) & set(current_tox_env_split)
+ for product in frozenset(_active_products):
+ _active_products |= tox_env_extra_browsers.get(product, set())
+else:
+ _active_products = set(_products)
+
+
+class all_products:
+ def __init__(self, arg, marks={}):
+ self.arg = arg
+ self.marks = marks
+
+ def __call__(self, f):
+ params = []
+ for product in _products:
+ if product in self.marks:
+ params.append(pytest.param(product, marks=self.marks[product]))
+ else:
+ params.append(product)
+ return pytest.mark.parametrize(self.arg, params)(f)
+
+
+class active_products:
+ def __init__(self, arg, marks={}):
+ self.arg = arg
+ self.marks = marks
+
+ def __call__(self, f):
+ params = []
+ for product in _products:
+ if product not in _active_products:
+ params.append(pytest.param(product, marks=pytest.mark.skip(reason="wrong toxenv")))
+ elif product in self.marks:
+ params.append(pytest.param(product, marks=self.marks[product]))
+ else:
+ params.append(product)
+ return pytest.mark.parametrize(self.arg, params)(f)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/__init__.py
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/test_sauce.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/test_sauce.py
new file mode 100644
index 0000000000..a9d11fc9d9
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/test_sauce.py
@@ -0,0 +1,170 @@
+# mypy: allow-untyped-defs
+
+import logging
+import sys
+from unittest import mock
+
+import pytest
+
+from os.path import join, dirname
+
+sys.path.insert(0, join(dirname(__file__), "..", "..", ".."))
+
+sauce = pytest.importorskip("wptrunner.browsers.sauce")
+
+from wptserve.config import ConfigBuilder
+
+logger = logging.getLogger()
+
+
+def test_sauceconnect_success():
+ with mock.patch.object(sauce.SauceConnect, "upload_prerun_exec"),\
+ mock.patch.object(sauce.subprocess, "Popen") as Popen,\
+ mock.patch.object(sauce.os.path, "exists") as exists:
+ # Act as if it's still running
+ Popen.return_value.poll.return_value = None
+ Popen.return_value.returncode = None
+ # Act as if file created
+ exists.return_value = True
+
+ sauce_connect = sauce.SauceConnect(
+ sauce_user="aaa",
+ sauce_key="bbb",
+ sauce_tunnel_id="ccc",
+ sauce_connect_binary="ddd",
+ sauce_connect_args=[])
+
+ with ConfigBuilder(logger, browser_host="example.net") as env_config:
+ sauce_connect(None, env_config)
+ with sauce_connect:
+ pass
+
+
+@pytest.mark.parametrize("readyfile,returncode", [
+ (True, 0),
+ (True, 1),
+ (True, 2),
+ (False, 0),
+ (False, 1),
+ (False, 2),
+])
+def test_sauceconnect_failure_exit(readyfile, returncode):
+ with mock.patch.object(sauce.SauceConnect, "upload_prerun_exec"),\
+ mock.patch.object(sauce.subprocess, "Popen") as Popen,\
+ mock.patch.object(sauce.os.path, "exists") as exists,\
+ mock.patch.object(sauce.time, "sleep") as sleep:
+ Popen.return_value.poll.return_value = returncode
+ Popen.return_value.returncode = returncode
+ exists.return_value = readyfile
+
+ sauce_connect = sauce.SauceConnect(
+ sauce_user="aaa",
+ sauce_key="bbb",
+ sauce_tunnel_id="ccc",
+ sauce_connect_binary="ddd",
+ sauce_connect_args=[])
+
+ with ConfigBuilder(logger, browser_host="example.net") as env_config:
+ sauce_connect(None, env_config)
+ with pytest.raises(sauce.SauceException):
+ with sauce_connect:
+ pass
+
+ # Given we appear to exit immediately with these mocks, sleep shouldn't be called
+ sleep.assert_not_called()
+
+
+def test_sauceconnect_cleanup():
+ """Ensure that execution pauses when the process is closed while exiting
+ the context manager. This allow Sauce Connect to close any active
+ tunnels."""
+ with mock.patch.object(sauce.SauceConnect, "upload_prerun_exec"),\
+ mock.patch.object(sauce.subprocess, "Popen") as Popen,\
+ mock.patch.object(sauce.os.path, "exists") as exists,\
+ mock.patch.object(sauce.time, "sleep") as sleep:
+ Popen.return_value.poll.return_value = True
+ Popen.return_value.returncode = None
+ exists.return_value = True
+
+ sauce_connect = sauce.SauceConnect(
+ sauce_user="aaa",
+ sauce_key="bbb",
+ sauce_tunnel_id="ccc",
+ sauce_connect_binary="ddd",
+ sauce_connect_args=[])
+
+ with ConfigBuilder(logger, browser_host="example.net") as env_config:
+ sauce_connect(None, env_config)
+ with sauce_connect:
+ Popen.return_value.poll.return_value = None
+ sleep.assert_not_called()
+
+ sleep.assert_called()
+
+def test_sauceconnect_failure_never_ready():
+ with mock.patch.object(sauce.SauceConnect, "upload_prerun_exec"),\
+ mock.patch.object(sauce.subprocess, "Popen") as Popen,\
+ mock.patch.object(sauce.os.path, "exists") as exists,\
+ mock.patch.object(sauce.time, "sleep") as sleep:
+ Popen.return_value.poll.return_value = None
+ Popen.return_value.returncode = None
+ exists.return_value = False
+
+ sauce_connect = sauce.SauceConnect(
+ sauce_user="aaa",
+ sauce_key="bbb",
+ sauce_tunnel_id="ccc",
+ sauce_connect_binary="ddd",
+ sauce_connect_args=[])
+
+ with ConfigBuilder(logger, browser_host="example.net") as env_config:
+ sauce_connect(None, env_config)
+ with pytest.raises(sauce.SauceException):
+ with sauce_connect:
+ pass
+
+ # We should sleep while waiting for it to create the readyfile
+ sleep.assert_called()
+
+ # Check we actually kill it after termination fails
+ Popen.return_value.terminate.assert_called()
+ Popen.return_value.kill.assert_called()
+
+
+def test_sauceconnect_tunnel_domains():
+ with mock.patch.object(sauce.SauceConnect, "upload_prerun_exec"),\
+ mock.patch.object(sauce.subprocess, "Popen") as Popen,\
+ mock.patch.object(sauce.os.path, "exists") as exists:
+ Popen.return_value.poll.return_value = None
+ Popen.return_value.returncode = None
+ exists.return_value = True
+
+ sauce_connect = sauce.SauceConnect(
+ sauce_user="aaa",
+ sauce_key="bbb",
+ sauce_tunnel_id="ccc",
+ sauce_connect_binary="ddd",
+ sauce_connect_args=[])
+
+ with ConfigBuilder(logger,
+ browser_host="example.net",
+ alternate_hosts={"alt": "example.org"},
+ subdomains={"a", "b"},
+ not_subdomains={"x", "y"}) as env_config:
+ sauce_connect(None, env_config)
+ with sauce_connect:
+ Popen.assert_called_once()
+ args, kwargs = Popen.call_args
+ cmd = args[0]
+ assert "--tunnel-domains" in cmd
+ i = cmd.index("--tunnel-domains")
+ rest = cmd[i+1:]
+ assert len(rest) >= 1
+ if len(rest) > 1:
+ assert rest[1].startswith("-"), "--tunnel-domains takes a comma separated list (not a space separated list)"
+ assert set(rest[0].split(",")) == {'example.net',
+ 'a.example.net',
+ 'b.example.net',
+ 'example.org',
+ 'a.example.org',
+ 'b.example.org'}
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/test_webkitgtk.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/test_webkitgtk.py
new file mode 100644
index 0000000000..370cd86293
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/browsers/test_webkitgtk.py
@@ -0,0 +1,74 @@
+# mypy: allow-untyped-defs, allow-untyped-calls
+
+import logging
+from os.path import join, dirname
+
+import pytest
+
+from wptserve.config import ConfigBuilder
+from ..base import active_products
+from wptrunner import environment, products
+
+test_paths = {"/": {"tests_path": join(dirname(__file__), "..", "..", "..", "..", "..")}} # repo root
+environment.do_delayed_imports(None, test_paths)
+
+logger = logging.getLogger()
+
+
+@active_products("product")
+def test_webkitgtk_certificate_domain_list(product):
+
+ def domain_is_inside_certificate_list_cert(domain_to_find, webkitgtk_certificate_list, cert_file):
+ for domain in webkitgtk_certificate_list:
+ if domain["host"] == domain_to_find and domain["certificateFile"] == cert_file:
+ return True
+ return False
+
+ if product not in ["epiphany", "webkit", "webkitgtk_minibrowser"]:
+ pytest.skip("%s doesn't support certificate_domain_list" % product)
+
+ product_data = products.Product({}, product)
+
+ cert_file = "/home/user/wpt/tools/certs/cacert.pem"
+ valid_domains_test = ["a.example.org", "b.example.org", "example.org",
+ "a.example.net", "b.example.net", "example.net"]
+ invalid_domains_test = ["x.example.org", "y.example.org", "example.it",
+ "x.example.net", "y.example.net", "z.example.net"]
+ kwargs = {}
+ kwargs["timeout_multiplier"] = 1
+ kwargs["debug_info"] = None
+ kwargs["host_cert_path"] = cert_file
+ kwargs["webkit_port"] = "gtk"
+ kwargs["binary"] = None
+ kwargs["webdriver_binary"] = None
+ kwargs["pause_after_test"] = False
+ kwargs["pause_on_unexpected"] = False
+ kwargs["debug_test"] = False
+ with ConfigBuilder(logger,
+ browser_host="example.net",
+ alternate_hosts={"alt": "example.org"},
+ subdomains={"a", "b"},
+ not_subdomains={"x", "y"}) as env_config:
+
+ # We don't want to actually create a test environment; the get_executor_kwargs
+ # function only really wants an object with the config key
+
+ class MockEnvironment:
+ def __init__(self, config):
+ self.config = config
+
+ executor_args = product_data.get_executor_kwargs(None,
+ None,
+ MockEnvironment(env_config),
+ {},
+ **kwargs)
+ assert('capabilities' in executor_args)
+ assert('webkitgtk:browserOptions' in executor_args['capabilities'])
+ assert('certificates' in executor_args['capabilities']['webkitgtk:browserOptions'])
+ cert_list = executor_args['capabilities']['webkitgtk:browserOptions']['certificates']
+ for valid_domain in valid_domains_test:
+ assert(domain_is_inside_certificate_list_cert(valid_domain, cert_list, cert_file))
+ assert(not domain_is_inside_certificate_list_cert(valid_domain, cert_list, cert_file + ".backup_non_existent"))
+ for invalid_domain in invalid_domains_test:
+ assert(not domain_is_inside_certificate_list_cert(invalid_domain, cert_list, cert_file))
+ assert(not domain_is_inside_certificate_list_cert(invalid_domain, cert_list, cert_file + ".backup_non_existent"))
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_executors.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_executors.py
new file mode 100644
index 0000000000..682a34e5df
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_executors.py
@@ -0,0 +1,17 @@
+# mypy: allow-untyped-defs
+
+import pytest
+
+from ..executors import base
+
+@pytest.mark.parametrize("ranges_value, total_pages, expected", [
+ ([], 3, {1, 2, 3}),
+ ([[1, 2]], 3, {1, 2}),
+ ([[1], [3, 4]], 5, {1, 3, 4}),
+ ([[1],[3]], 5, {1, 3}),
+ ([[2, None]], 5, {2, 3, 4, 5}),
+ ([[None, 2]], 5, {1, 2}),
+ ([[None, 2], [2, None]], 5, {1, 2, 3, 4, 5}),
+ ([[1], [6, 7], [8]], 5, {1})])
+def test_get_pages_valid(ranges_value, total_pages, expected):
+ assert base.get_pages(ranges_value, total_pages) == expected
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_expectedtree.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_expectedtree.py
new file mode 100644
index 0000000000..b8a1120246
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_expectedtree.py
@@ -0,0 +1,120 @@
+# mypy: allow-untyped-defs
+
+from .. import expectedtree, metadata
+from collections import defaultdict
+
+
+def dump_tree(tree):
+ rv = []
+
+ def dump_node(node, indent=0):
+ prefix = " " * indent
+ if not node.prop:
+ data = "root"
+ else:
+ data = f"{node.prop}:{node.value}"
+ if node.result_values:
+ data += " result_values:%s" % (",".join(sorted(node.result_values)))
+ rv.append(f"{prefix}<{data}>")
+ for child in sorted(node.children, key=lambda x:x.value):
+ dump_node(child, indent + 2)
+ dump_node(tree)
+ return "\n".join(rv)
+
+
+def results_object(results):
+ results_obj = defaultdict(lambda: defaultdict(int))
+ for run_info, status in results:
+ run_info = metadata.RunInfo(run_info)
+ results_obj[run_info][status] += 1
+ return results_obj
+
+
+def test_build_tree_0():
+ # Pass if debug
+ results = [({"os": "linux", "version": "18.04", "debug": True}, "FAIL"),
+ ({"os": "linux", "version": "18.04", "debug": False}, "PASS"),
+ ({"os": "linux", "version": "16.04", "debug": False}, "PASS"),
+ ({"os": "mac", "version": "10.12", "debug": True}, "FAIL"),
+ ({"os": "mac", "version": "10.12", "debug": False}, "PASS"),
+ ({"os": "win", "version": "7", "debug": False}, "PASS"),
+ ({"os": "win", "version": "10", "debug": False}, "PASS")]
+ results_obj = results_object(results)
+ tree = expectedtree.build_tree(["os", "version", "debug"], {}, results_obj)
+
+ expected = """<root>
+ <debug:False result_values:PASS>
+ <debug:True result_values:FAIL>"""
+
+ assert dump_tree(tree) == expected
+
+
+def test_build_tree_1():
+ # Pass if linux or windows 10
+ results = [({"os": "linux", "version": "18.04", "debug": True}, "PASS"),
+ ({"os": "linux", "version": "18.04", "debug": False}, "PASS"),
+ ({"os": "linux", "version": "16.04", "debug": False}, "PASS"),
+ ({"os": "mac", "version": "10.12", "debug": True}, "FAIL"),
+ ({"os": "mac", "version": "10.12", "debug": False}, "FAIL"),
+ ({"os": "win", "version": "7", "debug": False}, "FAIL"),
+ ({"os": "win", "version": "10", "debug": False}, "PASS")]
+ results_obj = results_object(results)
+ tree = expectedtree.build_tree(["os", "debug"], {"os": ["version"]}, results_obj)
+
+ expected = """<root>
+ <os:linux result_values:PASS>
+ <os:mac result_values:FAIL>
+ <os:win>
+ <version:10 result_values:PASS>
+ <version:7 result_values:FAIL>"""
+
+ assert dump_tree(tree) == expected
+
+
+def test_build_tree_2():
+ # Fails in a specific configuration
+ results = [({"os": "linux", "version": "18.04", "debug": True}, "PASS"),
+ ({"os": "linux", "version": "18.04", "debug": False}, "FAIL"),
+ ({"os": "linux", "version": "16.04", "debug": False}, "PASS"),
+ ({"os": "linux", "version": "16.04", "debug": True}, "PASS"),
+ ({"os": "mac", "version": "10.12", "debug": True}, "PASS"),
+ ({"os": "mac", "version": "10.12", "debug": False}, "PASS"),
+ ({"os": "win", "version": "7", "debug": False}, "PASS"),
+ ({"os": "win", "version": "10", "debug": False}, "PASS")]
+ results_obj = results_object(results)
+ tree = expectedtree.build_tree(["os", "debug"], {"os": ["version"]}, results_obj)
+
+ expected = """<root>
+ <os:linux>
+ <debug:False>
+ <version:16.04 result_values:PASS>
+ <version:18.04 result_values:FAIL>
+ <debug:True result_values:PASS>
+ <os:mac result_values:PASS>
+ <os:win result_values:PASS>"""
+
+ assert dump_tree(tree) == expected
+
+
+def test_build_tree_3():
+
+ results = [({"os": "linux", "version": "18.04", "debug": True, "unused": False}, "PASS"),
+ ({"os": "linux", "version": "18.04", "debug": True, "unused": True}, "FAIL")]
+ results_obj = results_object(results)
+ tree = expectedtree.build_tree(["os", "debug"], {"os": ["version"]}, results_obj)
+
+ expected = """<root result_values:FAIL,PASS>"""
+
+ assert dump_tree(tree) == expected
+
+
+def test_build_tree_4():
+ # Check counts for multiple statuses
+ results = [({"os": "linux", "version": "18.04", "debug": False}, "FAIL"),
+ ({"os": "linux", "version": "18.04", "debug": False}, "PASS"),
+ ({"os": "linux", "version": "18.04", "debug": False}, "PASS")]
+ results_obj = results_object(results)
+ tree = expectedtree.build_tree(["os", "version", "debug"], {}, results_obj)
+
+ assert tree.result_values["PASS"] == 2
+ assert tree.result_values["FAIL"] == 1
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_formatters.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_formatters.py
new file mode 100644
index 0000000000..3f66f77bea
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_formatters.py
@@ -0,0 +1,152 @@
+# mypy: allow-untyped-defs
+
+import json
+import time
+from io import StringIO
+
+from mozlog import handlers, structuredlog
+
+from ..formatters.wptscreenshot import WptscreenshotFormatter
+from ..formatters.wptreport import WptreportFormatter
+
+
+def test_wptreport_runtime(capfd):
+ # setup the logger
+ output = StringIO()
+ logger = structuredlog.StructuredLogger("test_a")
+ logger.add_handler(handlers.StreamHandler(output, WptreportFormatter()))
+
+ # output a bunch of stuff
+ logger.suite_start(["test-id-1"], run_info={})
+ logger.test_start("test-id-1")
+ time.sleep(0.125)
+ logger.test_end("test-id-1", "PASS")
+ logger.suite_end()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_obj = json.load(output)
+ # be relatively lax in case of low resolution timers
+ # 62 is 0.125s = 125ms / 2 = 62ms (assuming int maths)
+ # this provides a margin of 62ms, sufficient for even DOS (55ms timer)
+ assert output_obj["results"][0]["duration"] >= 62
+
+
+def test_wptreport_run_info_optional(capfd):
+ """per the mozlog docs, run_info is optional; check we work without it"""
+ # setup the logger
+ output = StringIO()
+ logger = structuredlog.StructuredLogger("test_a")
+ logger.add_handler(handlers.StreamHandler(output, WptreportFormatter()))
+
+ # output a bunch of stuff
+ logger.suite_start(["test-id-1"]) # no run_info arg!
+ logger.test_start("test-id-1")
+ logger.test_end("test-id-1", "PASS")
+ logger.suite_end()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_obj = json.load(output)
+ assert "run_info" not in output_obj or output_obj["run_info"] == {}
+
+
+def test_wptreport_lone_surrogate(capfd):
+ output = StringIO()
+ logger = structuredlog.StructuredLogger("test_a")
+ logger.add_handler(handlers.StreamHandler(output, WptreportFormatter()))
+
+ # output a bunch of stuff
+ logger.suite_start(["test-id-1"]) # no run_info arg!
+ logger.test_start("test-id-1")
+ logger.test_status("test-id-1",
+ subtest="Name with surrogate\uD800",
+ status="FAIL",
+ message="\U0001F601 \uDE0A\uD83D")
+ logger.test_end("test-id-1",
+ status="PASS",
+ message="\uDE0A\uD83D \U0001F601")
+ logger.suite_end()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_obj = json.load(output)
+ test = output_obj["results"][0]
+ assert test["message"] == "U+de0aU+d83d \U0001F601"
+ subtest = test["subtests"][0]
+ assert subtest["name"] == "Name with surrogateU+d800"
+ assert subtest["message"] == "\U0001F601 U+de0aU+d83d"
+
+
+def test_wptreport_known_intermittent(capfd):
+ output = StringIO()
+ logger = structuredlog.StructuredLogger("test_a")
+ logger.add_handler(handlers.StreamHandler(output, WptreportFormatter()))
+
+ # output a bunch of stuff
+ logger.suite_start(["test-id-1"]) # no run_info arg!
+ logger.test_start("test-id-1")
+ logger.test_status("test-id-1",
+ "a-subtest",
+ status="FAIL",
+ expected="PASS",
+ known_intermittent=["FAIL"])
+ logger.test_end("test-id-1",
+ status="OK",)
+ logger.suite_end()
+
+ # check nothing got output to stdout/stderr
+ # (note that mozlog outputs exceptions during handling to stderr!)
+ captured = capfd.readouterr()
+ assert captured.out == ""
+ assert captured.err == ""
+
+ # check the actual output of the formatter
+ output.seek(0)
+ output_obj = json.load(output)
+ test = output_obj["results"][0]
+ assert test["status"] == "OK"
+ subtest = test["subtests"][0]
+ assert subtest["expected"] == "PASS"
+ assert subtest["known_intermittent"] == ['FAIL']
+
+
+def test_wptscreenshot_test_end(capfd):
+ formatter = WptscreenshotFormatter()
+
+ # Empty
+ data = {}
+ assert formatter.test_end(data) is None
+
+ # No items
+ data['extra'] = {"reftest_screenshots": []}
+ assert formatter.test_end(data) is None
+
+ # Invalid item
+ data['extra']['reftest_screenshots'] = ["no dict item"]
+ assert formatter.test_end(data) is None
+
+ # Random hash
+ data['extra']['reftest_screenshots'] = [{"hash": "HASH", "screenshot": "DATA"}]
+ assert 'data:image/png;base64,DATA\n' == formatter.test_end(data)
+
+ # Already cached hash
+ assert formatter.test_end(data) is None
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_manifestexpected.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_manifestexpected.py
new file mode 100644
index 0000000000..03f4fe8c9e
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_manifestexpected.py
@@ -0,0 +1,36 @@
+# mypy: allow-untyped-defs
+
+from io import BytesIO
+
+import pytest
+
+from .. import manifestexpected
+
+
+@pytest.mark.parametrize("fuzzy, expected", [
+ (b"ref.html:1;200", [("ref.html", ((1, 1), (200, 200)))]),
+ (b"ref.html:0-1;100-200", [("ref.html", ((0, 1), (100, 200)))]),
+ (b"0-1;100-200", [(None, ((0, 1), (100, 200)))]),
+ (b"maxDifference=1;totalPixels=200", [(None, ((1, 1), (200, 200)))]),
+ (b"totalPixels=200;maxDifference=1", [(None, ((1, 1), (200, 200)))]),
+ (b"totalPixels=200;1", [(None, ((1, 1), (200, 200)))]),
+ (b"maxDifference=1;200", [(None, ((1, 1), (200, 200)))]),
+ (b"test.html==ref.html:maxDifference=1;totalPixels=200",
+ [(("test.html", "ref.html", "=="), ((1, 1), (200, 200)))]),
+ (b"test.html!=ref.html:maxDifference=1;totalPixels=200",
+ [(("test.html", "ref.html", "!="), ((1, 1), (200, 200)))]),
+ (b"[test.html!=ref.html:maxDifference=1;totalPixels=200, test.html==ref1.html:maxDifference=5-10;100]",
+ [(("test.html", "ref.html", "!="), ((1, 1), (200, 200))),
+ (("test.html", "ref1.html", "=="), ((5,10), (100, 100)))]),
+])
+def test_fuzzy(fuzzy, expected):
+ data = b"""
+[test.html]
+ fuzzy: %s""" % fuzzy
+ f = BytesIO(data)
+ manifest = manifestexpected.static.compile(f,
+ {},
+ data_cls_getter=manifestexpected.data_cls_getter,
+ test_path="test/test.html",
+ url_base="/")
+ assert manifest.get_test("/test/test.html").fuzzy == expected
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_metadata.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_metadata.py
new file mode 100644
index 0000000000..ee3d90915d
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_metadata.py
@@ -0,0 +1,47 @@
+import json
+import os
+
+import pytest
+
+from .. import metadata
+
+
+def write_properties(tmp_path, data): # type: ignore
+ path = os.path.join(tmp_path, "update_properties.json")
+ with open(path, "w") as f:
+ json.dump(data, f)
+ return path
+
+@pytest.mark.parametrize("data",
+ [{"properties": ["prop1"]}, # type: ignore
+ {"properties": ["prop1"], "dependents": {"prop1": ["prop2"]}},
+ ])
+def test_get_properties_file_valid(tmp_path, data):
+ path = write_properties(tmp_path, data)
+ expected = data["properties"], data.get("dependents", {})
+ actual = metadata.get_properties(properties_file=path)
+ assert actual == expected
+
+@pytest.mark.parametrize("data",
+ [{}, # type: ignore
+ {"properties": "prop1"},
+ {"properties": None},
+ {"properties": ["prop1", 1]},
+ {"dependents": {"prop1": ["prop1"]}},
+ {"properties": "prop1", "dependents": ["prop1"]},
+ {"properties": "prop1", "dependents": None},
+ {"properties": "prop1", "dependents": {"prop1": ["prop2", 2]}},
+ {"properties": ["prop1"], "dependents": {"prop2": ["prop3"]}},
+ ])
+def test_get_properties_file_invalid(tmp_path, data):
+ path = write_properties(tmp_path, data)
+ with pytest.raises(ValueError):
+ metadata.get_properties(properties_file=path)
+
+
+def test_extra_properties(tmp_path): # type: ignore
+ data = {"properties": ["prop1"], "dependents": {"prop1": ["prop2"]}}
+ path = write_properties(tmp_path, data)
+ actual = metadata.get_properties(properties_file=path, extra_properties=["prop4"])
+ expected = ["prop1", "prop4"], {"prop1": ["prop2"]}
+ assert actual == expected
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_products.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_products.py
new file mode 100644
index 0000000000..7f46c0e2d2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_products.py
@@ -0,0 +1,57 @@
+# mypy: allow-untyped-defs, allow-untyped-calls
+
+from os.path import join, dirname
+from unittest import mock
+
+import pytest
+
+from .base import all_products, active_products
+from .. import environment
+from .. import products
+
+test_paths = {"/": {"tests_path": join(dirname(__file__), "..", "..", "..", "..")}} # repo root
+environment.do_delayed_imports(None, test_paths)
+
+
+@active_products("product")
+def test_load_active_product(product):
+ """test we can successfully load the product of the current testenv"""
+ products.Product({}, product)
+ # test passes if it doesn't throw
+
+
+@all_products("product")
+def test_load_all_products(product):
+ """test every product either loads or throws ImportError"""
+ try:
+ products.Product({}, product)
+ except ImportError:
+ pass
+
+
+@active_products("product", marks={
+ "sauce": pytest.mark.skip("needs env extras kwargs"),
+})
+def test_server_start_config(product):
+ product_data = products.Product({}, product)
+
+ env_extras = product_data.get_env_extras()
+
+ with mock.patch.object(environment.serve, "start") as start:
+ with environment.TestEnvironment(test_paths,
+ 1,
+ False,
+ False,
+ None,
+ product_data.env_options,
+ {"type": "none"},
+ env_extras):
+ start.assert_called_once()
+ args = start.call_args
+ config = args[0][1]
+ if "server_host" in product_data.env_options:
+ assert config["server_host"] == product_data.env_options["server_host"]
+
+ else:
+ assert config["server_host"] == config["browser_host"]
+ assert isinstance(config["bind_address"], bool)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_stability.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_stability.py
new file mode 100644
index 0000000000..d6e7cc8f70
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_stability.py
@@ -0,0 +1,186 @@
+# mypy: allow-untyped-defs
+
+import sys
+from collections import OrderedDict, defaultdict
+from unittest import mock
+
+from mozlog.structuredlog import StructuredLogger
+from mozlog.formatters import TbplFormatter
+from mozlog.handlers import StreamHandler
+
+from .. import stability, wptrunner
+
+def test_is_inconsistent():
+ assert stability.is_inconsistent({"PASS": 10}, 10) is False
+ assert stability.is_inconsistent({"PASS": 9}, 10) is True
+ assert stability.is_inconsistent({"PASS": 9, "FAIL": 1}, 10) is True
+ assert stability.is_inconsistent({"PASS": 8, "FAIL": 1}, 10) is True
+
+
+def test_find_slow_status():
+ assert stability.find_slow_status({
+ "longest_duration": {"TIMEOUT": 10},
+ "timeout": 10}) is None
+ assert stability.find_slow_status({
+ "longest_duration": {"CRASH": 10},
+ "timeout": 10}) is None
+ assert stability.find_slow_status({
+ "longest_duration": {"ERROR": 10},
+ "timeout": 10}) is None
+ assert stability.find_slow_status({
+ "longest_duration": {"PASS": 1},
+ "timeout": 10}) is None
+ assert stability.find_slow_status({
+ "longest_duration": {"PASS": 81},
+ "timeout": 100}) == "PASS"
+ assert stability.find_slow_status({
+ "longest_duration": {"TIMEOUT": 10, "FAIL": 81},
+ "timeout": 100}) == "FAIL"
+ assert stability.find_slow_status({
+ "longest_duration": {"SKIP": 0}}) is None
+
+
+def test_get_steps():
+ logger = None
+
+ steps = stability.get_steps(logger, 0, 0, [])
+ assert len(steps) == 0
+
+ steps = stability.get_steps(logger, 0, 0, [{}])
+ assert len(steps) == 0
+
+ repeat_loop = 1
+ flag_name = 'flag'
+ flag_value = 'y'
+ steps = stability.get_steps(logger, repeat_loop, 0, [
+ {flag_name: flag_value}])
+ assert len(steps) == 1
+ assert steps[0][0] == "Running tests in a loop %d times with flags %s=%s" % (
+ repeat_loop, flag_name, flag_value)
+
+ repeat_loop = 0
+ repeat_restart = 1
+ flag_name = 'flag'
+ flag_value = 'n'
+ steps = stability.get_steps(logger, repeat_loop, repeat_restart, [
+ {flag_name: flag_value}])
+ assert len(steps) == 1
+ assert steps[0][0] == "Running tests in a loop with restarts %d times with flags %s=%s" % (
+ repeat_restart, flag_name, flag_value)
+
+ repeat_loop = 10
+ repeat_restart = 5
+ steps = stability.get_steps(logger, repeat_loop, repeat_restart, [{}])
+ assert len(steps) == 2
+ assert steps[0][0] == "Running tests in a loop %d times" % repeat_loop
+ assert steps[1][0] == (
+ "Running tests in a loop with restarts %d times" % repeat_restart)
+
+
+def test_log_handler():
+ handler = stability.LogHandler()
+ data = OrderedDict()
+ data["test"] = "test_name"
+ test = handler.find_or_create_test(data)
+ assert test["subtests"] == OrderedDict()
+ assert test["status"] == defaultdict(int)
+ assert test["longest_duration"] == defaultdict(float)
+ assert test == handler.find_or_create_test(data)
+
+ start_time = 100
+ data["time"] = start_time
+ handler.test_start(data)
+ assert test["start_time"] == start_time
+
+ data["subtest"] = "subtest_name"
+ subtest = handler.find_or_create_subtest(data)
+ assert subtest["status"] == defaultdict(int)
+ assert subtest["messages"] == set()
+ assert subtest == handler.find_or_create_subtest(data)
+
+ data["status"] = 0
+ assert subtest["status"][data["status"]] == 0
+ handler.test_status(data)
+ assert subtest["status"][data["status"]] == 1
+ handler.test_status(data)
+ assert subtest["status"][data["status"]] == 2
+ data["status"] = 1
+ assert subtest["status"][data["status"]] == 0
+ message = "test message"
+ data["message"] = message
+ handler.test_status(data)
+ assert subtest["status"][data["status"]] == 1
+ assert len(subtest["messages"]) == 1
+ assert message in subtest["messages"]
+
+ test_duration = 10
+ data["time"] = data["time"] + test_duration
+ handler.test_end(data)
+ assert test["longest_duration"][data["status"]] == test_duration
+ assert "timeout" not in test
+
+ data["test2"] = "test_name_2"
+ timeout = 5
+ data["extra"] = {}
+ data["extra"]["test_timeout"] = timeout
+ handler.test_start(data)
+ handler.test_end(data)
+ assert test["timeout"] == timeout * 1000
+
+
+def test_err_string():
+ assert stability.err_string(
+ {'OK': 1, 'FAIL': 1}, 1) == "**Duplicate subtest name**"
+ assert stability.err_string(
+ {'OK': 2, 'FAIL': 1}, 2) == "**Duplicate subtest name**"
+ assert stability.err_string({'SKIP': 1}, 0) == "Duplicate subtest name"
+ assert stability.err_string(
+ {'SKIP': 1, 'OK': 1}, 1) == "Duplicate subtest name"
+
+ assert stability.err_string(
+ {'FAIL': 1}, 2) == "**FAIL: 1/2, MISSING: 1/2**"
+ assert stability.err_string(
+ {'FAIL': 1, 'OK': 1}, 3) == "**FAIL: 1/3, OK: 1/3, MISSING: 1/3**"
+
+ assert stability.err_string(
+ {'OK': 1, 'FAIL': 1}, 2) == "**FAIL: 1/2, OK: 1/2**"
+
+ assert stability.err_string(
+ {'OK': 2, 'FAIL': 1, 'SKIP': 1}, 4) == "FAIL: 1/4, OK: 2/4, SKIP: 1/4"
+ assert stability.err_string(
+ {'FAIL': 1, 'SKIP': 1, 'OK': 2}, 4) == "FAIL: 1/4, OK: 2/4, SKIP: 1/4"
+
+
+def test_check_stability_iterations():
+ logger = StructuredLogger("test-stability")
+ logger.add_handler(StreamHandler(sys.stdout, TbplFormatter()))
+
+ kwargs = {"verify_log_full": False}
+
+ def mock_run_tests(**kwargs):
+ repeats = kwargs.get("repeat", 1)
+ for _ in range(repeats):
+ logger.suite_start(tests=[], name="test")
+ for _ in range(kwargs.get("rerun", 1)):
+ logger.test_start("/example/test.html")
+ logger.test_status("/example/test.html", subtest="test1", status="PASS")
+ logger.test_end("/example/test.html", status="OK")
+ logger.suite_end()
+
+ status = wptrunner.TestStatus()
+ status.total_tests = 1
+ status.repeated_runs = repeats
+ status.expected_repeated_runs = repeats
+
+ return (None, status)
+
+ # Don't actually load wptrunner, because that will end up starting a browser
+ # which we don't want to do in this test.
+ with mock.patch("wptrunner.stability.wptrunner.run_tests") as mock_run:
+ mock_run.side_effect = mock_run_tests
+ assert stability.check_stability(logger,
+ repeat_loop=10,
+ repeat_restart=5,
+ chaos_mode=False,
+ output_results=False,
+ **kwargs) is None
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_testloader.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_testloader.py
new file mode 100644
index 0000000000..0936c54ea9
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_testloader.py
@@ -0,0 +1,95 @@
+# mypy: ignore-errors
+
+import os
+import sys
+import tempfile
+
+import pytest
+
+from mozlog import structured
+from ..testloader import TestFilter as Filter, TestLoader as Loader
+from ..testloader import read_include_from_file
+from .test_wpttest import make_mock_manifest
+
+here = os.path.dirname(__file__)
+sys.path.insert(0, os.path.join(here, os.pardir, os.pardir, os.pardir))
+from manifest.manifest import Manifest as WPTManifest
+
+structured.set_default_logger(structured.structuredlog.StructuredLogger("TestLoader"))
+
+include_ini = """\
+skip: true
+[test_\u53F0]
+ skip: false
+"""
+
+
+def test_loader_h2_tests():
+ manifest_json = {
+ "items": {
+ "testharness": {
+ "a": {
+ "foo.html": [
+ "abcdef123456",
+ [None, {}],
+ ],
+ "bar.h2.html": [
+ "uvwxyz987654",
+ [None, {}],
+ ],
+ }
+ }
+ },
+ "url_base": "/",
+ "version": 8,
+ }
+ manifest = WPTManifest.from_json("/", manifest_json)
+
+ # By default, the loader should include the h2 test.
+ loader = Loader({manifest: {"metadata_path": ""}}, ["testharness"], None)
+ assert "testharness" in loader.tests
+ assert len(loader.tests["testharness"]) == 2
+ assert len(loader.disabled_tests) == 0
+
+ # We can also instruct it to skip them.
+ loader = Loader({manifest: {"metadata_path": ""}}, ["testharness"], None, include_h2=False)
+ assert "testharness" in loader.tests
+ assert len(loader.tests["testharness"]) == 1
+ assert "testharness" in loader.disabled_tests
+ assert len(loader.disabled_tests["testharness"]) == 1
+ assert loader.disabled_tests["testharness"][0].url == "/a/bar.h2.html"
+
+@pytest.mark.xfail(sys.platform == "win32",
+ reason="NamedTemporaryFile cannot be reopened on Win32")
+def test_include_file():
+ test_cases = """
+# This is a comment
+/foo/bar-error.https.html
+/foo/bar-success.https.html
+/foo/idlharness.https.any.html
+/foo/idlharness.https.any.worker.html
+ """
+
+ with tempfile.NamedTemporaryFile(mode="wt") as f:
+ f.write(test_cases)
+ f.flush()
+
+ include = read_include_from_file(f.name)
+
+ assert len(include) == 4
+ assert "/foo/bar-error.https.html" in include
+ assert "/foo/bar-success.https.html" in include
+ assert "/foo/idlharness.https.any.html" in include
+ assert "/foo/idlharness.https.any.worker.html" in include
+
+@pytest.mark.xfail(sys.platform == "win32",
+ reason="NamedTemporaryFile cannot be reopened on Win32")
+def test_filter_unicode():
+ tests = make_mock_manifest(("test", "a", 10), ("test", "a/b", 10),
+ ("test", "c", 10))
+
+ with tempfile.NamedTemporaryFile("wb", suffix=".ini") as f:
+ f.write(include_ini.encode('utf-8'))
+ f.flush()
+
+ Filter(manifest_path=f.name, test_manifests=tests)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_update.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_update.py
new file mode 100644
index 0000000000..35c75758f5
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_update.py
@@ -0,0 +1,1853 @@
+# mypy: ignore-errors
+
+import json
+import os
+import sys
+from io import BytesIO
+from unittest import mock
+
+import pytest
+
+from .. import metadata, manifestupdate, wptmanifest
+from ..update.update import WPTUpdate
+from ..update.base import StepRunner, Step
+from mozlog import structuredlog, handlers, formatters
+
+here = os.path.dirname(__file__)
+sys.path.insert(0, os.path.join(here, os.pardir, os.pardir, os.pardir))
+from manifest import manifest, item as manifest_item, utils
+
+
+def rel_path_to_test_url(rel_path):
+ assert not os.path.isabs(rel_path)
+ return rel_path.replace(os.sep, "/")
+
+
+def SourceFileWithTest(path, hash, cls, *args):
+ path_parts = tuple(path.split("/"))
+ path = utils.to_os_path(path)
+ s = mock.Mock(rel_path=path, rel_path_parts=path_parts, hash=hash)
+ test = cls("/foobar", path, "/", rel_path_to_test_url(path), *args)
+ s.manifest_items = mock.Mock(return_value=(cls.item_type, [test]))
+ return s
+
+
+def tree_and_sourcefile_mocks(source_files):
+ paths_dict = {}
+ tree = []
+ for source_file, file_hash, updated in source_files:
+ paths_dict[source_file.rel_path] = source_file
+ tree.append([source_file.rel_path, file_hash, updated])
+
+ def MockSourceFile(tests_root, path, url_base, file_hash):
+ return paths_dict[path]
+
+ return tree, MockSourceFile
+
+
+item_classes = {"testharness": manifest_item.TestharnessTest,
+ "reftest": manifest_item.RefTest,
+ "manual": manifest_item.ManualTest,
+ "wdspec": manifest_item.WebDriverSpecTest,
+ "conformancechecker": manifest_item.ConformanceCheckerTest,
+ "visual": manifest_item.VisualTest,
+ "support": manifest_item.SupportFile}
+
+
+default_run_info = {"debug": False, "os": "linux", "version": "18.04", "processor": "x86_64", "bits": 64}
+test_id = "/path/to/test.htm"
+dir_id = "path/to/__dir__"
+
+
+def reset_globals():
+ metadata.prop_intern.clear()
+ metadata.run_info_intern.clear()
+ metadata.status_intern.clear()
+
+
+def get_run_info(overrides):
+ run_info = default_run_info.copy()
+ run_info.update(overrides)
+ return run_info
+
+
+def update(tests, *logs, **kwargs):
+ full_update = kwargs.pop("full_update", False)
+ disable_intermittent = kwargs.pop("disable_intermittent", False)
+ update_intermittent = kwargs.pop("update_intermittent", False)
+ remove_intermittent = kwargs.pop("remove_intermittent", False)
+ assert not kwargs
+ id_test_map, updater = create_updater(tests)
+
+ for log in logs:
+ log = create_log(log)
+ updater.update_from_log(log)
+
+ update_properties = (["debug", "os", "version", "processor"],
+ {"os": ["version"], "processor": ["bits"]})
+
+ expected_data = {}
+ metadata.load_expected = lambda _, __, test_path, *args: expected_data.get(test_path)
+ for test_path, test_ids, test_type, manifest_str in tests:
+ test_path = utils.to_os_path(test_path)
+ expected_data[test_path] = manifestupdate.compile(BytesIO(manifest_str),
+ test_path,
+ "/",
+ update_properties,
+ update_intermittent,
+ remove_intermittent)
+
+ return list(metadata.update_results(id_test_map,
+ update_properties,
+ full_update,
+ disable_intermittent,
+ update_intermittent,
+ remove_intermittent))
+
+
+def create_updater(tests, url_base="/", **kwargs):
+ id_test_map = {}
+ m = create_test_manifest(tests, url_base)
+
+ reset_globals()
+ id_test_map = metadata.create_test_tree(None, m)
+
+ return id_test_map, metadata.ExpectedUpdater(id_test_map, **kwargs)
+
+
+def create_log(entries):
+ data = BytesIO()
+ if isinstance(entries, list):
+ logger = structuredlog.StructuredLogger("expected_test")
+ handler = handlers.StreamHandler(data, formatters.JSONFormatter())
+ logger.add_handler(handler)
+
+ for item in entries:
+ action, kwargs = item
+ getattr(logger, action)(**kwargs)
+ logger.remove_handler(handler)
+ else:
+ data.write(json.dumps(entries).encode())
+ data.seek(0)
+ return data
+
+
+def suite_log(entries, run_info=None):
+ _run_info = default_run_info.copy()
+ if run_info:
+ _run_info.update(run_info)
+ return ([("suite_start", {"tests": [], "run_info": _run_info})] +
+ entries +
+ [("suite_end", {})])
+
+
+def create_test_manifest(tests, url_base="/"):
+ source_files = []
+ for i, (test, _, test_type, _) in enumerate(tests):
+ if test_type:
+ source_files.append(SourceFileWithTest(test, str(i) * 40, item_classes[test_type]))
+ m = manifest.Manifest("")
+ tree, sourcefile_mock = tree_and_sourcefile_mocks((item, None, True) for item in source_files)
+ with mock.patch("manifest.manifest.SourceFile", side_effect=sourcefile_mock):
+ m.update(tree)
+ return m
+
+
+def test_update_0():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected: FAIL""")]
+
+ log = suite_log([("test_start", {"test": "/path/to/test.htm"}),
+ ("test_status", {"test": "/path/to/test.htm",
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "FAIL"}),
+ ("test_end", {"test": "/path/to/test.htm",
+ "status": "OK"})])
+
+ updated = update(tests, log)
+
+ assert len(updated) == 1
+ assert updated[0][1].is_empty
+
+
+def test_update_1():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected: ERROR""")]
+
+ log = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "ERROR"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log)
+
+ new_manifest = updated[0][1]
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get("expected", default_run_info) == "FAIL"
+
+
+def test_update_known_intermittent_1():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected: PASS""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_2 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0, log_1, log_2, update_intermittent=True)
+
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == ["PASS", "FAIL"]
+
+
+def test_update_known_intermittent_2():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected: PASS""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0, update_intermittent=True)
+
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == "FAIL"
+
+
+def test_update_existing_known_intermittent():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected: [PASS, FAIL]""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "ERROR",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_2 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0, log_1, log_2, update_intermittent=True)
+
+ new_manifest = updated[0][1]
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == ["PASS", "ERROR", "FAIL"]
+
+
+def test_update_remove_previous_intermittent():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected: [PASS, FAIL]""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "ERROR",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_2 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests,
+ log_0,
+ log_1,
+ log_2,
+ update_intermittent=True,
+ remove_intermittent=True)
+
+ new_manifest = updated[0][1]
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == ["PASS", "ERROR"]
+
+
+def test_update_new_test_with_intermittent():
+ tests = [("path/to/test.htm", [test_id], "testharness", None)]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_2 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0, log_1, log_2, update_intermittent=True)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test("test.htm") is None
+ assert len(new_manifest.get_test(test_id).children) == 1
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == ["PASS", "FAIL"]
+
+
+def test_update_expected_tie_resolution():
+ tests = [("path/to/test.htm", [test_id], "testharness", None)]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0, log_1, update_intermittent=True)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == ["PASS", "FAIL"]
+
+
+def test_update_reorder_expected():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected: [PASS, FAIL]""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_2 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0, log_1, log_2, update_intermittent=True)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == ["PASS", "FAIL"]
+
+
+def test_update_and_preserve_unchanged_expected_intermittent():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""
+[test.htm]
+ expected:
+ if os == "android": [PASS, FAIL]
+ FAIL""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "FAIL",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]})],
+ run_info={"os": "android"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "PASS",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]})],
+ run_info={"os": "android"})
+
+ log_2 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "PASS",
+ "expected": "FAIL"})])
+
+ updated = update(tests, log_0, log_1, log_2)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"os": "android"})
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).get(
+ "expected", run_info_1) == ["PASS", "FAIL"]
+ assert new_manifest.get_test(test_id).get(
+ "expected", default_run_info) == "PASS"
+
+
+def test_update_test_with_intermittent_to_one_expected_status():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected: [PASS, FAIL]""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "ERROR",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0)
+
+ new_manifest = updated[0][1]
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == "ERROR"
+
+
+def test_update_intermittent_with_conditions():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""
+[test.htm]
+ expected:
+ if os == "android": [PASS, FAIL]""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "TIMEOUT",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]})],
+ run_info={"os": "android"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "PASS",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]})],
+ run_info={"os": "android"})
+
+ updated = update(tests, log_0, log_1, update_intermittent=True)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"os": "android"})
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).get(
+ "expected", run_info_1) == ["PASS", "TIMEOUT", "FAIL"]
+
+
+def test_update_and_remove_intermittent_with_conditions():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""
+[test.htm]
+ expected:
+ if os == "android": [PASS, FAIL]""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "TIMEOUT",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]})],
+ run_info={"os": "android"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "PASS",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]})],
+ run_info={"os": "android"})
+
+ updated = update(tests, log_0, log_1, update_intermittent=True, remove_intermittent=True)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"os": "android"})
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).get(
+ "expected", run_info_1) == ["PASS", "TIMEOUT"]
+
+
+def test_update_intermittent_full():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected:
+ if os == "mac": [FAIL, TIMEOUT]
+ FAIL""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "FAIL",
+ "known_intermittent": ["TIMEOUT"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "mac"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0, log_1, update_intermittent=True, full_update=True)
+
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"os": "mac"})
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == ["FAIL", "TIMEOUT"]
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == "FAIL"
+
+
+def test_update_intermittent_full_remove():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected:
+ if os == "mac": [FAIL, TIMEOUT, PASS]
+ FAIL""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "FAIL",
+ "known_intermittent": ["TIMEOUT", "PASS"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "mac"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "TIMEOUT",
+ "expected": "FAIL",
+ "known_intermittent": ["TIMEOUT", "PASS"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "mac"})
+
+ log_2 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0, log_1, log_2, update_intermittent=True,
+ full_update=True, remove_intermittent=True)
+
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"os": "mac"})
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == ["FAIL", "TIMEOUT"]
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == "FAIL"
+
+
+def test_full_update():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected:
+ if os == "mac": [FAIL, TIMEOUT]
+ FAIL""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "FAIL",
+ "known_intermittent": ["TIMEOUT"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "mac"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0, log_1, full_update=True)
+
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"os": "mac"})
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == "FAIL"
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == "FAIL"
+
+
+def test_full_orphan():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected: FAIL
+ [subsub test]
+ expected: TIMEOUT
+ [test2]
+ expected: ERROR
+""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+
+ updated = update(tests, log_0, full_update=True)
+
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert len(new_manifest.get_test(test_id).children[0].children) == 0
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == "FAIL"
+ assert len(new_manifest.get_test(test_id).children) == 1
+
+
+def test_update_reorder_expected_full_conditions():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected:
+ if os == "mac": [FAIL, TIMEOUT]
+ [FAIL, PASS]""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "TIMEOUT",
+ "expected": "FAIL",
+ "known_intermittent": ["TIMEOUT"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "mac"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "TIMEOUT",
+ "expected": "FAIL",
+ "known_intermittent": ["TIMEOUT"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "mac"})
+
+ log_2 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "FAIL",
+ "known_intermittent": ["PASS"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ log_3 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "FAIL",
+ "known_intermittent": ["PASS"]}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0, log_1, log_2, log_3, update_intermittent=True, full_update=True)
+
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"os": "mac"})
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == ["TIMEOUT", "FAIL"]
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", default_run_info) == ["PASS", "FAIL"]
+
+
+def test_skip_0():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected: FAIL""")]
+
+ log = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log)
+ assert not updated
+
+
+def test_new_subtest():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ [test1]
+ expected: FAIL""")]
+
+ log = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "FAIL"}),
+ ("test_status", {"test": test_id,
+ "subtest": "test2",
+ "status": "FAIL",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+ updated = update(tests, log)
+ new_manifest = updated[0][1]
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get("expected", default_run_info) == "FAIL"
+ assert new_manifest.get_test(test_id).children[1].get("expected", default_run_info) == "FAIL"
+
+
+def test_update_subtest():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ expected:
+ if os == "linux": [OK, ERROR]
+ [test1]
+ expected: FAIL""")]
+
+ log = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "known_intermittent": []}),
+ ("test_status", {"test": test_id,
+ "subtest": "test2",
+ "status": "FAIL",
+ "expected": "PASS",
+ "known_intermittent": []}),
+ ("test_end", {"test": test_id,
+ "status": "OK",
+ "known_intermittent": ["ERROR"]})])
+ updated = update(tests, log)
+ new_manifest = updated[0][1]
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get("expected", default_run_info) == "FAIL"
+
+
+def test_update_multiple_0():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ [test1]
+ expected: FAIL""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": False, "os": "osx"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "TIMEOUT",
+ "expected": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": False, "os": "linux"})
+
+ updated = update(tests, log_0, log_1)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"debug": False, "os": "osx"})
+ run_info_2 = default_run_info.copy()
+ run_info_2.update({"debug": False, "os": "linux"})
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == "FAIL"
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", {"debug": False, "os": "linux"}) == "TIMEOUT"
+
+
+def test_update_multiple_1():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ [test1]
+ expected: FAIL""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "osx"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "TIMEOUT",
+ "expected": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "linux"})
+
+ updated = update(tests, log_0, log_1)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"os": "osx"})
+ run_info_2 = default_run_info.copy()
+ run_info_2.update({"os": "linux"})
+ run_info_3 = default_run_info.copy()
+ run_info_3.update({"os": "win"})
+
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == "FAIL"
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_2) == "TIMEOUT"
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_3) == "FAIL"
+
+
+def test_update_multiple_2():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ [test1]
+ expected: FAIL""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": False, "os": "osx"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "TIMEOUT",
+ "expected": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": True, "os": "osx"})
+
+ updated = update(tests, log_0, log_1)
+ new_manifest = updated[0][1]
+
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"debug": False, "os": "osx"})
+ run_info_2 = default_run_info.copy()
+ run_info_2.update({"debug": True, "os": "osx"})
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == "FAIL"
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_2) == "TIMEOUT"
+
+
+def test_update_multiple_3():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ [test1]
+ expected:
+ if debug: FAIL
+ if not debug and os == "osx": TIMEOUT""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": False, "os": "osx"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "TIMEOUT",
+ "expected": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": True, "os": "osx"})
+
+ updated = update(tests, log_0, log_1)
+ new_manifest = updated[0][1]
+
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"debug": False, "os": "osx"})
+ run_info_2 = default_run_info.copy()
+ run_info_2.update({"debug": True, "os": "osx"})
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == "FAIL"
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_2) == "TIMEOUT"
+
+
+def test_update_ignore_existing():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ [test1]
+ expected:
+ if debug: TIMEOUT
+ if not debug and os == "osx": NOTRUN""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": False, "os": "linux"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": True, "os": "windows"})
+
+ updated = update(tests, log_0, log_1)
+ new_manifest = updated[0][1]
+
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"debug": False, "os": "linux"})
+ run_info_2 = default_run_info.copy()
+ run_info_2.update({"debug": False, "os": "osx"})
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == "FAIL"
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_2) == "NOTRUN"
+
+
+def test_update_new_test():
+ tests = [("path/to/test.htm", [test_id], "testharness", None)]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+ updated = update(tests, log_0)
+ new_manifest = updated[0][1]
+
+ run_info_1 = default_run_info.copy()
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test("test.htm") is None
+ assert len(new_manifest.get_test(test_id).children) == 1
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == "FAIL"
+
+
+def test_update_duplicate():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""
+[test.htm]
+ expected: ERROR""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "PASS"})])
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "FAIL"})])
+
+ updated = update(tests, log_0, log_1)
+ new_manifest = updated[0][1]
+ run_info_1 = default_run_info.copy()
+
+ assert new_manifest.get_test(test_id).get(
+ "expected", run_info_1) == "ERROR"
+
+
+def test_update_disable_intermittent():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""
+[test.htm]
+ expected: ERROR""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "PASS"})])
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "FAIL"})])
+
+ updated = update(tests, log_0, log_1, disable_intermittent="Some message")
+ new_manifest = updated[0][1]
+ run_info_1 = default_run_info.copy()
+
+ assert new_manifest.get_test(test_id).get(
+ "disabled", run_info_1) == "Some message"
+
+
+def test_update_stability_conditional_instability():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""
+[test.htm]
+ expected: ERROR""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "PASS"})],
+ run_info={"os": "linux"})
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "FAIL"})],
+ run_info={"os": "linux"})
+ log_2 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "status": "FAIL"})],
+ run_info={"os": "mac"})
+
+ updated = update(tests, log_0, log_1, log_2, disable_intermittent="Some message")
+ new_manifest = updated[0][1]
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"os": "linux"})
+ run_info_2 = default_run_info.copy()
+ run_info_2.update({"os": "mac"})
+
+ assert new_manifest.get_test(test_id).get(
+ "disabled", run_info_1) == "Some message"
+ with pytest.raises(KeyError):
+ assert new_manifest.get_test(test_id).get(
+ "disabled", run_info_2)
+ assert new_manifest.get_test(test_id).get(
+ "expected", run_info_2) == "FAIL"
+
+
+def test_update_full():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ [test1]
+ expected:
+ if debug: TIMEOUT
+ if not debug and os == "osx": NOTRUN
+
+ [test2]
+ expected: FAIL
+
+[test.js]
+ [test1]
+ expected: FAIL
+""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": False})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "ERROR",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": True})
+
+ updated = update(tests, log_0, log_1, full_update=True)
+ new_manifest = updated[0][1]
+
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"debug": False, "os": "win"})
+ run_info_2 = default_run_info.copy()
+ run_info_2.update({"debug": True, "os": "osx"})
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test("test.js") is None
+ assert len(new_manifest.get_test(test_id).children) == 1
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == "FAIL"
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_2) == "ERROR"
+
+
+def test_update_full_unknown():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ [test1]
+ expected:
+ if release_or_beta: ERROR
+ if not debug and os == "osx": NOTRUN
+""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": False, "release_or_beta": False})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": True, "release_or_beta": False})
+
+ updated = update(tests, log_0, log_1, full_update=True)
+ new_manifest = updated[0][1]
+
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"release_or_beta": False})
+ run_info_2 = default_run_info.copy()
+ run_info_2.update({"release_or_beta": True})
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_1) == "FAIL"
+ assert new_manifest.get_test(test_id).children[0].get(
+ "expected", run_info_2) == "ERROR"
+
+
+def test_update_full_unknown_missing():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ [subtest_deleted]
+ expected:
+ if release_or_beta: ERROR
+ FAIL
+""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "PASS"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"debug": False, "release_or_beta": False})
+
+ updated = update(tests, log_0, full_update=True)
+ assert len(updated) == 0
+
+
+def test_update_default():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ [test1]
+ expected:
+ if os == "mac": FAIL
+ ERROR""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "FAIL"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "mac"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "expected": "ERROR"}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "linux"})
+
+ updated = update(tests, log_0, log_1)
+ new_manifest = updated[0][1]
+
+ assert new_manifest.is_empty
+ assert new_manifest.modified
+
+
+def test_update_default_1():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""
+[test.htm]
+ expected:
+ if os == "mac": TIMEOUT
+ ERROR""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "expected": "ERROR",
+ "status": "FAIL"})],
+ run_info={"os": "linux"})
+
+ updated = update(tests, log_0)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"os": "mac"})
+ run_info_2 = default_run_info.copy()
+ run_info_2.update({"os": "win"})
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).get(
+ "expected", run_info_1) == "TIMEOUT"
+ assert new_manifest.get_test(test_id).get(
+ "expected", run_info_2) == "FAIL"
+
+
+def test_update_default_2():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""
+[test.htm]
+ expected:
+ if os == "mac": TIMEOUT
+ ERROR""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_end", {"test": test_id,
+ "expected": "ERROR",
+ "status": "TIMEOUT"})],
+ run_info={"os": "linux"})
+
+ updated = update(tests, log_0)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+
+ run_info_1 = default_run_info.copy()
+ run_info_1.update({"os": "mac"})
+ run_info_2 = default_run_info.copy()
+ run_info_2.update({"os": "win"})
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).get(
+ "expected", run_info_1) == "TIMEOUT"
+ assert new_manifest.get_test(test_id).get(
+ "expected", run_info_2) == "TIMEOUT"
+
+
+def test_update_assertion_count_0():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ max-asserts: 4
+ min-asserts: 2
+""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("assertion_count", {"test": test_id,
+ "count": 6,
+ "min_expected": 2,
+ "max_expected": 4}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).get("max-asserts") == "7"
+ assert new_manifest.get_test(test_id).get("min-asserts") == "2"
+
+
+def test_update_assertion_count_1():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ max-asserts: 4
+ min-asserts: 2
+""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("assertion_count", {"test": test_id,
+ "count": 1,
+ "min_expected": 2,
+ "max_expected": 4}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).get("max-asserts") == "4"
+ assert new_manifest.get_test(test_id).has_key("min-asserts") is False
+
+
+def test_update_assertion_count_2():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ max-asserts: 4
+ min-asserts: 2
+""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("assertion_count", {"test": test_id,
+ "count": 3,
+ "min_expected": 2,
+ "max_expected": 4}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})])
+
+ updated = update(tests, log_0)
+ assert not updated
+
+
+def test_update_assertion_count_3():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]
+ max-asserts: 4
+ min-asserts: 2
+""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("assertion_count", {"test": test_id,
+ "count": 6,
+ "min_expected": 2,
+ "max_expected": 4}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "windows"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("assertion_count", {"test": test_id,
+ "count": 7,
+ "min_expected": 2,
+ "max_expected": 4}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "linux"})
+
+ updated = update(tests, log_0, log_1)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).get("max-asserts") == "8"
+ assert new_manifest.get_test(test_id).get("min-asserts") == "2"
+
+
+def test_update_assertion_count_4():
+ tests = [("path/to/test.htm", [test_id], "testharness", b"""[test.htm]""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("assertion_count", {"test": test_id,
+ "count": 6,
+ "min_expected": 0,
+ "max_expected": 0}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "windows"})
+
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("assertion_count", {"test": test_id,
+ "count": 7,
+ "min_expected": 0,
+ "max_expected": 0}),
+ ("test_end", {"test": test_id,
+ "status": "OK"})],
+ run_info={"os": "linux"})
+
+ updated = update(tests, log_0, log_1)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get_test(test_id).get("max-asserts") == "8"
+ assert new_manifest.get_test(test_id).has_key("min-asserts") is False
+
+
+def test_update_lsan_0():
+ tests = [("path/to/test.htm", [test_id], "testharness", b""),
+ ("path/to/__dir__", [dir_id], None, b"")]
+
+ log_0 = suite_log([("lsan_leak", {"scope": "path/to/",
+ "frames": ["foo", "bar"]})])
+
+
+ updated = update(tests, log_0)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get("lsan-allowed") == ["foo"]
+
+
+def test_update_lsan_1():
+ tests = [("path/to/test.htm", [test_id], "testharness", b""),
+ ("path/to/__dir__", [dir_id], None, b"""
+lsan-allowed: [foo]""")]
+
+ log_0 = suite_log([("lsan_leak", {"scope": "path/to/",
+ "frames": ["foo", "bar"]}),
+ ("lsan_leak", {"scope": "path/to/",
+ "frames": ["baz", "foobar"]})])
+
+
+ updated = update(tests, log_0)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get("lsan-allowed") == ["baz", "foo"]
+
+
+def test_update_lsan_2():
+ tests = [("path/to/test.htm", [test_id], "testharness", b""),
+ ("path/__dir__", ["path/__dir__"], None, b"""
+lsan-allowed: [foo]"""),
+ ("path/to/__dir__", [dir_id], None, b"")]
+
+ log_0 = suite_log([("lsan_leak", {"scope": "path/to/",
+ "frames": ["foo", "bar"],
+ "allowed_match": ["foo"]}),
+ ("lsan_leak", {"scope": "path/to/",
+ "frames": ["baz", "foobar"]})])
+
+
+ updated = update(tests, log_0)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get("lsan-allowed") == ["baz"]
+
+
+def test_update_lsan_3():
+ tests = [("path/to/test.htm", [test_id], "testharness", b""),
+ ("path/to/__dir__", [dir_id], None, b"")]
+
+ log_0 = suite_log([("lsan_leak", {"scope": "path/to/",
+ "frames": ["foo", "bar"]})],
+ run_info={"os": "win"})
+
+ log_1 = suite_log([("lsan_leak", {"scope": "path/to/",
+ "frames": ["baz", "foobar"]})],
+ run_info={"os": "linux"})
+
+
+ updated = update(tests, log_0, log_1)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get("lsan-allowed") == ["baz", "foo"]
+
+
+def test_update_wptreport_0():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ [test1]
+ expected: FAIL""")]
+
+ log = {"run_info": default_run_info.copy(),
+ "results": [
+ {"test": "/path/to/test.htm",
+ "subtests": [{"name": "test1",
+ "status": "PASS",
+ "expected": "FAIL"}],
+ "status": "OK"}]}
+
+ updated = update(tests, log)
+
+ assert len(updated) == 1
+ assert updated[0][1].is_empty
+
+
+def test_update_wptreport_1():
+ tests = [("path/to/test.htm", [test_id], "testharness", b""),
+ ("path/to/__dir__", [dir_id], None, b"")]
+
+ log = {"run_info": default_run_info.copy(),
+ "results": [],
+ "lsan_leaks": [{"scope": "path/to/",
+ "frames": ["baz", "foobar"]}]}
+
+ updated = update(tests, log)
+
+ assert len(updated) == 1
+ assert updated[0][1].get("lsan-allowed") == ["baz"]
+
+
+def test_update_leak_total_0():
+ tests = [("path/to/test.htm", [test_id], "testharness", b""),
+ ("path/to/__dir__", [dir_id], None, b"")]
+
+ log_0 = suite_log([("mozleak_total", {"scope": "path/to/",
+ "process": "default",
+ "bytes": 100,
+ "threshold": 0,
+ "objects": []})])
+
+ updated = update(tests, log_0)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get("leak-threshold") == ['default:51200']
+
+
+def test_update_leak_total_1():
+ tests = [("path/to/test.htm", [test_id], "testharness", b""),
+ ("path/to/__dir__", [dir_id], None, b"")]
+
+ log_0 = suite_log([("mozleak_total", {"scope": "path/to/",
+ "process": "default",
+ "bytes": 100,
+ "threshold": 1000,
+ "objects": []})])
+
+ updated = update(tests, log_0)
+ assert not updated
+
+
+def test_update_leak_total_2():
+ tests = [("path/to/test.htm", [test_id], "testharness", b""),
+ ("path/to/__dir__", [dir_id], None, b"""
+leak-total: 110""")]
+
+ log_0 = suite_log([("mozleak_total", {"scope": "path/to/",
+ "process": "default",
+ "bytes": 100,
+ "threshold": 110,
+ "objects": []})])
+
+ updated = update(tests, log_0)
+ assert not updated
+
+
+def test_update_leak_total_3():
+ tests = [("path/to/test.htm", [test_id], "testharness", b""),
+ ("path/to/__dir__", [dir_id], None, b"""
+leak-total: 100""")]
+
+ log_0 = suite_log([("mozleak_total", {"scope": "path/to/",
+ "process": "default",
+ "bytes": 1000,
+ "threshold": 100,
+ "objects": []})])
+
+ updated = update(tests, log_0)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.get("leak-threshold") == ['default:51200']
+
+
+def test_update_leak_total_4():
+ tests = [("path/to/test.htm", [test_id], "testharness", b""),
+ ("path/to/__dir__", [dir_id], None, b"""
+leak-total: 110""")]
+
+ log_0 = suite_log([
+ ("lsan_leak", {"scope": "path/to/",
+ "frames": ["foo", "bar"]}),
+ ("mozleak_total", {"scope": "path/to/",
+ "process": "default",
+ "bytes": 100,
+ "threshold": 110,
+ "objects": []})])
+
+ updated = update(tests, log_0)
+ new_manifest = updated[0][1]
+
+ assert not new_manifest.is_empty
+ assert new_manifest.modified
+ assert new_manifest.has_key("leak-threshold") is False
+
+
+class TestStep(Step):
+ def create(self, state):
+ tests = [("path/to/test.htm", [test_id], "testharness", "")]
+ state.foo = create_test_manifest(tests)
+
+
+class UpdateRunner(StepRunner):
+ steps = [TestStep]
+
+
+def test_update_pickle():
+ logger = structuredlog.StructuredLogger("expected_test")
+ args = {
+ "test_paths": {
+ "/": {"tests_path": os.path.abspath(os.path.join(here,
+ os.pardir,
+ os.pardir,
+ os.pardir,
+ os.pardir))},
+ },
+ "abort": False,
+ "continue": False,
+ "sync": False,
+ }
+ args2 = args.copy()
+ args2["abort"] = True
+ wptupdate = WPTUpdate(logger, **args2)
+ wptupdate = WPTUpdate(logger, runner_cls=UpdateRunner, **args)
+ wptupdate.run()
+
+
+def test_update_serialize_quoted():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ expected: "ERROR"
+ [test1]
+ expected:
+ if os == "linux": ["PASS", "FAIL"]
+ "ERROR"
+""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "expected": "ERROR",
+ "status": "OK"})],
+ run_info={"os": "linux"})
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "expected": "ERROR",
+ "status": "OK"})],
+ run_info={"os": "linux"})
+ log_2 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "ERROR"}),
+ ("test_end", {"test": test_id,
+ "expected": "ERROR",
+ "status": "OK"})],
+ run_info={"os": "win"})
+
+ updated = update(tests, log_0, log_1, log_2, full_update=True, update_intermittent=True)
+
+
+ manifest_str = wptmanifest.serialize(updated[0][1].node,
+ skip_empty_data=True)
+ assert manifest_str == """[test.htm]
+ [test1]
+ expected:
+ if os == "linux": [PASS, FAIL]
+ ERROR
+"""
+
+
+def test_update_serialize_unquoted():
+ tests = [("path/to/test.htm", [test_id], "testharness",
+ b"""[test.htm]
+ expected: ERROR
+ [test1]
+ expected:
+ if os == "linux": [PASS, FAIL]
+ ERROR
+""")]
+
+ log_0 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "expected": "ERROR",
+ "status": "OK"})],
+ run_info={"os": "linux"})
+ log_1 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "FAIL",
+ "expected": "PASS",
+ "known_intermittent": ["FAIL"]}),
+ ("test_end", {"test": test_id,
+ "expected": "ERROR",
+ "status": "OK"})],
+ run_info={"os": "linux"})
+ log_2 = suite_log([("test_start", {"test": test_id}),
+ ("test_status", {"test": test_id,
+ "subtest": "test1",
+ "status": "ERROR"}),
+ ("test_end", {"test": test_id,
+ "expected": "ERROR",
+ "status": "OK"})],
+ run_info={"os": "win"})
+
+ updated = update(tests, log_0, log_1, log_2, full_update=True, update_intermittent=True)
+
+
+ manifest_str = wptmanifest.serialize(updated[0][1].node,
+ skip_empty_data=True)
+ assert manifest_str == """[test.htm]
+ [test1]
+ expected:
+ if os == "linux": [PASS, FAIL]
+ ERROR
+"""
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_wpttest.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_wpttest.py
new file mode 100644
index 0000000000..272fffd817
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/tests/test_wpttest.py
@@ -0,0 +1,232 @@
+# mypy: ignore-errors
+
+from io import BytesIO
+from unittest import mock
+
+from manifest import manifest as wptmanifest
+from manifest.item import TestharnessTest, RefTest
+from manifest.utils import to_os_path
+from . test_update import tree_and_sourcefile_mocks
+from .. import manifestexpected, wpttest
+
+
+dir_ini_0 = b"""\
+prefs: [a:b]
+"""
+
+dir_ini_1 = b"""\
+prefs: [@Reset, b:c]
+max-asserts: 2
+min-asserts: 1
+tags: [b, c]
+"""
+
+dir_ini_2 = b"""\
+lsan-max-stack-depth: 42
+"""
+
+test_0 = b"""\
+[0.html]
+ prefs: [c:d]
+ max-asserts: 3
+ tags: [a, @Reset]
+"""
+
+test_1 = b"""\
+[1.html]
+ prefs:
+ if os == 'win': [a:b, c:d]
+ expected:
+ if os == 'win': FAIL
+"""
+
+test_2 = b"""\
+[2.html]
+ lsan-max-stack-depth: 42
+"""
+
+test_3 = b"""\
+[3.html]
+ [subtest1]
+ expected: [PASS, FAIL]
+
+ [subtest2]
+ disabled: reason
+
+ [subtest3]
+ expected: FAIL
+"""
+
+test_4 = b"""\
+[4.html]
+ expected: FAIL
+"""
+
+test_5 = b"""\
+[5.html]
+"""
+
+test_6 = b"""\
+[6.html]
+ expected: [OK, FAIL]
+"""
+
+test_fuzzy = b"""\
+[fuzzy.html]
+ fuzzy: fuzzy-ref.html:1;200
+"""
+
+
+testharness_test = b"""<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>"""
+
+
+def make_mock_manifest(*items):
+ rv = mock.Mock(tests_root="/foobar")
+ tests = []
+ rv.__iter__ = lambda self: iter(tests)
+ rv.__getitem__ = lambda self, k: tests[k]
+ for test_type, dir_path, num_tests in items:
+ for i in range(num_tests):
+ filename = dir_path + "/%i.html" % i
+ tests.append((test_type,
+ filename,
+ {TestharnessTest("/foo.bar", filename, "/", filename)}))
+ return rv
+
+def make_test_object(test_name,
+ test_path,
+ index,
+ items,
+ inherit_metadata=None,
+ iterate=False,
+ condition=None):
+ inherit_metadata = inherit_metadata if inherit_metadata is not None else []
+ condition = condition if condition is not None else {}
+ tests = make_mock_manifest(*items) if isinstance(items, list) else make_mock_manifest(items)
+
+ test_metadata = manifestexpected.static.compile(BytesIO(test_name),
+ condition,
+ data_cls_getter=manifestexpected.data_cls_getter,
+ test_path=test_path,
+ url_base="/")
+
+ test = next(iter(tests[index][2])) if iterate else tests[index][2].pop()
+ return wpttest.from_manifest(tests, test, inherit_metadata, test_metadata.get_test(test.id))
+
+
+def test_run_info():
+ run_info = wpttest.get_run_info("/", "fake-product", debug=False)
+ assert isinstance(run_info["bits"], int)
+ assert isinstance(run_info["os"], str)
+ assert isinstance(run_info["os_version"], str)
+ assert isinstance(run_info["processor"], str)
+ assert isinstance(run_info["product"], str)
+ assert isinstance(run_info["python_version"], int)
+
+
+def test_metadata_inherit():
+ items = [("test", "a", 10), ("test", "a/b", 10), ("test", "c", 10)]
+ inherit_metadata = [
+ manifestexpected.static.compile(
+ BytesIO(item),
+ {},
+ data_cls_getter=lambda x,y: manifestexpected.DirectoryManifest)
+ for item in [dir_ini_0, dir_ini_1]]
+
+ test_obj = make_test_object(test_0, "a/0.html", 0, items, inherit_metadata, True)
+
+ assert test_obj.max_assertion_count == 3
+ assert test_obj.min_assertion_count == 1
+ assert test_obj.prefs == {"b": "c", "c": "d"}
+ assert test_obj.tags == {"a", "dir:a"}
+
+
+def test_conditional():
+ items = [("test", "a", 10), ("test", "a/b", 10), ("test", "c", 10)]
+
+ test_obj = make_test_object(test_1, "a/1.html", 1, items, None, True, {"os": "win"})
+
+ assert test_obj.prefs == {"a": "b", "c": "d"}
+ assert test_obj.expected() == "FAIL"
+
+
+def test_metadata_lsan_stack_depth():
+ items = [("test", "a", 10), ("test", "a/b", 10)]
+
+ test_obj = make_test_object(test_2, "a/2.html", 2, items, None, True)
+
+ assert test_obj.lsan_max_stack_depth == 42
+
+ test_obj = make_test_object(test_2, "a/2.html", 1, items, None, True)
+
+ assert test_obj.lsan_max_stack_depth is None
+
+ inherit_metadata = [
+ manifestexpected.static.compile(
+ BytesIO(dir_ini_2),
+ {},
+ data_cls_getter=lambda x,y: manifestexpected.DirectoryManifest)
+ ]
+
+ test_obj = make_test_object(test_0, "a/0/html", 0, items, inherit_metadata, False)
+
+ assert test_obj.lsan_max_stack_depth == 42
+
+
+def test_subtests():
+ test_obj = make_test_object(test_3, "a/3.html", 3, ("test", "a", 4), None, False)
+ assert test_obj.expected("subtest1") == "PASS"
+ assert test_obj.known_intermittent("subtest1") == ["FAIL"]
+ assert test_obj.expected("subtest2") == "PASS"
+ assert test_obj.known_intermittent("subtest2") == []
+ assert test_obj.expected("subtest3") == "FAIL"
+ assert test_obj.known_intermittent("subtest3") == []
+
+
+def test_expected_fail():
+ test_obj = make_test_object(test_4, "a/4.html", 4, ("test", "a", 5), None, False)
+ assert test_obj.expected() == "FAIL"
+ assert test_obj.known_intermittent() == []
+
+
+def test_no_expected():
+ test_obj = make_test_object(test_5, "a/5.html", 5, ("test", "a", 6), None, False)
+ assert test_obj.expected() == "OK"
+ assert test_obj.known_intermittent() == []
+
+
+def test_known_intermittent():
+ test_obj = make_test_object(test_6, "a/6.html", 6, ("test", "a", 7), None, False)
+ assert test_obj.expected() == "OK"
+ assert test_obj.known_intermittent() == ["FAIL"]
+
+
+def test_metadata_fuzzy():
+ item = RefTest(tests_root=".",
+ path="a/fuzzy.html",
+ url_base="/",
+ url="a/fuzzy.html",
+ references=[["/a/fuzzy-ref.html", "=="]],
+ fuzzy=[[["/a/fuzzy.html", '/a/fuzzy-ref.html', '=='],
+ [[2, 3], [10, 15]]]])
+ s = mock.Mock(rel_path="a/fuzzy.html", rel_path_parts=("a", "fuzzy.html"), hash="0"*40)
+ s.manifest_items = mock.Mock(return_value=(item.item_type, [item]))
+
+ manifest = wptmanifest.Manifest("")
+
+ tree, sourcefile_mock = tree_and_sourcefile_mocks([(s, None, True)])
+ with mock.patch("manifest.manifest.SourceFile", side_effect=sourcefile_mock):
+ assert manifest.update(tree) is True
+
+ test_metadata = manifestexpected.static.compile(BytesIO(test_fuzzy),
+ {},
+ data_cls_getter=manifestexpected.data_cls_getter,
+ test_path="a/fuzzy.html",
+ url_base="/")
+
+ test = next(manifest.iterpath(to_os_path("a/fuzzy.html")))
+ test_obj = wpttest.from_manifest(manifest, test, [], test_metadata.get_test(test.id))
+
+ assert test_obj.fuzzy == {('/a/fuzzy.html', '/a/fuzzy-ref.html', '=='): [[2, 3], [10, 15]]}
+ assert test_obj.fuzzy_override == {'/a/fuzzy-ref.html': ((1, 1), (200, 200))}
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/update/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/__init__.py
new file mode 100644
index 0000000000..1a58837f8d
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/__init__.py
@@ -0,0 +1,47 @@
+# mypy: allow-untyped-defs
+
+import sys
+
+from mozlog.structured import structuredlog, commandline
+
+from .. import wptcommandline
+
+from .update import WPTUpdate
+
+def remove_logging_args(args):
+ """Take logging args out of the dictionary of command line arguments so
+ they are not passed in as kwargs to the update code. This is particularly
+ necessary here because the arguments are often of type file, which cannot
+ be serialized.
+
+ :param args: Dictionary of command line arguments.
+ """
+ for name in list(args.keys()):
+ if name.startswith("log_"):
+ args.pop(name)
+
+
+def setup_logging(args, defaults):
+ """Use the command line arguments to set up the logger.
+
+ :param args: Dictionary of command line arguments.
+ :param defaults: Dictionary of {formatter_name: stream} to use if
+ no command line logging is specified"""
+ logger = commandline.setup_logging("web-platform-tests-update", args, defaults)
+
+ remove_logging_args(args)
+
+ return logger
+
+
+def run_update(logger, **kwargs):
+ updater = WPTUpdate(logger, **kwargs)
+ return updater.run()
+
+
+def main():
+ args = wptcommandline.parse_args_update()
+ logger = setup_logging(args, {"mach": sys.stdout})
+ assert structuredlog.get_default_logger() is not None
+ success = run_update(logger, **args)
+ sys.exit(0 if success else 1)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/update/base.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/base.py
new file mode 100644
index 0000000000..bd39e23b86
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/base.py
@@ -0,0 +1,69 @@
+# mypy: allow-untyped-defs
+
+from typing import ClassVar, List, Type
+
+exit_unclean = object()
+exit_clean = object()
+
+
+class Step:
+ provides = [] # type: ClassVar[List[str]]
+
+ def __init__(self, logger):
+ self.logger = logger
+
+ def run(self, step_index, state):
+ """Base class for state-creating steps.
+
+ When a Step is run() the current state is checked to see
+ if the state from this step has already been created. If it
+ has the restore() method is invoked. Otherwise the create()
+ method is invoked with the state object. This is expected to
+ add items with all the keys in __class__.provides to the state
+ object.
+ """
+
+ name = self.__class__.__name__
+
+ try:
+ stored_step = state.steps[step_index]
+ except IndexError:
+ stored_step = None
+
+ if stored_step == name:
+ self.restore(state)
+ elif stored_step is None:
+ self.create(state)
+ assert set(self.provides).issubset(set(state.keys()))
+ state.steps = state.steps + [name]
+ else:
+ raise ValueError(f"Expected a {name} step, got a {stored_step} step")
+
+ def create(self, data):
+ raise NotImplementedError
+
+ def restore(self, state):
+ self.logger.debug(f"Step {self.__class__.__name__} using stored state")
+ for key in self.provides:
+ assert key in state
+
+
+class StepRunner:
+ steps = [] # type: ClassVar[List[Type[Step]]]
+
+ def __init__(self, logger, state):
+ """Class that runs a specified series of Steps with a common State"""
+ self.state = state
+ self.logger = logger
+ if "steps" not in state:
+ state.steps = []
+
+ def run(self):
+ rv = None
+ for step_index, step in enumerate(self.steps):
+ self.logger.debug("Starting step %s" % step.__name__)
+ rv = step(self.logger).run(step_index, self.state)
+ if rv in (exit_clean, exit_unclean):
+ break
+
+ return rv
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/update/metadata.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/metadata.py
new file mode 100644
index 0000000000..388b569bcc
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/metadata.py
@@ -0,0 +1,62 @@
+# mypy: allow-untyped-defs
+
+import os
+
+from .. import metadata, products
+
+from .base import Step, StepRunner
+
+
+class GetUpdatePropertyList(Step):
+ provides = ["update_properties"]
+
+ def create(self, state):
+ state.update_properties = products.load_product_update(state.config, state.product)
+
+
+class UpdateExpected(Step):
+ """Do the metadata update on the local checkout"""
+
+ def create(self, state):
+ metadata.update_expected(state.paths,
+ state.run_log,
+ update_properties=state.update_properties,
+ full_update=state.full_update,
+ disable_intermittent=state.disable_intermittent,
+ update_intermittent=state.update_intermittent,
+ remove_intermittent=state.remove_intermittent)
+
+
+class CreateMetadataPatch(Step):
+ """Create a patch/commit for the metadata checkout"""
+
+ def create(self, state):
+ if not state.patch:
+ return
+
+ local_tree = state.local_tree
+ sync_tree = state.sync_tree
+
+ if sync_tree is not None:
+ name = "web-platform-tests_update_%s_metadata" % sync_tree.rev
+ message = f"Update {state.suite_name} expected data to revision {sync_tree.rev}"
+ else:
+ name = "web-platform-tests_update_metadata"
+ message = "Update %s expected data" % state.suite_name
+
+ local_tree.create_patch(name, message)
+
+ if not local_tree.is_clean:
+ metadata_paths = [manifest_path["metadata_path"]
+ for manifest_path in state.paths.itervalues()]
+ for path in metadata_paths:
+ local_tree.add_new(os.path.relpath(path, local_tree.root))
+ local_tree.update_patch(include=metadata_paths)
+ local_tree.commit_patch()
+
+
+class MetadataUpdateRunner(StepRunner):
+ """(Sub)Runner for updating metadata"""
+ steps = [GetUpdatePropertyList,
+ UpdateExpected,
+ CreateMetadataPatch]
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/update/state.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/state.py
new file mode 100644
index 0000000000..2c23ad66c2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/state.py
@@ -0,0 +1,159 @@
+# mypy: allow-untyped-defs
+
+import os
+import pickle
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+class BaseState:
+ def __new__(cls, logger):
+ rv = cls.load(logger)
+ if rv is not None:
+ logger.debug("Existing state found")
+ return rv
+
+ logger.debug("No existing state found")
+ return super().__new__(cls)
+
+ def __init__(self, logger):
+ """Object containing state variables created when running Steps.
+
+ Variables are set and get as attributes e.g. state_obj.spam = "eggs".
+
+ :param parent: Parent State object or None if this is the root object.
+ """
+
+ if hasattr(self, "_data"):
+ return
+
+ self._data = [{}]
+ self._logger = logger
+ self._index = 0
+
+ def __getstate__(self):
+ rv = self.__dict__.copy()
+ del rv["_logger"]
+ return rv
+
+
+ def push(self, init_values):
+ """Push a new clean state dictionary
+
+ :param init_values: List of variable names in the current state dict to copy
+ into the new state dict."""
+
+ return StateContext(self, init_values)
+
+ def is_empty(self):
+ return len(self._data) == 1 and self._data[0] == {}
+
+ def clear(self):
+ """Remove all state and delete the stored copy."""
+ self._data = [{}]
+
+ def __setattr__(self, key, value):
+ if key.startswith("_"):
+ object.__setattr__(self, key, value)
+ else:
+ self._data[self._index][key] = value
+ self.save()
+
+ def __getattr__(self, key):
+ if key.startswith("_"):
+ raise AttributeError
+ try:
+ return self._data[self._index][key]
+ except KeyError:
+ raise AttributeError
+
+ def __contains__(self, key):
+ return key in self._data[self._index]
+
+ def update(self, items):
+ """Add a dictionary of {name: value} pairs to the state"""
+ self._data[self._index].update(items)
+ self.save()
+
+ def keys(self):
+ return self._data[self._index].keys()
+
+
+ @classmethod
+ def load(cls):
+ raise NotImplementedError
+
+ def save(self):
+ raise NotImplementedError
+
+
+class SavedState(BaseState):
+ """On write the state is serialized to disk, such that it can be restored in
+ the event that the program is interrupted before all steps are complete.
+ Note that this only works well if the values are immutable; mutating an
+ existing value will not cause the data to be serialized."""
+ filename = os.path.join(here, ".wpt-update.lock")
+
+ @classmethod
+ def load(cls, logger):
+ """Load saved state from a file"""
+ try:
+ if not os.path.isfile(cls.filename):
+ return None
+ with open(cls.filename, "rb") as f:
+ try:
+ rv = pickle.load(f)
+ logger.debug(f"Loading data {rv._data!r}")
+ rv._logger = logger
+ rv._index = 0
+ return rv
+ except EOFError:
+ logger.warning("Found empty state file")
+ except OSError:
+ logger.debug("IOError loading stored state")
+
+ def save(self):
+ """Write the state to disk"""
+ with open(self.filename, "wb") as f:
+ pickle.dump(self, f)
+
+ def clear(self):
+ super().clear()
+ try:
+ os.unlink(self.filename)
+ except OSError:
+ pass
+
+
+class UnsavedState(BaseState):
+ @classmethod
+ def load(cls, logger):
+ return None
+
+ def save(self):
+ return
+
+
+class StateContext:
+ def __init__(self, state, init_values):
+ self.state = state
+ self.init_values = init_values
+
+ def __enter__(self):
+ if len(self.state._data) == self.state._index + 1:
+ # This is the case where there is no stored state
+ new_state = {}
+ for key in self.init_values:
+ new_state[key] = self.state._data[self.state._index][key]
+ self.state._data.append(new_state)
+ self.state._index += 1
+ self.state._logger.debug("Incremented index to %s" % self.state._index)
+
+ def __exit__(self, *args, **kwargs):
+ if len(self.state._data) > 1:
+ assert self.state._index == len(self.state._data) - 1
+ self.state._data.pop()
+ self.state._index -= 1
+ self.state._logger.debug("Decremented index to %s" % self.state._index)
+ assert self.state._index >= 0
+ else:
+ raise ValueError("Tried to pop the top state")
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/update/sync.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/sync.py
new file mode 100644
index 0000000000..b1dcf2d6c2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/sync.py
@@ -0,0 +1,150 @@
+# mypy: allow-untyped-defs
+
+import fnmatch
+import os
+import re
+import shutil
+import sys
+import uuid
+
+from .base import Step, StepRunner
+from .tree import Commit
+
+here = os.path.abspath(os.path.dirname(__file__))
+
+
+def copy_wpt_tree(tree, dest, excludes=None, includes=None):
+ """Copy the working copy of a Tree to a destination directory.
+
+ :param tree: The Tree to copy.
+ :param dest: The destination directory"""
+ if os.path.exists(dest):
+ assert os.path.isdir(dest)
+
+ shutil.rmtree(dest)
+
+ os.mkdir(dest)
+
+ if excludes is None:
+ excludes = []
+
+ excludes = [re.compile(fnmatch.translate(item)) for item in excludes]
+
+ if includes is None:
+ includes = []
+
+ includes = [re.compile(fnmatch.translate(item)) for item in includes]
+
+ for tree_path in tree.paths():
+ if (any(item.match(tree_path) for item in excludes) and
+ not any(item.match(tree_path) for item in includes)):
+ continue
+
+ source_path = os.path.join(tree.root, tree_path)
+ dest_path = os.path.join(dest, tree_path)
+
+ dest_dir = os.path.dirname(dest_path)
+ if not os.path.isdir(source_path):
+ if not os.path.exists(dest_dir):
+ os.makedirs(dest_dir)
+ shutil.copy2(source_path, dest_path)
+
+ for source, destination in [("testharness_runner.html", ""),
+ ("testdriver-vendor.js", "resources/")]:
+ source_path = os.path.join(here, os.pardir, source)
+ dest_path = os.path.join(dest, destination, os.path.basename(source))
+ shutil.copy2(source_path, dest_path)
+
+
+class UpdateCheckout(Step):
+ """Pull changes from upstream into the local sync tree."""
+
+ provides = ["local_branch"]
+
+ def create(self, state):
+ sync_tree = state.sync_tree
+ state.local_branch = uuid.uuid4().hex
+ sync_tree.update(state.sync["remote_url"],
+ state.sync["branch"],
+ state.local_branch)
+ sync_path = os.path.abspath(sync_tree.root)
+ if sync_path not in sys.path:
+ from .update import setup_paths
+ setup_paths(sync_path)
+
+ def restore(self, state):
+ assert os.path.abspath(state.sync_tree.root) in sys.path
+ Step.restore(self, state)
+
+
+class GetSyncTargetCommit(Step):
+ """Find the commit that we will sync to."""
+
+ provides = ["sync_commit"]
+
+ def create(self, state):
+ if state.target_rev is None:
+ #Use upstream branch HEAD as the base commit
+ state.sync_commit = state.sync_tree.get_remote_sha1(state.sync["remote_url"],
+ state.sync["branch"])
+ else:
+ state.sync_commit = Commit(state.sync_tree, state.rev)
+
+ state.sync_tree.checkout(state.sync_commit.sha1, state.local_branch, force=True)
+ self.logger.debug("New base commit is %s" % state.sync_commit.sha1)
+
+
+class UpdateManifest(Step):
+ """Update the manifest to match the tests in the sync tree checkout"""
+
+ provides = ["manifest_path", "test_manifest"]
+
+ def create(self, state):
+ from manifest import manifest # type: ignore
+ state.manifest_path = os.path.join(state.metadata_path, "MANIFEST.json")
+ state.test_manifest = manifest.load_and_update(state.sync["path"],
+ state.manifest_path,
+ "/",
+ write_manifest=True)
+
+
+class CopyWorkTree(Step):
+ """Copy the sync tree over to the destination in the local tree"""
+
+ def create(self, state):
+ copy_wpt_tree(state.sync_tree,
+ state.tests_path,
+ excludes=state.path_excludes,
+ includes=state.path_includes)
+
+
+class CreateSyncPatch(Step):
+ """Add the updated test files to a commit/patch in the local tree."""
+
+ def create(self, state):
+ if not state.patch:
+ return
+
+ local_tree = state.local_tree
+ sync_tree = state.sync_tree
+
+ local_tree.create_patch("web-platform-tests_update_%s" % sync_tree.rev,
+ f"Update {state.suite_name} to revision {sync_tree.rev}")
+ test_prefix = os.path.relpath(state.tests_path, local_tree.root)
+ local_tree.add_new(test_prefix)
+ local_tree.add_ignored(sync_tree, test_prefix)
+ updated = local_tree.update_patch(include=[state.tests_path,
+ state.metadata_path])
+ local_tree.commit_patch()
+
+ if not updated:
+ self.logger.info("Nothing to sync")
+
+
+class SyncFromUpstreamRunner(StepRunner):
+ """(Sub)Runner for doing an upstream sync"""
+ steps = [UpdateCheckout,
+ GetSyncTargetCommit,
+ UpdateManifest,
+ CopyWorkTree,
+ CreateSyncPatch]
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/update/tree.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/tree.py
new file mode 100644
index 0000000000..8c1b6a5f1b
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/tree.py
@@ -0,0 +1,407 @@
+# mypy: allow-untyped-defs
+
+import os
+import re
+import subprocess
+import tempfile
+
+from .. import vcs
+from ..vcs import git, hg
+
+
+def get_unique_name(existing, initial):
+ """Get a name either equal to initial or of the form initial_N, for some
+ integer N, that is not in the set existing.
+
+
+ :param existing: Set of names that must not be chosen.
+ :param initial: Name, or name prefix, to use"""
+ if initial not in existing:
+ return initial
+ for i in range(len(existing) + 1):
+ test = f"{initial}_{i + 1}"
+ if test not in existing:
+ return test
+ assert False
+
+class NoVCSTree:
+ name = "non-vcs"
+
+ def __init__(self, root=None):
+ if root is None:
+ root = os.path.abspath(os.curdir)
+ self.root = root
+
+ @classmethod
+ def is_type(cls, path=None):
+ return True
+
+ @property
+ def is_clean(self):
+ return True
+
+ def add_new(self, prefix=None):
+ pass
+
+ def add_ignored(self, sync_tree, prefix):
+ pass
+
+ def create_patch(self, patch_name, message):
+ pass
+
+ def update_patch(self, include=None):
+ pass
+
+ def commit_patch(self):
+ pass
+
+
+class HgTree:
+ name = "mercurial"
+
+ def __init__(self, root=None):
+ if root is None:
+ root = hg("root").strip()
+ self.root = root
+ self.hg = vcs.bind_to_repo(hg, self.root)
+
+ def __getstate__(self):
+ rv = self.__dict__.copy()
+ del rv['hg']
+ return rv
+
+ def __setstate__(self, dict):
+ self.__dict__.update(dict)
+ self.hg = vcs.bind_to_repo(vcs.hg, self.root)
+
+ @classmethod
+ def is_type(cls, path=None):
+ kwargs = {"log_error": False}
+ if path is not None:
+ kwargs["repo"] = path
+ try:
+ hg("root", **kwargs)
+ except Exception:
+ return False
+ return True
+
+ @property
+ def is_clean(self):
+ return self.hg("status").strip() == b""
+
+ def add_new(self, prefix=None):
+ if prefix is not None:
+ args = ("-I", prefix)
+ else:
+ args = ()
+ self.hg("add", *args)
+
+ def add_ignored(self, sync_tree, prefix):
+ pass
+
+ def create_patch(self, patch_name, message):
+ try:
+ self.hg("qinit", log_error=False)
+ except subprocess.CalledProcessError:
+ pass
+
+ patch_names = [item.strip() for item in self.hg("qseries").split(b"\n") if item.strip()]
+
+ suffix = 0
+ test_name = patch_name
+ while test_name in patch_names:
+ suffix += 1
+ test_name = "%s-%i" % (patch_name, suffix)
+
+ self.hg("qnew", test_name, "-X", self.root, "-m", message)
+
+ def update_patch(self, include=None):
+ if include is not None:
+ args = []
+ for item in include:
+ args.extend(["-I", item])
+ else:
+ args = ()
+
+ self.hg("qrefresh", *args)
+ return True
+
+ def commit_patch(self):
+ self.hg("qfinish")
+
+ def contains_commit(self, commit):
+ try:
+ self.hg("identify", "-r", commit.sha1)
+ return True
+ except subprocess.CalledProcessError:
+ return False
+
+
+class GitTree:
+ name = "git"
+
+ def __init__(self, root=None, log_error=True):
+ if root is None:
+ root = git("rev-parse", "--show-toplevel", log_error=log_error).strip().decode('utf-8')
+ self.root = root
+ self.git = vcs.bind_to_repo(git, self.root, log_error=log_error)
+ self.message = None
+ self.commit_cls = Commit
+
+ def __getstate__(self):
+ rv = self.__dict__.copy()
+ del rv['git']
+ return rv
+
+ def __setstate__(self, dict):
+ self.__dict__.update(dict)
+ self.git = vcs.bind_to_repo(vcs.git, self.root)
+
+ @classmethod
+ def is_type(cls, path=None):
+ kwargs = {"log_error": False}
+ if path is not None:
+ kwargs["repo"] = path
+ try:
+ git("rev-parse", "--show-toplevel", **kwargs)
+ except Exception:
+ return False
+ return True
+
+ @property
+ def rev(self):
+ """Current HEAD revision"""
+ if vcs.is_git_root(self.root):
+ return self.git("rev-parse", "HEAD").strip()
+ else:
+ return None
+
+ @property
+ def is_clean(self):
+ return self.git("status").strip() == b""
+
+ def add_new(self, prefix=None):
+ """Add files to the staging area.
+
+ :param prefix: None to include all files or a path prefix to
+ add all files under that path.
+ """
+ if prefix is None:
+ args = ["-a"]
+ else:
+ args = ["--no-ignore-removal", prefix]
+ self.git("add", *args)
+
+ def add_ignored(self, sync_tree, prefix):
+ """Add files to the staging area that are explicitly ignored by git.
+
+ :param prefix: None to include all files or a path prefix to
+ add all files under that path.
+ """
+ with tempfile.TemporaryFile() as f:
+ sync_tree.git("ls-tree", "-z", "-r", "--name-only", "HEAD", stdout=f)
+ f.seek(0)
+ ignored_files = sync_tree.git("check-ignore", "--no-index", "--stdin", "-z", stdin=f)
+ args = []
+ for entry in ignored_files.decode('utf-8').split('\0'):
+ args.append(os.path.join(prefix, entry))
+ if args:
+ self.git("add", "--force", *args)
+
+ def list_refs(self, ref_filter=None):
+ """Get a list of sha1, name tuples for references in a repository.
+
+ :param ref_filter: Pattern that reference name must match (from the end,
+ matching whole /-delimited segments only
+ """
+ args = []
+ if ref_filter is not None:
+ args.append(ref_filter)
+ data = self.git("show-ref", *args)
+ rv = []
+ for line in data.split(b"\n"):
+ if not line.strip():
+ continue
+ sha1, ref = line.split()
+ rv.append((sha1, ref))
+ return rv
+
+ def list_remote(self, remote, ref_filter=None):
+ """Return a list of (sha1, name) tupes for references in a remote.
+
+ :param remote: URL of the remote to list.
+ :param ref_filter: Pattern that the reference name must match.
+ """
+ args = []
+ if ref_filter is not None:
+ args.append(ref_filter)
+ data = self.git("ls-remote", remote, *args)
+ rv = []
+ for line in data.split(b"\n"):
+ if not line.strip():
+ continue
+ sha1, ref = line.split()
+ rv.append((sha1, ref))
+ return rv
+
+ def get_remote_sha1(self, remote, branch):
+ """Return the SHA1 of a particular branch in a remote.
+
+ :param remote: the remote URL
+ :param branch: the branch name"""
+ for sha1, ref in self.list_remote(remote, branch):
+ if ref.decode('utf-8') == "refs/heads/%s" % branch:
+ return self.commit_cls(self, sha1.decode('utf-8'))
+ assert False
+
+ def create_patch(self, patch_name, message):
+ # In git a patch is actually a commit
+ self.message = message
+
+ def update_patch(self, include=None):
+ """Commit the staged changes, or changes to listed files.
+
+ :param include: Either None, to commit staged changes, or a list
+ of filenames (which must already be in the repo)
+ to commit
+ """
+ if include is not None:
+ args = tuple(include)
+ else:
+ args = ()
+
+ if self.git("status", "-uno", "-z", *args).strip():
+ self.git("add", *args)
+ return True
+ return False
+
+ def commit_patch(self):
+ assert self.message is not None
+
+ if self.git("diff", "--name-only", "--staged", "-z").strip():
+ self.git("commit", "-m", self.message)
+ return True
+
+ return False
+
+ def init(self):
+ self.git("init")
+ assert vcs.is_git_root(self.root)
+
+ def checkout(self, rev, branch=None, force=False):
+ """Checkout a particular revision, optionally into a named branch.
+
+ :param rev: Revision identifier (e.g. SHA1) to checkout
+ :param branch: Branch name to use
+ :param force: Force-checkout
+ """
+ assert rev is not None
+
+ args = []
+ if branch:
+ branches = [ref[len("refs/heads/"):].decode('utf-8') for sha1, ref in self.list_refs()
+ if ref.startswith(b"refs/heads/")]
+ branch = get_unique_name(branches, branch)
+
+ args += ["-b", branch]
+
+ if force:
+ args.append("-f")
+ args.append(rev)
+ self.git("checkout", *args)
+
+ def update(self, remote, remote_branch, local_branch):
+ """Fetch from the remote and checkout into a local branch.
+
+ :param remote: URL to the remote repository
+ :param remote_branch: Branch on the remote repository to check out
+ :param local_branch: Local branch name to check out into
+ """
+ if not vcs.is_git_root(self.root):
+ self.init()
+ self.git("clean", "-xdf")
+ self.git("fetch", remote, f"{remote_branch}:{local_branch}")
+ self.checkout(local_branch)
+ self.git("submodule", "update", "--init", "--recursive")
+
+ def clean(self):
+ self.git("checkout", self.rev)
+ self.git("branch", "-D", self.local_branch)
+
+ def paths(self):
+ """List paths in the tree"""
+ repo_paths = [self.root] + [os.path.join(self.root, path)
+ for path in self.submodules()]
+
+ rv = []
+
+ for repo_path in repo_paths:
+ paths = vcs.git("ls-tree", "-r", "--name-only", "HEAD", repo=repo_path).split(b"\n")
+ rv.extend(os.path.relpath(os.path.join(repo_path, item.decode('utf-8')), self.root) for item in paths
+ if item.strip())
+ return rv
+
+ def submodules(self):
+ """List submodule directories"""
+ output = self.git("submodule", "status", "--recursive")
+ rv = []
+ for line in output.split(b"\n"):
+ line = line.strip()
+ if not line:
+ continue
+ parts = line.split(b" ")
+ rv.append(parts[1])
+ return rv
+
+ def contains_commit(self, commit):
+ try:
+ self.git("rev-parse", "--verify", commit.sha1)
+ return True
+ except subprocess.CalledProcessError:
+ return False
+
+
+class CommitMessage:
+ def __init__(self, text):
+ self.text = text
+ self._parse_message()
+
+ def __str__(self):
+ return self.text
+
+ def _parse_message(self):
+ lines = self.text.splitlines()
+ self.full_summary = lines[0]
+ self.body = "\n".join(lines[1:])
+
+
+class Commit:
+ msg_cls = CommitMessage
+
+ _sha1_re = re.compile("^[0-9a-f]{40}$")
+
+ def __init__(self, tree, sha1):
+ """Object representing a commit in a specific GitTree.
+
+ :param tree: GitTree to which this commit belongs.
+ :param sha1: Full sha1 string for the commit
+ """
+ assert self._sha1_re.match(sha1)
+
+ self.tree = tree
+ self.git = tree.git
+ self.sha1 = sha1
+ self.author, self.email, self.message = self._get_meta()
+
+ def __getstate__(self):
+ rv = self.__dict__.copy()
+ del rv['git']
+ return rv
+
+ def __setstate__(self, dict):
+ self.__dict__.update(dict)
+ self.git = self.tree.git
+
+ def _get_meta(self):
+ author, email, message = self.git("show", "-s", "--format=format:%an\n%ae\n%B", self.sha1).decode('utf-8').split("\n", 2)
+ return author, email, self.msg_cls(message)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/update/update.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/update.py
new file mode 100644
index 0000000000..1e9be41504
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/update/update.py
@@ -0,0 +1,191 @@
+# mypy: allow-untyped-defs
+
+import os
+import sys
+
+from .metadata import MetadataUpdateRunner
+from .sync import SyncFromUpstreamRunner
+from .tree import GitTree, HgTree, NoVCSTree
+
+from .base import Step, StepRunner, exit_clean, exit_unclean
+from .state import SavedState, UnsavedState
+
+def setup_paths(sync_path):
+ sys.path.insert(0, os.path.abspath(sync_path))
+ from tools import localpaths # noqa: F401
+
+class LoadConfig(Step):
+ """Step for loading configuration from the ini file and kwargs."""
+
+ provides = ["sync", "paths", "metadata_path", "tests_path"]
+
+ def create(self, state):
+ state.sync = {"remote_url": state.kwargs["remote_url"],
+ "branch": state.kwargs["branch"],
+ "path": state.kwargs["sync_path"]}
+
+ state.paths = state.kwargs["test_paths"]
+ state.tests_path = state.paths["/"]["tests_path"]
+ state.metadata_path = state.paths["/"]["metadata_path"]
+
+ assert os.path.isabs(state.tests_path)
+
+
+class LoadTrees(Step):
+ """Step for creating a Tree for the local copy and a GitTree for the
+ upstream sync."""
+
+ provides = ["local_tree", "sync_tree"]
+
+ def create(self, state):
+ if os.path.exists(state.sync["path"]):
+ sync_tree = GitTree(root=state.sync["path"])
+ else:
+ sync_tree = None
+
+ if GitTree.is_type():
+ local_tree = GitTree()
+ elif HgTree.is_type():
+ local_tree = HgTree()
+ else:
+ local_tree = NoVCSTree()
+
+ state.update({"local_tree": local_tree,
+ "sync_tree": sync_tree})
+
+
+class SyncFromUpstream(Step):
+ """Step that synchronises a local copy of the code with upstream."""
+
+ def create(self, state):
+ if not state.kwargs["sync"]:
+ return
+
+ if not state.sync_tree:
+ os.mkdir(state.sync["path"])
+ state.sync_tree = GitTree(root=state.sync["path"])
+
+ kwargs = state.kwargs
+ with state.push(["sync", "paths", "metadata_path", "tests_path", "local_tree",
+ "sync_tree"]):
+ state.target_rev = kwargs["rev"]
+ state.patch = kwargs["patch"]
+ state.suite_name = kwargs["suite_name"]
+ state.path_excludes = kwargs["exclude"]
+ state.path_includes = kwargs["include"]
+ runner = SyncFromUpstreamRunner(self.logger, state)
+ runner.run()
+
+
+class UpdateMetadata(Step):
+ """Update the expectation metadata from a set of run logs"""
+
+ def create(self, state):
+ if not state.kwargs["run_log"]:
+ return
+
+ kwargs = state.kwargs
+ with state.push(["local_tree", "sync_tree", "paths", "serve_root"]):
+ state.run_log = kwargs["run_log"]
+ state.disable_intermittent = kwargs["disable_intermittent"]
+ state.update_intermittent = kwargs["update_intermittent"]
+ state.remove_intermittent = kwargs["remove_intermittent"]
+ state.patch = kwargs["patch"]
+ state.suite_name = kwargs["suite_name"]
+ state.product = kwargs["product"]
+ state.config = kwargs["config"]
+ state.full_update = kwargs["full"]
+ state.extra_properties = kwargs["extra_property"]
+ runner = MetadataUpdateRunner(self.logger, state)
+ runner.run()
+
+
+class RemoveObsolete(Step):
+ """Remove metadata files that don't corespond to an existing test file"""
+
+ def create(self, state):
+ if not state.kwargs["remove_obsolete"]:
+ return
+
+ paths = state.kwargs["test_paths"]
+ state.tests_path = state.paths["/"]["tests_path"]
+ state.metadata_path = state.paths["/"]["metadata_path"]
+
+ for url_paths in paths.values():
+ tests_path = url_paths["tests_path"]
+ metadata_path = url_paths["metadata_path"]
+ for dirpath, dirnames, filenames in os.walk(metadata_path):
+ for filename in filenames:
+ if filename == "__dir__.ini":
+ continue
+ if filename.endswith(".ini"):
+ full_path = os.path.join(dirpath, filename)
+ rel_path = os.path.relpath(full_path, metadata_path)
+ test_path = os.path.join(tests_path, rel_path[:-4])
+ if not os.path.exists(test_path):
+ os.unlink(full_path)
+
+
+class UpdateRunner(StepRunner):
+ """Runner for doing an overall update."""
+ steps = [LoadConfig,
+ LoadTrees,
+ SyncFromUpstream,
+ RemoveObsolete,
+ UpdateMetadata]
+
+
+class WPTUpdate:
+ def __init__(self, logger, runner_cls=UpdateRunner, **kwargs):
+ """Object that controls the running of a whole wptupdate.
+
+ :param runner_cls: Runner subclass holding the overall list of
+ steps to run.
+ :param kwargs: Command line arguments
+ """
+ self.runner_cls = runner_cls
+ self.serve_root = kwargs["test_paths"]["/"]["tests_path"]
+
+ if not kwargs["sync"]:
+ setup_paths(self.serve_root)
+ else:
+ if os.path.exists(kwargs["sync_path"]):
+ # If the sync path doesn't exist we defer this until it does
+ setup_paths(kwargs["sync_path"])
+
+ if kwargs.get("store_state", False):
+ self.state = SavedState(logger)
+ else:
+ self.state = UnsavedState(logger)
+ self.kwargs = kwargs
+ self.logger = logger
+
+ def run(self, **kwargs):
+ if self.kwargs["abort"]:
+ self.abort()
+ return exit_clean
+
+ if not self.kwargs["continue"] and not self.state.is_empty():
+ self.logger.critical("Found existing state. Run with --continue to resume or --abort to clear state")
+ return exit_unclean
+
+ if self.kwargs["continue"]:
+ if self.state.is_empty():
+ self.logger.error("No sync in progress?")
+ return exit_clean
+
+ self.kwargs = self.state.kwargs
+ else:
+ self.state.kwargs = self.kwargs
+
+ self.state.serve_root = self.serve_root
+
+ update_runner = self.runner_cls(self.logger, self.state)
+ rv = update_runner.run()
+ if rv in (exit_clean, None):
+ self.state.clear()
+
+ return rv
+
+ def abort(self):
+ self.state.clear()
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/vcs.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/vcs.py
new file mode 100644
index 0000000000..790fdc9833
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/vcs.py
@@ -0,0 +1,67 @@
+# mypy: allow-untyped-defs
+
+import subprocess
+from functools import partial
+from typing import Callable
+
+from mozlog import get_default_logger
+
+from wptserve.utils import isomorphic_decode
+
+logger = None
+
+def vcs(bin_name: str) -> Callable[..., None]:
+ def inner(command, *args, **kwargs):
+ global logger
+
+ if logger is None:
+ logger = get_default_logger("vcs")
+
+ repo = kwargs.pop("repo", None)
+ log_error = kwargs.pop("log_error", True)
+ stdout = kwargs.pop("stdout", None)
+ stdin = kwargs.pop("stdin", None)
+ if kwargs:
+ raise TypeError(kwargs)
+
+ args = list(args)
+
+ proc_kwargs = {}
+ if repo is not None:
+ # Make sure `cwd` is str type to work in different sub-versions of Python 3.
+ # Before 3.8, bytes were not accepted on Windows for `cwd`.
+ proc_kwargs["cwd"] = isomorphic_decode(repo)
+ if stdout is not None:
+ proc_kwargs["stdout"] = stdout
+ if stdin is not None:
+ proc_kwargs["stdin"] = stdin
+
+ command_line = [bin_name, command] + args
+ logger.debug(" ".join(command_line))
+ try:
+ func = subprocess.check_output if not stdout else subprocess.check_call
+ return func(command_line, stderr=subprocess.STDOUT, **proc_kwargs)
+ except OSError as e:
+ if log_error:
+ logger.error(e)
+ raise
+ except subprocess.CalledProcessError as e:
+ if log_error:
+ logger.error(e.output)
+ raise
+ return inner
+
+git = vcs("git")
+hg = vcs("hg")
+
+
+def bind_to_repo(vcs_func, repo, log_error=True):
+ return partial(vcs_func, repo=repo, log_error=log_error)
+
+
+def is_git_root(path, log_error=True):
+ try:
+ rv = git("rev-parse", "--show-cdup", repo=path, log_error=log_error)
+ except subprocess.CalledProcessError:
+ return False
+ return rv == b"\n"
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptcommandline.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptcommandline.py
new file mode 100644
index 0000000000..89788fe411
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptcommandline.py
@@ -0,0 +1,777 @@
+# mypy: allow-untyped-defs
+
+import argparse
+import os
+import sys
+from collections import OrderedDict
+from distutils.spawn import find_executable
+from datetime import timedelta
+
+from . import config
+from . import wpttest
+from .formatters import chromium, wptreport, wptscreenshot
+
+def abs_path(path):
+ return os.path.abspath(os.path.expanduser(path))
+
+
+def url_or_path(path):
+ from urllib.parse import urlparse
+
+ parsed = urlparse(path)
+ if len(parsed.scheme) > 2:
+ return path
+ else:
+ return abs_path(path)
+
+
+def require_arg(kwargs, name, value_func=None):
+ if value_func is None:
+ value_func = lambda x: x is not None
+
+ if name not in kwargs or not value_func(kwargs[name]):
+ print("Missing required argument %s" % name, file=sys.stderr)
+ sys.exit(1)
+
+
+def create_parser(product_choices=None):
+ from mozlog import commandline
+
+ from . import products
+
+ if product_choices is None:
+ product_choices = products.product_list
+
+ parser = argparse.ArgumentParser(description="""Runner for web-platform-tests tests.""",
+ usage="""%(prog)s [OPTION]... [TEST]...
+
+TEST is either the full path to a test file to run, or the URL of a test excluding
+scheme host and port.""")
+ parser.add_argument("--manifest-update", action="store_true", default=None,
+ help="Regenerate the test manifest.")
+ parser.add_argument("--no-manifest-update", action="store_false", dest="manifest_update",
+ help="Prevent regeneration of the test manifest.")
+ parser.add_argument("--manifest-download", action="store_true", default=None,
+ help="Attempt to download a preexisting manifest when updating.")
+ parser.add_argument("--no-manifest-download", action="store_false", dest="manifest_download",
+ help="Prevent download of the test manifest.")
+
+ parser.add_argument("--timeout-multiplier", action="store", type=float, default=None,
+ help="Multiplier relative to standard test timeout to use")
+ parser.add_argument("--run-by-dir", type=int, nargs="?", default=False,
+ help="Split run into groups by directories. With a parameter,"
+ "limit the depth of splits e.g. --run-by-dir=1 to split by top-level"
+ "directory")
+ parser.add_argument("--processes", action="store", type=int, default=None,
+ help="Number of simultaneous processes to use")
+
+ parser.add_argument("--no-capture-stdio", action="store_true", default=False,
+ help="Don't capture stdio and write to logging")
+ parser.add_argument("--no-fail-on-unexpected", action="store_false",
+ default=True,
+ dest="fail_on_unexpected",
+ help="Exit with status code 0 when test expectations are violated")
+ parser.add_argument("--no-fail-on-unexpected-pass", action="store_false",
+ default=True,
+ dest="fail_on_unexpected_pass",
+ help="Exit with status code 0 when all unexpected results are PASS")
+ parser.add_argument("--no-restart-on-new-group", action="store_false",
+ default=True,
+ dest="restart_on_new_group",
+ help="Don't restart test runner when start a new test group")
+
+ mode_group = parser.add_argument_group("Mode")
+ mode_group.add_argument("--list-test-groups", action="store_true",
+ default=False,
+ help="List the top level directories containing tests that will run.")
+ mode_group.add_argument("--list-disabled", action="store_true",
+ default=False,
+ help="List the tests that are disabled on the current platform")
+ mode_group.add_argument("--list-tests", action="store_true",
+ default=False,
+ help="List all tests that will run")
+ stability_group = mode_group.add_mutually_exclusive_group()
+ stability_group.add_argument("--verify", action="store_true",
+ default=False,
+ help="Run a stability check on the selected tests")
+ stability_group.add_argument("--stability", action="store_true",
+ default=False,
+ help=argparse.SUPPRESS)
+ mode_group.add_argument("--verify-log-full", action="store_true",
+ default=False,
+ help="Output per-iteration test results when running verify")
+ mode_group.add_argument("--verify-repeat-loop", action="store",
+ default=10,
+ help="Number of iterations for a run that reloads each test without restart.",
+ type=int)
+ mode_group.add_argument("--verify-repeat-restart", action="store",
+ default=5,
+ help="Number of iterations, for a run that restarts the runner between each iteration",
+ type=int)
+ chaos_mode_group = mode_group.add_mutually_exclusive_group()
+ chaos_mode_group.add_argument("--verify-no-chaos-mode", action="store_false",
+ default=True,
+ dest="verify_chaos_mode",
+ help="Disable chaos mode when running on Firefox")
+ chaos_mode_group.add_argument("--verify-chaos-mode", action="store_true",
+ default=True,
+ dest="verify_chaos_mode",
+ help="Enable chaos mode when running on Firefox")
+ mode_group.add_argument("--verify-max-time", action="store",
+ default=None,
+ help="The maximum number of minutes for the job to run",
+ type=lambda x: timedelta(minutes=float(x)))
+ mode_group.add_argument("--repeat-max-time", action="store",
+ default=100,
+ help="The maximum number of minutes for the test suite to attempt repeat runs",
+ type=int)
+ output_results_group = mode_group.add_mutually_exclusive_group()
+ output_results_group.add_argument("--verify-no-output-results", action="store_false",
+ dest="verify_output_results",
+ default=True,
+ help="Prints individuals test results and messages")
+ output_results_group.add_argument("--verify-output-results", action="store_true",
+ dest="verify_output_results",
+ default=True,
+ help="Disable printing individuals test results and messages")
+
+ test_selection_group = parser.add_argument_group("Test Selection")
+ test_selection_group.add_argument("--test-types", action="store",
+ nargs="*", default=wpttest.enabled_tests,
+ choices=wpttest.enabled_tests,
+ help="Test types to run")
+ test_selection_group.add_argument("--include", action="append",
+ help="URL prefix to include")
+ test_selection_group.add_argument("--include-file", action="store",
+ help="A file listing URL prefix for tests")
+ test_selection_group.add_argument("--exclude", action="append",
+ help="URL prefix to exclude")
+ test_selection_group.add_argument("--include-manifest", type=abs_path,
+ help="Path to manifest listing tests to include")
+ test_selection_group.add_argument("--test-groups", dest="test_groups_file", type=abs_path,
+ help="Path to json file containing a mapping {group_name: [test_ids]}")
+ test_selection_group.add_argument("--skip-timeout", action="store_true",
+ help="Skip tests that are expected to time out")
+ test_selection_group.add_argument("--skip-implementation-status",
+ action="append",
+ choices=["not-implementing", "backlog", "implementing"],
+ help="Skip tests that have the given implementation status")
+ # TODO(bashi): Remove this when WebTransport over HTTP/3 server is enabled by default.
+ test_selection_group.add_argument("--enable-webtransport-h3",
+ action="store_true",
+ dest="enable_webtransport_h3",
+ default=None,
+ help="Enable tests that require WebTransport over HTTP/3 server (default: false)")
+ test_selection_group.add_argument("--no-enable-webtransport-h3", action="store_false", dest="enable_webtransport_h3",
+ help="Do not enable WebTransport tests on experimental channels")
+ test_selection_group.add_argument("--tag", action="append", dest="tags",
+ help="Labels applied to tests to include in the run. "
+ "Labels starting dir: are equivalent to top-level directories.")
+ test_selection_group.add_argument("--default-exclude", action="store_true",
+ default=False,
+ help="Only run the tests explicitly given in arguments. "
+ "No tests will run if the list is empty, and the "
+ "program will exit with status code 0.")
+
+ debugging_group = parser.add_argument_group("Debugging")
+ debugging_group.add_argument('--debugger', const="__default__", nargs="?",
+ help="run under a debugger, e.g. gdb or valgrind")
+ debugging_group.add_argument('--debugger-args', help="arguments to the debugger")
+ debugging_group.add_argument("--rerun", action="store", type=int, default=1,
+ help="Number of times to re run each test without restarts")
+ debugging_group.add_argument("--repeat", action="store", type=int, default=1,
+ help="Number of times to run the tests, restarting between each run")
+ debugging_group.add_argument("--repeat-until-unexpected", action="store_true", default=None,
+ help="Run tests in a loop until one returns an unexpected result")
+ debugging_group.add_argument('--retry-unexpected', type=int, default=0,
+ help=('Maximum number of times to retry unexpected tests. '
+ 'A test is retried until it gets one of the expected status, '
+ 'or until it exhausts the maximum number of retries.'))
+ debugging_group.add_argument('--pause-after-test', action="store_true", default=None,
+ help="Halt the test runner after each test (this happens by default if only a single test is run)")
+ debugging_group.add_argument('--no-pause-after-test', dest="pause_after_test", action="store_false",
+ help="Don't halt the test runner irrespective of the number of tests run")
+ debugging_group.add_argument('--debug-test', dest="debug_test", action="store_true",
+ help="Run tests with additional debugging features enabled")
+
+ debugging_group.add_argument('--pause-on-unexpected', action="store_true",
+ help="Halt the test runner when an unexpected result is encountered")
+ debugging_group.add_argument('--no-restart-on-unexpected', dest="restart_on_unexpected",
+ default=True, action="store_false",
+ help="Don't restart on an unexpected result")
+
+ debugging_group.add_argument("--symbols-path", action="store", type=url_or_path,
+ help="Path or url to symbols file used to analyse crash minidumps.")
+ debugging_group.add_argument("--stackwalk-binary", action="store", type=abs_path,
+ help="Path to stackwalker program used to analyse minidumps.")
+ debugging_group.add_argument("--pdb", action="store_true",
+ help="Drop into pdb on python exception")
+
+ android_group = parser.add_argument_group("Android specific arguments")
+ android_group.add_argument("--adb-binary", action="store",
+ help="Path to adb binary to use")
+ android_group.add_argument("--package-name", action="store",
+ help="Android package name to run tests against")
+ android_group.add_argument("--keep-app-data-directory", action="store_true",
+ help="Don't delete the app data directory")
+ android_group.add_argument("--device-serial", action="append", default=[],
+ help="Running Android instances to connect to, if not emulator-5554")
+
+ config_group = parser.add_argument_group("Configuration")
+ config_group.add_argument("--binary", action="store",
+ type=abs_path, help="Desktop binary to run tests against")
+ config_group.add_argument('--binary-arg',
+ default=[], action="append", dest="binary_args",
+ help="Extra argument for the binary")
+ config_group.add_argument("--webdriver-binary", action="store", metavar="BINARY",
+ type=abs_path, help="WebDriver server binary to use")
+ config_group.add_argument('--webdriver-arg',
+ default=[], action="append", dest="webdriver_args",
+ help="Extra argument for the WebDriver binary")
+ config_group.add_argument("--metadata", action="store", type=abs_path, dest="metadata_root",
+ help="Path to root directory containing test metadata"),
+ config_group.add_argument("--tests", action="store", type=abs_path, dest="tests_root",
+ help="Path to root directory containing test files"),
+ config_group.add_argument("--manifest", action="store", type=abs_path, dest="manifest_path",
+ help="Path to test manifest (default is ${metadata_root}/MANIFEST.json)")
+ config_group.add_argument("--run-info", action="store", type=abs_path,
+ help="Path to directory containing extra json files to add to run info")
+ config_group.add_argument("--product", action="store", choices=product_choices,
+ default=None, help="Browser against which to run tests")
+ config_group.add_argument("--browser-version", action="store",
+ default=None, help="Informative string detailing the browser "
+ "release version. This is included in the run_info data.")
+ config_group.add_argument("--browser-channel", action="store",
+ default=None, help="Informative string detailing the browser "
+ "release channel. This is included in the run_info data.")
+ config_group.add_argument("--config", action="store", type=abs_path, dest="config",
+ help="Path to config file")
+ config_group.add_argument("--install-fonts", action="store_true",
+ default=None,
+ help="Install additional system fonts on your system")
+ config_group.add_argument("--no-install-fonts", dest="install_fonts", action="store_false",
+ help="Do not install additional system fonts on your system")
+ config_group.add_argument("--font-dir", action="store", type=abs_path, dest="font_dir",
+ help="Path to local font installation directory", default=None)
+ config_group.add_argument("--inject-script", action="store", dest="inject_script", default=None,
+ help="Path to script file to inject, useful for testing polyfills.")
+ config_group.add_argument("--headless", action="store_true",
+ help="Run browser in headless mode", default=None)
+ config_group.add_argument("--no-headless", action="store_false", dest="headless",
+ help="Don't run browser in headless mode")
+ config_group.add_argument("--instrument-to-file", action="store",
+ help="Path to write instrumentation logs to")
+
+ build_type = parser.add_mutually_exclusive_group()
+ build_type.add_argument("--debug-build", dest="debug", action="store_true",
+ default=None,
+ help="Build is a debug build (overrides any mozinfo file)")
+ build_type.add_argument("--release-build", dest="debug", action="store_false",
+ default=None,
+ help="Build is a release (overrides any mozinfo file)")
+
+ chunking_group = parser.add_argument_group("Test Chunking")
+ chunking_group.add_argument("--total-chunks", action="store", type=int, default=1,
+ help="Total number of chunks to use")
+ chunking_group.add_argument("--this-chunk", action="store", type=int, default=1,
+ help="Chunk number to run")
+ chunking_group.add_argument("--chunk-type", action="store", choices=["none", "hash", "dir_hash"],
+ default=None, help="Chunking type to use")
+
+ ssl_group = parser.add_argument_group("SSL/TLS")
+ ssl_group.add_argument("--ssl-type", action="store", default=None,
+ choices=["openssl", "pregenerated", "none"],
+ help="Type of ssl support to enable (running without ssl may lead to spurious errors)")
+
+ ssl_group.add_argument("--openssl-binary", action="store",
+ help="Path to openssl binary", default="openssl")
+ ssl_group.add_argument("--certutil-binary", action="store",
+ help="Path to certutil binary for use with Firefox + ssl")
+
+ ssl_group.add_argument("--ca-cert-path", action="store", type=abs_path,
+ help="Path to ca certificate when using pregenerated ssl certificates")
+ ssl_group.add_argument("--host-key-path", action="store", type=abs_path,
+ help="Path to host private key when using pregenerated ssl certificates")
+ ssl_group.add_argument("--host-cert-path", action="store", type=abs_path,
+ help="Path to host certificate when using pregenerated ssl certificates")
+
+ gecko_group = parser.add_argument_group("Gecko-specific")
+ gecko_group.add_argument("--prefs-root", dest="prefs_root", action="store", type=abs_path,
+ help="Path to the folder containing browser prefs")
+ gecko_group.add_argument("--preload-browser", dest="preload_browser", action="store_true",
+ default=None, help="Preload a gecko instance for faster restarts")
+ gecko_group.add_argument("--no-preload-browser", dest="preload_browser", action="store_false",
+ default=None, help="Don't preload a gecko instance for faster restarts")
+ gecko_group.add_argument("--disable-e10s", dest="gecko_e10s", action="store_false", default=True,
+ help="Run tests without electrolysis preferences")
+ gecko_group.add_argument("--disable-fission", dest="disable_fission", action="store_true", default=False,
+ help="Disable fission in Gecko.")
+ gecko_group.add_argument("--stackfix-dir", dest="stackfix_dir", action="store",
+ help="Path to directory containing assertion stack fixing scripts")
+ gecko_group.add_argument("--specialpowers-path", action="store",
+ help="Path to specialPowers extension xpi file")
+ gecko_group.add_argument("--setpref", dest="extra_prefs", action='append',
+ default=[], metavar="PREF=VALUE",
+ help="Defines an extra user preference (overrides those in prefs_root)")
+ gecko_group.add_argument("--leak-check", dest="leak_check", action="store_true", default=None,
+ help="Enable leak checking (enabled by default for debug builds, "
+ "silently ignored for opt, mobile)")
+ gecko_group.add_argument("--no-leak-check", dest="leak_check", action="store_false", default=None,
+ help="Disable leak checking")
+ gecko_group.add_argument("--stylo-threads", action="store", type=int, default=1,
+ help="Number of parallel threads to use for stylo")
+ gecko_group.add_argument("--reftest-internal", dest="reftest_internal", action="store_true",
+ default=None, help="Enable reftest runner implemented inside Marionette")
+ gecko_group.add_argument("--reftest-external", dest="reftest_internal", action="store_false",
+ help="Disable reftest runner implemented inside Marionette")
+ gecko_group.add_argument("--reftest-screenshot", dest="reftest_screenshot", action="store",
+ choices=["always", "fail", "unexpected"], default=None,
+ help="With --reftest-internal, when to take a screenshot")
+ gecko_group.add_argument("--chaos", dest="chaos_mode_flags", action="store",
+ nargs="?", const=0xFFFFFFFF, type=lambda x: int(x, 16),
+ help="Enable chaos mode with the specified feature flag "
+ "(see http://searchfox.org/mozilla-central/source/mfbt/ChaosMode.h for "
+ "details). If no value is supplied, all features are activated")
+
+ servo_group = parser.add_argument_group("Servo-specific")
+ servo_group.add_argument("--user-stylesheet",
+ default=[], action="append", dest="user_stylesheets",
+ help="Inject a user CSS stylesheet into every test.")
+
+ chrome_group = parser.add_argument_group("Chrome-specific")
+ chrome_group.add_argument("--enable-mojojs", action="store_true", default=False,
+ help="Enable MojoJS for testing. Note that this flag is usally "
+ "enabled automatically by `wpt run`, if it succeeds in downloading "
+ "the right version of mojojs.zip or if --mojojs-path is specified.")
+ chrome_group.add_argument("--mojojs-path",
+ help="Path to mojojs gen/ directory. If it is not specified, `wpt run` "
+ "will download and extract mojojs.zip into _venv2/mojojs/gen.")
+ chrome_group.add_argument("--enable-swiftshader", action="store_true", default=False,
+ help="Enable SwiftShader for CPU-based 3D graphics. This can be used "
+ "in environments with no hardware GPU available.")
+ chrome_group.add_argument("--enable-experimental", action="store_true", dest="enable_experimental",
+ help="Enable --enable-experimental-web-platform-features flag", default=None)
+ chrome_group.add_argument("--no-enable-experimental", action="store_false", dest="enable_experimental",
+ help="Do not enable --enable-experimental-web-platform-features flag "
+ "on experimental channels")
+
+ sauce_group = parser.add_argument_group("Sauce Labs-specific")
+ sauce_group.add_argument("--sauce-browser", dest="sauce_browser",
+ help="Sauce Labs browser name")
+ sauce_group.add_argument("--sauce-platform", dest="sauce_platform",
+ help="Sauce Labs OS platform")
+ sauce_group.add_argument("--sauce-version", dest="sauce_version",
+ help="Sauce Labs browser version")
+ sauce_group.add_argument("--sauce-build", dest="sauce_build",
+ help="Sauce Labs build identifier")
+ sauce_group.add_argument("--sauce-tags", dest="sauce_tags", nargs="*",
+ help="Sauce Labs identifying tag", default=[])
+ sauce_group.add_argument("--sauce-tunnel-id", dest="sauce_tunnel_id",
+ help="Sauce Connect tunnel identifier")
+ sauce_group.add_argument("--sauce-user", dest="sauce_user",
+ help="Sauce Labs user name")
+ sauce_group.add_argument("--sauce-key", dest="sauce_key",
+ default=os.environ.get("SAUCE_ACCESS_KEY"),
+ help="Sauce Labs access key")
+ sauce_group.add_argument("--sauce-connect-binary",
+ dest="sauce_connect_binary",
+ help="Path to Sauce Connect binary")
+ sauce_group.add_argument("--sauce-init-timeout", action="store",
+ type=int, default=30,
+ help="Number of seconds to wait for Sauce "
+ "Connect tunnel to be available before "
+ "aborting")
+ sauce_group.add_argument("--sauce-connect-arg", action="append",
+ default=[], dest="sauce_connect_args",
+ help="Command-line argument to forward to the "
+ "Sauce Connect binary (repeatable)")
+
+ taskcluster_group = parser.add_argument_group("Taskcluster-specific")
+ taskcluster_group.add_argument("--github-checks-text-file",
+ type=str,
+ help="Path to GitHub checks output file")
+
+ webkit_group = parser.add_argument_group("WebKit-specific")
+ webkit_group.add_argument("--webkit-port", dest="webkit_port",
+ help="WebKit port")
+
+ safari_group = parser.add_argument_group("Safari-specific")
+ safari_group.add_argument("--kill-safari", dest="kill_safari", action="store_true", default=False,
+ help="Kill Safari when stopping the browser")
+
+ parser.add_argument("test_list", nargs="*",
+ help="List of URLs for tests to run, or paths including tests to run. "
+ "(equivalent to --include)")
+
+ def screenshot_api_wrapper(formatter, api):
+ formatter.api = api
+ return formatter
+
+ commandline.fmt_options["api"] = (screenshot_api_wrapper,
+ "Cache API (default: %s)" % wptscreenshot.DEFAULT_API,
+ {"wptscreenshot"}, "store")
+
+ commandline.log_formatters["chromium"] = (chromium.ChromiumFormatter, "Chromium Layout Tests format")
+ commandline.log_formatters["wptreport"] = (wptreport.WptreportFormatter, "wptreport format")
+ commandline.log_formatters["wptscreenshot"] = (wptscreenshot.WptscreenshotFormatter, "wpt.fyi screenshots")
+
+ commandline.add_logging_group(parser)
+ return parser
+
+
+def set_from_config(kwargs):
+ if kwargs["config"] is None:
+ config_path = config.path()
+ else:
+ config_path = kwargs["config"]
+
+ kwargs["config_path"] = config_path
+
+ kwargs["config"] = config.read(kwargs["config_path"])
+
+ keys = {"paths": [("prefs", "prefs_root", True),
+ ("run_info", "run_info", True)],
+ "web-platform-tests": [("remote_url", "remote_url", False),
+ ("branch", "branch", False),
+ ("sync_path", "sync_path", True)],
+ "SSL": [("openssl_binary", "openssl_binary", True),
+ ("certutil_binary", "certutil_binary", True),
+ ("ca_cert_path", "ca_cert_path", True),
+ ("host_cert_path", "host_cert_path", True),
+ ("host_key_path", "host_key_path", True)]}
+
+ for section, values in keys.items():
+ for config_value, kw_value, is_path in values:
+ if kw_value in kwargs and kwargs[kw_value] is None:
+ if not is_path:
+ new_value = kwargs["config"].get(section, config.ConfigDict({})).get(config_value)
+ else:
+ new_value = kwargs["config"].get(section, config.ConfigDict({})).get_path(config_value)
+ kwargs[kw_value] = new_value
+
+ kwargs["test_paths"] = get_test_paths(kwargs["config"])
+
+ if kwargs["tests_root"]:
+ if "/" not in kwargs["test_paths"]:
+ kwargs["test_paths"]["/"] = {}
+ kwargs["test_paths"]["/"]["tests_path"] = kwargs["tests_root"]
+
+ if kwargs["metadata_root"]:
+ if "/" not in kwargs["test_paths"]:
+ kwargs["test_paths"]["/"] = {}
+ kwargs["test_paths"]["/"]["metadata_path"] = kwargs["metadata_root"]
+
+ if kwargs.get("manifest_path"):
+ if "/" not in kwargs["test_paths"]:
+ kwargs["test_paths"]["/"] = {}
+ kwargs["test_paths"]["/"]["manifest_path"] = kwargs["manifest_path"]
+
+ kwargs["suite_name"] = kwargs["config"].get("web-platform-tests", {}).get("name", "web-platform-tests")
+
+
+ check_paths(kwargs)
+
+
+def get_test_paths(config):
+ # Set up test_paths
+ test_paths = OrderedDict()
+
+ for section in config.keys():
+ if section.startswith("manifest:"):
+ manifest_opts = config.get(section)
+ url_base = manifest_opts.get("url_base", "/")
+ test_paths[url_base] = {
+ "tests_path": manifest_opts.get_path("tests"),
+ "metadata_path": manifest_opts.get_path("metadata"),
+ }
+ if "manifest" in manifest_opts:
+ test_paths[url_base]["manifest_path"] = manifest_opts.get_path("manifest")
+
+ return test_paths
+
+
+def exe_path(name):
+ if name is None:
+ return
+
+ path = find_executable(name)
+ if path and os.access(path, os.X_OK):
+ return path
+ else:
+ return None
+
+
+def check_paths(kwargs):
+ for test_paths in kwargs["test_paths"].values():
+ if not ("tests_path" in test_paths and
+ "metadata_path" in test_paths):
+ print("Fatal: must specify both a test path and metadata path")
+ sys.exit(1)
+ if "manifest_path" not in test_paths:
+ test_paths["manifest_path"] = os.path.join(test_paths["metadata_path"],
+ "MANIFEST.json")
+ for key, path in test_paths.items():
+ name = key.split("_", 1)[0]
+
+ if name == "manifest":
+ # For the manifest we can create it later, so just check the path
+ # actually exists
+ path = os.path.dirname(path)
+
+ if not os.path.exists(path):
+ print(f"Fatal: {name} path {path} does not exist")
+ sys.exit(1)
+
+ if not os.path.isdir(path):
+ print(f"Fatal: {name} path {path} is not a directory")
+ sys.exit(1)
+
+
+def check_args(kwargs):
+ set_from_config(kwargs)
+
+ if kwargs["product"] is None:
+ kwargs["product"] = "firefox"
+
+ if kwargs["manifest_update"] is None:
+ kwargs["manifest_update"] = True
+
+ if "sauce" in kwargs["product"]:
+ kwargs["pause_after_test"] = False
+
+ if kwargs["test_list"]:
+ if kwargs["include"] is not None:
+ kwargs["include"].extend(kwargs["test_list"])
+ else:
+ kwargs["include"] = kwargs["test_list"]
+
+ if kwargs["run_info"] is None:
+ kwargs["run_info"] = kwargs["config_path"]
+
+ if kwargs["this_chunk"] > 1:
+ require_arg(kwargs, "total_chunks", lambda x: x >= kwargs["this_chunk"])
+
+ if kwargs["chunk_type"] is None:
+ if kwargs["total_chunks"] > 1:
+ kwargs["chunk_type"] = "dir_hash"
+ else:
+ kwargs["chunk_type"] = "none"
+
+ if kwargs["test_groups_file"] is not None:
+ if kwargs["run_by_dir"] is not False:
+ print("Can't pass --test-groups and --run-by-dir")
+ sys.exit(1)
+ if not os.path.exists(kwargs["test_groups_file"]):
+ print("--test-groups file %s not found" % kwargs["test_groups_file"])
+ sys.exit(1)
+
+ # When running on Android, the number of workers is decided by the number of
+ # emulators. Each worker will use one emulator to run the Android browser.
+ if kwargs["device_serial"]:
+ if kwargs["processes"] is None:
+ kwargs["processes"] = len(kwargs["device_serial"])
+ elif len(kwargs["device_serial"]) != kwargs["processes"]:
+ print("--processes does not match number of devices")
+ sys.exit(1)
+ elif len(set(kwargs["device_serial"])) != len(kwargs["device_serial"]):
+ print("Got duplicate --device-serial value")
+ sys.exit(1)
+
+ if kwargs["processes"] is None:
+ kwargs["processes"] = 1
+
+ if kwargs["debugger"] is not None:
+ import mozdebug
+ if kwargs["debugger"] == "__default__":
+ kwargs["debugger"] = mozdebug.get_default_debugger_name()
+ debug_info = mozdebug.get_debugger_info(kwargs["debugger"],
+ kwargs["debugger_args"])
+ if debug_info and debug_info.interactive:
+ if kwargs["processes"] != 1:
+ kwargs["processes"] = 1
+ kwargs["no_capture_stdio"] = True
+ kwargs["debug_info"] = debug_info
+ else:
+ kwargs["debug_info"] = None
+
+ if kwargs["binary"] is not None:
+ if not os.path.exists(kwargs["binary"]):
+ print("Binary path %s does not exist" % kwargs["binary"], file=sys.stderr)
+ sys.exit(1)
+
+ if kwargs["ssl_type"] is None:
+ if None not in (kwargs["ca_cert_path"], kwargs["host_cert_path"], kwargs["host_key_path"]):
+ kwargs["ssl_type"] = "pregenerated"
+ elif exe_path(kwargs["openssl_binary"]) is not None:
+ kwargs["ssl_type"] = "openssl"
+ else:
+ kwargs["ssl_type"] = "none"
+
+ if kwargs["ssl_type"] == "pregenerated":
+ require_arg(kwargs, "ca_cert_path", lambda x:os.path.exists(x))
+ require_arg(kwargs, "host_cert_path", lambda x:os.path.exists(x))
+ require_arg(kwargs, "host_key_path", lambda x:os.path.exists(x))
+
+ elif kwargs["ssl_type"] == "openssl":
+ path = exe_path(kwargs["openssl_binary"])
+ if path is None:
+ print("openssl-binary argument missing or not a valid executable", file=sys.stderr)
+ sys.exit(1)
+ kwargs["openssl_binary"] = path
+
+ if kwargs["ssl_type"] != "none" and kwargs["product"] == "firefox" and kwargs["certutil_binary"]:
+ path = exe_path(kwargs["certutil_binary"])
+ if path is None:
+ print("certutil-binary argument missing or not a valid executable", file=sys.stderr)
+ sys.exit(1)
+ kwargs["certutil_binary"] = path
+
+ if kwargs['extra_prefs']:
+ missing = any('=' not in prefarg for prefarg in kwargs['extra_prefs'])
+ if missing:
+ print("Preferences via --setpref must be in key=value format", file=sys.stderr)
+ sys.exit(1)
+ kwargs['extra_prefs'] = [tuple(prefarg.split('=', 1)) for prefarg in
+ kwargs['extra_prefs']]
+
+ if kwargs["reftest_internal"] is None:
+ kwargs["reftest_internal"] = True
+
+ if kwargs["reftest_screenshot"] is None:
+ kwargs["reftest_screenshot"] = "unexpected" if not kwargs["debug_test"] else "always"
+
+ if kwargs["preload_browser"] is None:
+ # Default to preloading a gecko instance if we're only running a single process
+ kwargs["preload_browser"] = kwargs["processes"] == 1
+
+ return kwargs
+
+
+def check_args_metadata_update(kwargs):
+ set_from_config(kwargs)
+
+ if kwargs["product"] is None:
+ kwargs["product"] = "firefox"
+
+ for item in kwargs["run_log"]:
+ if os.path.isdir(item):
+ print("Log file %s is a directory" % item, file=sys.stderr)
+ sys.exit(1)
+
+ if kwargs["properties_file"] is None and not kwargs["no_properties_file"]:
+ default_file = os.path.join(kwargs["test_paths"]["/"]["metadata_path"],
+ "update_properties.json")
+ if os.path.exists(default_file):
+ kwargs["properties_file"] = default_file
+
+ return kwargs
+
+
+def check_args_update(kwargs):
+ kwargs = check_args_metadata_update(kwargs)
+
+ if kwargs["patch"] is None:
+ kwargs["patch"] = kwargs["sync"]
+
+ return kwargs
+
+
+def create_parser_metadata_update(product_choices=None):
+ from mozlog.structured import commandline
+
+ from . import products
+
+ if product_choices is None:
+ product_choices = products.product_list
+
+ parser = argparse.ArgumentParser("web-platform-tests-update",
+ description="Update script for web-platform-tests tests.")
+ # This will be removed once all consumers are updated to the properties-file based system
+ parser.add_argument("--product", action="store", choices=product_choices,
+ default=None, help=argparse.SUPPRESS)
+ parser.add_argument("--config", action="store", type=abs_path, help="Path to config file")
+ parser.add_argument("--metadata", action="store", type=abs_path, dest="metadata_root",
+ help="Path to the folder containing test metadata"),
+ parser.add_argument("--tests", action="store", type=abs_path, dest="tests_root",
+ help="Path to web-platform-tests"),
+ parser.add_argument("--manifest", action="store", type=abs_path, dest="manifest_path",
+ help="Path to test manifest (default is ${metadata_root}/MANIFEST.json)")
+ parser.add_argument("--full", action="store_true", default=False,
+ help="For all tests that are updated, remove any existing conditions and missing subtests")
+ parser.add_argument("--disable-intermittent", nargs="?", action="store", const="unstable", default=None,
+ help=("Reason for disabling tests. When updating test results, disable tests that have "
+ "inconsistent results across many runs with the given reason."))
+ parser.add_argument("--update-intermittent", action="store_true", default=False,
+ help="Update test metadata with expected intermittent statuses.")
+ parser.add_argument("--remove-intermittent", action="store_true", default=False,
+ help="Remove obsolete intermittent statuses from expected statuses.")
+ parser.add_argument("--no-remove-obsolete", action="store_false", dest="remove_obsolete", default=True,
+ help="Don't remove metadata files that no longer correspond to a test file")
+ parser.add_argument("--properties-file",
+ help="""Path to a JSON file containing run_info properties to use in update. This must be of the form
+ {"properties": [<name>], "dependents": {<property name>: [<name>]}}""")
+ parser.add_argument("--no-properties-file", action="store_true",
+ help="Don't use the default properties file at "
+ "${metadata_root}/update_properties.json, even if it exists.")
+ parser.add_argument("--extra-property", action="append", default=[],
+ help="Extra property from run_info.json to use in metadata update.")
+ # TODO: Should make this required iff run=logfile
+ parser.add_argument("run_log", nargs="*", type=abs_path,
+ help="Log file from run of tests")
+ commandline.add_logging_group(parser)
+ return parser
+
+
+def create_parser_update(product_choices=None):
+ parser = create_parser_metadata_update(product_choices)
+ parser.add_argument("--sync-path", action="store", type=abs_path,
+ help="Path to store git checkout of web-platform-tests during update"),
+ parser.add_argument("--remote_url", action="store",
+ help="URL of web-platfrom-tests repository to sync against"),
+ parser.add_argument("--branch", action="store", type=abs_path,
+ help="Remote branch to sync against")
+ parser.add_argument("--rev", action="store", help="Revision to sync to")
+ parser.add_argument("--patch", action="store_true", dest="patch", default=None,
+ help="Create a VCS commit containing the changes.")
+ parser.add_argument("--no-patch", action="store_false", dest="patch",
+ help="Don't create a VCS commit containing the changes.")
+ parser.add_argument("--sync", dest="sync", action="store_true", default=False,
+ help="Sync the tests with the latest from upstream (implies --patch)")
+ parser.add_argument("--no-store-state", action="store_false", dest="store_state",
+ help="Store state so that steps can be resumed after failure")
+ parser.add_argument("--continue", action="store_true",
+ help="Continue a previously started run of the update script")
+ parser.add_argument("--abort", action="store_true",
+ help="Clear state from a previous incomplete run of the update script")
+ parser.add_argument("--exclude", action="store", nargs="*",
+ help="List of glob-style paths to exclude when syncing tests")
+ parser.add_argument("--include", action="store", nargs="*",
+ help="List of glob-style paths to include which would otherwise be excluded when syncing tests")
+ return parser
+
+
+def create_parser_reduce(product_choices=None):
+ parser = create_parser(product_choices)
+ parser.add_argument("target", action="store", help="Test id that is unstable")
+ return parser
+
+
+def parse_args():
+ parser = create_parser()
+ rv = vars(parser.parse_args())
+ check_args(rv)
+ return rv
+
+
+def parse_args_update():
+ parser = create_parser_update()
+ rv = vars(parser.parse_args())
+ check_args_update(rv)
+ return rv
+
+
+def parse_args_reduce():
+ parser = create_parser_reduce()
+ rv = vars(parser.parse_args())
+ check_args(rv)
+ return rv
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptlogging.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptlogging.py
new file mode 100644
index 0000000000..06b34dabdb
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptlogging.py
@@ -0,0 +1,109 @@
+# mypy: allow-untyped-defs
+
+import logging
+from threading import Thread
+
+from mozlog import commandline, stdadapter, set_default_logger
+from mozlog.structuredlog import StructuredLogger, log_levels
+
+
+def setup(args, defaults, formatter_defaults=None):
+ logger = args.pop('log', None)
+ if logger:
+ set_default_logger(logger)
+ StructuredLogger._logger_states["web-platform-tests"] = logger._state
+ else:
+ logger = commandline.setup_logging("web-platform-tests", args, defaults,
+ formatter_defaults=formatter_defaults)
+ setup_stdlib_logger()
+
+ for name in list(args.keys()):
+ if name.startswith("log_"):
+ args.pop(name)
+
+ return logger
+
+
+def setup_stdlib_logger():
+ logging.root.handlers = []
+ logging.root = stdadapter.std_logging_adapter(logging.root)
+
+
+class LogLevelRewriter:
+ """Filter that replaces log messages at specified levels with messages
+ at a different level.
+
+ This can be used to e.g. downgrade log messages from ERROR to WARNING
+ in some component where ERRORs are not critical.
+
+ :param inner: Handler to use for messages that pass this filter
+ :param from_levels: List of levels which should be affected
+ :param to_level: Log level to set for the affected messages
+ """
+ def __init__(self, inner, from_levels, to_level):
+ self.inner = inner
+ self.from_levels = [item.upper() for item in from_levels]
+ self.to_level = to_level.upper()
+
+ def __call__(self, data):
+ if data["action"] == "log" and data["level"].upper() in self.from_levels:
+ data = data.copy()
+ data["level"] = self.to_level
+ return self.inner(data)
+
+
+class LoggedAboveLevelHandler:
+ """Filter that records whether any log message above a certain level has been
+ seen.
+
+ :param min_level: Minimum level to record as a str (e.g., "CRITICAL")
+
+ """
+ def __init__(self, min_level):
+ self.min_level = log_levels[min_level.upper()]
+ self.has_log = False
+
+ def __call__(self, data):
+ if (data["action"] == "log" and
+ not self.has_log and
+ log_levels[data["level"]] <= self.min_level):
+ self.has_log = True
+
+
+class QueueHandler(logging.Handler):
+ def __init__(self, queue, level=logging.NOTSET):
+ self.queue = queue
+ logging.Handler.__init__(self, level=level)
+
+ def createLock(self):
+ # The queue provides its own locking
+ self.lock = None
+
+ def emit(self, record):
+ msg = self.format(record)
+ data = {"action": "log",
+ "level": record.levelname,
+ "thread": record.threadName,
+ "pid": record.process,
+ "source": self.name,
+ "message": msg}
+ self.queue.put(data)
+
+
+class LogQueueThread(Thread):
+ """Thread for handling log messages from a queue"""
+ def __init__(self, queue, logger):
+ self.queue = queue
+ self.logger = logger
+ super().__init__(name="Thread-Log")
+
+ def run(self):
+ while True:
+ try:
+ data = self.queue.get()
+ except (EOFError, OSError):
+ break
+ if data is None:
+ # A None message is used to shut down the logging thread
+ break
+ self.logger.log_raw(data)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/__init__.py
new file mode 100644
index 0000000000..e354d5ff4f
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/__init__.py
@@ -0,0 +1,5 @@
+# flake8: noqa (not ideal, but nicer than adding noqa: F401 to every line!)
+from .serializer import serialize
+from .parser import parse
+from .backends.static import compile as compile_static
+from .backends.conditional import compile as compile_condition
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/__init__.py
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/base.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/base.py
new file mode 100644
index 0000000000..c1ec206b75
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/base.py
@@ -0,0 +1,221 @@
+# mypy: allow-untyped-defs
+
+import abc
+
+from ..node import NodeVisitor
+from ..parser import parse
+
+
+class Compiler(NodeVisitor):
+ __metaclass__ = abc.ABCMeta
+
+ def compile(self, tree, data_cls_getter=None, **kwargs):
+ self._kwargs = kwargs
+ return self._compile(tree, data_cls_getter, **kwargs)
+
+ def _compile(self, tree, data_cls_getter=None, **kwargs):
+ """Compile a raw AST into a form where conditional expressions
+ are represented by ConditionalValue objects that can be evaluated
+ at runtime.
+
+ tree - The root node of the wptmanifest AST to compile
+
+ data_cls_getter - A function taking two parameters; the previous
+ output node and the current ast node and returning
+ the class of the output node to use for the current
+ ast node
+ """
+ if data_cls_getter is None:
+ self.data_cls_getter = lambda x, y: ManifestItem
+ else:
+ self.data_cls_getter = data_cls_getter
+
+ self.tree = tree
+ self.output_node = self._initial_output_node(tree, **kwargs)
+ self.visit(tree)
+ if hasattr(self.output_node, "set_defaults"):
+ self.output_node.set_defaults()
+ assert self.output_node is not None
+ return self.output_node
+
+ def _initial_output_node(self, node, **kwargs):
+ return self.data_cls_getter(None, None)(node, **kwargs)
+
+ def visit_DataNode(self, node):
+ if node != self.tree:
+ output_parent = self.output_node
+ self.output_node = self.data_cls_getter(self.output_node, node)(node, **self._kwargs)
+ else:
+ output_parent = None
+
+ assert self.output_node is not None
+
+ for child in node.children:
+ self.visit(child)
+
+ if output_parent is not None:
+ # Append to the parent *after* processing all the node data
+ output_parent.append(self.output_node)
+ self.output_node = self.output_node.parent
+
+ assert self.output_node is not None
+
+ @abc.abstractmethod
+ def visit_KeyValueNode(self, node):
+ pass
+
+ def visit_ListNode(self, node):
+ return [self.visit(child) for child in node.children]
+
+ def visit_ValueNode(self, node):
+ return node.data
+
+ def visit_AtomNode(self, node):
+ return node.data
+
+ @abc.abstractmethod
+ def visit_ConditionalNode(self, node):
+ pass
+
+ def visit_StringNode(self, node):
+ indexes = [self.visit(child) for child in node.children]
+
+ def value(x):
+ rv = node.data
+ for index in indexes:
+ rv = rv[index(x)]
+ return rv
+ return value
+
+ def visit_NumberNode(self, node):
+ if "." in node.data:
+ return float(node.data)
+ else:
+ return int(node.data)
+
+ def visit_VariableNode(self, node):
+ indexes = [self.visit(child) for child in node.children]
+
+ def value(x):
+ data = x[node.data]
+ for index in indexes:
+ data = data[index(x)]
+ return data
+ return value
+
+ def visit_IndexNode(self, node):
+ assert len(node.children) == 1
+ return self.visit(node.children[0])
+
+ @abc.abstractmethod
+ def visit_UnaryExpressionNode(self, node):
+ pass
+
+ @abc.abstractmethod
+ def visit_BinaryExpressionNode(self, node):
+ pass
+
+ @abc.abstractmethod
+ def visit_UnaryOperatorNode(self, node):
+ pass
+
+ @abc.abstractmethod
+ def visit_BinaryOperatorNode(self, node):
+ pass
+
+
+class ManifestItem:
+ def __init__(self, node, **kwargs):
+ self.parent = None
+ self.node = node
+ self.children = []
+ self._data = {}
+
+ def __repr__(self):
+ return f"<{self.__class__} {self.node.data}>"
+
+ def __str__(self):
+ rv = [repr(self)]
+ for item in self.children:
+ rv.extend(" %s" % line for line in str(item).split("\n"))
+ return "\n".join(rv)
+
+ def set_defaults(self):
+ pass
+
+ @property
+ def is_empty(self):
+ if self._data:
+ return False
+ return all(child.is_empty for child in self.children)
+
+ @property
+ def root(self):
+ node = self
+ while node.parent is not None:
+ node = node.parent
+ return node
+
+ @property
+ def name(self):
+ return self.node.data
+
+ def get(self, key):
+ for node in [self, self.root]:
+ if key in node._data:
+ return node._data[key]
+ raise KeyError
+
+ def set(self, name, value):
+ self._data[name] = value
+
+ def remove(self):
+ if self.parent:
+ self.parent.children.remove(self)
+ self.parent = None
+
+ def iterchildren(self, name=None):
+ for item in self.children:
+ if item.name == name or name is None:
+ yield item
+
+ def has_key(self, key):
+ for node in [self, self.root]:
+ if key in node._data:
+ return True
+ return False
+
+ def _flatten(self):
+ rv = {}
+ for node in [self, self.root]:
+ for name, value in node._data.items():
+ if name not in rv:
+ rv[name] = value
+ return rv
+
+ def iteritems(self):
+ yield from self._flatten().items()
+
+ def iterkeys(self):
+ yield from self._flatten().keys()
+
+ def itervalues(self):
+ yield from self._flatten().values()
+
+ def append(self, child):
+ child.parent = self
+ self.children.append(child)
+ return child
+
+
+def compile_ast(compiler, ast, data_cls_getter=None, **kwargs):
+ return compiler().compile(ast,
+ data_cls_getter=data_cls_getter,
+ **kwargs)
+
+
+def compile(compiler, stream, data_cls_getter=None, **kwargs):
+ return compile_ast(compiler,
+ parse(stream),
+ data_cls_getter=data_cls_getter,
+ **kwargs)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/conditional.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/conditional.py
new file mode 100644
index 0000000000..7d4f257f1a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/conditional.py
@@ -0,0 +1,402 @@
+# mypy: allow-untyped-defs
+
+import operator
+
+from ..node import NodeVisitor, DataNode, ConditionalNode, KeyValueNode, ListNode, ValueNode, BinaryExpressionNode, VariableNode
+from ..parser import parse
+
+
+class ConditionalValue:
+ def __init__(self, node, condition_func):
+ self.node = node
+ assert callable(condition_func)
+ self.condition_func = condition_func
+ if isinstance(node, ConditionalNode):
+ assert len(node.children) == 2
+ self.condition_node = self.node.children[0]
+ assert isinstance(node.children[1], (ValueNode, ListNode))
+ self.value_node = self.node.children[1]
+ else:
+ assert isinstance(node, (ValueNode, ListNode))
+ self.condition_node = None
+ self.value_node = self.node
+
+ @property
+ def value(self):
+ if isinstance(self.value_node, ValueNode):
+ return self.value_node.data
+ else:
+ return [item.data for item in self.value_node.children]
+
+ @value.setter
+ def value(self, value):
+ if isinstance(self.value_node, ValueNode):
+ self.value_node.data = value
+ else:
+ assert(isinstance(self.value_node, ListNode))
+ while self.value_node.children:
+ self.value_node.children[0].remove()
+ assert len(self.value_node.children) == 0
+ for list_value in value:
+ self.value_node.append(ValueNode(list_value))
+
+ def __call__(self, run_info):
+ return self.condition_func(run_info)
+
+ def value_as(self, type_func):
+ """Get value and convert to a given type.
+
+ This is unfortunate, but we don't currently have a good way to specify that
+ specific properties should have their data returned as specific types"""
+ value = self.value
+ if type_func is not None:
+ value = type_func(value)
+ return value
+
+ def remove(self):
+ if len(self.node.parent.children) == 1:
+ self.node.parent.remove()
+ self.node.remove()
+
+ @property
+ def variables(self):
+ rv = set()
+ if self.condition_node is None:
+ return rv
+ stack = [self.condition_node]
+ while stack:
+ node = stack.pop()
+ if isinstance(node, VariableNode):
+ rv.add(node.data)
+ for child in reversed(node.children):
+ stack.append(child)
+ return rv
+
+
+class Compiler(NodeVisitor):
+ def compile(self, tree, data_cls_getter=None, **kwargs):
+ """Compile a raw AST into a form where conditional expressions
+ are represented by ConditionalValue objects that can be evaluated
+ at runtime.
+
+ tree - The root node of the wptmanifest AST to compile
+
+ data_cls_getter - A function taking two parameters; the previous
+ output node and the current ast node and returning
+ the class of the output node to use for the current
+ ast node
+ """
+ if data_cls_getter is None:
+ self.data_cls_getter = lambda x, y: ManifestItem
+ else:
+ self.data_cls_getter = data_cls_getter
+
+ self.tree = tree
+ self.output_node = self._initial_output_node(tree, **kwargs)
+ self.visit(tree)
+ if hasattr(self.output_node, "set_defaults"):
+ self.output_node.set_defaults()
+ assert self.output_node is not None
+ return self.output_node
+
+ def compile_condition(self, condition):
+ """Compile a ConditionalNode into a ConditionalValue.
+
+ condition: A ConditionalNode"""
+ data_node = DataNode()
+ key_value_node = KeyValueNode()
+ key_value_node.append(condition.copy())
+ data_node.append(key_value_node)
+ manifest_item = self.compile(data_node)
+ return manifest_item._data[None][0]
+
+ def _initial_output_node(self, node, **kwargs):
+ return self.data_cls_getter(None, None)(node, **kwargs)
+
+ def visit_DataNode(self, node):
+ if node != self.tree:
+ output_parent = self.output_node
+ self.output_node = self.data_cls_getter(self.output_node, node)(node)
+ else:
+ output_parent = None
+
+ assert self.output_node is not None
+
+ for child in node.children:
+ self.visit(child)
+
+ if output_parent is not None:
+ # Append to the parent *after* processing all the node data
+ output_parent.append(self.output_node)
+ self.output_node = self.output_node.parent
+
+ assert self.output_node is not None
+
+ def visit_KeyValueNode(self, node):
+ key_values = []
+ for child in node.children:
+ condition, value = self.visit(child)
+ key_values.append(ConditionalValue(child, condition))
+
+ self.output_node._add_key_value(node, key_values)
+
+ def visit_ListNode(self, node):
+ return (lambda x:True, [self.visit(child) for child in node.children])
+
+ def visit_ValueNode(self, node):
+ return (lambda x: True, node.data)
+
+ def visit_AtomNode(self, node):
+ return (lambda x: True, node.data)
+
+ def visit_ConditionalNode(self, node):
+ return self.visit(node.children[0]), self.visit(node.children[1])
+
+ def visit_StringNode(self, node):
+ indexes = [self.visit(child) for child in node.children]
+
+ def value(x):
+ rv = node.data
+ for index in indexes:
+ rv = rv[index(x)]
+ return rv
+ return value
+
+ def visit_NumberNode(self, node):
+ if "." in node.data:
+ return lambda x: float(node.data)
+ else:
+ return lambda x: int(node.data)
+
+ def visit_VariableNode(self, node):
+ indexes = [self.visit(child) for child in node.children]
+
+ def value(x):
+ data = x[node.data]
+ for index in indexes:
+ data = data[index(x)]
+ return data
+ return value
+
+ def visit_IndexNode(self, node):
+ assert len(node.children) == 1
+ return self.visit(node.children[0])
+
+ def visit_UnaryExpressionNode(self, node):
+ assert len(node.children) == 2
+ operator = self.visit(node.children[0])
+ operand = self.visit(node.children[1])
+
+ return lambda x: operator(operand(x))
+
+ def visit_BinaryExpressionNode(self, node):
+ assert len(node.children) == 3
+ operator = self.visit(node.children[0])
+ operand_0 = self.visit(node.children[1])
+ operand_1 = self.visit(node.children[2])
+
+ assert operand_0 is not None
+ assert operand_1 is not None
+
+ return lambda x: operator(operand_0(x), operand_1(x))
+
+ def visit_UnaryOperatorNode(self, node):
+ return {"not": operator.not_}[node.data]
+
+ def visit_BinaryOperatorNode(self, node):
+ assert isinstance(node.parent, BinaryExpressionNode)
+ return {"and": operator.and_,
+ "or": operator.or_,
+ "==": operator.eq,
+ "!=": operator.ne}[node.data]
+
+
+class ManifestItem:
+ def __init__(self, node=None, **kwargs):
+ self.node = node
+ self.parent = None
+ self.children = []
+ self._data = {}
+
+ def __repr__(self):
+ return "<conditional.ManifestItem %s>" % (self.node.data)
+
+ def __str__(self):
+ rv = [repr(self)]
+ for item in self.children:
+ rv.extend(" %s" % line for line in str(item).split("\n"))
+ return "\n".join(rv)
+
+ def __contains__(self, key):
+ return key in self._data
+
+ def __iter__(self):
+ yield self
+ for child in self.children:
+ yield from child
+
+ @property
+ def is_empty(self):
+ if self._data:
+ return False
+ return all(child.is_empty for child in self.children)
+
+ @property
+ def root(self):
+ node = self
+ while node.parent is not None:
+ node = node.parent
+ return node
+
+ @property
+ def name(self):
+ return self.node.data
+
+ def has_key(self, key):
+ for node in [self, self.root]:
+ if key in node._data:
+ return True
+ return False
+
+ def get(self, key, run_info=None):
+ if run_info is None:
+ run_info = {}
+
+ for node in [self, self.root]:
+ if key in node._data:
+ for cond_value in node._data[key]:
+ try:
+ matches = cond_value(run_info)
+ except KeyError:
+ matches = False
+ if matches:
+ return cond_value.value
+ raise KeyError
+
+ def set(self, key, value, condition=None):
+ # First try to update the existing value
+ if key in self._data:
+ cond_values = self._data[key]
+ for cond_value in cond_values:
+ if cond_value.condition_node == condition:
+ cond_value.value = value
+ return
+ # If there isn't a conditional match reuse the existing KeyValueNode as the
+ # parent
+ node = None
+ for child in self.node.children:
+ if child.data == key:
+ node = child
+ break
+ assert node is not None
+
+ else:
+ node = KeyValueNode(key)
+ self.node.append(node)
+
+ if isinstance(value, list):
+ value_node = ListNode()
+ for item in value:
+ value_node.append(ValueNode(str(item)))
+ else:
+ value_node = ValueNode(str(value))
+ if condition is not None:
+ if not isinstance(condition, ConditionalNode):
+ conditional_node = ConditionalNode()
+ conditional_node.append(condition)
+ conditional_node.append(value_node)
+ else:
+ conditional_node = condition
+ node.append(conditional_node)
+ cond_value = Compiler().compile_condition(conditional_node)
+ else:
+ node.append(value_node)
+ cond_value = ConditionalValue(value_node, lambda x: True)
+
+ # Update the cache of child values. This is pretty annoying and maybe
+ # it should just work directly on the tree
+ if key not in self._data:
+ self._data[key] = []
+ if self._data[key] and self._data[key][-1].condition_node is None:
+ self._data[key].insert(len(self._data[key]) - 1, cond_value)
+ else:
+ self._data[key].append(cond_value)
+
+ def clear(self, key):
+ """Clear all the expected data for this node"""
+ if key in self._data:
+ for child in self.node.children:
+ if (isinstance(child, KeyValueNode) and
+ child.data == key):
+ child.remove()
+ del self._data[key]
+ break
+
+ def get_conditions(self, property_name):
+ if property_name in self._data:
+ return self._data[property_name]
+ return []
+
+ def _add_key_value(self, node, values):
+ """Called during construction to set a key-value node"""
+ self._data[node.data] = values
+
+ def append(self, child):
+ self.children.append(child)
+ child.parent = self
+ if child.node.parent != self.node:
+ self.node.append(child.node)
+ return child
+
+ def remove(self):
+ if self.parent:
+ self.parent._remove_child(self)
+
+ def _remove_child(self, child):
+ self.children.remove(child)
+ child.parent = None
+ child.node.remove()
+
+ def iterchildren(self, name=None):
+ for item in self.children:
+ if item.name == name or name is None:
+ yield item
+
+ def _flatten(self):
+ rv = {}
+ for node in [self, self.root]:
+ for name, value in node._data.items():
+ if name not in rv:
+ rv[name] = value
+ return rv
+
+ def iteritems(self):
+ yield from self._flatten().items()
+
+ def iterkeys(self):
+ yield from self._flatten().keys()
+
+ def iter_properties(self):
+ for item in self._data:
+ yield item, self._data[item]
+
+ def remove_value(self, key, value):
+ if key not in self._data:
+ return
+ try:
+ self._data[key].remove(value)
+ except ValueError:
+ return
+ if not self._data[key]:
+ del self._data[key]
+ value.remove()
+
+
+def compile_ast(ast, data_cls_getter=None, **kwargs):
+ return Compiler().compile(ast, data_cls_getter=data_cls_getter, **kwargs)
+
+
+def compile(stream, data_cls_getter=None, **kwargs):
+ return compile_ast(parse(stream),
+ data_cls_getter=data_cls_getter,
+ **kwargs)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/static.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/static.py
new file mode 100644
index 0000000000..5bec942e0b
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/backends/static.py
@@ -0,0 +1,102 @@
+# mypy: allow-untyped-defs
+
+import operator
+
+from . import base
+from ..parser import parse
+
+
+class Compiler(base.Compiler):
+ """Compiler backend that evaluates conditional expressions
+ to give static output"""
+
+ def compile(self, tree, expr_data, data_cls_getter=None, **kwargs):
+ """Compile a raw AST into a form with conditional expressions
+ evaluated.
+
+ tree - The root node of the wptmanifest AST to compile
+
+ expr_data - A dictionary of key / value pairs to use when
+ evaluating conditional expressions
+
+ data_cls_getter - A function taking two parameters; the previous
+ output node and the current ast node and returning
+ the class of the output node to use for the current
+ ast node
+ """
+
+ self._kwargs = kwargs
+ self.expr_data = expr_data
+
+ return self._compile(tree, data_cls_getter, **kwargs)
+
+ def visit_KeyValueNode(self, node):
+ key_name = node.data
+ key_value = None
+ for child in node.children:
+ value = self.visit(child)
+ if value is not None:
+ key_value = value
+ break
+ if key_value is not None:
+ self.output_node.set(key_name, key_value)
+
+ def visit_ConditionalNode(self, node):
+ assert len(node.children) == 2
+ if self.visit(node.children[0]):
+ return self.visit(node.children[1])
+
+ def visit_StringNode(self, node):
+ value = node.data
+ for child in node.children:
+ value = self.visit(child)(value)
+ return value
+
+ def visit_VariableNode(self, node):
+ value = self.expr_data[node.data]
+ for child in node.children:
+ value = self.visit(child)(value)
+ return value
+
+ def visit_IndexNode(self, node):
+ assert len(node.children) == 1
+ index = self.visit(node.children[0])
+ return lambda x: x[index]
+
+ def visit_UnaryExpressionNode(self, node):
+ assert len(node.children) == 2
+ operator = self.visit(node.children[0])
+ operand = self.visit(node.children[1])
+
+ return operator(operand)
+
+ def visit_BinaryExpressionNode(self, node):
+ assert len(node.children) == 3
+ operator = self.visit(node.children[0])
+ operand_0 = self.visit(node.children[1])
+ operand_1 = self.visit(node.children[2])
+
+ return operator(operand_0, operand_1)
+
+ def visit_UnaryOperatorNode(self, node):
+ return {"not": operator.not_}[node.data]
+
+ def visit_BinaryOperatorNode(self, node):
+ return {"and": operator.and_,
+ "or": operator.or_,
+ "==": operator.eq,
+ "!=": operator.ne}[node.data]
+
+
+def compile_ast(ast, expr_data, data_cls_getter=None, **kwargs):
+ return Compiler().compile(ast,
+ expr_data,
+ data_cls_getter=data_cls_getter,
+ **kwargs)
+
+
+def compile(stream, expr_data, data_cls_getter=None, **kwargs):
+ return compile_ast(parse(stream),
+ expr_data,
+ data_cls_getter=data_cls_getter,
+ **kwargs)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/node.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/node.py
new file mode 100644
index 0000000000..437de54f5b
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/node.py
@@ -0,0 +1,173 @@
+# mypy: allow-untyped-defs
+
+class NodeVisitor:
+ def visit(self, node):
+ # This is ugly as hell, but we don't have multimethods and
+ # they aren't trivial to fake without access to the class
+ # object from the class body
+ func = getattr(self, "visit_%s" % (node.__class__.__name__))
+ return func(node)
+
+
+class Node:
+ def __init__(self, data=None, comments=None):
+ self.data = data
+ self.parent = None
+ self.children = []
+ self.comments = comments or []
+
+ def append(self, other):
+ other.parent = self
+ self.children.append(other)
+
+ def remove(self):
+ self.parent.children.remove(self)
+
+ def __repr__(self):
+ return f"<{self.__class__.__name__} {self.data}>"
+
+ def __str__(self):
+ rv = [repr(self)]
+ for item in self.children:
+ rv.extend(" %s" % line for line in str(item).split("\n"))
+ return "\n".join(rv)
+
+ def __eq__(self, other):
+ if not (self.__class__ == other.__class__ and
+ self.data == other.data and
+ len(self.children) == len(other.children)):
+ return False
+ for child, other_child in zip(self.children, other.children):
+ if not child == other_child:
+ return False
+ return True
+
+ def copy(self):
+ new = self.__class__(self.data, self.comments)
+ for item in self.children:
+ new.append(item.copy())
+ return new
+
+
+class DataNode(Node):
+ def append(self, other):
+ # Append that retains the invariant that child data nodes
+ # come after child nodes of other types
+ other.parent = self
+ if isinstance(other, DataNode):
+ self.children.append(other)
+ else:
+ index = len(self.children)
+ while index > 0 and isinstance(self.children[index - 1], DataNode):
+ index -= 1
+ for i in range(index):
+ if other.data == self.children[i].data:
+ raise ValueError("Duplicate key %s" % self.children[i].data)
+ self.children.insert(index, other)
+
+
+class KeyValueNode(Node):
+ def append(self, other):
+ # Append that retains the invariant that conditional nodes
+ # come before unconditional nodes
+ other.parent = self
+ if not isinstance(other, (ListNode, ValueNode, ConditionalNode)):
+ raise TypeError
+ if isinstance(other, (ListNode, ValueNode)):
+ if self.children:
+ assert not isinstance(self.children[-1], (ListNode, ValueNode))
+ self.children.append(other)
+ else:
+ if self.children and isinstance(self.children[-1], ValueNode):
+ self.children.insert(len(self.children) - 1, other)
+ else:
+ self.children.append(other)
+
+
+class ListNode(Node):
+ def append(self, other):
+ other.parent = self
+ self.children.append(other)
+
+
+class ValueNode(Node):
+ def append(self, other):
+ raise TypeError
+
+
+class AtomNode(ValueNode):
+ pass
+
+
+class ConditionalNode(Node):
+ def append(self, other):
+ if not len(self.children):
+ if not isinstance(other, (BinaryExpressionNode, UnaryExpressionNode, VariableNode)):
+ raise TypeError
+ else:
+ if len(self.children) > 1:
+ raise ValueError
+ if not isinstance(other, (ListNode, ValueNode)):
+ raise TypeError
+ other.parent = self
+ self.children.append(other)
+
+
+class UnaryExpressionNode(Node):
+ def __init__(self, operator, operand):
+ Node.__init__(self)
+ self.append(operator)
+ self.append(operand)
+
+ def append(self, other):
+ Node.append(self, other)
+ assert len(self.children) <= 2
+
+ def copy(self):
+ new = self.__class__(self.children[0].copy(),
+ self.children[1].copy())
+ return new
+
+
+class BinaryExpressionNode(Node):
+ def __init__(self, operator, operand_0, operand_1):
+ Node.__init__(self)
+ self.append(operator)
+ self.append(operand_0)
+ self.append(operand_1)
+
+ def append(self, other):
+ Node.append(self, other)
+ assert len(self.children) <= 3
+
+ def copy(self):
+ new = self.__class__(self.children[0].copy(),
+ self.children[1].copy(),
+ self.children[2].copy())
+ return new
+
+
+class UnaryOperatorNode(Node):
+ def append(self, other):
+ raise TypeError
+
+
+class BinaryOperatorNode(Node):
+ def append(self, other):
+ raise TypeError
+
+
+class IndexNode(Node):
+ pass
+
+
+class VariableNode(Node):
+ pass
+
+
+class StringNode(Node):
+ pass
+
+
+class NumberNode(ValueNode):
+ pass
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/parser.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/parser.py
new file mode 100644
index 0000000000..c778895ed2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/parser.py
@@ -0,0 +1,873 @@
+# mypy: allow-untyped-defs
+
+#default_value:foo
+#include: other.manifest
+#
+#[test_name.js]
+# expected: ERROR
+#
+# [subtest 1]
+# expected:
+# os == win: FAIL #This is a comment
+# PASS
+#
+
+
+from io import BytesIO
+
+from .node import (Node, AtomNode, BinaryExpressionNode, BinaryOperatorNode,
+ ConditionalNode, DataNode, IndexNode, KeyValueNode, ListNode,
+ NumberNode, StringNode, UnaryExpressionNode,
+ UnaryOperatorNode, ValueNode, VariableNode)
+
+
+class ParseError(Exception):
+ def __init__(self, filename, line, detail):
+ self.line = line
+ self.filename = filename
+ self.detail = detail
+ self.message = f"{self.detail}: {self.filename} line {self.line}"
+ Exception.__init__(self, self.message)
+
+eol = object
+group_start = object
+group_end = object
+digits = "0123456789"
+open_parens = "[("
+close_parens = "])"
+parens = open_parens + close_parens
+operator_chars = "=!"
+
+unary_operators = ["not"]
+binary_operators = ["==", "!=", "and", "or"]
+
+operators = ["==", "!=", "not", "and", "or"]
+
+atoms = {"True": True,
+ "False": False,
+ "Reset": object()}
+
+def decode(s):
+ assert isinstance(s, str)
+ return s
+
+
+def precedence(operator_node):
+ return len(operators) - operators.index(operator_node.data)
+
+
+class TokenTypes:
+ def __init__(self) -> None:
+ for type in [
+ "group_start",
+ "group_end",
+ "paren",
+ "list_start",
+ "list_end",
+ "separator",
+ "ident",
+ "string",
+ "number",
+ "atom",
+ # Without an end-of-line token type, we need two different comment
+ # token types to distinguish between:
+ # [heading1] # Comment attached to heading 1
+ # [heading2]
+ #
+ # and
+ # [heading1]
+ # # Comment attached to heading 2
+ # [heading2]
+ "comment",
+ "inline_comment",
+ "eof",
+ ]:
+ setattr(self, type, type)
+
+token_types = TokenTypes()
+
+
+class Tokenizer:
+ def __init__(self):
+ self.reset()
+
+ def reset(self):
+ self.indent_levels = [0]
+ self.state = self.line_start_state
+ self.next_state = self.data_line_state
+ self.line_number = 0
+ self.filename = ""
+
+ def tokenize(self, stream):
+ self.reset()
+ assert not isinstance(stream, str)
+ if isinstance(stream, bytes):
+ stream = BytesIO(stream)
+ if not hasattr(stream, "name"):
+ self.filename = ""
+ else:
+ self.filename = stream.name
+
+ self.next_line_state = self.line_start_state
+ for i, line in enumerate(stream):
+ assert isinstance(line, bytes)
+ self.state = self.next_line_state
+ assert self.state is not None
+ states = []
+ self.next_line_state = None
+ self.line_number = i + 1
+ self.index = 0
+ self.line = line.decode('utf-8').rstrip()
+ assert isinstance(self.line, str)
+ while self.state != self.eol_state:
+ states.append(self.state)
+ tokens = self.state()
+ if tokens:
+ yield from tokens
+ self.state()
+ while True:
+ yield (token_types.eof, None)
+
+ def char(self):
+ if self.index == len(self.line):
+ return eol
+ return self.line[self.index]
+
+ def consume(self):
+ if self.index < len(self.line):
+ self.index += 1
+
+ def peek(self, length):
+ return self.line[self.index:self.index + length]
+
+ def skip_whitespace(self):
+ while self.char() == " ":
+ self.consume()
+
+ def eol_state(self):
+ if self.next_line_state is None:
+ self.next_line_state = self.line_start_state
+
+ def line_start_state(self):
+ self.skip_whitespace()
+ if self.char() == eol:
+ self.state = self.eol_state
+ return
+ if self.char() == "#":
+ self.state = self.comment_state
+ return
+ if self.index > self.indent_levels[-1]:
+ self.indent_levels.append(self.index)
+ yield (token_types.group_start, None)
+ else:
+ if self.index < self.indent_levels[-1]:
+ while self.index < self.indent_levels[-1]:
+ self.indent_levels.pop()
+ yield (token_types.group_end, None)
+ # This is terrible; if we were parsing an expression
+ # then the next_state will be expr_or_value but when we deindent
+ # it must always be a heading or key next so we go back to data_line_state
+ self.next_state = self.data_line_state
+ if self.index != self.indent_levels[-1]:
+ raise ParseError(self.filename, self.line_number, "Unexpected indent")
+
+ self.state = self.next_state
+
+ def data_line_state(self):
+ if self.char() == "[":
+ yield (token_types.paren, self.char())
+ self.consume()
+ self.state = self.heading_state
+ else:
+ self.state = self.key_state
+
+ def heading_state(self):
+ rv = ""
+ while True:
+ c = self.char()
+ if c == "\\":
+ rv += self.consume_escape()
+ elif c == "]":
+ break
+ elif c == eol:
+ raise ParseError(self.filename, self.line_number, "EOL in heading")
+ else:
+ rv += c
+ self.consume()
+
+ yield (token_types.string, decode(rv))
+ yield (token_types.paren, "]")
+ self.consume()
+ self.state = self.line_end_state
+ self.next_state = self.data_line_state
+
+ def key_state(self):
+ rv = ""
+ while True:
+ c = self.char()
+ if c == " ":
+ self.skip_whitespace()
+ if self.char() != ":":
+ raise ParseError(self.filename, self.line_number, "Space in key name")
+ break
+ elif c == ":":
+ break
+ elif c == eol:
+ raise ParseError(self.filename, self.line_number, "EOL in key name (missing ':'?)")
+ elif c == "\\":
+ rv += self.consume_escape()
+ else:
+ rv += c
+ self.consume()
+ yield (token_types.string, decode(rv))
+ yield (token_types.separator, ":")
+ self.consume()
+ self.state = self.after_key_state
+
+ def after_key_state(self):
+ self.skip_whitespace()
+ c = self.char()
+ if c in {"#", eol}:
+ self.next_state = self.expr_or_value_state
+ self.state = self.line_end_state
+ elif c == "[":
+ self.state = self.list_start_state
+ else:
+ self.state = self.value_state
+
+ def after_expr_state(self):
+ self.skip_whitespace()
+ c = self.char()
+ if c in {"#", eol}:
+ self.next_state = self.after_expr_state
+ self.state = self.line_end_state
+ elif c == "[":
+ self.state = self.list_start_state
+ else:
+ self.state = self.value_state
+
+ def list_start_state(self):
+ yield (token_types.list_start, "[")
+ self.consume()
+ self.state = self.list_value_start_state
+
+ def list_value_start_state(self):
+ self.skip_whitespace()
+ if self.char() == "]":
+ self.state = self.list_end_state
+ elif self.char() in ("'", '"'):
+ quote_char = self.char()
+ self.consume()
+ yield (token_types.string, self.consume_string(quote_char))
+ self.skip_whitespace()
+ if self.char() == "]":
+ self.state = self.list_end_state
+ elif self.char() != ",":
+ raise ParseError(self.filename, self.line_number, "Junk after quoted string")
+ self.consume()
+ elif self.char() in {"#", eol}:
+ self.state = self.line_end_state
+ self.next_line_state = self.list_value_start_state
+ elif self.char() == ",":
+ raise ParseError(self.filename, self.line_number, "List item started with separator")
+ elif self.char() == "@":
+ self.state = self.list_value_atom_state
+ else:
+ self.state = self.list_value_state
+
+ def list_value_state(self):
+ rv = ""
+ spaces = 0
+ while True:
+ c = self.char()
+ if c == "\\":
+ escape = self.consume_escape()
+ rv += escape
+ elif c == eol:
+ raise ParseError(self.filename, self.line_number, "EOL in list value")
+ elif c == "#":
+ raise ParseError(self.filename, self.line_number, "EOL in list value (comment)")
+ elif c == ",":
+ self.state = self.list_value_start_state
+ self.consume()
+ break
+ elif c == " ":
+ spaces += 1
+ self.consume()
+ elif c == "]":
+ self.state = self.list_end_state
+ self.consume()
+ break
+ else:
+ rv += " " * spaces
+ spaces = 0
+ rv += c
+ self.consume()
+
+ if rv:
+ yield (token_types.string, decode(rv))
+
+ def list_value_atom_state(self):
+ self.consume()
+ for _, value in self.list_value_state():
+ yield token_types.atom, value
+
+ def list_end_state(self):
+ self.consume()
+ yield (token_types.list_end, "]")
+ self.state = self.line_end_state
+
+ def value_state(self):
+ self.skip_whitespace()
+ c = self.char()
+ if c in ("'", '"'):
+ quote_char = self.char()
+ self.consume()
+ yield (token_types.string, self.consume_string(quote_char))
+ self.state = self.line_end_state
+ elif c == "@":
+ self.consume()
+ for _, value in self.value_inner_state():
+ yield token_types.atom, value
+ elif c == "[":
+ self.state = self.list_start_state
+ else:
+ self.state = self.value_inner_state
+
+ def value_inner_state(self):
+ rv = ""
+ spaces = 0
+ while True:
+ c = self.char()
+ if c == "\\":
+ rv += self.consume_escape()
+ elif c in {"#", eol}:
+ self.state = self.line_end_state
+ break
+ elif c == " ":
+ # prevent whitespace before comments from being included in the value
+ spaces += 1
+ self.consume()
+ else:
+ rv += " " * spaces
+ spaces = 0
+ rv += c
+ self.consume()
+ rv = decode(rv)
+ if rv.startswith("if "):
+ # Hack to avoid a problem where people write
+ # disabled: if foo
+ # and expect that to disable conditionally
+ raise ParseError(self.filename, self.line_number, "Strings starting 'if ' must be quoted "
+ "(expressions must start on a newline and be indented)")
+ yield (token_types.string, rv)
+
+ def _consume_comment(self):
+ assert self.char() == "#"
+ self.consume()
+ comment = ''
+ while self.char() is not eol:
+ comment += self.char()
+ self.consume()
+ return comment
+
+ def comment_state(self):
+ yield (token_types.comment, self._consume_comment())
+ self.state = self.eol_state
+
+ def inline_comment_state(self):
+ yield (token_types.inline_comment, self._consume_comment())
+ self.state = self.eol_state
+
+ def line_end_state(self):
+ self.skip_whitespace()
+ c = self.char()
+ if c == "#":
+ self.state = self.inline_comment_state
+ elif c == eol:
+ self.state = self.eol_state
+ else:
+ raise ParseError(self.filename, self.line_number, "Junk before EOL %s" % c)
+
+ def consume_string(self, quote_char):
+ rv = ""
+ while True:
+ c = self.char()
+ if c == "\\":
+ rv += self.consume_escape()
+ elif c == quote_char:
+ self.consume()
+ break
+ elif c == eol:
+ raise ParseError(self.filename, self.line_number, "EOL in quoted string")
+ else:
+ rv += c
+ self.consume()
+
+ return decode(rv)
+
+ def expr_or_value_state(self):
+ if self.peek(3) == "if ":
+ self.state = self.expr_state
+ else:
+ self.state = self.value_state
+
+ def expr_state(self):
+ self.skip_whitespace()
+ c = self.char()
+ if c == eol:
+ raise ParseError(self.filename, self.line_number, "EOL in expression")
+ elif c in "'\"":
+ self.consume()
+ yield (token_types.string, self.consume_string(c))
+ elif c == "#":
+ raise ParseError(self.filename, self.line_number, "Comment before end of expression")
+ elif c == ":":
+ yield (token_types.separator, c)
+ self.consume()
+ self.state = self.after_expr_state
+ elif c in parens:
+ self.consume()
+ yield (token_types.paren, c)
+ elif c in ("!", "="):
+ self.state = self.operator_state
+ elif c in digits:
+ self.state = self.digit_state
+ else:
+ self.state = self.ident_state
+
+ def operator_state(self):
+ # Only symbolic operators
+ index_0 = self.index
+ while True:
+ c = self.char()
+ if c == eol:
+ break
+ elif c in operator_chars:
+ self.consume()
+ else:
+ self.state = self.expr_state
+ break
+ yield (token_types.ident, self.line[index_0:self.index])
+
+ def digit_state(self):
+ index_0 = self.index
+ seen_dot = False
+ while True:
+ c = self.char()
+ if c == eol:
+ break
+ elif c in digits:
+ self.consume()
+ elif c == ".":
+ if seen_dot:
+ raise ParseError(self.filename, self.line_number, "Invalid number")
+ self.consume()
+ seen_dot = True
+ elif c in parens:
+ break
+ elif c in operator_chars:
+ break
+ elif c == " ":
+ break
+ elif c == ":":
+ break
+ else:
+ raise ParseError(self.filename, self.line_number, "Invalid character in number")
+
+ self.state = self.expr_state
+ yield (token_types.number, self.line[index_0:self.index])
+
+ def ident_state(self):
+ index_0 = self.index
+ while True:
+ c = self.char()
+ if c == eol:
+ break
+ elif c == ".":
+ break
+ elif c in parens:
+ break
+ elif c in operator_chars:
+ break
+ elif c == " ":
+ break
+ elif c == ":":
+ break
+ else:
+ self.consume()
+ self.state = self.expr_state
+ yield (token_types.ident, self.line[index_0:self.index])
+
+ def consume_escape(self):
+ assert self.char() == "\\"
+ self.consume()
+ c = self.char()
+ self.consume()
+ if c == "x":
+ return self.decode_escape(2)
+ elif c == "u":
+ return self.decode_escape(4)
+ elif c == "U":
+ return self.decode_escape(6)
+ elif c in ["a", "b", "f", "n", "r", "t", "v"]:
+ return eval(r"'\%s'" % c)
+ elif c is eol:
+ raise ParseError(self.filename, self.line_number, "EOL in escape")
+ else:
+ return c
+
+ def decode_escape(self, length):
+ value = 0
+ for i in range(length):
+ c = self.char()
+ value *= 16
+ value += self.escape_value(c)
+ self.consume()
+
+ return chr(value)
+
+ def escape_value(self, c):
+ if '0' <= c <= '9':
+ return ord(c) - ord('0')
+ elif 'a' <= c <= 'f':
+ return ord(c) - ord('a') + 10
+ elif 'A' <= c <= 'F':
+ return ord(c) - ord('A') + 10
+ else:
+ raise ParseError(self.filename, self.line_number, "Invalid character escape")
+
+
+class Parser:
+ def __init__(self):
+ self.reset()
+
+ def reset(self):
+ self.token = None
+ self.unary_operators = "!"
+ self.binary_operators = frozenset(["&&", "||", "=="])
+ self.tokenizer = Tokenizer()
+ self.token_generator = None
+ self.tree = Treebuilder(DataNode(None))
+ self.expr_builder = None
+ self.expr_builders = []
+ self.comments = []
+
+ def parse(self, input):
+ try:
+ self.reset()
+ self.token_generator = self.tokenizer.tokenize(input)
+ self.consume()
+ self.manifest()
+ return self.tree.node
+ except Exception as e:
+ if not isinstance(e, ParseError):
+ raise ParseError(self.tokenizer.filename,
+ self.tokenizer.line_number,
+ str(e))
+ raise
+
+ def consume(self):
+ self.token = next(self.token_generator)
+
+ def expect(self, type, value=None):
+ if self.token[0] != type:
+ raise ParseError(self.tokenizer.filename, self.tokenizer.line_number,
+ f"Token '{self.token[0]}' doesn't equal expected type '{type}'")
+ if value is not None:
+ if self.token[1] != value:
+ raise ParseError(self.tokenizer.filename, self.tokenizer.line_number,
+ f"Token '{self.token[1]}' doesn't equal expected value '{value}'")
+
+ self.consume()
+
+ def maybe_consume_inline_comment(self):
+ if self.token[0] == token_types.inline_comment:
+ self.comments.append(self.token)
+ self.consume()
+
+ def consume_comments(self):
+ while self.token[0] == token_types.comment:
+ self.comments.append(self.token)
+ self.consume()
+
+ def flush_comments(self, target_node=None):
+ """Transfer comments from the parser's buffer to a parse tree node.
+
+ Use the tree's current node if no target node is explicitly specified.
+
+ The comments are buffered because the target node they should belong to
+ may not exist yet. For example:
+
+ [heading]
+ # comment to be attached to the subheading
+ [subheading]
+ """
+ (target_node or self.tree.node).comments.extend(self.comments)
+ self.comments.clear()
+
+ def manifest(self):
+ self.data_block()
+ self.expect(token_types.eof)
+
+ def data_block(self):
+ while self.token[0] in {token_types.comment, token_types.string,
+ token_types.paren}:
+ if self.token[0] == token_types.comment:
+ self.consume_comments()
+ elif self.token[0] == token_types.string:
+ self.tree.append(KeyValueNode(self.token[1]))
+ self.consume()
+ self.expect(token_types.separator)
+ self.maybe_consume_inline_comment()
+ self.flush_comments()
+ self.consume_comments()
+ self.value_block()
+ self.flush_comments()
+ self.tree.pop()
+ else:
+ self.expect(token_types.paren, "[")
+ if self.token[0] != token_types.string:
+ raise ParseError(self.tokenizer.filename,
+ self.tokenizer.line_number,
+ f"Token '{self.token[0]}' is not a string")
+ self.tree.append(DataNode(self.token[1]))
+ self.consume()
+ self.expect(token_types.paren, "]")
+ self.maybe_consume_inline_comment()
+ self.flush_comments()
+ self.consume_comments()
+ if self.token[0] == token_types.group_start:
+ self.consume()
+ self.data_block()
+ self.eof_or_end_group()
+ self.tree.pop()
+
+ def eof_or_end_group(self):
+ if self.token[0] != token_types.eof:
+ self.expect(token_types.group_end)
+
+ def value_block(self):
+ if self.token[0] == token_types.list_start:
+ self.consume()
+ self.list_value()
+ elif self.token[0] == token_types.string:
+ self.value()
+ elif self.token[0] == token_types.group_start:
+ self.consume()
+ self.expression_values()
+ default_value = None
+ if self.token[0] == token_types.string:
+ default_value = self.value
+ elif self.token[0] == token_types.atom:
+ default_value = self.atom
+ elif self.token[0] == token_types.list_start:
+ self.consume()
+ default_value = self.list_value
+ if default_value:
+ default_value()
+ # For this special case where a group exists, attach comments to
+ # the string/list value, not the key-value node. That is,
+ # key:
+ # ...
+ # # comment attached to condition default
+ # value
+ #
+ # should not read
+ # # comment attached to condition default
+ # key:
+ # ...
+ # value
+ self.consume_comments()
+ self.flush_comments(
+ self.tree.node.children[-1] if default_value else None)
+ self.eof_or_end_group()
+ elif self.token[0] == token_types.atom:
+ self.atom()
+ else:
+ raise ParseError(self.tokenizer.filename, self.tokenizer.line_number,
+ f"Token '{self.token[0]}' is not a known type")
+
+ def list_value(self):
+ self.tree.append(ListNode())
+ self.maybe_consume_inline_comment()
+ while self.token[0] in (token_types.atom, token_types.string):
+ if self.token[0] == token_types.atom:
+ self.atom()
+ else:
+ self.value()
+ self.expect(token_types.list_end)
+ self.maybe_consume_inline_comment()
+ self.tree.pop()
+
+ def expression_values(self):
+ self.consume_comments()
+ while self.token == (token_types.ident, "if"):
+ self.consume()
+ self.tree.append(ConditionalNode())
+ self.expr_start()
+ self.expect(token_types.separator)
+ self.value_block()
+ self.flush_comments()
+ self.tree.pop()
+ self.consume_comments()
+
+ def value(self):
+ self.tree.append(ValueNode(self.token[1]))
+ self.consume()
+ self.maybe_consume_inline_comment()
+ self.tree.pop()
+
+ def atom(self):
+ if self.token[1] not in atoms:
+ raise ParseError(self.tokenizer.filename, self.tokenizer.line_number, "Unrecognised symbol @%s" % self.token[1])
+ self.tree.append(AtomNode(atoms[self.token[1]]))
+ self.consume()
+ self.maybe_consume_inline_comment()
+ self.tree.pop()
+
+ def expr_start(self):
+ self.expr_builder = ExpressionBuilder(self.tokenizer)
+ self.expr_builders.append(self.expr_builder)
+ self.expr()
+ expression = self.expr_builder.finish()
+ self.expr_builders.pop()
+ self.expr_builder = self.expr_builders[-1] if self.expr_builders else None
+ if self.expr_builder:
+ self.expr_builder.operands[-1].children[-1].append(expression)
+ else:
+ self.tree.append(expression)
+ self.tree.pop()
+
+ def expr(self):
+ self.expr_operand()
+ while (self.token[0] == token_types.ident and self.token[1] in binary_operators):
+ self.expr_bin_op()
+ self.expr_operand()
+
+ def expr_operand(self):
+ if self.token == (token_types.paren, "("):
+ self.consume()
+ self.expr_builder.left_paren()
+ self.expr()
+ self.expect(token_types.paren, ")")
+ self.expr_builder.right_paren()
+ elif self.token[0] == token_types.ident and self.token[1] in unary_operators:
+ self.expr_unary_op()
+ self.expr_operand()
+ elif self.token[0] in [token_types.string, token_types.ident]:
+ self.expr_value()
+ elif self.token[0] == token_types.number:
+ self.expr_number()
+ else:
+ raise ParseError(self.tokenizer.filename, self.tokenizer.line_number, "Unrecognised operand")
+
+ def expr_unary_op(self):
+ if self.token[1] in unary_operators:
+ self.expr_builder.push_operator(UnaryOperatorNode(self.token[1]))
+ self.consume()
+ else:
+ raise ParseError(self.tokenizer.filename, self.tokenizer.line_number, "Expected unary operator")
+
+ def expr_bin_op(self):
+ if self.token[1] in binary_operators:
+ self.expr_builder.push_operator(BinaryOperatorNode(self.token[1]))
+ self.consume()
+ else:
+ raise ParseError(self.tokenizer.filename, self.tokenizer.line_number, "Expected binary operator")
+
+ def expr_value(self):
+ node_type = {token_types.string: StringNode,
+ token_types.ident: VariableNode}[self.token[0]]
+ self.expr_builder.push_operand(node_type(self.token[1]))
+ self.consume()
+ if self.token == (token_types.paren, "["):
+ self.consume()
+ self.expr_builder.operands[-1].append(IndexNode())
+ self.expr_start()
+ self.expect(token_types.paren, "]")
+
+ def expr_number(self):
+ self.expr_builder.push_operand(NumberNode(self.token[1]))
+ self.consume()
+
+
+class Treebuilder:
+ def __init__(self, root):
+ self.root = root
+ self.node = root
+
+ def append(self, node):
+ assert isinstance(node, Node)
+ self.node.append(node)
+ self.node = node
+ assert self.node is not None
+ return node
+
+ def pop(self):
+ node = self.node
+ self.node = self.node.parent
+ assert self.node is not None
+ return node
+
+
+class ExpressionBuilder:
+ def __init__(self, tokenizer):
+ self.operands = []
+ self.operators = [None]
+ self.tokenizer = tokenizer
+
+ def finish(self):
+ while self.operators[-1] is not None:
+ self.pop_operator()
+ rv = self.pop_operand()
+ assert self.is_empty()
+ return rv
+
+ def left_paren(self):
+ self.operators.append(None)
+
+ def right_paren(self):
+ while self.operators[-1] is not None:
+ self.pop_operator()
+ if not self.operators:
+ raise ParseError(self.tokenizer.filename, self.tokenizer.line,
+ "Unbalanced parens")
+
+ assert self.operators.pop() is None
+
+ def push_operator(self, operator):
+ assert operator is not None
+ while self.precedence(self.operators[-1]) > self.precedence(operator):
+ self.pop_operator()
+
+ self.operators.append(operator)
+
+ def pop_operator(self):
+ operator = self.operators.pop()
+ if isinstance(operator, BinaryOperatorNode):
+ operand_1 = self.operands.pop()
+ operand_0 = self.operands.pop()
+ self.operands.append(BinaryExpressionNode(operator, operand_0, operand_1))
+ else:
+ operand_0 = self.operands.pop()
+ self.operands.append(UnaryExpressionNode(operator, operand_0))
+
+ def push_operand(self, node):
+ self.operands.append(node)
+
+ def pop_operand(self):
+ return self.operands.pop()
+
+ def is_empty(self):
+ return len(self.operands) == 0 and all(item is None for item in self.operators)
+
+ def precedence(self, operator):
+ if operator is None:
+ return 0
+ return precedence(operator)
+
+
+def parse(stream):
+ p = Parser()
+ return p.parse(stream)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/serializer.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/serializer.py
new file mode 100644
index 0000000000..e749add74e
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/serializer.py
@@ -0,0 +1,160 @@
+# mypy: allow-untyped-defs
+
+from six import ensure_text
+
+from .node import NodeVisitor, ValueNode, ListNode, BinaryExpressionNode
+from .parser import atoms, precedence, token_types
+
+atom_names = {v: "@%s" % k for (k,v) in atoms.items()}
+
+named_escapes = {"\a", "\b", "\f", "\n", "\r", "\t", "\v"}
+
+def escape(string, extras=""):
+ # Assumes input bytes are either UTF8 bytes or unicode.
+ rv = ""
+ for c in string:
+ if c in named_escapes:
+ rv += c.encode("unicode_escape").decode()
+ elif c == "\\":
+ rv += "\\\\"
+ elif c < '\x20':
+ rv += "\\x%02x" % ord(c)
+ elif c in extras:
+ rv += "\\" + c
+ else:
+ rv += c
+ return ensure_text(rv)
+
+
+class ManifestSerializer(NodeVisitor):
+ def __init__(self, skip_empty_data=False):
+ self.skip_empty_data = skip_empty_data
+
+ def serialize(self, root):
+ self.indent = 2
+ rv = "\n".join(self.visit(root))
+ if not rv:
+ return rv
+ rv = rv.strip()
+ if rv[-1] != "\n":
+ rv = rv + "\n"
+ return rv
+
+ def visit(self, node):
+ lines = super().visit(node)
+ comments = [f"#{comment}" for _, comment in node.comments]
+ # Simply checking if the first line contains '#' is less than ideal; the
+ # character might be escaped or within a string.
+ if lines and "#" not in lines[0]:
+ for i, (token_type, comment) in enumerate(node.comments):
+ if token_type == token_types.inline_comment:
+ lines[0] += f" #{comment}"
+ comments.pop(i)
+ break
+ return comments + lines
+
+ def visit_DataNode(self, node):
+ rv = []
+ if not self.skip_empty_data or node.children:
+ if node.data:
+ rv.append("[%s]" % escape(node.data, extras="]"))
+ indent = self.indent * " "
+ else:
+ indent = ""
+
+ for child in node.children:
+ rv.extend("%s%s" % (indent if item else "", item) for item in self.visit(child))
+
+ if node.parent:
+ rv.append("")
+
+ return rv
+
+ def visit_KeyValueNode(self, node):
+ rv = [escape(node.data, ":") + ":"]
+ indent = " " * self.indent
+
+ if len(node.children) == 1 and isinstance(node.children[0], (ValueNode, ListNode)):
+ rv[0] += " %s" % self.visit(node.children[0])[0]
+ else:
+ for child in node.children:
+ rv.extend(indent + line for line in self.visit(child))
+
+ return rv
+
+ def visit_ListNode(self, node):
+ rv = ["["]
+ rv.extend(", ".join(self.visit(child)[0] for child in node.children))
+ rv.append("]")
+ return ["".join(rv)]
+
+ def visit_ValueNode(self, node):
+ data = ensure_text(node.data)
+ if ("#" in data or
+ data.startswith("if ") or
+ (isinstance(node.parent, ListNode) and
+ ("," in data or "]" in data))):
+ if "\"" in data:
+ quote = "'"
+ else:
+ quote = "\""
+ else:
+ quote = ""
+ return [quote + escape(data, extras=quote) + quote]
+
+ def visit_AtomNode(self, node):
+ return [atom_names[node.data]]
+
+ def visit_ConditionalNode(self, node):
+ return ["if %s: %s" % tuple(self.visit(item)[0] for item in node.children)]
+
+ def visit_StringNode(self, node):
+ rv = ["\"%s\"" % escape(node.data, extras="\"")]
+ for child in node.children:
+ rv[0] += self.visit(child)[0]
+ return rv
+
+ def visit_NumberNode(self, node):
+ return [ensure_text(node.data)]
+
+ def visit_VariableNode(self, node):
+ rv = escape(node.data)
+ for child in node.children:
+ rv += self.visit(child)
+ return [rv]
+
+ def visit_IndexNode(self, node):
+ assert len(node.children) == 1
+ return ["[%s]" % self.visit(node.children[0])[0]]
+
+ def visit_UnaryExpressionNode(self, node):
+ children = []
+ for child in node.children:
+ child_str = self.visit(child)[0]
+ if isinstance(child, BinaryExpressionNode):
+ child_str = "(%s)" % child_str
+ children.append(child_str)
+ return [" ".join(children)]
+
+ def visit_BinaryExpressionNode(self, node):
+ assert len(node.children) == 3
+ children = []
+ for child_index in [1, 0, 2]:
+ child = node.children[child_index]
+ child_str = self.visit(child)[0]
+ if (isinstance(child, BinaryExpressionNode) and
+ precedence(node.children[0]) < precedence(child.children[0])):
+ child_str = "(%s)" % child_str
+ children.append(child_str)
+ return [" ".join(children)]
+
+ def visit_UnaryOperatorNode(self, node):
+ return [ensure_text(node.data)]
+
+ def visit_BinaryOperatorNode(self, node):
+ return [ensure_text(node.data)]
+
+
+def serialize(tree, *args, **kwargs):
+ s = ManifestSerializer(*args, **kwargs)
+ return s.serialize(tree)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/__init__.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/__init__.py
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_conditional.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_conditional.py
new file mode 100644
index 0000000000..0059b98556
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_conditional.py
@@ -0,0 +1,143 @@
+# mypy: allow-untyped-defs
+
+import unittest
+
+from ..backends import conditional
+from ..node import BinaryExpressionNode, BinaryOperatorNode, VariableNode, NumberNode
+
+
+class TestConditional(unittest.TestCase):
+ def compile(self, input_text):
+ return conditional.compile(input_text)
+
+ def test_get_0(self):
+ data = b"""
+key: value
+
+[Heading 1]
+ other_key:
+ if a == 1: value_1
+ if a == 2: value_2
+ value_3
+"""
+
+ manifest = self.compile(data)
+
+ self.assertEqual(manifest.get("key"), "value")
+ children = list(item for item in manifest.iterchildren())
+ self.assertEqual(len(children), 1)
+ section = children[0]
+ self.assertEqual(section.name, "Heading 1")
+
+ self.assertEqual(section.get("other_key", {"a": 1}), "value_1")
+ self.assertEqual(section.get("other_key", {"a": 2}), "value_2")
+ self.assertEqual(section.get("other_key", {"a": 7}), "value_3")
+ self.assertEqual(section.get("key"), "value")
+
+ def test_get_1(self):
+ data = b"""
+key: value
+
+[Heading 1]
+ other_key:
+ if a == "1": value_1
+ if a == 2: value_2
+ value_3
+"""
+
+ manifest = self.compile(data)
+
+ children = list(item for item in manifest.iterchildren())
+ section = children[0]
+
+ self.assertEqual(section.get("other_key", {"a": "1"}), "value_1")
+ self.assertEqual(section.get("other_key", {"a": 1}), "value_3")
+
+ def test_get_2(self):
+ data = b"""
+key:
+ if a[1] == "b": value_1
+ if a[1] == 2: value_2
+ value_3
+"""
+
+ manifest = self.compile(data)
+
+ self.assertEqual(manifest.get("key", {"a": "ab"}), "value_1")
+ self.assertEqual(manifest.get("key", {"a": [1, 2]}), "value_2")
+
+ def test_get_3(self):
+ data = b"""
+key:
+ if a[1] == "ab"[1]: value_1
+ if a[1] == 2: value_2
+ value_3
+"""
+
+ manifest = self.compile(data)
+
+ self.assertEqual(manifest.get("key", {"a": "ab"}), "value_1")
+ self.assertEqual(manifest.get("key", {"a": [1, 2]}), "value_2")
+
+ def test_set_0(self):
+ data = b"""
+key:
+ if a == "a": value_1
+ if a == "b": value_2
+ value_3
+"""
+ manifest = self.compile(data)
+
+ manifest.set("new_key", "value_new")
+
+ self.assertEqual(manifest.get("new_key"), "value_new")
+
+ def test_set_1(self):
+ data = b"""
+key:
+ if a == "a": value_1
+ if a == "b": value_2
+ value_3
+"""
+
+ manifest = self.compile(data)
+
+ manifest.set("key", "value_new")
+
+ self.assertEqual(manifest.get("key"), "value_new")
+ self.assertEqual(manifest.get("key", {"a": "a"}), "value_1")
+
+ def test_set_2(self):
+ data = b"""
+key:
+ if a == "a": value_1
+ if a == "b": value_2
+ value_3
+"""
+
+ manifest = self.compile(data)
+
+ expr = BinaryExpressionNode(BinaryOperatorNode("=="),
+ VariableNode("a"),
+ NumberNode("1"))
+
+ manifest.set("key", "value_new", expr)
+
+ self.assertEqual(manifest.get("key", {"a": 1}), "value_new")
+ self.assertEqual(manifest.get("key", {"a": "a"}), "value_1")
+
+ def test_api_0(self):
+ data = b"""
+key:
+ if a == 1.5: value_1
+ value_2
+key_1: other_value
+"""
+ manifest = self.compile(data)
+
+ self.assertFalse(manifest.is_empty)
+ self.assertEqual(manifest.root, manifest)
+ self.assertTrue(manifest.has_key("key_1"))
+ self.assertFalse(manifest.has_key("key_2"))
+
+ self.assertEqual(set(manifest.iterkeys()), {"key", "key_1"})
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_parser.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_parser.py
new file mode 100644
index 0000000000..a220307088
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_parser.py
@@ -0,0 +1,155 @@
+# mypy: allow-untyped-defs
+
+import unittest
+
+from .. import parser
+
+# There aren't many tests here because it turns out to be way more convenient to
+# use test_serializer for the majority of cases
+
+
+class TestExpression(unittest.TestCase):
+ def setUp(self):
+ self.parser = parser.Parser()
+
+ def parse(self, input_str):
+ return self.parser.parse(input_str)
+
+ def compare(self, input_text, expected):
+ actual = self.parse(input_text)
+ self.match(expected, actual)
+
+ def match(self, expected_node, actual_node):
+ self.assertEqual(expected_node[0], actual_node.__class__.__name__)
+ self.assertEqual(expected_node[1], actual_node.data)
+ self.assertEqual(len(expected_node[2]), len(actual_node.children))
+ for expected_child, actual_child in zip(expected_node[2], actual_node.children):
+ self.match(expected_child, actual_child)
+
+ def test_expr_0(self):
+ self.compare(
+ b"""
+key:
+ if x == 1 : value""",
+ ["DataNode", None,
+ [["KeyValueNode", "key",
+ [["ConditionalNode", None,
+ [["BinaryExpressionNode", None,
+ [["BinaryOperatorNode", "==", []],
+ ["VariableNode", "x", []],
+ ["NumberNode", "1", []]
+ ]],
+ ["ValueNode", "value", []],
+ ]]]]]]
+ )
+
+ def test_expr_1(self):
+ self.compare(
+ b"""
+key:
+ if not x and y : value""",
+ ["DataNode", None,
+ [["KeyValueNode", "key",
+ [["ConditionalNode", None,
+ [["BinaryExpressionNode", None,
+ [["BinaryOperatorNode", "and", []],
+ ["UnaryExpressionNode", None,
+ [["UnaryOperatorNode", "not", []],
+ ["VariableNode", "x", []]
+ ]],
+ ["VariableNode", "y", []]
+ ]],
+ ["ValueNode", "value", []],
+ ]]]]]]
+ )
+
+ def test_expr_2(self):
+ self.compare(
+ b"""
+key:
+ if x == 1 : [value1, value2]""",
+ ["DataNode", None,
+ [["KeyValueNode", "key",
+ [["ConditionalNode", None,
+ [["BinaryExpressionNode", None,
+ [["BinaryOperatorNode", "==", []],
+ ["VariableNode", "x", []],
+ ["NumberNode", "1", []]
+ ]],
+ ["ListNode", None,
+ [["ValueNode", "value1", []],
+ ["ValueNode", "value2", []]]],
+ ]]]]]]
+ )
+
+ def test_expr_3(self):
+ self.compare(
+ b"""
+key:
+ if x == 1: 'if b: value'""",
+ ["DataNode", None,
+ [["KeyValueNode", "key",
+ [["ConditionalNode", None,
+ [["BinaryExpressionNode", None,
+ [["BinaryOperatorNode", "==", []],
+ ["VariableNode", "x", []],
+ ["NumberNode", "1", []]
+ ]],
+ ["ValueNode", "if b: value", []],
+ ]]]]]]
+ )
+
+ def test_atom_0(self):
+ with self.assertRaises(parser.ParseError):
+ self.parse(b"key: @Unknown")
+
+ def test_atom_1(self):
+ with self.assertRaises(parser.ParseError):
+ self.parse(b"key: @true")
+
+ def test_list_expr(self):
+ self.compare(
+ b"""
+key:
+ if x == 1: [a]
+ [b]""",
+ ["DataNode", None,
+ [["KeyValueNode", "key",
+ [["ConditionalNode", None,
+ [["BinaryExpressionNode", None,
+ [["BinaryOperatorNode", "==", []],
+ ["VariableNode", "x", []],
+ ["NumberNode", "1", []]
+ ]],
+ ["ListNode", None,
+ [["ValueNode", "a", []]]],
+ ]],
+ ["ListNode", None,
+ [["ValueNode", "b", []]]]]]]])
+
+ def test_list_heading(self):
+ self.compare(
+ b"""
+key:
+ if x == 1: [a]
+[b]""",
+ ["DataNode", None,
+ [["KeyValueNode", "key",
+ [["ConditionalNode", None,
+ [["BinaryExpressionNode", None,
+ [["BinaryOperatorNode", "==", []],
+ ["VariableNode", "x", []],
+ ["NumberNode", "1", []]
+ ]],
+ ["ListNode", None,
+ [["ValueNode", "a", []]]],
+ ]]]],
+ ["DataNode", "b", []]]])
+
+ def test_if_1(self):
+ with self.assertRaises(parser.ParseError):
+ self.parse(b"key: if foo")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_serializer.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_serializer.py
new file mode 100644
index 0000000000..d73668ac64
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_serializer.py
@@ -0,0 +1,356 @@
+# mypy: allow-untyped-defs
+
+import textwrap
+import unittest
+
+from .. import parser, serializer
+
+
+class SerializerTest(unittest.TestCase):
+ def setUp(self):
+ self.serializer = serializer.ManifestSerializer()
+ self.parser = parser.Parser()
+
+ def serialize(self, input_str):
+ return self.serializer.serialize(self.parser.parse(input_str))
+
+ def compare(self, input_str, expected=None):
+ if expected is None:
+ expected = input_str.decode("utf-8")
+ actual = self.serialize(input_str)
+ self.assertEqual(actual, expected)
+
+ def test_0(self):
+ self.compare(b"""key: value
+[Heading 1]
+ other_key: other_value
+""")
+
+ def test_1(self):
+ self.compare(b"""key: value
+[Heading 1]
+ other_key:
+ if a or b: other_value
+""")
+
+ def test_2(self):
+ self.compare(b"""key: value
+[Heading 1]
+ other_key:
+ if a or b: other_value
+ fallback_value
+""")
+
+ def test_3(self):
+ self.compare(b"""key: value
+[Heading 1]
+ other_key:
+ if a == 1: other_value
+ fallback_value
+""")
+
+ def test_4(self):
+ self.compare(b"""key: value
+[Heading 1]
+ other_key:
+ if a == "1": other_value
+ fallback_value
+""")
+
+ def test_5(self):
+ self.compare(b"""key: value
+[Heading 1]
+ other_key:
+ if a == "abc"[1]: other_value
+ fallback_value
+""")
+
+ def test_6(self):
+ self.compare(b"""key: value
+[Heading 1]
+ other_key:
+ if a == "abc"[c]: other_value
+ fallback_value
+""")
+
+ def test_7(self):
+ self.compare(b"""key: value
+[Heading 1]
+ other_key:
+ if (a or b) and c: other_value
+ fallback_value
+""",
+"""key: value
+[Heading 1]
+ other_key:
+ if a or b and c: other_value
+ fallback_value
+""")
+
+ def test_8(self):
+ self.compare(b"""key: value
+[Heading 1]
+ other_key:
+ if a or (b and c): other_value
+ fallback_value
+""")
+
+ def test_9(self):
+ self.compare(b"""key: value
+[Heading 1]
+ other_key:
+ if not (a and b): other_value
+ fallback_value
+""")
+
+ def test_10(self):
+ self.compare(b"""key: value
+[Heading 1]
+ some_key: some_value
+
+[Heading 2]
+ other_key: other_value
+""")
+
+ def test_11(self):
+ self.compare(b"""key:
+ if not a and b and c and d: true
+""")
+
+ def test_12(self):
+ self.compare(b"""[Heading 1]
+ key: [a:1, b:2]
+""")
+
+ def test_13(self):
+ self.compare(b"""key: [a:1, "b:#"]
+""")
+
+ def test_14(self):
+ self.compare(b"""key: [","]
+""")
+
+ def test_15(self):
+ self.compare(b"""key: ,
+""")
+
+ def test_16(self):
+ self.compare(b"""key: ["]", b]
+""")
+
+ def test_17(self):
+ self.compare(b"""key: ]
+""")
+
+ def test_18(self):
+ self.compare(br"""key: \]
+ """, """key: ]
+""")
+
+ def test_atom_as_default(self):
+ self.compare(
+ textwrap.dedent(
+ """\
+ key:
+ if a == 1: @True
+ @False
+ """).encode())
+
+ def test_escape_0(self):
+ self.compare(br"""k\t\:y: \a\b\f\n\r\t\v""",
+ r"""k\t\:y: \x07\x08\x0c\n\r\t\x0b
+""")
+
+ def test_escape_1(self):
+ self.compare(br"""k\x00: \x12A\x45""",
+ r"""k\x00: \x12AE
+""")
+
+ def test_escape_2(self):
+ self.compare(br"""k\u0045y: \u1234A\uABc6""",
+ """kEy: \u1234A\uabc6
+""")
+
+ def test_escape_3(self):
+ self.compare(br"""k\u0045y: \u1234A\uABc6""",
+ """kEy: \u1234A\uabc6
+""")
+
+ def test_escape_4(self):
+ self.compare(br"""key: '\u1234A\uABc6'""",
+ """key: \u1234A\uabc6
+""")
+
+ def test_escape_5(self):
+ self.compare(br"""key: [\u1234A\uABc6]""",
+ """key: [\u1234A\uabc6]
+""")
+
+ def test_escape_6(self):
+ self.compare(br"""key: [\u1234A\uABc6\,]""",
+ """key: ["\u1234A\uabc6,"]
+""")
+
+ def test_escape_7(self):
+ self.compare(br"""key: [\,\]\#]""",
+ r"""key: [",]#"]
+""")
+
+ def test_escape_8(self):
+ self.compare(br"""key: \#""",
+ r"""key: "#"
+""")
+
+ def test_escape_9(self):
+ self.compare(br"""key: \U10FFFFabc""",
+ """key: \U0010FFFFabc
+""")
+
+ def test_escape_10(self):
+ self.compare(br"""key: \u10FFab""",
+ """key: \u10FFab
+""")
+
+ def test_escape_11(self):
+ self.compare(br"""key: \\ab
+""")
+
+ def test_atom_1(self):
+ self.compare(br"""key: @True
+""")
+
+ def test_atom_2(self):
+ self.compare(br"""key: @False
+""")
+
+ def test_atom_3(self):
+ self.compare(br"""key: @Reset
+""")
+
+ def test_atom_4(self):
+ self.compare(br"""key: [a, @Reset, b]
+""")
+
+ def test_conditional_1(self):
+ self.compare(b"""foo:
+ if a or b: [1, 2]
+""")
+
+ def test_if_string_0(self):
+ self.compare(b"""foo: "if bar"
+""")
+
+ def test_non_ascii_1(self):
+ self.compare(b"""[\xf0\x9f\x99\x84]
+""")
+
+ def test_comments_preceding_kv_pair(self):
+ self.compare(
+ textwrap.dedent(
+ """\
+ # These two comments should be attached
+ # to the first key-value pair.
+ key1: value
+ # Attached to the second pair.
+ key2: value
+ """).encode())
+
+ def test_comments_preceding_headings(self):
+ self.compare(
+ textwrap.dedent(
+ """\
+ # Attached to the first heading.
+ [test1.html]
+
+ # Attached to the second heading.
+ [test2.html]
+ # Attached to subheading.
+ # Also attached to subheading.
+ [subheading] # Also attached to subheading (inline).
+ """).encode(),
+ textwrap.dedent(
+ """\
+ # Attached to the first heading.
+ [test1.html]
+
+ # Attached to the second heading.
+ [test2.html]
+ # Attached to subheading.
+ # Also attached to subheading.
+ [subheading] # Also attached to subheading (inline).
+ """))
+
+ def test_comments_inline(self):
+ self.compare(
+ textwrap.dedent(
+ """\
+ key1: # inline after key
+ value # inline after string value
+ key2:
+ [value] # inline after list in group
+ [test.html] # inline after heading
+ key1: @True # inline after atom
+ key2: [ # inline after list start
+ @False, # inline after atom in list
+ value1, # inline after value in list
+ value2] # inline after list end
+ """).encode(),
+ textwrap.dedent(
+ """\
+ # inline after key
+ key1: value # inline after string value
+ key2: [value] # inline after list in group
+ [test.html] # inline after heading
+ key1: @True # inline after atom
+ # inline after atom in list
+ # inline after value in list
+ # inline after list end
+ key2: [@False, value1, value2] # inline after list start
+ """))
+
+ def test_comments_conditions(self):
+ self.compare(
+ textwrap.dedent(
+ """\
+ key1:
+ # cond 1
+ if cond == 1: value
+ # cond 2
+ if cond == 2: value # cond 2
+ # cond 3
+ # cond 3
+ if cond == 3: value
+ # default 0
+ default # default 1
+ # default 2
+ # default 3
+ key2:
+ if cond == 1: value
+ [value]
+ # list default
+ key3:
+ if cond == 1: value
+ # no default
+ """).encode(),
+ textwrap.dedent(
+ """\
+ key1:
+ # cond 1
+ if cond == 1: value
+ # cond 2
+ if cond == 2: value # cond 2
+ # cond 3
+ # cond 3
+ if cond == 3: value
+ # default 0
+ # default 2
+ # default 3
+ default # default 1
+ key2:
+ if cond == 1: value
+ # list default
+ [value]
+ # no default
+ key3:
+ if cond == 1: value
+ """))
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_static.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_static.py
new file mode 100644
index 0000000000..0ded07f42d
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_static.py
@@ -0,0 +1,98 @@
+# mypy: allow-untyped-defs
+
+import unittest
+
+from ..backends import static
+
+# There aren't many tests here because it turns out to be way more convenient to
+# use test_serializer for the majority of cases
+
+
+class TestStatic(unittest.TestCase):
+ def compile(self, input_text, input_data):
+ return static.compile(input_text, input_data)
+
+ def test_get_0(self):
+ data = b"""
+key: value
+
+[Heading 1]
+ other_key:
+ if a == 1: value_1
+ if a == 2: value_2
+ value_3
+"""
+
+ manifest = self.compile(data, {"a": 2})
+
+ self.assertEqual(manifest.get("key"), "value")
+ children = list(item for item in manifest.iterchildren())
+ self.assertEqual(len(children), 1)
+ section = children[0]
+ self.assertEqual(section.name, "Heading 1")
+
+ self.assertEqual(section.get("other_key"), "value_2")
+ self.assertEqual(section.get("key"), "value")
+
+ def test_get_1(self):
+ data = b"""
+key: value
+
+[Heading 1]
+ other_key:
+ if a == 1: value_1
+ if a == 2: value_2
+ value_3
+"""
+ manifest = self.compile(data, {"a": 3})
+
+ children = list(item for item in manifest.iterchildren())
+ section = children[0]
+ self.assertEqual(section.get("other_key"), "value_3")
+
+ def test_get_3(self):
+ data = b"""key:
+ if a == "1": value_1
+ if a[0] == "ab"[0]: value_2
+"""
+ manifest = self.compile(data, {"a": "1"})
+ self.assertEqual(manifest.get("key"), "value_1")
+
+ manifest = self.compile(data, {"a": "ac"})
+ self.assertEqual(manifest.get("key"), "value_2")
+
+ def test_get_4(self):
+ data = b"""key:
+ if not a: value_1
+ value_2
+"""
+ manifest = self.compile(data, {"a": True})
+ self.assertEqual(manifest.get("key"), "value_2")
+
+ manifest = self.compile(data, {"a": False})
+ self.assertEqual(manifest.get("key"), "value_1")
+
+ def test_api(self):
+ data = b"""key:
+ if a == 1.5: value_1
+ value_2
+key_1: other_value
+"""
+ manifest = self.compile(data, {"a": 1.5})
+
+ self.assertFalse(manifest.is_empty)
+ self.assertEqual(manifest.root, manifest)
+ self.assertTrue(manifest.has_key("key_1"))
+ self.assertFalse(manifest.has_key("key_2"))
+
+ self.assertEqual(set(manifest.iterkeys()), {"key", "key_1"})
+ self.assertEqual(set(manifest.itervalues()), {"value_1", "other_value"})
+
+ def test_is_empty_1(self):
+ data = b"""
+[Section]
+ [Subsection]
+"""
+ manifest = self.compile(data, {})
+
+ self.assertTrue(manifest.is_empty)
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_tokenizer.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_tokenizer.py
new file mode 100644
index 0000000000..6b9d052560
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptmanifest/tests/test_tokenizer.py
@@ -0,0 +1,385 @@
+# mypy: allow-untyped-defs
+
+import textwrap
+import unittest
+
+from .. import parser
+from ..parser import token_types
+
+class TokenizerTest(unittest.TestCase):
+ def setUp(self):
+ self.tokenizer = parser.Tokenizer()
+
+ def tokenize(self, input_str):
+ rv = []
+ for item in self.tokenizer.tokenize(input_str):
+ rv.append(item)
+ if item[0] == token_types.eof:
+ break
+ return rv
+
+ def compare(self, input_text, expected):
+ expected = expected + [(token_types.eof, None)]
+ actual = self.tokenize(input_text)
+ self.assertEqual(actual, expected)
+
+ def test_heading_0(self):
+ self.compare(b"""[Heading text]""",
+ [(token_types.paren, "["),
+ (token_types.string, "Heading text"),
+ (token_types.paren, "]")])
+
+ def test_heading_1(self):
+ self.compare(br"""[Heading [text\]]""",
+ [(token_types.paren, "["),
+ (token_types.string, "Heading [text]"),
+ (token_types.paren, "]")])
+
+ def test_heading_2(self):
+ self.compare(b"""[Heading #text]""",
+ [(token_types.paren, "["),
+ (token_types.string, "Heading #text"),
+ (token_types.paren, "]")])
+
+ def test_heading_3(self):
+ self.compare(br"""[Heading [\]text]""",
+ [(token_types.paren, "["),
+ (token_types.string, "Heading []text"),
+ (token_types.paren, "]")])
+
+ def test_heading_4(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"[Heading")
+
+ def test_heading_5(self):
+ self.compare(br"""[Heading [\]text] #comment""",
+ [(token_types.paren, "["),
+ (token_types.string, "Heading []text"),
+ (token_types.paren, "]"),
+ (token_types.inline_comment, "comment")])
+
+ def test_heading_6(self):
+ self.compare(br"""[Heading \ttext]""",
+ [(token_types.paren, "["),
+ (token_types.string, "Heading \ttext"),
+ (token_types.paren, "]")])
+
+ def test_key_0(self):
+ self.compare(b"""key:value""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.string, "value")])
+
+ def test_key_1(self):
+ self.compare(b"""key : value""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.string, "value")])
+
+ def test_key_2(self):
+ self.compare(b"""key : val ue""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.string, "val ue")])
+
+ def test_key_3(self):
+ self.compare(b"""key: value#comment""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.string, "value"),
+ (token_types.inline_comment, "comment")])
+
+ def test_key_4(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"""ke y: value""")
+
+ def test_key_5(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"""key""")
+
+ def test_key_6(self):
+ self.compare(b"""key: "value\"""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.string, "value")])
+
+ def test_key_7(self):
+ self.compare(b"""key: 'value'""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.string, "value")])
+
+ def test_key_8(self):
+ self.compare(b"""key: "#value\"""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.string, "#value")])
+
+ def test_key_9(self):
+ self.compare(b"""key: '#value\'""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.string, "#value")])
+
+ def test_key_10(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"""key: "value""")
+
+ def test_key_11(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"""key: 'value""")
+
+ def test_key_12(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"""key: 'value""")
+
+ def test_key_13(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"""key: 'value' abc""")
+
+ def test_key_14(self):
+ self.compare(br"""key: \\nb""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.string, r"\nb")])
+
+ def test_list_0(self):
+ self.compare(b"""
+key: []""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.list_start, "["),
+ (token_types.list_end, "]")])
+
+ def test_list_1(self):
+ self.compare(b"""
+key: [a, "b"]""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.list_start, "["),
+ (token_types.string, "a"),
+ (token_types.string, "b"),
+ (token_types.list_end, "]")])
+
+ def test_list_2(self):
+ self.compare(b"""
+key: [a,
+ b]""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.list_start, "["),
+ (token_types.string, "a"),
+ (token_types.string, "b"),
+ (token_types.list_end, "]")])
+
+ def test_list_3(self):
+ self.compare(b"""
+key: [a, #b]
+ c]""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.list_start, "["),
+ (token_types.string, "a"),
+ (token_types.inline_comment, "b]"),
+ (token_types.string, "c"),
+ (token_types.list_end, "]")])
+
+ def test_list_4(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"""key: [a #b]
+ c]""")
+
+ def test_list_5(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"""key: [a \\
+ c]""")
+
+ def test_list_6(self):
+ self.compare(b"""key: [a , b]""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.list_start, "["),
+ (token_types.string, "a"),
+ (token_types.string, "b"),
+ (token_types.list_end, "]")])
+
+ def test_expr_0(self):
+ self.compare(b"""
+key:
+ if cond == 1: value""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.group_start, None),
+ (token_types.ident, "if"),
+ (token_types.ident, "cond"),
+ (token_types.ident, "=="),
+ (token_types.number, "1"),
+ (token_types.separator, ":"),
+ (token_types.string, "value")])
+
+ def test_expr_1(self):
+ self.compare(b"""
+key:
+ if cond == 1: value1
+ value2""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.group_start, None),
+ (token_types.ident, "if"),
+ (token_types.ident, "cond"),
+ (token_types.ident, "=="),
+ (token_types.number, "1"),
+ (token_types.separator, ":"),
+ (token_types.string, "value1"),
+ (token_types.string, "value2")])
+
+ def test_expr_2(self):
+ self.compare(b"""
+key:
+ if cond=="1": value""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.group_start, None),
+ (token_types.ident, "if"),
+ (token_types.ident, "cond"),
+ (token_types.ident, "=="),
+ (token_types.string, "1"),
+ (token_types.separator, ":"),
+ (token_types.string, "value")])
+
+ def test_expr_3(self):
+ self.compare(b"""
+key:
+ if cond==1.1: value""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.group_start, None),
+ (token_types.ident, "if"),
+ (token_types.ident, "cond"),
+ (token_types.ident, "=="),
+ (token_types.number, "1.1"),
+ (token_types.separator, ":"),
+ (token_types.string, "value")])
+
+ def test_expr_4(self):
+ self.compare(b"""
+key:
+ if cond==1.1 and cond2 == "a": value""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.group_start, None),
+ (token_types.ident, "if"),
+ (token_types.ident, "cond"),
+ (token_types.ident, "=="),
+ (token_types.number, "1.1"),
+ (token_types.ident, "and"),
+ (token_types.ident, "cond2"),
+ (token_types.ident, "=="),
+ (token_types.string, "a"),
+ (token_types.separator, ":"),
+ (token_types.string, "value")])
+
+ def test_expr_5(self):
+ self.compare(b"""
+key:
+ if (cond==1.1 ): value""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.group_start, None),
+ (token_types.ident, "if"),
+ (token_types.paren, "("),
+ (token_types.ident, "cond"),
+ (token_types.ident, "=="),
+ (token_types.number, "1.1"),
+ (token_types.paren, ")"),
+ (token_types.separator, ":"),
+ (token_types.string, "value")])
+
+ def test_expr_6(self):
+ self.compare(b"""
+key:
+ if "\\ttest": value""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.group_start, None),
+ (token_types.ident, "if"),
+ (token_types.string, "\ttest"),
+ (token_types.separator, ":"),
+ (token_types.string, "value")])
+
+ def test_expr_7(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"""
+key:
+ if 1A: value""")
+
+ def test_expr_8(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"""
+key:
+ if 1a: value""")
+
+ def test_expr_9(self):
+ with self.assertRaises(parser.ParseError):
+ self.tokenize(b"""
+key:
+ if 1.1.1: value""")
+
+ def test_expr_10(self):
+ self.compare(b"""
+key:
+ if 1.: value""",
+ [(token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.group_start, None),
+ (token_types.ident, "if"),
+ (token_types.number, "1."),
+ (token_types.separator, ":"),
+ (token_types.string, "value")])
+
+ def test_comment_with_indents(self):
+ self.compare(
+ textwrap.dedent(
+ """\
+ # comment 0
+ [Heading]
+ # comment 1
+ # comment 2
+ """).encode(),
+ [(token_types.comment, " comment 0"),
+ (token_types.paren, "["),
+ (token_types.string, "Heading"),
+ (token_types.paren, "]"),
+ (token_types.comment, " comment 1"),
+ (token_types.comment, " comment 2")])
+
+ def test_comment_inline(self):
+ self.compare(
+ textwrap.dedent(
+ """\
+ [Heading] # after heading
+ key: # after key
+ # before group start
+ if cond: value1 # after value1
+ value2 # after value2
+ """).encode(),
+ [(token_types.paren, "["),
+ (token_types.string, "Heading"),
+ (token_types.paren, "]"),
+ (token_types.inline_comment, " after heading"),
+ (token_types.string, "key"),
+ (token_types.separator, ":"),
+ (token_types.inline_comment, " after key"),
+ (token_types.comment, " before group start"),
+ (token_types.group_start, None),
+ (token_types.ident, "if"),
+ (token_types.ident, "cond"),
+ (token_types.separator, ":"),
+ (token_types.string, "value1"),
+ (token_types.inline_comment, " after value1"),
+ (token_types.string, "value2"),
+ (token_types.inline_comment, " after value2")])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wptrunner.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptrunner.py
new file mode 100644
index 0000000000..da3b63ba5b
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wptrunner.py
@@ -0,0 +1,536 @@
+# mypy: allow-untyped-defs
+
+import json
+import os
+import signal
+import sys
+from collections import defaultdict
+from datetime import datetime, timedelta
+
+import wptserve
+from wptserve import sslutils
+
+from . import environment as env
+from . import instruments
+from . import mpcontext
+from . import products
+from . import testloader
+from . import wptcommandline
+from . import wptlogging
+from . import wpttest
+from mozlog import capture, handlers
+from .font import FontInstaller
+from .testrunner import ManagerGroup, TestImplementation
+
+here = os.path.dirname(__file__)
+
+logger = None
+
+"""Runner for web-platform-tests
+
+The runner has several design goals:
+
+* Tests should run with no modification from upstream.
+
+* Tests should be regarded as "untrusted" so that errors, timeouts and even
+ crashes in the tests can be handled without failing the entire test run.
+
+* For performance tests can be run in multiple browsers in parallel.
+
+The upstream repository has the facility for creating a test manifest in JSON
+format. This manifest is used directly to determine which tests exist. Local
+metadata files are used to store the expected test results.
+"""
+
+def setup_logging(*args, **kwargs):
+ global logger
+ logger = wptlogging.setup(*args, **kwargs)
+ return logger
+
+
+def get_loader(test_paths, product, debug=None, run_info_extras=None, chunker_kwargs=None,
+ test_groups=None, **kwargs):
+ if run_info_extras is None:
+ run_info_extras = {}
+
+ run_info = wpttest.get_run_info(kwargs["run_info"], product,
+ browser_version=kwargs.get("browser_version"),
+ browser_channel=kwargs.get("browser_channel"),
+ verify=kwargs.get("verify"),
+ debug=debug,
+ extras=run_info_extras,
+ device_serials=kwargs.get("device_serial"),
+ adb_binary=kwargs.get("adb_binary"))
+
+ test_manifests = testloader.ManifestLoader(test_paths, force_manifest_update=kwargs["manifest_update"],
+ manifest_download=kwargs["manifest_download"]).load()
+
+ manifest_filters = []
+
+ include = kwargs["include"]
+ if kwargs["include_file"]:
+ include = include or []
+ include.extend(testloader.read_include_from_file(kwargs["include_file"]))
+ if test_groups:
+ include = testloader.update_include_for_groups(test_groups, include)
+
+ if include or kwargs["exclude"] or kwargs["include_manifest"] or kwargs["default_exclude"]:
+ manifest_filters.append(testloader.TestFilter(include=include,
+ exclude=kwargs["exclude"],
+ manifest_path=kwargs["include_manifest"],
+ test_manifests=test_manifests,
+ explicit=kwargs["default_exclude"]))
+
+ ssl_enabled = sslutils.get_cls(kwargs["ssl_type"]).ssl_enabled
+ h2_enabled = wptserve.utils.http2_compatible()
+ test_loader = testloader.TestLoader(test_manifests,
+ kwargs["test_types"],
+ run_info,
+ manifest_filters=manifest_filters,
+ chunk_type=kwargs["chunk_type"],
+ total_chunks=kwargs["total_chunks"],
+ chunk_number=kwargs["this_chunk"],
+ include_https=ssl_enabled,
+ include_h2=h2_enabled,
+ include_webtransport_h3=kwargs["enable_webtransport_h3"],
+ skip_timeout=kwargs["skip_timeout"],
+ skip_implementation_status=kwargs["skip_implementation_status"],
+ chunker_kwargs=chunker_kwargs)
+ return run_info, test_loader
+
+
+def list_test_groups(test_paths, product, **kwargs):
+ env.do_delayed_imports(logger, test_paths)
+
+ run_info_extras = products.Product(kwargs["config"], product).run_info_extras(**kwargs)
+
+ run_info, test_loader = get_loader(test_paths, product,
+ run_info_extras=run_info_extras, **kwargs)
+
+ for item in sorted(test_loader.groups(kwargs["test_types"])):
+ print(item)
+
+
+def list_disabled(test_paths, product, **kwargs):
+ env.do_delayed_imports(logger, test_paths)
+
+ rv = []
+
+ run_info_extras = products.Product(kwargs["config"], product).run_info_extras(**kwargs)
+
+ run_info, test_loader = get_loader(test_paths, product,
+ run_info_extras=run_info_extras, **kwargs)
+
+ for test_type, tests in test_loader.disabled_tests.items():
+ for test in tests:
+ rv.append({"test": test.id, "reason": test.disabled()})
+ print(json.dumps(rv, indent=2))
+
+
+def list_tests(test_paths, product, **kwargs):
+ env.do_delayed_imports(logger, test_paths)
+
+ run_info_extras = products.Product(kwargs["config"], product).run_info_extras(**kwargs)
+
+ run_info, test_loader = get_loader(test_paths, product,
+ run_info_extras=run_info_extras, **kwargs)
+
+ for test in test_loader.test_ids:
+ print(test)
+
+
+def get_pause_after_test(test_loader, **kwargs):
+ if kwargs["pause_after_test"] is None:
+ if kwargs["repeat_until_unexpected"]:
+ return False
+ if kwargs["headless"]:
+ return False
+ if kwargs["debug_test"]:
+ return True
+ tests = test_loader.tests
+ is_single_testharness = (sum(len(item) for item in tests.values()) == 1 and
+ len(tests.get("testharness", [])) == 1)
+ if kwargs["repeat"] == 1 and kwargs["rerun"] == 1 and is_single_testharness:
+ return True
+ return False
+ return kwargs["pause_after_test"]
+
+
+def run_test_iteration(test_status, test_loader, test_source_kwargs, test_source_cls, run_info,
+ recording, test_environment, product, run_test_kwargs):
+ """Runs the entire test suite.
+ This is called for each repeat run requested."""
+ tests_by_type = defaultdict(list)
+ for test_type in test_loader.test_types:
+ tests_by_type[test_type].extend(test_loader.tests[test_type])
+
+ try:
+ test_groups = test_source_cls.tests_by_group(
+ tests_by_type, **test_source_kwargs)
+ except Exception:
+ logger.critical("Loading tests failed")
+ return False
+
+ logger.suite_start(tests_by_type,
+ name='web-platform-test',
+ run_info=run_info,
+ extra={"run_by_dir": run_test_kwargs["run_by_dir"]})
+
+ test_implementation_by_type = {}
+
+ for test_type in run_test_kwargs["test_types"]:
+ executor_cls = product.executor_classes.get(test_type)
+ if executor_cls is None:
+ logger.warning(f"Unsupported test type {test_type} for product {product.name}")
+ continue
+ executor_kwargs = product.get_executor_kwargs(logger,
+ test_type,
+ test_environment,
+ run_info,
+ **run_test_kwargs)
+ browser_cls = product.get_browser_cls(test_type)
+ browser_kwargs = product.get_browser_kwargs(logger,
+ test_type,
+ run_info,
+ config=test_environment.config,
+ num_test_groups=len(test_groups),
+ **run_test_kwargs)
+ test_implementation_by_type[test_type] = TestImplementation(executor_cls,
+ executor_kwargs,
+ browser_cls,
+ browser_kwargs)
+
+ tests_to_run = {}
+ for test_type, test_implementation in test_implementation_by_type.items():
+ executor_cls = test_implementation.executor_cls
+
+ for test in test_loader.disabled_tests[test_type]:
+ logger.test_start(test.id)
+ logger.test_end(test.id, status="SKIP")
+ test_status.skipped += 1
+
+ if test_type == "testharness":
+ tests_to_run[test_type] = []
+ for test in test_loader.tests[test_type]:
+ if ((test.testdriver and not executor_cls.supports_testdriver) or
+ (test.jsshell and not executor_cls.supports_jsshell)):
+ logger.test_start(test.id)
+ logger.test_end(test.id, status="SKIP")
+ test_status.skipped += 1
+ else:
+ tests_to_run[test_type].append(test)
+ else:
+ tests_to_run[test_type] = test_loader.tests[test_type]
+
+ unexpected_tests = set()
+ unexpected_pass_tests = set()
+ recording.pause()
+ retry_counts = run_test_kwargs["retry_unexpected"]
+ for i in range(retry_counts + 1):
+ if i > 0:
+ if not run_test_kwargs["fail_on_unexpected_pass"]:
+ unexpected_fail_tests = unexpected_tests - unexpected_pass_tests
+ else:
+ unexpected_fail_tests = unexpected_tests
+ if len(unexpected_fail_tests) == 0:
+ break
+ for test_type, tests in tests_to_run.items():
+ tests_to_run[test_type] = [test for test in tests
+ if test.id in unexpected_fail_tests]
+
+ logger.suite_end()
+ logger.suite_start(tests_to_run,
+ name='web-platform-test',
+ run_info=run_info,
+ extra={"run_by_dir": run_test_kwargs["run_by_dir"]})
+
+ with ManagerGroup("web-platform-tests",
+ run_test_kwargs["processes"],
+ test_source_cls,
+ test_source_kwargs,
+ test_implementation_by_type,
+ run_test_kwargs["rerun"],
+ run_test_kwargs["pause_after_test"],
+ run_test_kwargs["pause_on_unexpected"],
+ run_test_kwargs["restart_on_unexpected"],
+ run_test_kwargs["debug_info"],
+ not run_test_kwargs["no_capture_stdio"],
+ run_test_kwargs["restart_on_new_group"],
+ recording=recording) as manager_group:
+ try:
+ handle_interrupt_signals()
+ manager_group.run(tests_to_run)
+ except KeyboardInterrupt:
+ logger.critical("Main thread got signal")
+ manager_group.stop()
+ raise
+
+ test_status.total_tests += manager_group.test_count()
+ unexpected_tests = manager_group.unexpected_tests()
+ unexpected_pass_tests = manager_group.unexpected_pass_tests()
+
+ test_status.unexpected += len(unexpected_tests)
+ test_status.unexpected_pass += len(unexpected_pass_tests)
+
+ logger.suite_end()
+
+ return True
+
+def handle_interrupt_signals():
+ def termination_handler(_signum, _unused_frame):
+ raise KeyboardInterrupt()
+ if sys.platform == "win32":
+ signal.signal(signal.SIGBREAK, termination_handler)
+ else:
+ signal.signal(signal.SIGTERM, termination_handler)
+
+
+def evaluate_runs(test_status, run_test_kwargs):
+ """Evaluates the test counts after the given number of repeat runs has finished"""
+ if test_status.total_tests == 0:
+ if test_status.skipped > 0:
+ logger.warning("All requested tests were skipped")
+ else:
+ if run_test_kwargs["default_exclude"]:
+ logger.info("No tests ran")
+ return True
+ else:
+ logger.critical("No tests ran")
+ return False
+
+ if test_status.unexpected and not run_test_kwargs["fail_on_unexpected"]:
+ logger.info(f"Tolerating {test_status.unexpected} unexpected results")
+ return True
+
+ all_unexpected_passed = (test_status.unexpected and
+ test_status.unexpected == test_status.unexpected_pass)
+ if all_unexpected_passed and not run_test_kwargs["fail_on_unexpected_pass"]:
+ logger.info(f"Tolerating {test_status.unexpected_pass} unexpected results "
+ "because they all PASS")
+ return True
+
+ return test_status.unexpected == 0
+
+
+class TestStatus:
+ """Class that stores information on the results of test runs for later reference"""
+ def __init__(self):
+ self.total_tests = 0
+ self.skipped = 0
+ self.unexpected = 0
+ self.unexpected_pass = 0
+ self.repeated_runs = 0
+ self.expected_repeated_runs = 0
+ self.all_skipped = False
+
+
+def run_tests(config, test_paths, product, **kwargs):
+ """Set up the test environment, load the list of tests to be executed, and
+ invoke the remainder of the code to execute tests"""
+ mp = mpcontext.get_context()
+ if kwargs["instrument_to_file"] is None:
+ recorder = instruments.NullInstrument()
+ else:
+ recorder = instruments.Instrument(kwargs["instrument_to_file"])
+ with recorder as recording, capture.CaptureIO(logger,
+ not kwargs["no_capture_stdio"],
+ mp_context=mp):
+ recording.set(["startup"])
+ env.do_delayed_imports(logger, test_paths)
+
+ product = products.Product(config, product)
+
+ env_extras = product.get_env_extras(**kwargs)
+
+ product.check_args(**kwargs)
+
+ if kwargs["install_fonts"]:
+ env_extras.append(FontInstaller(
+ logger,
+ font_dir=kwargs["font_dir"],
+ ahem=os.path.join(test_paths["/"]["tests_path"], "fonts/Ahem.ttf")
+ ))
+
+ recording.set(["startup", "load_tests"])
+
+ test_groups = (testloader.TestGroupsFile(logger, kwargs["test_groups_file"])
+ if kwargs["test_groups_file"] else None)
+
+ (test_source_cls,
+ test_source_kwargs,
+ chunker_kwargs) = testloader.get_test_src(logger=logger,
+ test_groups=test_groups,
+ **kwargs)
+ run_info, test_loader = get_loader(test_paths,
+ product.name,
+ run_info_extras=product.run_info_extras(**kwargs),
+ chunker_kwargs=chunker_kwargs,
+ test_groups=test_groups,
+ **kwargs)
+
+ logger.info("Using %i client processes" % kwargs["processes"])
+
+ test_status = TestStatus()
+ repeat = kwargs["repeat"]
+ test_status.expected_repeated_runs = repeat
+
+ if len(test_loader.test_ids) == 0 and kwargs["test_list"]:
+ logger.critical("Unable to find any tests at the path(s):")
+ for path in kwargs["test_list"]:
+ logger.critical(" %s" % path)
+ logger.critical("Please check spelling and make sure there are tests in the specified path(s).")
+ return False, test_status
+ kwargs["pause_after_test"] = get_pause_after_test(test_loader, **kwargs)
+
+ ssl_config = {"type": kwargs["ssl_type"],
+ "openssl": {"openssl_binary": kwargs["openssl_binary"]},
+ "pregenerated": {"host_key_path": kwargs["host_key_path"],
+ "host_cert_path": kwargs["host_cert_path"],
+ "ca_cert_path": kwargs["ca_cert_path"]}}
+
+ testharness_timeout_multipler = product.get_timeout_multiplier("testharness",
+ run_info,
+ **kwargs)
+
+ mojojs_path = kwargs["mojojs_path"] if kwargs["enable_mojojs"] else None
+ inject_script = kwargs["inject_script"] if kwargs["inject_script"] else None
+
+ recording.set(["startup", "start_environment"])
+ with env.TestEnvironment(test_paths,
+ testharness_timeout_multipler,
+ kwargs["pause_after_test"],
+ kwargs["debug_test"],
+ kwargs["debug_info"],
+ product.env_options,
+ ssl_config,
+ env_extras,
+ kwargs["enable_webtransport_h3"],
+ mojojs_path,
+ inject_script) as test_environment:
+ recording.set(["startup", "ensure_environment"])
+ try:
+ test_environment.ensure_started()
+ start_time = datetime.now()
+ except env.TestEnvironmentError as e:
+ logger.critical("Error starting test environment: %s" % e)
+ raise
+
+ recording.set(["startup"])
+
+ max_time = None
+ if "repeat_max_time" in kwargs:
+ max_time = timedelta(minutes=kwargs["repeat_max_time"])
+
+ repeat_until_unexpected = kwargs["repeat_until_unexpected"]
+
+ # keep track of longest time taken to complete a test suite iteration
+ # so that the runs can be stopped to avoid a possible TC timeout.
+ longest_iteration_time = timedelta()
+
+ while test_status.repeated_runs < repeat or repeat_until_unexpected:
+ # if the next repeat run could cause the TC timeout to be reached,
+ # stop now and use the test results we have.
+ # Pad the total time by 10% to ensure ample time for the next iteration(s).
+ estimate = (datetime.now() +
+ timedelta(seconds=(longest_iteration_time.total_seconds() * 1.1)))
+ if not repeat_until_unexpected and max_time and estimate >= start_time + max_time:
+ logger.info(f"Ran {test_status.repeated_runs} of {repeat} iterations.")
+ break
+
+ # begin tracking runtime of the test suite
+ iteration_start = datetime.now()
+ test_status.repeated_runs += 1
+ if repeat_until_unexpected:
+ logger.info(f"Repetition {test_status.repeated_runs}")
+ elif repeat > 1:
+ logger.info(f"Repetition {test_status.repeated_runs} / {repeat}")
+
+ iter_success = run_test_iteration(test_status, test_loader, test_source_kwargs,
+ test_source_cls, run_info, recording,
+ test_environment, product, kwargs)
+ # if there were issues with the suite run(tests not loaded, etc.) return
+ if not iter_success:
+ return False, test_status
+ recording.set(["after-end"])
+ logger.info(f"Got {test_status.unexpected} unexpected results, "
+ f"with {test_status.unexpected_pass} unexpected passes")
+
+ # Note this iteration's runtime
+ iteration_runtime = datetime.now() - iteration_start
+ # determine the longest test suite runtime seen.
+ longest_iteration_time = max(longest_iteration_time,
+ iteration_runtime)
+
+ if repeat_until_unexpected and test_status.unexpected > 0:
+ break
+ if test_status.repeated_runs == 1 and len(test_loader.test_ids) == test_status.skipped:
+ test_status.all_skipped = True
+ break
+
+ # Return the evaluation of the runs and the number of repeated iterations that were run.
+ return evaluate_runs(test_status, kwargs), test_status
+
+
+def check_stability(**kwargs):
+ from . import stability
+ if kwargs["stability"]:
+ logger.warning("--stability is deprecated; please use --verify instead!")
+ kwargs['verify_max_time'] = None
+ kwargs['verify_chaos_mode'] = False
+ kwargs['verify_repeat_loop'] = 0
+ kwargs['verify_repeat_restart'] = 10 if kwargs['repeat'] == 1 else kwargs['repeat']
+ kwargs['verify_output_results'] = True
+
+ return stability.check_stability(logger,
+ max_time=kwargs['verify_max_time'],
+ chaos_mode=kwargs['verify_chaos_mode'],
+ repeat_loop=kwargs['verify_repeat_loop'],
+ repeat_restart=kwargs['verify_repeat_restart'],
+ output_results=kwargs['verify_output_results'],
+ **kwargs)
+
+
+def start(**kwargs):
+ assert logger is not None
+
+ logged_critical = wptlogging.LoggedAboveLevelHandler("CRITICAL")
+ handler = handlers.LogLevelFilter(logged_critical, "CRITICAL")
+ logger.add_handler(handler)
+
+ rv = False
+ try:
+ if kwargs["list_test_groups"]:
+ list_test_groups(**kwargs)
+ elif kwargs["list_disabled"]:
+ list_disabled(**kwargs)
+ elif kwargs["list_tests"]:
+ list_tests(**kwargs)
+ elif kwargs["verify"] or kwargs["stability"]:
+ rv = check_stability(**kwargs) or logged_critical.has_log
+ else:
+ rv = not run_tests(**kwargs)[0] or logged_critical.has_log
+ finally:
+ logger.shutdown()
+ logger.remove_handler(handler)
+ return rv
+
+
+def main():
+ """Main entry point when calling from the command line"""
+ kwargs = wptcommandline.parse_args()
+
+ try:
+ if kwargs["prefs_root"] is None:
+ kwargs["prefs_root"] = os.path.abspath(os.path.join(here, "prefs"))
+
+ setup_logging(kwargs, {"raw": sys.stdout})
+
+ return start(**kwargs)
+ except Exception:
+ if kwargs["pdb"]:
+ import pdb
+ import traceback
+ print(traceback.format_exc())
+ pdb.post_mortem()
+ else:
+ raise
diff --git a/testing/web-platform/tests/tools/wptrunner/wptrunner/wpttest.py b/testing/web-platform/tests/tools/wptrunner/wptrunner/wpttest.py
new file mode 100644
index 0000000000..c1093f18f4
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptrunner/wptrunner/wpttest.py
@@ -0,0 +1,715 @@
+# mypy: allow-untyped-defs
+
+import os
+import subprocess
+import sys
+from collections import defaultdict
+from typing import Any, ClassVar, Dict, Type
+from urllib.parse import urljoin
+
+from .wptmanifest.parser import atoms
+
+atom_reset = atoms["Reset"]
+enabled_tests = {"testharness", "reftest", "wdspec", "crashtest", "print-reftest"}
+
+
+class Result:
+ def __init__(self,
+ status,
+ message,
+ expected=None,
+ extra=None,
+ stack=None,
+ known_intermittent=None):
+ if status not in self.statuses:
+ raise ValueError("Unrecognised status %s" % status)
+ self.status = status
+ self.message = message
+ self.expected = expected
+ self.known_intermittent = known_intermittent if known_intermittent is not None else []
+ self.extra = extra if extra is not None else {}
+ self.stack = stack
+
+ def __repr__(self):
+ return f"<{self.__module__}.{self.__class__.__name__} {self.status}>"
+
+
+class SubtestResult:
+ def __init__(self, name, status, message, stack=None, expected=None, known_intermittent=None):
+ self.name = name
+ if status not in self.statuses:
+ raise ValueError("Unrecognised status %s" % status)
+ self.status = status
+ self.message = message
+ self.stack = stack
+ self.expected = expected
+ self.known_intermittent = known_intermittent if known_intermittent is not None else []
+
+ def __repr__(self):
+ return f"<{self.__module__}.{self.__class__.__name__} {self.name} {self.status}>"
+
+
+class TestharnessResult(Result):
+ default_expected = "OK"
+ statuses = {"OK", "ERROR", "INTERNAL-ERROR", "TIMEOUT", "EXTERNAL-TIMEOUT", "CRASH", "PRECONDITION_FAILED"}
+
+
+class TestharnessSubtestResult(SubtestResult):
+ default_expected = "PASS"
+ statuses = {"PASS", "FAIL", "TIMEOUT", "NOTRUN", "PRECONDITION_FAILED"}
+
+
+class ReftestResult(Result):
+ default_expected = "PASS"
+ statuses = {"PASS", "FAIL", "ERROR", "INTERNAL-ERROR", "TIMEOUT", "EXTERNAL-TIMEOUT",
+ "CRASH"}
+
+
+class WdspecResult(Result):
+ default_expected = "OK"
+ statuses = {"OK", "ERROR", "INTERNAL-ERROR", "TIMEOUT", "EXTERNAL-TIMEOUT", "CRASH"}
+
+
+class WdspecSubtestResult(SubtestResult):
+ default_expected = "PASS"
+ statuses = {"PASS", "FAIL", "ERROR"}
+
+
+class CrashtestResult(Result):
+ default_expected = "PASS"
+ statuses = {"PASS", "ERROR", "INTERNAL-ERROR", "TIMEOUT", "EXTERNAL-TIMEOUT",
+ "CRASH"}
+
+
+def get_run_info(metadata_root, product, **kwargs):
+ return RunInfo(metadata_root, product, **kwargs)
+
+
+class RunInfo(Dict[str, Any]):
+ def __init__(self, metadata_root, product, debug,
+ browser_version=None,
+ browser_channel=None,
+ verify=None,
+ extras=None,
+ device_serials=None,
+ adb_binary=None):
+ import mozinfo
+ self._update_mozinfo(metadata_root)
+ self.update(mozinfo.info)
+
+ from .update.tree import GitTree
+ try:
+ # GitTree.__init__ throws if we are not in a git tree.
+ rev = GitTree(log_error=False).rev
+ except (OSError, subprocess.CalledProcessError):
+ rev = None
+ if rev:
+ self["revision"] = rev.decode("utf-8")
+
+ self["python_version"] = sys.version_info.major
+ self["product"] = product
+ if debug is not None:
+ self["debug"] = debug
+ elif "debug" not in self:
+ # Default to release
+ self["debug"] = False
+ if browser_version:
+ self["browser_version"] = browser_version
+ if browser_channel:
+ self["browser_channel"] = browser_channel
+
+ self["verify"] = verify
+ if "wasm" not in self:
+ self["wasm"] = False
+ if extras is not None:
+ self.update(extras)
+ if "headless" not in self:
+ self["headless"] = False
+
+ if adb_binary:
+ self["adb_binary"] = adb_binary
+ if device_serials:
+ # Assume all emulators are identical, so query an arbitrary one.
+ self._update_with_emulator_info(device_serials[0])
+ self.pop("linux_distro", None)
+
+ def _adb_run(self, device_serial, args, **kwargs):
+ adb_binary = self.get("adb_binary", "adb")
+ cmd = [adb_binary, "-s", device_serial, *args]
+ return subprocess.check_output(cmd, **kwargs)
+
+ def _adb_get_property(self, device_serial, prop, **kwargs):
+ args = ["shell", "getprop", prop]
+ value = self._adb_run(device_serial, args, **kwargs)
+ return value.strip()
+
+ def _update_with_emulator_info(self, device_serial):
+ """Override system info taken from the host if using an Android
+ emulator."""
+ try:
+ self._adb_run(device_serial, ["wait-for-device"])
+ emulator_info = {
+ "os": "android",
+ "os_version": self._adb_get_property(
+ device_serial,
+ "ro.build.version.release",
+ encoding="utf-8",
+ ),
+ }
+ emulator_info["version"] = emulator_info["os_version"]
+
+ # Detect CPU info (https://developer.android.com/ndk/guides/abis#sa)
+ abi64, *_ = self._adb_get_property(
+ device_serial,
+ "ro.product.cpu.abilist64",
+ encoding="utf-8",
+ ).split(',')
+ if abi64:
+ emulator_info["processor"] = abi64
+ emulator_info["bits"] = 64
+ else:
+ emulator_info["processor"], *_ = self._adb_get_property(
+ device_serial,
+ "ro.product.cpu.abilist32",
+ encoding="utf-8",
+ ).split(',')
+ emulator_info["bits"] = 32
+
+ self.update(emulator_info)
+ except (OSError, subprocess.CalledProcessError):
+ pass
+
+ def _update_mozinfo(self, metadata_root):
+ """Add extra build information from a mozinfo.json file in a parent
+ directory"""
+ import mozinfo
+
+ path = metadata_root
+ dirs = set()
+ while path != os.path.expanduser('~'):
+ if path in dirs:
+ break
+ dirs.add(str(path))
+ path = os.path.dirname(path)
+
+ mozinfo.find_and_update_from_json(*dirs)
+
+
+def server_protocol(manifest_item):
+ if hasattr(manifest_item, "h2") and manifest_item.h2:
+ return "h2"
+ if hasattr(manifest_item, "https") and manifest_item.https:
+ return "https"
+ return "http"
+
+
+class Test:
+
+ 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, pac=None):
+ self.url_base = url_base
+ self.tests_root = tests_root
+ self.url = url
+ self._inherit_metadata = inherit_metadata
+ self._test_metadata = test_metadata
+ self.timeout = timeout if timeout is not None else self.default_timeout
+ self.path = path
+
+ self.subdomain = subdomain
+ self.environment = {"url_base": url_base,
+ "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
+ return self.id == other.id
+
+ # Python 2 does not have this delegation, while Python 3 does.
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def update_metadata(self, metadata=None):
+ if metadata is None:
+ metadata = {}
+ return metadata
+
+ @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
+ return cls(manifest_file.url_base,
+ manifest_file.tests_root,
+ manifest_item.url,
+ inherit_metadata,
+ test_metadata,
+ timeout=timeout,
+ path=os.path.join(manifest_file.tests_root, manifest_item.path),
+ protocol=server_protocol(manifest_item),
+ subdomain=manifest_item.subdomain)
+
+ @property
+ def id(self):
+ return self.url
+
+ @property
+ def keys(self):
+ return tuple()
+
+ @property
+ def abs_path(self):
+ return os.path.join(self.tests_root, self.path)
+
+ def _get_metadata(self, subtest=None):
+ if self._test_metadata is not None and subtest is not None:
+ return self._test_metadata.get_subtest(subtest)
+ else:
+ return self._test_metadata
+
+ def itermeta(self, subtest=None):
+ if self._test_metadata is not None:
+ if subtest is not None:
+ subtest_meta = self._get_metadata(subtest)
+ if subtest_meta is not None:
+ yield subtest_meta
+ yield self._get_metadata()
+ yield from reversed(self._inherit_metadata)
+
+ def disabled(self, subtest=None):
+ for meta in self.itermeta(subtest):
+ disabled = meta.disabled
+ if disabled is not None:
+ return disabled
+ return None
+
+ @property
+ def restart_after(self):
+ for meta in self.itermeta(None):
+ restart_after = meta.restart_after
+ if restart_after is not None:
+ return True
+ return False
+
+ @property
+ def leaks(self):
+ for meta in self.itermeta(None):
+ leaks = meta.leaks
+ if leaks is not None:
+ return leaks
+ return False
+
+ @property
+ def min_assertion_count(self):
+ for meta in self.itermeta(None):
+ count = meta.min_assertion_count
+ if count is not None:
+ return count
+ return 0
+
+ @property
+ def max_assertion_count(self):
+ for meta in self.itermeta(None):
+ count = meta.max_assertion_count
+ if count is not None:
+ return count
+ return 0
+
+ @property
+ def lsan_disabled(self):
+ for meta in self.itermeta():
+ if meta.lsan_disabled is not None:
+ return meta.lsan_disabled
+ return False
+
+ @property
+ def lsan_allowed(self):
+ lsan_allowed = set()
+ for meta in self.itermeta():
+ lsan_allowed |= meta.lsan_allowed
+ if atom_reset in lsan_allowed:
+ lsan_allowed.remove(atom_reset)
+ break
+ return lsan_allowed
+
+ @property
+ def lsan_max_stack_depth(self):
+ for meta in self.itermeta(None):
+ depth = meta.lsan_max_stack_depth
+ if depth is not None:
+ return depth
+ return None
+
+ @property
+ def mozleak_allowed(self):
+ mozleak_allowed = set()
+ for meta in self.itermeta():
+ mozleak_allowed |= meta.leak_allowed
+ if atom_reset in mozleak_allowed:
+ mozleak_allowed.remove(atom_reset)
+ break
+ return mozleak_allowed
+
+ @property
+ def mozleak_threshold(self):
+ rv = {}
+ for meta in self.itermeta(None):
+ threshold = meta.leak_threshold
+ for key, value in threshold.items():
+ if key not in rv:
+ rv[key] = value
+ return rv
+
+ @property
+ def tags(self):
+ tags = set()
+ for meta in self.itermeta():
+ meta_tags = meta.tags
+ tags |= meta_tags
+ if atom_reset in meta_tags:
+ tags.remove(atom_reset)
+ break
+
+ tags.add("dir:%s" % self.id.lstrip("/").split("/")[0])
+
+ return tags
+
+ @property
+ def prefs(self):
+ prefs = {}
+ for meta in reversed(list(self.itermeta())):
+ meta_prefs = meta.prefs
+ if atom_reset in meta_prefs:
+ del meta_prefs[atom_reset]
+ prefs = {}
+ prefs.update(meta_prefs)
+ return prefs
+
+ def expected(self, subtest=None):
+ if subtest is None:
+ default = self.result_cls.default_expected
+ else:
+ default = self.subtest_result_cls.default_expected
+
+ metadata = self._get_metadata(subtest)
+ if metadata is None:
+ return default
+
+ try:
+ expected = metadata.get("expected")
+ if isinstance(expected, str):
+ return expected
+ elif isinstance(expected, list):
+ return expected[0]
+ elif expected is None:
+ return default
+ except KeyError:
+ return default
+
+ def implementation_status(self):
+ implementation_status = None
+ for meta in self.itermeta():
+ implementation_status = meta.implementation_status
+ if implementation_status:
+ return implementation_status
+
+ # assuming no specific case, we are implementing it
+ return "implementing"
+
+ def known_intermittent(self, subtest=None):
+ metadata = self._get_metadata(subtest)
+ if metadata is None:
+ return []
+
+ try:
+ expected = metadata.get("expected")
+ if isinstance(expected, list):
+ return expected[1:]
+ return []
+ except KeyError:
+ return []
+
+ def __repr__(self):
+ return f"<{self.__module__}.{self.__class__.__name__} {self.id}>"
+
+
+class TestharnessTest(Test):
+ result_cls = TestharnessResult
+ subtest_result_cls = TestharnessSubtestResult
+ test_type = "testharness"
+
+ 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, pac=None):
+ Test.__init__(self, url_base, tests_root, url, inherit_metadata, test_metadata, timeout,
+ path, protocol, subdomain, pac)
+
+ self.testdriver = testdriver
+ self.jsshell = jsshell
+ self.scripts = scripts or []
+
+ @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 []
+ scripts = [v for (k, v) in script_metadata
+ if k == "script"]
+ return cls(manifest_file.url_base,
+ manifest_file.tests_root,
+ manifest_item.url,
+ 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,
+ jsshell=jsshell,
+ scripts=scripts,
+ subdomain=manifest_item.subdomain)
+
+ @property
+ def id(self):
+ return self.url
+
+
+class ManualTest(Test):
+ test_type = "manual"
+
+ @property
+ def id(self):
+ return self.url
+
+
+class ReftestTest(Test):
+ """A reftest
+
+ A reftest should be considered to pass if one of its references matches (see below) *and* the
+ reference passes if it has any references recursively.
+
+ Attributes:
+ references (List[Tuple[str, str]]): a list of alternate references, where one must match for the test to pass
+ viewport_size (Optional[Tuple[int, int]]): size of the viewport for this test, if not default
+ dpi (Optional[int]): dpi to use when rendering this test, if not default
+
+ """
+ result_cls = ReftestResult
+ test_type = "reftest"
+
+ def __init__(self, url_base, tests_root, url, inherit_metadata, test_metadata, references,
+ timeout=None, path=None, viewport_size=None, dpi=None, fuzzy=None,
+ protocol="http", subdomain=False):
+ Test.__init__(self, url_base, tests_root, url, inherit_metadata, test_metadata, timeout,
+ path, protocol, subdomain)
+
+ for _, ref_type in references:
+ if ref_type not in ("==", "!="):
+ raise ValueError
+
+ self.references = references
+ self.viewport_size = self.get_viewport_size(viewport_size)
+ self.dpi = dpi
+ self._fuzzy = fuzzy or {}
+
+ @classmethod
+ def cls_kwargs(cls, manifest_test):
+ return {"viewport_size": manifest_test.viewport_size,
+ "dpi": manifest_test.dpi,
+ "protocol": server_protocol(manifest_test),
+ "fuzzy": manifest_test.fuzzy}
+
+ @classmethod
+ def from_manifest(cls,
+ manifest_file,
+ manifest_test,
+ inherit_metadata,
+ test_metadata):
+
+ timeout = cls.long_timeout if manifest_test.timeout == "long" else cls.default_timeout
+
+ url = manifest_test.url
+
+ node = cls(manifest_file.url_base,
+ manifest_file.tests_root,
+ manifest_test.url,
+ inherit_metadata,
+ test_metadata,
+ [],
+ timeout=timeout,
+ path=manifest_test.path,
+ subdomain=manifest_test.subdomain,
+ **cls.cls_kwargs(manifest_test))
+
+ refs_by_type = defaultdict(list)
+
+ for ref_url, ref_type in manifest_test.references:
+ refs_by_type[ref_type].append(ref_url)
+
+ # Construct a list of all the mismatches, where we end up with mismatch_1 != url !=
+ # mismatch_2 != url != mismatch_3 etc.
+ #
+ # Per the logic documented above, this means that none of the mismatches provided match,
+ mismatch_walk = None
+ if refs_by_type["!="]:
+ mismatch_walk = ReftestTest(manifest_file.url_base,
+ manifest_file.tests_root,
+ refs_by_type["!="][0],
+ [],
+ None,
+ [])
+ cmp_ref = mismatch_walk
+ for ref_url in refs_by_type["!="][1:]:
+ cmp_self = ReftestTest(manifest_file.url_base,
+ manifest_file.tests_root,
+ url,
+ [],
+ None,
+ [])
+ cmp_ref.references.append((cmp_self, "!="))
+ cmp_ref = ReftestTest(manifest_file.url_base,
+ manifest_file.tests_root,
+ ref_url,
+ [],
+ None,
+ [])
+ cmp_self.references.append((cmp_ref, "!="))
+
+ if mismatch_walk is None:
+ mismatch_refs = []
+ else:
+ mismatch_refs = [(mismatch_walk, "!=")]
+
+ if refs_by_type["=="]:
+ # For each == ref, add a reference to this node whose tail is the mismatch list.
+ # Per the logic documented above, this means any one of the matches must pass plus all the mismatches.
+ for ref_url in refs_by_type["=="]:
+ ref = ReftestTest(manifest_file.url_base,
+ manifest_file.tests_root,
+ ref_url,
+ [],
+ None,
+ mismatch_refs)
+ node.references.append((ref, "=="))
+ else:
+ # Otherwise, we just add the mismatches directly as we are immediately into the
+ # mismatch chain with no alternates.
+ node.references.extend(mismatch_refs)
+
+ return node
+
+ def update_metadata(self, metadata):
+ if "url_count" not in metadata:
+ metadata["url_count"] = defaultdict(int)
+ for reference, _ in self.references:
+ # We assume a naive implementation in which a url with multiple
+ # possible screenshots will need to take both the lhs and rhs screenshots
+ # for each possible match
+ metadata["url_count"][(self.environment["protocol"], reference.url)] += 1
+ reference.update_metadata(metadata)
+ return metadata
+
+ def get_viewport_size(self, override):
+ return override
+
+ @property
+ def id(self):
+ return self.url
+
+ @property
+ def keys(self):
+ return ("reftype", "refurl")
+
+ @property
+ def fuzzy(self):
+ return self._fuzzy
+
+ @property
+ def fuzzy_override(self):
+ values = {}
+ for meta in reversed(list(self.itermeta(None))):
+ value = meta.fuzzy
+ if not value:
+ continue
+ if atom_reset in value:
+ value.remove(atom_reset)
+ values = {}
+ for key, data in value:
+ if isinstance(key, (tuple, list)):
+ key = list(key)
+ key[0] = urljoin(self.url, key[0])
+ key[1] = urljoin(self.url, key[1])
+ key = tuple(key)
+ elif key:
+ # Key is just a relative url to a ref
+ key = urljoin(self.url, key)
+ values[key] = data
+ return values
+
+ @property
+ def page_ranges(self):
+ return {}
+
+
+class PrintReftestTest(ReftestTest):
+ test_type = "print-reftest"
+
+ def __init__(self, url_base, tests_root, url, inherit_metadata, test_metadata, references,
+ timeout=None, path=None, viewport_size=None, dpi=None, fuzzy=None,
+ page_ranges=None, protocol="http", subdomain=False):
+ super().__init__(url_base, tests_root, url, inherit_metadata, test_metadata,
+ references, timeout, path, viewport_size, dpi,
+ fuzzy, protocol, subdomain=subdomain)
+ self._page_ranges = page_ranges
+
+ @classmethod
+ def cls_kwargs(cls, manifest_test):
+ rv = super().cls_kwargs(manifest_test)
+ rv["page_ranges"] = manifest_test.page_ranges
+ return rv
+
+ def get_viewport_size(self, override):
+ assert override is None
+ return (5*2.54, 3*2.54)
+
+ @property
+ def page_ranges(self):
+ return self._page_ranges
+
+
+class WdspecTest(Test):
+ result_cls = WdspecResult
+ subtest_result_cls = WdspecSubtestResult
+ test_type = "wdspec"
+
+ default_timeout = 25
+ long_timeout = 180 # 3 minutes
+
+
+class CrashTest(Test):
+ result_cls = CrashtestResult
+ test_type = "crashtest"
+
+
+manifest_test_cls = {"reftest": ReftestTest,
+ "print-reftest": PrintReftestTest,
+ "testharness": TestharnessTest,
+ "manual": ManualTest,
+ "wdspec": WdspecTest,
+ "crashtest": CrashTest}
+
+
+def from_manifest(manifest_file, manifest_test, inherit_metadata, test_metadata):
+ test_cls = manifest_test_cls[manifest_test.item_type]
+ return test_cls.from_manifest(manifest_file, manifest_test, inherit_metadata, test_metadata)