summaryrefslogtreecommitdiffstats
path: root/testing/web-platform/tests/tools/wpt
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--testing/web-platform/tests/tools/wpt/__init__.py0
-rw-r--r--testing/web-platform/tests/tools/wpt/android.py181
-rw-r--r--testing/web-platform/tests/tools/wpt/browser.py2048
-rw-r--r--testing/web-platform/tests/tools/wpt/commands.json94
-rw-r--r--testing/web-platform/tests/tools/wpt/create.py133
-rw-r--r--testing/web-platform/tests/tools/wpt/install.py120
-rw-r--r--testing/web-platform/tests/tools/wpt/markdown.py44
-rw-r--r--testing/web-platform/tests/tools/wpt/paths7
-rw-r--r--testing/web-platform/tests/tools/wpt/requirements.txt1
-rw-r--r--testing/web-platform/tests/tools/wpt/requirements_install.txt1
-rw-r--r--testing/web-platform/tests/tools/wpt/revlist.py107
-rw-r--r--testing/web-platform/tests/tools/wpt/run.py873
-rw-r--r--testing/web-platform/tests/tools/wpt/testfiles.py442
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/__init__.py0
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/latest_mozilla_central.txt20834
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2018-05-17.html30
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2018-09-19.html33
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-06-04.html33
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-07-16.html36
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-11-14.html33
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2021-06-08.html33
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2021-10-28.html31
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-05-29.html44
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-06-22.html46
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-07-05.html37
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-07-07.html46
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-08-25.html48
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/test_browser.py386
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/test_install.py81
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/test_markdown.py37
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/test_revlist.py156
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/test_run.py76
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/test_testfiles.py71
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/test_update_expectations.py130
-rw-r--r--testing/web-platform/tests/tools/wpt/tests/test_wpt.py406
-rw-r--r--testing/web-platform/tests/tools/wpt/tox.ini19
-rw-r--r--testing/web-platform/tests/tools/wpt/update.py56
-rw-r--r--testing/web-platform/tests/tools/wpt/utils.py168
-rw-r--r--testing/web-platform/tests/tools/wpt/virtualenv.py137
-rw-r--r--testing/web-platform/tests/tools/wpt/wpt.py240
-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
-rw-r--r--testing/web-platform/tests/tools/wptserve/.coveragerc11
-rw-r--r--testing/web-platform/tests/tools/wptserve/.gitignore40
-rw-r--r--testing/web-platform/tests/tools/wptserve/MANIFEST.in1
-rw-r--r--testing/web-platform/tests/tools/wptserve/README.md6
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/Makefile153
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/conf.py242
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/handlers.rst108
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/index.rst27
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/introduction.rst51
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/make.bat190
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/pipes.rst8
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/request.rst10
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/response.rst41
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/router.rst78
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/server.rst20
-rw-r--r--testing/web-platform/tests/tools/wptserve/docs/stash.rst31
-rw-r--r--testing/web-platform/tests/tools/wptserve/setup.py23
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/__init__.py0
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/base.py148
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/__init__.py0
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/bar.any.worker.js10
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/document.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.html15
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.serviceworker.html15
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.sharedworker.html9
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.worker.html9
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.window.html8
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.worker.html9
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/invalid.py3
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/no_main.py3
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub.sub.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash.sub.txt6
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash_subject.txt2
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash_unrecognized.sub.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_header_or_default.sub.txt2
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_headers.sub.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_headers.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_location.sub.txt8
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_params.sub.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_params.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_url_base.sub.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_uuid.sub.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_var.sub.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/__init__.py0
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/example_module.py2
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/file.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/import_handler.py5
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/sub_path.sub.txt3
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test.asis5
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_data.py2
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_headers.py3
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_headers_data.py6
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_string.py3
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_tuple_2.py2
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_tuple_3.py2
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/with_headers.txt1
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/docroot/with_headers.txt.sub.headers6
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/test_cookies.py66
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/test_handlers.py439
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/test_input_file.py149
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/test_pipes.py233
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/test_request.py183
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/test_response.py323
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/test_server.py118
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/functional/test_stash.py44
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/test_config.py384
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/test_replacement_tokenizer.py38
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/test_request.py104
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/test_response.py32
-rw-r--r--testing/web-platform/tests/tools/wptserve/tests/test_stash.py146
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/__init__.py3
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/config.py328
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/constants.py98
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/handlers.py512
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/logger.py5
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/pipes.py561
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/ranges.py96
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/request.py690
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/response.py818
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/router.py180
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/routes.py6
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/server.py927
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/sslutils/__init__.py16
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/sslutils/base.py19
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/sslutils/openssl.py424
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/sslutils/pregenerated.py28
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/stash.py235
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/utils.py195
-rwxr-xr-xtesting/web-platform/tests/tools/wptserve/wptserve/wptserve.py35
-rw-r--r--testing/web-platform/tests/tools/wptserve/wptserve/ws_h2_handshake.py72
267 files changed, 63133 insertions, 0 deletions
diff --git a/testing/web-platform/tests/tools/wpt/__init__.py b/testing/web-platform/tests/tools/wpt/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/__init__.py
diff --git a/testing/web-platform/tests/tools/wpt/android.py b/testing/web-platform/tests/tools/wpt/android.py
new file mode 100644
index 0000000000..366502cc6c
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/android.py
@@ -0,0 +1,181 @@
+# mypy: allow-untyped-defs
+
+import argparse
+import os
+import platform
+import shutil
+import subprocess
+
+import requests
+from .wpt import venv_dir
+
+android_device = None
+
+here = os.path.abspath(os.path.dirname(__file__))
+wpt_root = os.path.abspath(os.path.join(here, os.pardir, os.pardir))
+
+
+def do_delayed_imports():
+ global android_device
+ from mozrunner.devices import android_device
+ android_device.TOOLTOOL_PATH = os.path.join(os.path.dirname(__file__),
+ os.pardir,
+ "third_party",
+ "tooltool",
+ "tooltool.py")
+
+
+def get_parser_install():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--reinstall", action="store_true", default=False,
+ help="Force reinstall even if the emulator already exists")
+ return parser
+
+
+def get_parser_start():
+ return get_parser_install()
+
+
+def get_sdk_path(dest):
+ if dest is None:
+ # os.getcwd() doesn't include the venv path
+ dest = os.path.join(wpt_root, venv_dir())
+ dest = os.path.join(dest, 'android-sdk')
+ return os.path.abspath(os.environ.get('ANDROID_SDK_PATH', dest))
+
+
+def uninstall_sdk(dest=None):
+ path = get_sdk_path(dest)
+ if os.path.exists(path) and os.path.isdir(path):
+ shutil.rmtree(path)
+
+
+def install_sdk(logger, dest=None):
+ sdk_path = get_sdk_path(dest)
+ if os.path.isdir(sdk_path):
+ logger.info("Using SDK installed at %s" % sdk_path)
+ return sdk_path, False
+
+ if not os.path.exists(sdk_path):
+ os.makedirs(sdk_path)
+
+ os_name = platform.system().lower()
+ if os_name not in ["darwin", "linux", "windows"]:
+ logger.critical("Unsupported platform %s" % os_name)
+ raise NotImplementedError
+
+ os_name = 'darwin' if os_name == 'macosx' else os_name
+ # TODO: either always use the latest version or have some way to
+ # configure a per-product version if there are strong requirements
+ # to use a specific version.
+ url = f'https://dl.google.com/android/repository/sdk-tools-{os_name}-4333796.zip'
+
+ logger.info("Getting SDK from %s" % url)
+ temp_path = os.path.join(sdk_path, url.rsplit("/", 1)[1])
+ try:
+ with open(temp_path, "wb") as f:
+ with requests.get(url, stream=True) as resp:
+ shutil.copyfileobj(resp.raw, f)
+
+ # Python's zipfile module doesn't seem to work here
+ subprocess.check_call(["unzip", temp_path], cwd=sdk_path)
+ finally:
+ os.unlink(temp_path)
+
+ return sdk_path, True
+
+
+def install_android_packages(logger, sdk_path, no_prompt=False):
+ sdk_manager_path = os.path.join(sdk_path, "tools", "bin", "sdkmanager")
+ if not os.path.exists(sdk_manager_path):
+ raise OSError("Can't find sdkmanager at %s" % sdk_manager_path)
+
+ packages = ["platform-tools",
+ "build-tools;33.0.1",
+ "platforms;android-33",
+ "emulator"]
+
+ # TODO: make this work non-internactively
+ logger.info("Installing SDK packages")
+ cmd = [sdk_manager_path] + packages
+
+ proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
+ if no_prompt:
+ data = "Y\n" * 100 if no_prompt else None
+ proc.communicate(data)
+ else:
+ proc.wait()
+ if proc.returncode != 0:
+ raise subprocess.CalledProcessError(proc.returncode, cmd)
+
+
+def get_emulator(sdk_path, device_serial=None):
+ if android_device is None:
+ do_delayed_imports()
+ if "ANDROID_SDK_ROOT" not in os.environ:
+ os.environ["ANDROID_SDK_ROOT"] = sdk_path
+ substs = {"top_srcdir": wpt_root, "TARGET_CPU": "x86"}
+ emulator = android_device.AndroidEmulator("*", substs=substs, device_serial=device_serial)
+ emulator.emulator_path = os.path.join(sdk_path, "emulator", "emulator")
+ return emulator
+
+
+def install(logger, reinstall=False, no_prompt=False, device_serial=None):
+ if reinstall:
+ uninstall_sdk()
+
+ dest, new_install = install_sdk(logger)
+ if new_install:
+ install_android_packages(logger, dest, no_prompt)
+
+ if "ANDROID_SDK_ROOT" not in os.environ:
+ os.environ["ANDROID_SDK_ROOT"] = dest
+
+ emulator = get_emulator(dest, device_serial=device_serial)
+ return emulator
+
+
+def start(logger, emulator=None, reinstall=False, device_serial=None):
+ if reinstall:
+ install(reinstall=True)
+
+ sdk_path = get_sdk_path(None)
+
+ if emulator is None:
+ emulator = get_emulator(sdk_path, device_serial=device_serial)
+
+ if not emulator.check_avd():
+ logger.critical("Android AVD not found, please run |mach bootstrap|")
+ raise NotImplementedError
+
+ emulator.start()
+ emulator.wait_for_start()
+ return emulator
+
+
+def run_install(venv, **kwargs):
+ try:
+ import logging
+ logging.basicConfig()
+ logger = logging.getLogger()
+
+ install(logger, **kwargs)
+ except Exception:
+ import traceback
+ traceback.print_exc()
+ import pdb
+ pdb.post_mortem()
+
+
+def run_start(venv, **kwargs):
+ try:
+ import logging
+ logging.basicConfig()
+ logger = logging.getLogger()
+
+ start(logger, **kwargs)
+ except Exception:
+ import traceback
+ traceback.print_exc()
+ import pdb
+ pdb.post_mortem()
diff --git a/testing/web-platform/tests/tools/wpt/browser.py b/testing/web-platform/tests/tools/wpt/browser.py
new file mode 100644
index 0000000000..66796a8968
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/browser.py
@@ -0,0 +1,2048 @@
+# mypy: allow-untyped-defs
+import os
+import platform
+import re
+import shutil
+import stat
+import subprocess
+import tempfile
+from abc import ABCMeta, abstractmethod
+from datetime import datetime, timedelta
+from distutils.spawn import find_executable
+from urllib.parse import urlsplit
+
+import html5lib
+import requests
+from packaging.specifiers import SpecifierSet
+
+from .utils import (
+ call,
+ get,
+ get_download_to_descriptor,
+ rmtree,
+ sha256sum,
+ untar,
+ unzip,
+)
+from .wpt import venv_dir
+
+uname = platform.uname()
+
+# the rootUrl for the firefox-ci deployment of Taskcluster
+FIREFOX_CI_ROOT_URL = 'https://firefox-ci-tc.services.mozilla.com'
+
+
+def _get_fileversion(binary, logger=None):
+ command = "(Get-Item '%s').VersionInfo.FileVersion" % binary.replace("'", "''")
+ try:
+ return call("powershell.exe", command).strip()
+ except (subprocess.CalledProcessError, OSError):
+ if logger is not None:
+ logger.warning("Failed to call %s in PowerShell" % command)
+ return None
+
+
+def get_ext(filename):
+ """Get the extension from a filename with special handling for .tar.foo"""
+ name, ext = os.path.splitext(filename)
+ if name.endswith(".tar"):
+ ext = ".tar%s" % ext
+ return ext
+
+
+def get_download_filename(resp, default=None):
+ """Get the filename from a requests.Response, or default"""
+ filename = None
+
+ content_disposition = resp.headers.get("content-disposition")
+ if content_disposition:
+ filenames = re.findall("filename=(.+)", content_disposition)
+ if filenames:
+ filename = filenames[0]
+
+ if not filename:
+ filename = urlsplit(resp.url).path.rsplit("/", 1)[1]
+
+ return filename or default
+
+
+def get_taskcluster_artifact(index, path):
+ TC_INDEX_BASE = FIREFOX_CI_ROOT_URL + "/api/index/v1/"
+
+ resp = get(TC_INDEX_BASE + "task/%s/artifacts/%s" % (index, path))
+ resp.raise_for_status()
+
+ return resp
+
+
+class Browser:
+ __metaclass__ = ABCMeta
+
+ def __init__(self, logger):
+ self.logger = logger
+
+ def _get_browser_binary_dir(self, dest, channel):
+ if dest is None:
+ # os.getcwd() doesn't include the venv path
+ dest = os.path.join(os.getcwd(), venv_dir())
+
+ dest = os.path.join(dest, "browsers", channel)
+
+ if not os.path.exists(dest):
+ os.makedirs(dest)
+
+ return dest
+
+ @abstractmethod
+ def download(self, dest=None, channel=None, rename=None):
+ """Download a package or installer for the browser
+ :param dest: Directory in which to put the dowloaded package
+ :param channel: Browser channel to download
+ :param rename: Optional name for the downloaded package; the original
+ extension is preserved.
+ :return: The path to the downloaded package/installer
+ """
+ return NotImplemented
+
+ @abstractmethod
+ def install(self, dest=None, channel=None):
+ """Download and install the browser.
+
+ This method usually calls download().
+
+ :param dest: Directory in which to install the browser
+ :param channel: Browser channel to install
+ :return: The path to the installed browser
+ """
+ return NotImplemented
+
+ @abstractmethod
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ """Download and install the WebDriver implementation for this browser.
+
+ :param dest: Directory in which to install the WebDriver
+ :param channel: Browser channel to install
+ :param browser_binary: The path to the browser binary
+ :return: The path to the installed WebDriver
+ """
+ return NotImplemented
+
+ @abstractmethod
+ def find_binary(self, venv_path=None, channel=None):
+ """Find the binary of the browser.
+
+ If the WebDriver for the browser is able to find the binary itself, this
+ method doesn't need to be implemented, in which case NotImplementedError
+ is suggested to be raised to prevent accidental use.
+ """
+ return NotImplemented
+
+ @abstractmethod
+ def find_webdriver(self, venv_path=None, channel=None):
+ """Find the binary of the WebDriver."""
+ return NotImplemented
+
+ @abstractmethod
+ def version(self, binary=None, webdriver_binary=None):
+ """Retrieve the release version of the installed browser."""
+ return NotImplemented
+
+ @abstractmethod
+ def requirements(self):
+ """Name of the browser-specific wptrunner requirements file"""
+ return NotImplemented
+
+
+class Firefox(Browser):
+ """Firefox-specific interface.
+
+ Includes installation, webdriver installation, and wptrunner setup methods.
+ """
+
+ product = "firefox"
+ binary = "browsers/firefox/firefox"
+ requirements = "requirements_firefox.txt"
+
+ platform = {
+ "Linux": "linux",
+ "Windows": "win",
+ "Darwin": "macos"
+ }.get(uname[0])
+
+ application_name = {
+ "stable": "Firefox.app",
+ "beta": "Firefox.app",
+ "nightly": "Firefox Nightly.app"
+ }
+
+ def platform_string_geckodriver(self):
+ if self.platform is None:
+ raise ValueError("Unable to construct a valid Geckodriver package name for current platform")
+
+ if self.platform in ("linux", "win"):
+ bits = "64" if uname[4] == "x86_64" else "32"
+ elif self.platform == "macos" and uname.machine == "arm64":
+ bits = "-aarch64"
+ else:
+ bits = ""
+
+ return "%s%s" % (self.platform, bits)
+
+ def download(self, dest=None, channel="nightly", rename=None):
+ product = {
+ "nightly": "firefox-nightly-latest-ssl",
+ "beta": "firefox-beta-latest-ssl",
+ "stable": "firefox-latest-ssl"
+ }
+
+ os_builds = {
+ ("linux", "x86"): "linux",
+ ("linux", "x86_64"): "linux64",
+ ("win", "x86"): "win",
+ ("win", "AMD64"): "win64",
+ ("macos", "x86_64"): "osx",
+ }
+ os_key = (self.platform, uname[4])
+
+ if dest is None:
+ dest = self._get_browser_binary_dir(None, channel)
+
+ if channel not in product:
+ raise ValueError("Unrecognised release channel: %s" % channel)
+
+ if os_key not in os_builds:
+ raise ValueError("Unsupported platform: %s %s" % os_key)
+
+ url = "https://download.mozilla.org/?product=%s&os=%s&lang=en-US" % (product[channel],
+ os_builds[os_key])
+ self.logger.info("Downloading Firefox from %s" % url)
+ resp = get(url)
+
+ filename = get_download_filename(resp, "firefox.tar.bz2")
+
+ if rename:
+ filename = "%s%s" % (rename, get_ext(filename))
+
+ installer_path = os.path.join(dest, filename)
+
+ with open(installer_path, "wb") as f:
+ f.write(resp.content)
+
+ return installer_path
+
+ def install(self, dest=None, channel="nightly"):
+ """Install Firefox."""
+ import mozinstall
+
+ dest = self._get_browser_binary_dir(dest, channel)
+
+ filename = os.path.basename(dest)
+
+ installer_path = self.download(dest, channel)
+
+ try:
+ mozinstall.install(installer_path, dest)
+ except mozinstall.mozinstall.InstallError:
+ if self.platform == "macos" and os.path.exists(os.path.join(dest, self.application_name.get(channel, "Firefox Nightly.app"))):
+ # mozinstall will fail if nightly is already installed in the venv because
+ # mac installation uses shutil.copy_tree
+ mozinstall.uninstall(os.path.join(dest, self.application_name.get(channel, "Firefox Nightly.app")))
+ mozinstall.install(filename, dest)
+ else:
+ raise
+
+ os.remove(installer_path)
+ return self.find_binary_path(dest)
+
+ def find_binary_path(self, path=None, channel="nightly"):
+ """Looks for the firefox binary in the virtual environment"""
+
+ if path is None:
+ path = self._get_browser_binary_dir(None, channel)
+
+ binary = None
+
+ if self.platform == "linux":
+ binary = find_executable("firefox", os.path.join(path, "firefox"))
+ elif self.platform == "win":
+ import mozinstall
+ try:
+ binary = mozinstall.get_binary(path, "firefox")
+ except mozinstall.InvalidBinary:
+ # ignore the case where we fail to get a binary
+ pass
+ elif self.platform == "macos":
+ binary = find_executable("firefox", os.path.join(path, self.application_name.get(channel, "Firefox Nightly.app"),
+ "Contents", "MacOS"))
+
+ return binary
+
+ def find_binary(self, venv_path=None, channel="nightly"):
+
+ path = self._get_browser_binary_dir(venv_path, channel)
+ binary = self.find_binary_path(path, channel)
+
+ if not binary and self.platform == "win":
+ winpaths = [os.path.expandvars("$SYSTEMDRIVE\\Program Files\\Mozilla Firefox"),
+ os.path.expandvars("$SYSTEMDRIVE\\Program Files (x86)\\Mozilla Firefox")]
+ for winpath in winpaths:
+ binary = self.find_binary_path(winpath, channel)
+ if binary is not None:
+ break
+
+ if not binary and self.platform == "macos":
+ macpaths = ["/Applications/Firefox Nightly.app/Contents/MacOS",
+ os.path.expanduser("~/Applications/Firefox Nightly.app/Contents/MacOS"),
+ "/Applications/Firefox Developer Edition.app/Contents/MacOS",
+ os.path.expanduser("~/Applications/Firefox Developer Edition.app/Contents/MacOS"),
+ "/Applications/Firefox.app/Contents/MacOS",
+ os.path.expanduser("~/Applications/Firefox.app/Contents/MacOS")]
+ return find_executable("firefox", os.pathsep.join(macpaths))
+
+ if binary is None:
+ return find_executable("firefox")
+
+ return binary
+
+ def find_certutil(self):
+ path = find_executable("certutil")
+ if path is None:
+ return None
+ if os.path.splitdrive(os.path.normcase(path))[1].split(os.path.sep) == ["", "windows", "system32", "certutil.exe"]:
+ return None
+ return path
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ return find_executable("geckodriver")
+
+ def get_version_and_channel(self, binary):
+ version_string = call(binary, "--version").strip()
+ m = re.match(r"Mozilla Firefox (\d+\.\d+(?:\.\d+)?)(a|b)?", version_string)
+ if not m:
+ return None, "nightly"
+ version, status = m.groups()
+ channel = {"a": "nightly", "b": "beta"}
+ return version, channel.get(status, "stable")
+
+ def get_profile_bundle_url(self, version, channel):
+ if channel == "stable":
+ repo = "https://hg.mozilla.org/releases/mozilla-release"
+ tag = "FIREFOX_%s_RELEASE" % version.replace(".", "_")
+ elif channel == "beta":
+ repo = "https://hg.mozilla.org/releases/mozilla-beta"
+ major_version = version.split(".", 1)[0]
+ # For beta we have a different format for betas that are now in stable releases
+ # vs those that are not
+ tags = get("https://hg.mozilla.org/releases/mozilla-beta/json-tags").json()["tags"]
+ tags = {item["tag"] for item in tags}
+ end_tag = "FIREFOX_BETA_%s_END" % major_version
+ if end_tag in tags:
+ tag = end_tag
+ else:
+ tag = "tip"
+ else:
+ repo = "https://hg.mozilla.org/mozilla-central"
+ # Always use tip as the tag for nightly; this isn't quite right
+ # but to do better we need the actual build revision, which we
+ # can get if we have an application.ini file
+ tag = "tip"
+
+ return "%s/archive/%s.zip/testing/profiles/" % (repo, tag)
+
+ def install_prefs(self, binary, dest=None, channel=None):
+ if binary:
+ version, channel_ = self.get_version_and_channel(binary)
+ if channel is not None and channel != channel_:
+ # Beta doesn't always seem to have the b in the version string, so allow the
+ # manually supplied value to override the one from the binary
+ self.logger.warning("Supplied channel doesn't match binary, using supplied channel")
+ elif channel is None:
+ channel = channel_
+ else:
+ version = None
+
+ if dest is None:
+ dest = os.curdir
+
+ dest = os.path.join(dest, "profiles", channel)
+ if version:
+ dest = os.path.join(dest, version)
+ have_cache = False
+ if os.path.exists(dest) and len(os.listdir(dest)) > 0:
+ if channel != "nightly":
+ have_cache = True
+ else:
+ now = datetime.now()
+ have_cache = (datetime.fromtimestamp(os.stat(dest).st_mtime) >
+ now - timedelta(days=1))
+
+ # If we don't have a recent download, grab and extract the latest one
+ if not have_cache:
+ if os.path.exists(dest):
+ rmtree(dest)
+ os.makedirs(dest)
+
+ url = self.get_profile_bundle_url(version, channel)
+
+ self.logger.info("Installing test prefs from %s" % url)
+ try:
+ extract_dir = tempfile.mkdtemp()
+ unzip(get(url).raw, dest=extract_dir)
+
+ profiles = os.path.join(extract_dir, os.listdir(extract_dir)[0], 'testing', 'profiles')
+ for name in os.listdir(profiles):
+ path = os.path.join(profiles, name)
+ shutil.move(path, dest)
+ finally:
+ rmtree(extract_dir)
+ else:
+ self.logger.info("Using cached test prefs from %s" % dest)
+
+ return dest
+
+ def _latest_geckodriver_version(self):
+ """Get and return latest version number for geckodriver."""
+ # This is used rather than an API call to avoid rate limits
+ tags = call("git", "ls-remote", "--tags", "--refs",
+ "https://github.com/mozilla/geckodriver.git")
+ release_re = re.compile(r".*refs/tags/v(\d+)\.(\d+)\.(\d+)")
+ latest_release = (0, 0, 0)
+ for item in tags.split("\n"):
+ m = release_re.match(item)
+ if m:
+ version = tuple(int(item) for item in m.groups())
+ if version > latest_release:
+ latest_release = version
+ assert latest_release != (0, 0, 0)
+ return "v%s.%s.%s" % tuple(str(item) for item in latest_release)
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ """Install latest Geckodriver."""
+ if dest is None:
+ dest = os.getcwd()
+
+ path = None
+ if channel == "nightly":
+ path = self.install_geckodriver_nightly(dest)
+ if path is None:
+ self.logger.warning("Nightly webdriver not found; falling back to release")
+
+ if path is None:
+ version = self._latest_geckodriver_version()
+ format = "zip" if uname[0] == "Windows" else "tar.gz"
+ self.logger.debug("Latest geckodriver release %s" % version)
+ url = ("https://github.com/mozilla/geckodriver/releases/download/%s/geckodriver-%s-%s.%s" %
+ (version, version, self.platform_string_geckodriver(), format))
+ if format == "zip":
+ unzip(get(url).raw, dest=dest)
+ else:
+ untar(get(url).raw, dest=dest)
+ path = find_executable(os.path.join(dest, "geckodriver"))
+
+ assert path is not None
+ self.logger.info("Installed %s" %
+ subprocess.check_output([path, "--version"]).splitlines()[0])
+ return path
+
+ def install_geckodriver_nightly(self, dest):
+ self.logger.info("Attempting to install webdriver from nightly")
+
+ platform_bits = ("64" if uname[4] == "x86_64" else
+ ("32" if self.platform == "win" else ""))
+ tc_platform = "%s%s" % (self.platform, platform_bits)
+
+ archive_ext = ".zip" if uname[0] == "Windows" else ".tar.gz"
+ archive_name = "public/build/geckodriver%s" % archive_ext
+
+ try:
+ resp = get_taskcluster_artifact(
+ "gecko.v2.mozilla-central.latest.geckodriver.%s" % tc_platform,
+ archive_name)
+ except Exception:
+ self.logger.info("Geckodriver download failed")
+ return
+
+ if archive_ext == ".zip":
+ unzip(resp.raw, dest)
+ else:
+ untar(resp.raw, dest)
+
+ exe_ext = ".exe" if uname[0] == "Windows" else ""
+ path = os.path.join(dest, "geckodriver%s" % exe_ext)
+
+ self.logger.info("Extracted geckodriver to %s" % path)
+
+ return path
+
+ def version(self, binary=None, webdriver_binary=None):
+ """Retrieve the release version of the installed browser."""
+ version_string = call(binary, "--version").strip()
+ m = re.match(r"Mozilla Firefox (.*)", version_string)
+ if not m:
+ return None
+ return m.group(1)
+
+
+class FirefoxAndroid(Browser):
+ """Android-specific Firefox interface."""
+
+ product = "firefox_android"
+ requirements = "requirements_firefox.txt"
+
+ def __init__(self, logger):
+ super().__init__(logger)
+ self.apk_path = None
+
+ def download(self, dest=None, channel=None, rename=None):
+ if dest is None:
+ dest = os.pwd
+
+ resp = get_taskcluster_artifact(
+ "gecko.v2.mozilla-central.latest.mobile.android-x86_64-opt",
+ "public/build/geckoview-androidTest.apk")
+
+ filename = "geckoview-androidTest.apk"
+ if rename:
+ filename = "%s%s" % (rename, get_ext(filename)[1])
+ self.apk_path = os.path.join(dest, filename)
+
+ with open(self.apk_path, "wb") as f:
+ f.write(resp.content)
+
+ return self.apk_path
+
+ def install(self, dest=None, channel=None):
+ return self.download(dest, channel)
+
+ def install_prefs(self, binary, dest=None, channel=None):
+ fx_browser = Firefox(self.logger)
+ return fx_browser.install_prefs(binary, dest, channel)
+
+ def find_binary(self, venv_path=None, channel=None):
+ return self.apk_path
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ raise NotImplementedError
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ raise NotImplementedError
+
+ def version(self, binary=None, webdriver_binary=None):
+ return None
+
+
+class ChromeChromiumBase(Browser):
+ """
+ Chrome/Chromium base Browser class for shared functionality between Chrome and Chromium
+
+ For a detailed description on the installation and detection of these browser components,
+ see https://web-platform-tests.org/running-tests/chrome-chromium-installation-detection.html
+ """
+
+ requirements = "requirements_chromium.txt"
+ platform = {
+ "Linux": "Linux",
+ "Windows": "Win",
+ "Darwin": "Mac",
+ }.get(uname[0])
+
+ def _build_snapshots_url(self, revision, filename):
+ return ("https://storage.googleapis.com/chromium-browser-snapshots/"
+ f"{self._chromium_platform_string}/{revision}/{filename}")
+
+ def _get_latest_chromium_revision(self):
+ """Returns latest Chromium revision available for download."""
+ # This is only used if the user explicitly passes "latest" for the revision flag.
+ # The pinned revision is used by default to avoid unexpected failures as versions update.
+ revision_url = ("https://storage.googleapis.com/chromium-browser-snapshots/"
+ f"{self._chromium_platform_string}/LAST_CHANGE")
+ return get(revision_url).text.strip()
+
+ def _get_pinned_chromium_revision(self):
+ """Returns the pinned Chromium revision number."""
+ return get("https://storage.googleapis.com/wpt-versions/pinned_chromium_revision").text.strip()
+
+ def _get_chromium_revision(self, filename=None, version=None):
+ """Retrieve a valid Chromium revision to download a browser component."""
+
+ # If a specific version is passed as an argument, we will use it.
+ if version is not None:
+ # Detect a revision number based on the version passed.
+ revision = self._get_base_revision_from_version(version)
+ if revision is not None:
+ # File name is needed to test if request is valid.
+ url = self._build_snapshots_url(revision, filename)
+ try:
+ # Check the status without downloading the content (this is a streaming request).
+ get(url)
+ return revision
+ except requests.RequestException:
+ self.logger.warning("404: Unsuccessful attempt to download file "
+ f"based on version. {url}")
+ # If no URL was used in a previous install
+ # and no version was passed, use the pinned Chromium revision.
+ revision = self._get_pinned_chromium_revision()
+
+ # If the url is successfully used to download/install, it will be used again
+ # if another component is also installed during this run (browser/webdriver).
+ return revision
+
+ def _get_base_revision_from_version(self, version):
+ """Get a Chromium revision number that is associated with a given version."""
+ # This is not the single revision associated with the version,
+ # but instead is where it branched from. Chromium revisions are just counting
+ # commits on the master branch, there are no Chromium revisions for branches.
+
+ version = self._remove_version_suffix(version)
+
+ # Try to find the Chromium build with the same revision.
+ try:
+ omaha = get(f"https://omahaproxy.appspot.com/deps.json?version={version}").json()
+ detected_revision = omaha['chromium_base_position']
+ return detected_revision
+ except requests.RequestException:
+ self.logger.debug("Unsuccessful attempt to detect revision based on version")
+ return None
+
+ def _remove_existing_chromedriver_binary(self, path):
+ """Remove an existing ChromeDriver for this product if it exists
+ in the virtual environment.
+ """
+ # There may be an existing chromedriver binary from a previous install.
+ # To provide a clean install experience, remove the old binary - this
+ # avoids tricky issues like unzipping over a read-only file.
+ existing_chromedriver_path = find_executable("chromedriver", path)
+ if existing_chromedriver_path:
+ self.logger.info(f"Removing existing ChromeDriver binary: {existing_chromedriver_path}")
+ os.chmod(existing_chromedriver_path, stat.S_IWUSR)
+ os.remove(existing_chromedriver_path)
+
+ def _remove_version_suffix(self, version):
+ """Removes channel suffixes from Chrome/Chromium version string (e.g. " dev")."""
+ return version.split(' ')[0]
+
+ @property
+ def _chromedriver_platform_string(self):
+ """Returns a string that represents the suffix of the ChromeDriver
+ file name when downloaded from Chromium Snapshots.
+ """
+ if self.platform == "Linux":
+ bits = "64" if uname[4] == "x86_64" else "32"
+ elif self.platform == "Mac":
+ bits = "64"
+ elif self.platform == "Win":
+ bits = "32"
+ return f"{self.platform.lower()}{bits}"
+
+ @property
+ def _chromium_platform_string(self):
+ """Returns a string that is used for the platform directory in Chromium Snapshots"""
+ if (self.platform == "Linux" or self.platform == "Win") and uname[4] == "x86_64":
+ return f"{self.platform}_x64"
+ if self.platform == "Mac" and uname.machine == "arm64":
+ return "Mac_Arm"
+ return self.platform
+
+ def find_webdriver(self, venv_path=None, channel=None, browser_binary=None):
+ if venv_path:
+ venv_path = os.path.join(venv_path, self.product)
+ return find_executable("chromedriver", path=venv_path)
+
+ def install_mojojs(self, dest, browser_binary):
+ """Install MojoJS web framework."""
+ # MojoJS is platform agnostic, but the version number must be an
+ # exact match of the Chrome/Chromium version to be compatible.
+ chrome_version = self.version(binary=browser_binary)
+ if not chrome_version:
+ return None
+ chrome_version = self._remove_version_suffix(chrome_version)
+
+ try:
+ # MojoJS version url must match the browser binary version exactly.
+ url = ("https://storage.googleapis.com/chrome-wpt-mojom/"
+ f"{chrome_version}/linux64/mojojs.zip")
+ # Check the status without downloading the content (this is a streaming request).
+ get(url)
+ except requests.RequestException:
+ # If a valid matching version cannot be found in the wpt archive,
+ # download from Chromium snapshots bucket. However,
+ # MojoJS is only bundled with Linux from Chromium snapshots.
+ if self.platform == "Linux":
+ filename = "mojojs.zip"
+ revision = self._get_chromium_revision(filename, chrome_version)
+ url = self._build_snapshots_url(revision, filename)
+ else:
+ self.logger.error("A valid MojoJS version cannot be found "
+ f"for browser binary version {chrome_version}.")
+ return None
+
+ extracted = os.path.join(dest, "mojojs", "gen")
+ last_url_file = os.path.join(extracted, "DOWNLOADED_FROM")
+ if os.path.exists(last_url_file):
+ with open(last_url_file, "rt") as f:
+ last_url = f.read().strip()
+ if last_url == url:
+ self.logger.info("Mojo bindings already up to date")
+ return extracted
+ rmtree(extracted)
+
+ try:
+ self.logger.info(f"Downloading Mojo bindings from {url}")
+ unzip(get(url).raw, dest)
+ with open(last_url_file, "wt") as f:
+ f.write(url)
+ return extracted
+ except Exception as e:
+ self.logger.error(f"Cannot enable MojoJS: {e}")
+ return None
+
+ def install_webdriver_by_version(self, version, dest, revision=None):
+ dest = os.path.join(dest, self.product)
+ self._remove_existing_chromedriver_binary(dest)
+ # _get_webdriver_url is implemented differently for Chrome and Chromium because
+ # they download their respective versions of ChromeDriver from different sources.
+ url = self._get_webdriver_url(version, revision)
+ self.logger.info(f"Downloading ChromeDriver from {url}")
+ unzip(get(url).raw, dest)
+
+ # The two sources of ChromeDriver have different zip structures:
+ # * Chromium archives the binary inside a chromedriver_* directory;
+ # * Chrome archives the binary directly.
+ # We want to make sure the binary always ends up directly in bin/.
+ chromedriver_dir = os.path.join(dest,
+ f"chromedriver_{self._chromedriver_platform_string}")
+ chromedriver_path = find_executable("chromedriver", chromedriver_dir)
+ if chromedriver_path is not None:
+ shutil.move(chromedriver_path, dest)
+ rmtree(chromedriver_dir)
+
+ chromedriver_path = find_executable("chromedriver", dest)
+ assert chromedriver_path is not None
+ return chromedriver_path
+
+ def version(self, binary=None, webdriver_binary=None):
+ if not binary:
+ self.logger.warning("No browser binary provided.")
+ return None
+
+ if uname[0] == "Windows":
+ return _get_fileversion(binary, self.logger)
+
+ try:
+ version_string = call(binary, "--version").strip()
+ except (subprocess.CalledProcessError, OSError) as e:
+ self.logger.warning(f"Failed to call {binary}: {e}")
+ return None
+ m = re.match(r"(?:Google Chrome|Chromium) (.*)", version_string)
+ if not m:
+ self.logger.warning(f"Failed to extract version from: {version_string}")
+ return None
+ return m.group(1)
+
+ def webdriver_version(self, webdriver_binary):
+ if webdriver_binary is None:
+ self.logger.warning("No valid webdriver supplied to detect version.")
+ return None
+
+ try:
+ version_string = call(webdriver_binary, "--version").strip()
+ except (subprocess.CalledProcessError, OSError) as e:
+ self.logger.warning(f"Failed to call {webdriver_binary}: {e}")
+ return None
+ m = re.match(r"ChromeDriver ([0-9][0-9.]*)", version_string)
+ if not m:
+ self.logger.warning(f"Failed to extract version from: {version_string}")
+ return None
+ return m.group(1)
+
+
+class Chromium(ChromeChromiumBase):
+ """Chromium-specific interface.
+
+ Includes browser binary installation and detection.
+ Webdriver installation and wptrunner setup shared in base class with Chrome
+
+ For a detailed description on the installation and detection of these browser components,
+ see https://web-platform-tests.org/running-tests/chrome-chromium-installation-detection.html
+ """
+ product = "chromium"
+
+ @property
+ def _chromium_package_name(self):
+ return f"chrome-{self.platform.lower()}"
+
+ def _get_existing_browser_revision(self, venv_path, channel):
+ revision = None
+ try:
+ # A file referencing the revision number is saved with the binary.
+ # Check if this revision number exists and use it if it does.
+ path = os.path.join(self._get_browser_binary_dir(None, channel), "revision")
+ with open(path) as f:
+ revision = f.read().strip()
+ except FileNotFoundError:
+ # If there is no information about the revision downloaded,
+ # use the pinned revision.
+ revision = self._get_pinned_chromium_revision()
+ return revision
+
+ def _find_binary_in_directory(self, directory):
+ """Search for Chromium browser binary in a given directory."""
+ if uname[0] == "Darwin":
+ return find_executable("Chromium", os.path.join(directory,
+ self._chromium_package_name,
+ "Chromium.app",
+ "Contents",
+ "MacOS"))
+ # find_executable will add .exe on Windows automatically.
+ return find_executable("chrome", os.path.join(directory, self._chromium_package_name))
+
+ def _get_webdriver_url(self, version, revision=None):
+ """Get Chromium Snapshots url to download Chromium ChromeDriver."""
+ filename = f"chromedriver_{self._chromedriver_platform_string}.zip"
+
+ # Make sure we use the same revision in an invocation.
+ # If we have a url that was last used successfully during this run,
+ # that url takes priority over trying to form another.
+ if hasattr(self, "last_revision_used") and self.last_revision_used is not None:
+ return self._build_snapshots_url(self.last_revision_used, filename)
+ if revision is None:
+ revision = self._get_chromium_revision(filename, version)
+ elif revision == "latest":
+ revision = self._get_latest_chromium_revision()
+ elif revision == "pinned":
+ revision = self._get_pinned_chromium_revision()
+
+ return self._build_snapshots_url(revision, filename)
+
+ def download(self, dest=None, channel=None, rename=None, version=None, revision=None):
+ if dest is None:
+ dest = self._get_browser_binary_dir(None, channel)
+
+ filename = f"{self._chromium_package_name}.zip"
+
+ if revision is None:
+ revision = self._get_chromium_revision(filename, version)
+ elif revision == "latest":
+ revision = self._get_latest_chromium_revision()
+ elif revision == "pinned":
+ revision = self._get_pinned_chromium_revision()
+
+ url = self._build_snapshots_url(revision, filename)
+ self.logger.info(f"Downloading Chromium from {url}")
+ resp = get(url)
+ installer_path = os.path.join(dest, filename)
+ with open(installer_path, "wb") as f:
+ f.write(resp.content)
+
+ # Revision successfully used. Keep this revision if another component install is needed.
+ self.last_revision_used = revision
+ with open(os.path.join(dest, "revision"), "w") as f:
+ f.write(revision)
+ return installer_path
+
+ def find_binary(self, venv_path=None, channel=None):
+ return self._find_binary_in_directory(self._get_browser_binary_dir(venv_path, channel))
+
+ def install(self, dest=None, channel=None, version=None, revision=None):
+ dest = self._get_browser_binary_dir(dest, channel)
+ installer_path = self.download(dest, channel, version=version, revision=revision)
+ with open(installer_path, "rb") as f:
+ unzip(f, dest)
+ os.remove(installer_path)
+ return self._find_binary_in_directory(dest)
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None, revision=None):
+ if dest is None:
+ dest = os.pwd
+
+ if revision is None:
+ # If a revision was not given, we will need to detect the browser version.
+ # The ChromeDriver that is installed will match this version.
+ revision = self._get_existing_browser_revision(dest, channel)
+
+ chromedriver_path = self.install_webdriver_by_version(None, dest, revision)
+
+ return chromedriver_path
+
+ def webdriver_supports_browser(self, webdriver_binary, browser_binary, browser_channel=None):
+ """Check that the browser binary and ChromeDriver versions are a valid match."""
+ browser_version = self.version(browser_binary)
+ chromedriver_version = self.webdriver_version(webdriver_binary)
+
+ if not chromedriver_version:
+ self.logger.warning("Unable to get version for ChromeDriver "
+ f"{webdriver_binary}, rejecting it")
+ return False
+
+ if not browser_version:
+ # If we can't get the browser version,
+ # we just have to assume the ChromeDriver is good.
+ return True
+
+ # Because Chromium and its ChromeDriver should be pulled from the
+ # same revision number, their version numbers should match exactly.
+ if browser_version == chromedriver_version:
+ self.logger.debug("Browser and ChromeDriver versions match.")
+ return True
+ self.logger.warning(f"ChromeDriver version {chromedriver_version} does not match "
+ f"Chromium version {browser_version}.")
+ return False
+
+
+class Chrome(ChromeChromiumBase):
+ """Chrome-specific interface.
+
+ Includes browser binary installation and detection.
+ Webdriver installation and wptrunner setup shared in base class with Chromium.
+
+ For a detailed description on the installation and detection of these browser components,
+ see https://web-platform-tests.org/running-tests/chrome-chromium-installation-detection.html
+ """
+
+ product = "chrome"
+
+ @property
+ def _chromedriver_api_platform_string(self):
+ """chromedriver.storage.googleapis.com has a different filename for M1 binary,
+ while the snapshot URL has a different directory but the same filename."""
+ if self.platform == "Mac" and uname.machine == "arm64":
+ return "mac_arm64"
+ return self._chromedriver_platform_string
+
+ def _get_webdriver_url(self, version, revision=None):
+ """Get a ChromeDriver API URL to download a version of ChromeDriver that matches
+ the browser binary version. Version selection is described here:
+ https://chromedriver.chromium.org/downloads/version-selection"""
+ filename = f"chromedriver_{self._chromedriver_api_platform_string}.zip"
+
+ version = self._remove_version_suffix(version)
+
+ parts = version.split(".")
+ assert len(parts) == 4
+ latest_url = ("https://chromedriver.storage.googleapis.com/LATEST_RELEASE_"
+ f"{'.'.join(parts[:-1])}")
+ try:
+ latest = get(latest_url).text.strip()
+ except requests.RequestException:
+ latest_url = f"https://chromedriver.storage.googleapis.com/LATEST_RELEASE_{parts[0]}"
+ try:
+ latest = get(latest_url).text.strip()
+ except requests.RequestException:
+ # We currently use the latest Chromium revision to get a compatible Chromedriver
+ # version for Chrome Dev, since it is not available through the ChromeDriver API.
+ # If we've gotten to this point, it is assumed that this is Chrome Dev.
+ filename = f"chromedriver_{self._chromedriver_platform_string}.zip"
+ revision = self._get_chromium_revision(filename, version)
+ return self._build_snapshots_url(revision, filename)
+ return f"https://chromedriver.storage.googleapis.com/{latest}/{filename}"
+
+ def download(self, dest=None, channel=None, rename=None):
+ raise NotImplementedError("Downloading of Chrome browser binary not implemented.")
+
+ def find_binary(self, venv_path=None, channel=None):
+ if uname[0] == "Linux":
+ name = "google-chrome"
+ if channel == "stable":
+ name += "-stable"
+ elif channel == "beta":
+ name += "-beta"
+ elif channel == "dev":
+ name += "-unstable"
+ # No Canary on Linux.
+ return find_executable(name)
+ if uname[0] == "Darwin":
+ suffix = ""
+ if channel in ("beta", "dev", "canary"):
+ suffix = " " + channel.capitalize()
+ return f"/Applications/Google Chrome{suffix}.app/Contents/MacOS/Google Chrome{suffix}"
+ if uname[0] == "Windows":
+ name = "Chrome"
+ if channel == "beta":
+ name += " Beta"
+ elif channel == "dev":
+ name += " Dev"
+ path = os.path.expandvars(fr"$PROGRAMFILES\Google\{name}\Application\chrome.exe")
+ if channel == "canary":
+ path = os.path.expandvars(r"$LOCALAPPDATA\Google\Chrome SxS\Application\chrome.exe")
+ return path
+ self.logger.warning("Unable to find the browser binary.")
+ return None
+
+ def install(self, dest=None, channel=None):
+ raise NotImplementedError("Installing of Chrome browser binary not implemented.")
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None, revision=None):
+ if dest is None:
+ dest = os.pwd
+
+ # Detect the browser version.
+ # The ChromeDriver that is installed will match this version.
+ if browser_binary is None:
+ # If a browser binary path was not given, detect a valid path.
+ browser_binary = self.find_binary(channel=channel)
+ # We need a browser to version match, so if a browser binary path
+ # was not given and cannot be detected, raise an error.
+ if browser_binary is None:
+ raise FileNotFoundError("No browser binary detected. "
+ "Cannot install ChromeDriver without a browser version.")
+
+ version = self.version(browser_binary)
+ if version is None:
+ raise ValueError(f"Unable to detect browser version from binary at {browser_binary}. "
+ " Cannot install ChromeDriver without a valid version to match.")
+
+ chromedriver_path = self.install_webdriver_by_version(version, dest, revision)
+
+ return chromedriver_path
+
+ def webdriver_supports_browser(self, webdriver_binary, browser_binary, browser_channel):
+ """Check that the browser binary and ChromeDriver versions are a valid match."""
+ # TODO(DanielRyanSmith): The procedure for matching the browser and ChromeDriver
+ # versions here is too loose. More strict rules for version matching
+ # should be in place. (#33231)
+ chromedriver_version = self.webdriver_version(webdriver_binary)
+ if not chromedriver_version:
+ self.logger.warning("Unable to get version for ChromeDriver "
+ f"{webdriver_binary}, rejecting it")
+ return False
+
+ browser_version = self.version(browser_binary)
+ if not browser_version:
+ # If we can't get the browser version,
+ # we just have to assume the ChromeDriver is good.
+ return True
+
+ # Check that the ChromeDriver version matches the Chrome version.
+ chromedriver_major = int(chromedriver_version.split('.')[0])
+ browser_major = int(browser_version.split('.')[0])
+ if chromedriver_major != browser_major:
+ # There is no official ChromeDriver release for the dev channel -
+ # it switches between beta and tip-of-tree, so we accept version+1
+ # too for dev.
+ if browser_channel == "dev" and chromedriver_major == (browser_major + 1):
+ self.logger.debug(f"Accepting ChromeDriver {chromedriver_version} "
+ f"for Chrome/Chromium Dev {browser_version}")
+ return True
+ self.logger.warning(f"ChromeDriver {chromedriver_version} does not match "
+ f"Chrome/Chromium {browser_version}")
+ return False
+ return True
+
+
+class ContentShell(Browser):
+ """Interface for the Chromium content shell.
+ """
+
+ product = "content_shell"
+ requirements = None
+
+ def download(self, dest=None, channel=None, rename=None):
+ raise NotImplementedError
+
+ def install(self, dest=None, channel=None):
+ raise NotImplementedError
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ raise NotImplementedError
+
+ def find_binary(self, venv_path=None, channel=None):
+ if uname[0] == "Darwin":
+ return find_executable("Content Shell.app/Contents/MacOS/Content Shell")
+ return find_executable("content_shell") # .exe is added automatically for Windows
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ return None
+
+ def version(self, binary=None, webdriver_binary=None):
+ # content_shell does not return version information.
+ return "N/A"
+
+class ChromeAndroidBase(Browser):
+ """A base class for ChromeAndroid and AndroidWebView.
+
+ On Android, WebView is based on Chromium open source project, and on some
+ versions of Android we share the library with Chrome. Therefore, we have
+ a very similar WPT runner implementation.
+ Includes webdriver installation.
+ """
+ __metaclass__ = ABCMeta # This is an abstract class.
+
+ def __init__(self, logger):
+ super().__init__(logger)
+ self.device_serial = None
+ self.adb_binary = "adb"
+
+ def download(self, dest=None, channel=None, rename=None):
+ raise NotImplementedError
+
+ def install(self, dest=None, channel=None):
+ raise NotImplementedError
+
+ @abstractmethod
+ def find_binary(self, venv_path=None, channel=None):
+ raise NotImplementedError
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ return find_executable("chromedriver")
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ if browser_binary is None:
+ browser_binary = self.find_binary(channel)
+ chrome = Chrome(self.logger)
+ return chrome.install_webdriver_by_version(self.version(browser_binary), dest)
+
+ def version(self, binary=None, webdriver_binary=None):
+ if not binary:
+ self.logger.warning("No package name provided.")
+ return None
+
+ command = [self.adb_binary]
+ if self.device_serial:
+ # Assume we have same version of browser on all devices
+ command.extend(['-s', self.device_serial[0]])
+ command.extend(['shell', 'dumpsys', 'package', binary])
+ try:
+ output = call(*command)
+ except (subprocess.CalledProcessError, OSError):
+ self.logger.warning("Failed to call %s" % " ".join(command))
+ return None
+ match = re.search(r'versionName=(.*)', output)
+ if not match:
+ self.logger.warning("Failed to find versionName")
+ return None
+ return match.group(1)
+
+
+class ChromeAndroid(ChromeAndroidBase):
+ """Chrome-specific interface for Android.
+ """
+
+ product = "chrome_android"
+ requirements = "requirements_chromium.txt"
+
+ def find_binary(self, venv_path=None, channel=None):
+ if channel in ("beta", "dev", "canary"):
+ return "com.chrome." + channel
+ return "com.android.chrome"
+
+
+# TODO(aluo): This is largely copied from the AndroidWebView implementation.
+# Tests are not running for weblayer yet (crbug/1019521), this initial
+# implementation will help to reproduce and debug any issues.
+class AndroidWeblayer(ChromeAndroidBase):
+ """Weblayer-specific interface for Android."""
+
+ product = "android_weblayer"
+ # TODO(aluo): replace this with weblayer version after tests are working.
+ requirements = "requirements_chromium.txt"
+
+ def find_binary(self, venv_path=None, channel=None):
+ return "org.chromium.weblayer.shell"
+
+
+class AndroidWebview(ChromeAndroidBase):
+ """Webview-specific interface for Android.
+
+ Design doc:
+ https://docs.google.com/document/d/19cGz31lzCBdpbtSC92svXlhlhn68hrsVwSB7cfZt54o/view
+ """
+
+ product = "android_webview"
+ requirements = "requirements_chromium.txt"
+
+ def find_binary(self, venv_path=None, channel=None):
+ # Just get the current package name of the WebView provider.
+ # For WebView, it is not trivial to change the WebView provider, so
+ # we will just grab whatever is available.
+ # https://chromium.googlesource.com/chromium/src/+/HEAD/android_webview/docs/channels.md
+ command = [self.adb_binary]
+ if self.device_serial:
+ command.extend(['-s', self.device_serial[0]])
+ command.extend(['shell', 'dumpsys', 'webviewupdate'])
+ try:
+ output = call(*command)
+ except (subprocess.CalledProcessError, OSError):
+ self.logger.warning("Failed to call %s" % " ".join(command))
+ return None
+ m = re.search(r'^\s*Current WebView package \(name, version\): \((.*), ([0-9.]*)\)$',
+ output, re.M)
+ if m is None:
+ self.logger.warning("Unable to find current WebView package in dumpsys output")
+ return None
+ self.logger.warning("Final package name: " + m.group(1))
+ return m.group(1)
+
+
+class ChromeiOS(Browser):
+ """Chrome-specific interface for iOS.
+ """
+
+ product = "chrome_ios"
+ requirements = None
+
+ def download(self, dest=None, channel=None, rename=None):
+ raise NotImplementedError
+
+ def install(self, dest=None, channel=None):
+ raise NotImplementedError
+
+ def find_binary(self, venv_path=None, channel=None):
+ raise NotImplementedError
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ raise NotImplementedError
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ raise NotImplementedError
+
+ def version(self, binary=None, webdriver_binary=None):
+ return None
+
+
+class Opera(Browser):
+ """Opera-specific interface.
+
+ Includes webdriver installation, and wptrunner setup methods.
+ """
+
+ product = "opera"
+ requirements = "requirements_opera.txt"
+
+ @property
+ def binary(self):
+ if uname[0] == "Linux":
+ return "/usr/bin/opera"
+ # TODO Windows, Mac?
+ self.logger.warning("Unable to find the browser binary.")
+ return None
+
+ def download(self, dest=None, channel=None, rename=None):
+ raise NotImplementedError
+
+ def install(self, dest=None, channel=None):
+ raise NotImplementedError
+
+ def platform_string(self):
+ platform = {
+ "Linux": "linux",
+ "Windows": "win",
+ "Darwin": "mac"
+ }.get(uname[0])
+
+ if platform is None:
+ raise ValueError("Unable to construct a valid Opera package name for current platform")
+
+ if platform == "linux":
+ bits = "64" if uname[4] == "x86_64" else "32"
+ elif platform == "mac":
+ bits = "64"
+ elif platform == "win":
+ bits = "32"
+
+ return "%s%s" % (platform, bits)
+
+ def find_binary(self, venv_path=None, channel=None):
+ raise NotImplementedError
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ return find_executable("operadriver")
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ if dest is None:
+ dest = os.pwd
+ latest = get("https://api.github.com/repos/operasoftware/operachromiumdriver/releases/latest").json()["tag_name"]
+ url = "https://github.com/operasoftware/operachromiumdriver/releases/download/%s/operadriver_%s.zip" % (latest,
+ self.platform_string())
+ unzip(get(url).raw, dest)
+
+ operadriver_dir = os.path.join(dest, "operadriver_%s" % self.platform_string())
+ shutil.move(os.path.join(operadriver_dir, "operadriver"), dest)
+ rmtree(operadriver_dir)
+
+ path = find_executable("operadriver")
+ st = os.stat(path)
+ os.chmod(path, st.st_mode | stat.S_IEXEC)
+ return path
+
+ def version(self, binary=None, webdriver_binary=None):
+ """Retrieve the release version of the installed browser."""
+ binary = binary or self.binary
+ try:
+ output = call(binary, "--version")
+ except subprocess.CalledProcessError:
+ self.logger.warning("Failed to call %s" % binary)
+ return None
+ m = re.search(r"[0-9\.]+( [a-z]+)?$", output.strip())
+ if m:
+ return m.group(0)
+
+
+class EdgeChromium(Browser):
+ """MicrosoftEdge-specific interface."""
+ platform = {
+ "Linux": "linux",
+ "Windows": "win",
+ "Darwin": "macos"
+ }.get(uname[0])
+ product = "edgechromium"
+ edgedriver_name = "msedgedriver"
+ requirements = "requirements_chromium.txt"
+
+ def download(self, dest=None, channel=None, rename=None):
+ raise NotImplementedError
+
+ def install(self, dest=None, channel=None):
+ raise NotImplementedError
+
+ def find_binary(self, venv_path=None, channel=None):
+ self.logger.info(f'Finding Edge binary for channel {channel}')
+
+ if self.platform == "linux":
+ name = "microsoft-edge"
+ if channel == "stable":
+ name += "-stable"
+ elif channel == "beta":
+ name += "-beta"
+ elif channel == "dev":
+ name += "-dev"
+ # No Canary on Linux.
+ return find_executable(name)
+ if self.platform == "macos":
+ suffix = ""
+ if channel in ("beta", "dev", "canary"):
+ suffix = " " + channel.capitalize()
+ return f"/Applications/Microsoft Edge{suffix}.app/Contents/MacOS/Microsoft Edge{suffix}"
+ if self.platform == "win":
+ binaryname = "msedge"
+ if channel == "beta":
+ winpaths = [os.path.expandvars("$SYSTEMDRIVE\\Program Files\\Microsoft\\Edge Beta\\Application"),
+ os.path.expandvars("$SYSTEMDRIVE\\Program Files (x86)\\Microsoft\\Edge Beta\\Application")]
+ return find_executable(binaryname, os.pathsep.join(winpaths))
+ elif channel == "dev":
+ winpaths = [os.path.expandvars("$SYSTEMDRIVE\\Program Files\\Microsoft\\Edge Dev\\Application"),
+ os.path.expandvars("$SYSTEMDRIVE\\Program Files (x86)\\Microsoft\\Edge Dev\\Application")]
+ return find_executable(binaryname, os.pathsep.join(winpaths))
+ elif channel == "canary":
+ winpaths = [os.path.expanduser("~\\AppData\\Local\\Microsoft\\Edge\\Application"),
+ os.path.expanduser("~\\AppData\\Local\\Microsoft\\Edge SxS\\Application")]
+ return find_executable(binaryname, os.pathsep.join(winpaths))
+ else:
+ winpaths = [os.path.expandvars("$SYSTEMDRIVE\\Program Files\\Microsoft\\Edge\\Application"),
+ os.path.expandvars("$SYSTEMDRIVE\\Program Files (x86)\\Microsoft\\Edge\\Application")]
+ return find_executable(binaryname, os.pathsep.join(winpaths))
+
+ self.logger.warning("Unable to find the browser binary.")
+ return None
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ return find_executable("msedgedriver")
+
+ def webdriver_supports_browser(self, webdriver_binary, browser_binary):
+ edgedriver_version = self.webdriver_version(webdriver_binary)
+ if not edgedriver_version:
+ self.logger.warning(
+ f"Unable to get version for EdgeDriver {webdriver_binary}, rejecting it")
+ return False
+
+ browser_version = self.version(browser_binary)
+ if not browser_version:
+ # If we can't get the browser version, we just have to assume the
+ # EdgeDriver is good.
+ return True
+
+ # Check that the EdgeDriver version matches the Edge version.
+ edgedriver_major = int(edgedriver_version.split('.')[0])
+ browser_major = int(browser_version.split('.')[0])
+ if edgedriver_major != browser_major:
+ self.logger.warning(
+ f"EdgeDriver {edgedriver_version} does not match Edge {browser_version}")
+ return False
+ return True
+
+ def install_webdriver_by_version(self, version, dest=None):
+ if dest is None:
+ dest = os.pwd
+
+ if self.platform == "linux":
+ bits = "linux64"
+ edgedriver_path = os.path.join(dest, self.edgedriver_name)
+ elif self.platform == "macos":
+ bits = "mac64"
+ edgedriver_path = os.path.join(dest, self.edgedriver_name)
+ else:
+ bits = "win64" if uname[4] == "x86_64" else "win32"
+ edgedriver_path = os.path.join(dest, f"{self.edgedriver_name}.exe")
+ url = f"https://msedgedriver.azureedge.net/{version}/edgedriver_{bits}.zip"
+
+ # cleanup existing Edge driver files to avoid access_denied errors when unzipping
+ if os.path.isfile(edgedriver_path):
+ # remove read-only attribute
+ os.chmod(edgedriver_path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) # 0777
+ print(f"Delete {edgedriver_path} file")
+ os.remove(edgedriver_path)
+ driver_notes_path = os.path.join(dest, "Driver_notes")
+ if os.path.isdir(driver_notes_path):
+ print(f"Delete {driver_notes_path} folder")
+ rmtree(driver_notes_path)
+
+ self.logger.info(f"Downloading MSEdgeDriver from {url}")
+ unzip(get(url).raw, dest)
+ if os.path.isfile(edgedriver_path):
+ self.logger.info(f"Successfully downloaded MSEdgeDriver to {edgedriver_path}")
+ return find_executable(self.edgedriver_name, dest)
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ self.logger.info(f"Installing MSEdgeDriver for channel {channel}")
+
+ if browser_binary is None:
+ browser_binary = self.find_binary(channel=channel)
+ else:
+ self.logger.info(f"Installing matching MSEdgeDriver for Edge binary at {browser_binary}")
+
+ version = self.version(browser_binary)
+
+ # If an exact version can't be found, use a suitable fallback based on
+ # the browser channel, if available.
+ if version is None:
+ platforms = {
+ "linux": "LINUX",
+ "macos": "MACOS",
+ "win": "WINDOWS"
+ }
+ if channel is None:
+ channel = "dev"
+ platform = platforms[self.platform]
+ suffix = f"{channel.upper()}_{platform}"
+ version_url = f"https://msedgedriver.azureedge.net/LATEST_{suffix}"
+ version = get(version_url).text.strip()
+
+ return self.install_webdriver_by_version(version, dest)
+
+ def version(self, binary=None, webdriver_binary=None):
+ if not binary:
+ self.logger.warning("No browser binary provided.")
+ return None
+
+ if self.platform == "win":
+ return _get_fileversion(binary, self.logger)
+
+ try:
+ version_string = call(binary, "--version").strip()
+ except (subprocess.CalledProcessError, OSError) as e:
+ self.logger.warning(f"Failed to call {binary}: {e}")
+ return None
+ m = re.match(r"Microsoft Edge ([0-9][0-9.]*)", version_string)
+ if not m:
+ self.logger.warning(f"Failed to extract version from: {version_string}")
+ return None
+ return m.group(1)
+
+ def webdriver_version(self, webdriver_binary):
+ if webdriver_binary is None:
+ self.logger.warning("No valid webdriver supplied to detect version.")
+ return None
+ if self.platform == "win":
+ return _get_fileversion(webdriver_binary, self.logger)
+
+ try:
+ version_string = call(webdriver_binary, "--version").strip()
+ except (subprocess.CalledProcessError, OSError) as e:
+ self.logger.warning(f"Failed to call {webdriver_binary}: {e}")
+ return None
+ m = re.match(r"Microsoft Edge WebDriver ([0-9][0-9.]*)", version_string)
+ if not m:
+ self.logger.warning(f"Failed to extract version from: {version_string}")
+ return None
+ return m.group(1)
+
+
+class Edge(Browser):
+ """Edge-specific interface."""
+
+ product = "edge"
+ requirements = "requirements_edge.txt"
+
+ def download(self, dest=None, channel=None, rename=None):
+ raise NotImplementedError
+
+ def install(self, dest=None, channel=None):
+ raise NotImplementedError
+
+ def find_binary(self, venv_path=None, channel=None):
+ raise NotImplementedError
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ return find_executable("MicrosoftWebDriver")
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ raise NotImplementedError
+
+ def version(self, binary=None, webdriver_binary=None):
+ command = "(Get-AppxPackage Microsoft.MicrosoftEdge).Version"
+ try:
+ return call("powershell.exe", command).strip()
+ except (subprocess.CalledProcessError, OSError):
+ self.logger.warning("Failed to call %s in PowerShell" % command)
+ return None
+
+
+class EdgeWebDriver(Edge):
+ product = "edge_webdriver"
+
+
+class InternetExplorer(Browser):
+ """Internet Explorer-specific interface."""
+
+ product = "ie"
+ requirements = "requirements_ie.txt"
+
+ def download(self, dest=None, channel=None, rename=None):
+ raise NotImplementedError
+
+ def install(self, dest=None, channel=None):
+ raise NotImplementedError
+
+ def find_binary(self, venv_path=None, channel=None):
+ raise NotImplementedError
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ return find_executable("IEDriverServer.exe")
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ raise NotImplementedError
+
+ def version(self, binary=None, webdriver_binary=None):
+ return None
+
+
+class Safari(Browser):
+ """Safari-specific interface.
+
+ Includes installation, webdriver installation, and wptrunner setup methods.
+ """
+
+ product = "safari"
+ requirements = "requirements_safari.txt"
+
+ def _find_downloads(self):
+ def text_content(e, __output=None):
+ # this doesn't use etree.tostring so that we can add spaces for p and br
+ if __output is None:
+ __output = []
+
+ if e.tag == "p":
+ __output.append("\n\n")
+
+ if e.tag == "br":
+ __output.append("\n")
+
+ if e.text is not None:
+ __output.append(e.text)
+
+ for child in e:
+ text_content(child, __output)
+ if child.tail is not None:
+ __output.append(child.tail)
+
+ return "".join(__output)
+
+ self.logger.info("Finding STP download URLs")
+ resp = get("https://developer.apple.com/safari/download/")
+
+ doc = html5lib.parse(
+ resp.content,
+ "etree",
+ namespaceHTMLElements=False,
+ transport_encoding=resp.encoding,
+ )
+ ascii_ws = re.compile(r"[\x09\x0A\x0C\x0D\x20]+")
+
+ downloads = []
+ for candidate in doc.iterfind(".//li[@class]"):
+ class_names = set(ascii_ws.split(candidate.attrib["class"]))
+ if {"download", "dmg", "zip"} & class_names:
+ downloads.append(candidate)
+
+ # Note we use \s throughout for space as we don't care what form the whitespace takes
+ stp_link_text = re.compile(
+ r"^\s*Safari\s+Technology\s+Preview\s+(?:[0-9]+\s+)?for\s+macOS"
+ )
+ requirement = re.compile(
+ r"""(?x) # (extended regexp syntax for comments)
+ ^\s*Requires\s+macOS\s+ # Starting with the magic string
+ ([0-9]+(?:\.[0-9]+)*) # A macOS version number of numbers and dots
+ (?:\s+beta(?:\s+[0-9]+)?)? # Optionally a beta, itself optionally with a number (no dots!)
+ (?:\s+or\s+later)? # Optionally an 'or later'
+ \.?\s*$ # Optionally ending with a literal dot
+ """
+ )
+
+ stp_downloads = []
+ for download in downloads:
+ for link in download.iterfind(".//a[@href]"):
+ if stp_link_text.search(text_content(link)):
+ break
+ else:
+ self.logger.debug("non-matching anchor: " + text_content(link))
+ else:
+ continue
+
+ for el in download.iter():
+ # avoid assuming any given element here, just assume it is a single element
+ m = requirement.search(text_content(el))
+ if m:
+ version = m.group(1)
+
+ # This assumes the current macOS numbering, whereby X.Y is compatible
+ # with X.(Y+1), e.g. 12.4 is compatible with 12.3, but 13.0 isn't
+ # compatible with 12.3.
+ if version.count(".") >= (2 if version.startswith("10.") else 1):
+ spec = SpecifierSet(f"~={version}")
+ else:
+ spec = SpecifierSet(f"=={version}.*")
+
+ stp_downloads.append((spec, link.attrib["href"].strip()))
+ break
+ else:
+ self.logger.debug(
+ "Found a link but no requirement: " + text_content(download)
+ )
+
+ if stp_downloads:
+ self.logger.info(
+ "Found STP URLs for macOS " +
+ ", ".join(str(dl[0]) for dl in stp_downloads)
+ )
+ else:
+ self.logger.warning("Did not find any STP URLs")
+
+ return stp_downloads
+
+ def _download_image(self, downloads, dest, system_version=None):
+ if system_version is None:
+ system_version, _, _ = platform.mac_ver()
+
+ chosen_url = None
+ for version_spec, url in downloads:
+ if system_version in version_spec:
+ self.logger.debug(f"Will download Safari for {version_spec}")
+ chosen_url = url
+ break
+
+ if chosen_url is None:
+ raise ValueError(f"no download for {system_version}")
+
+ self.logger.info(f"Downloading Safari from {chosen_url}")
+ resp = get(chosen_url)
+
+ filename = get_download_filename(resp, "SafariTechnologyPreview.dmg")
+ installer_path = os.path.join(dest, filename)
+ with open(installer_path, "wb") as f:
+ f.write(resp.content)
+
+ return installer_path
+
+ def _download_extract(self, image_path, dest, rename=None):
+ with tempfile.TemporaryDirectory() as tmpdir:
+ self.logger.debug(f"Mounting {image_path}")
+ r = subprocess.run(
+ [
+ "hdiutil",
+ "attach",
+ "-readonly",
+ "-mountpoint",
+ tmpdir,
+ "-nobrowse",
+ "-verify",
+ "-noignorebadchecksums",
+ "-autofsck",
+ image_path,
+ ],
+ encoding="utf-8",
+ capture_output=True,
+ check=True,
+ )
+
+ mountpoint = None
+ for line in r.stdout.splitlines():
+ if not line.startswith("/dev/"):
+ continue
+
+ _, _, mountpoint = line.split("\t", 2)
+ if mountpoint:
+ break
+
+ if mountpoint is None:
+ raise ValueError("no volume mounted from image")
+
+ pkgs = [p for p in os.listdir(mountpoint) if p.endswith((".pkg", ".mpkg"))]
+ if len(pkgs) != 1:
+ raise ValueError(
+ f"Expected a single .pkg/.mpkg, found {len(pkgs)}: {', '.join(pkgs)}"
+ )
+
+ source_path = os.path.join(mountpoint, pkgs[0])
+ dest_path = os.path.join(
+ dest, (rename + get_ext(pkgs[0])) if rename is not None else pkgs[0]
+ )
+
+ self.logger.debug(f"Copying {source_path} to {dest_path}")
+ shutil.copy2(
+ source_path,
+ dest_path,
+ )
+
+ self.logger.debug(f"Unmounting {mountpoint}")
+ subprocess.run(
+ ["hdiutil", "detach", mountpoint],
+ encoding="utf-8",
+ capture_output=True,
+ check=True,
+ )
+
+ return dest_path
+
+ def download(self, dest=None, channel="preview", rename=None, system_version=None):
+ if channel != "preview":
+ raise ValueError(f"can only install 'preview', not '{channel}'")
+
+ if dest is None:
+ dest = self._get_browser_binary_dir(None, channel)
+
+ stp_downloads = self._find_downloads()
+
+ with tempfile.TemporaryDirectory() as tmpdir:
+ image_path = self._download_image(stp_downloads, tmpdir, system_version)
+ return self._download_extract(image_path, dest, rename)
+
+ def install(self, dest=None, channel=None):
+ # We can't do this because stable/beta releases are system components and STP
+ # requires admin permissions to install.
+ raise NotImplementedError
+
+ def find_binary(self, venv_path=None, channel=None):
+ raise NotImplementedError
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ path = None
+ if channel == "preview":
+ path = "/Applications/Safari Technology Preview.app/Contents/MacOS"
+ return find_executable("safaridriver", path)
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ raise NotImplementedError
+
+ def version(self, binary=None, webdriver_binary=None):
+ if webdriver_binary is None:
+ self.logger.warning("Cannot find Safari version without safaridriver")
+ return None
+ # Use `safaridriver --version` to get the version. Example output:
+ # "Included with Safari 12.1 (14607.1.11)"
+ # "Included with Safari Technology Preview (Release 67, 13607.1.9.0.1)"
+ # The `--version` flag was added in STP 67, so allow the call to fail.
+ try:
+ version_string = call(webdriver_binary, "--version").strip()
+ except subprocess.CalledProcessError:
+ self.logger.warning("Failed to call %s --version" % webdriver_binary)
+ return None
+ m = re.match(r"Included with Safari (.*)", version_string)
+ if not m:
+ self.logger.warning("Failed to extract version from: %s" % version_string)
+ return None
+ return m.group(1)
+
+
+class Servo(Browser):
+ """Servo-specific interface."""
+
+ product = "servo"
+ requirements = None
+
+ def platform_components(self):
+ platform = {
+ "Linux": "linux",
+ "Windows": "win",
+ "Darwin": "mac"
+ }.get(uname[0])
+
+ if platform is None:
+ raise ValueError("Unable to construct a valid Servo package for current platform")
+
+ if platform == "linux":
+ extension = ".tar.gz"
+ decompress = untar
+ elif platform == "win" or platform == "mac":
+ raise ValueError("Unable to construct a valid Servo package for current platform")
+
+ return (platform, extension, decompress)
+
+ def _get(self, channel="nightly"):
+ if channel != "nightly":
+ raise ValueError("Only nightly versions of Servo are available")
+
+ platform, extension, _ = self.platform_components()
+ url = "https://download.servo.org/nightly/%s/servo-latest%s" % (platform, extension)
+ return get(url)
+
+ def download(self, dest=None, channel="nightly", rename=None):
+ if dest is None:
+ dest = os.pwd
+
+ resp = self._get(dest, channel)
+ _, extension, _ = self.platform_components()
+
+ filename = rename if rename is not None else "servo-latest"
+ with open(os.path.join(dest, "%s%s" % (filename, extension,)), "w") as f:
+ f.write(resp.content)
+
+ def install(self, dest=None, channel="nightly"):
+ """Install latest Browser Engine."""
+ if dest is None:
+ dest = os.pwd
+
+ _, _, decompress = self.platform_components()
+
+ resp = self._get(channel)
+ decompress(resp.raw, dest=dest)
+ path = find_executable("servo", os.path.join(dest, "servo"))
+ st = os.stat(path)
+ os.chmod(path, st.st_mode | stat.S_IEXEC)
+ return path
+
+ def find_binary(self, venv_path=None, channel=None):
+ path = find_executable("servo", os.path.join(venv_path, "servo"))
+ if path is None:
+ path = find_executable("servo")
+ return path
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ return None
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ raise NotImplementedError
+
+ def version(self, binary=None, webdriver_binary=None):
+ """Retrieve the release version of the installed browser."""
+ output = call(binary, "--version")
+ m = re.search(r"Servo ([0-9\.]+-[a-f0-9]+)?(-dirty)?$", output.strip())
+ if m:
+ return m.group(0)
+
+
+class ServoWebDriver(Servo):
+ product = "servodriver"
+
+
+class Sauce(Browser):
+ """Sauce-specific interface."""
+
+ product = "sauce"
+ requirements = "requirements_sauce.txt"
+
+ def download(self, dest=None, channel=None, rename=None):
+ raise NotImplementedError
+
+ def install(self, dest=None, channel=None):
+ raise NotImplementedError
+
+ def find_binary(self, venev_path=None, channel=None):
+ raise NotImplementedError
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ raise NotImplementedError
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ raise NotImplementedError
+
+ def version(self, binary=None, webdriver_binary=None):
+ return None
+
+
+class WebKit(Browser):
+ """WebKit-specific interface."""
+
+ product = "webkit"
+ requirements = None
+
+ def download(self, dest=None, channel=None, rename=None):
+ raise NotImplementedError
+
+ def install(self, dest=None, channel=None):
+ raise NotImplementedError
+
+ def find_binary(self, venv_path=None, channel=None):
+ return None
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ return None
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ raise NotImplementedError
+
+ def version(self, binary=None, webdriver_binary=None):
+ return None
+
+
+class WebKitGTKMiniBrowser(WebKit):
+
+
+ def _get_osidversion(self):
+ with open('/etc/os-release') as osrelease_handle:
+ for line in osrelease_handle.readlines():
+ if line.startswith('ID='):
+ os_id = line.split('=')[1].strip().strip('"')
+ if line.startswith('VERSION_ID='):
+ version_id = line.split('=')[1].strip().strip('"')
+ assert(os_id)
+ assert(version_id)
+ osidversion = os_id + '-' + version_id
+ assert(' ' not in osidversion)
+ assert(len(osidversion) > 3)
+ return osidversion.capitalize()
+
+
+ def download(self, dest=None, channel=None, rename=None):
+ base_dowload_uri = "https://webkitgtk.org/built-products/"
+ base_download_dir = base_dowload_uri + "x86_64/release/" + channel + "/" + self._get_osidversion() + "/MiniBrowser/"
+ try:
+ response = get(base_download_dir + "LAST-IS")
+ except requests.exceptions.HTTPError as e:
+ if e.response.status_code == 404:
+ raise RuntimeError("Can't find a WebKitGTK MiniBrowser %s bundle for %s at %s"
+ % (channel, self._get_osidversion(), base_dowload_uri))
+ raise
+
+ bundle_filename = response.text.strip()
+ bundle_url = base_download_dir + bundle_filename
+
+ if dest is None:
+ dest = self._get_browser_binary_dir(None, channel)
+ bundle_file_path = os.path.join(dest, bundle_filename)
+
+ self.logger.info("Downloading WebKitGTK MiniBrowser bundle from %s" % bundle_url)
+ with open(bundle_file_path, "w+b") as f:
+ get_download_to_descriptor(f, bundle_url)
+
+ bundle_filename_no_ext, _ = os.path.splitext(bundle_filename)
+ bundle_hash_url = base_download_dir + bundle_filename_no_ext + ".sha256sum"
+ bundle_expected_hash = get(bundle_hash_url).text.strip().split(" ")[0]
+ bundle_computed_hash = sha256sum(bundle_file_path)
+
+ if bundle_expected_hash != bundle_computed_hash:
+ self.logger.error("Calculated SHA256 hash is %s but was expecting %s" % (bundle_computed_hash,bundle_expected_hash))
+ raise RuntimeError("The WebKitGTK MiniBrowser bundle at %s has incorrect SHA256 hash." % bundle_file_path)
+ return bundle_file_path
+
+ def install(self, dest=None, channel=None, prompt=True):
+ dest = self._get_browser_binary_dir(dest, channel)
+ bundle_path = self.download(dest, channel)
+ bundle_uncompress_directory = os.path.join(dest, "webkitgtk_minibrowser")
+
+ # Clean it from previous runs
+ if os.path.exists(bundle_uncompress_directory):
+ rmtree(bundle_uncompress_directory)
+ os.mkdir(bundle_uncompress_directory)
+
+ with open(bundle_path, "rb") as f:
+ unzip(f, bundle_uncompress_directory)
+
+ install_dep_script = os.path.join(bundle_uncompress_directory, "install-dependencies.sh")
+ if os.path.isfile(install_dep_script):
+ self.logger.info("Executing install-dependencies.sh script from bundle.")
+ install_dep_cmd = [install_dep_script]
+ if not prompt:
+ install_dep_cmd.append("--autoinstall")
+ # use subprocess.check_call() directly to display unbuffered stdout/stderr in real-time.
+ subprocess.check_call(install_dep_cmd)
+
+ minibrowser_path = os.path.join(bundle_uncompress_directory, "MiniBrowser")
+ if not os.path.isfile(minibrowser_path):
+ raise RuntimeError("Can't find a MiniBrowser binary at %s" % minibrowser_path)
+
+ os.remove(bundle_path)
+ install_ok_file = os.path.join(bundle_uncompress_directory, ".installation-ok")
+ open(install_ok_file, "w").close() # touch
+ self.logger.info("WebKitGTK MiniBrowser bundle for channel %s installed." % channel)
+ return minibrowser_path
+
+ def _find_executable_in_channel_bundle(self, binary, venv_path=None, channel=None):
+ if venv_path:
+ venv_base_path = self._get_browser_binary_dir(venv_path, channel)
+ bundle_dir = os.path.join(venv_base_path, "webkitgtk_minibrowser")
+ install_ok_file = os.path.join(bundle_dir, ".installation-ok")
+ if os.path.isfile(install_ok_file):
+ return find_executable(binary, bundle_dir)
+ return None
+
+
+ def find_binary(self, venv_path=None, channel=None):
+ minibrowser_path = self._find_executable_in_channel_bundle("MiniBrowser", venv_path, channel)
+ if minibrowser_path:
+ return minibrowser_path
+
+ libexecpaths = ["/usr/libexec/webkit2gtk-4.0"] # Fedora path
+ triplet = "x86_64-linux-gnu"
+ # Try to use GCC to detect this machine triplet
+ gcc = find_executable("gcc")
+ if gcc:
+ try:
+ triplet = call(gcc, "-dumpmachine").strip()
+ except subprocess.CalledProcessError:
+ pass
+ # Add Debian/Ubuntu path
+ libexecpaths.append("/usr/lib/%s/webkit2gtk-4.0" % triplet)
+ return find_executable("MiniBrowser", os.pathsep.join(libexecpaths))
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ webdriver_path = self._find_executable_in_channel_bundle("WebKitWebDriver", venv_path, channel)
+ if not webdriver_path:
+ webdriver_path = find_executable("WebKitWebDriver")
+ return webdriver_path
+
+ def version(self, binary=None, webdriver_binary=None):
+ if binary is None:
+ return None
+ try: # WebKitGTK MiniBrowser before 2.26.0 doesn't support --version
+ output = call(binary, "--version").strip()
+ except subprocess.CalledProcessError:
+ return None
+ # Example output: "WebKitGTK 2.26.1"
+ if output:
+ m = re.match(r"WebKitGTK (.+)", output)
+ if not m:
+ self.logger.warning("Failed to extract version from: %s" % output)
+ return None
+ return m.group(1)
+ return None
+
+
+class Epiphany(Browser):
+ """Epiphany-specific interface."""
+
+ product = "epiphany"
+ requirements = None
+
+ def download(self, dest=None, channel=None, rename=None):
+ raise NotImplementedError
+
+ def install(self, dest=None, channel=None):
+ raise NotImplementedError
+
+ def find_binary(self, venv_path=None, channel=None):
+ return find_executable("epiphany")
+
+ def find_webdriver(self, venv_path=None, channel=None):
+ return find_executable("WebKitWebDriver")
+
+ def install_webdriver(self, dest=None, channel=None, browser_binary=None):
+ raise NotImplementedError
+
+ def version(self, binary=None, webdriver_binary=None):
+ if binary is None:
+ return None
+ output = call(binary, "--version")
+ if output:
+ # Stable release output looks like: "Web 3.30.2"
+ # Tech Preview output looks like "Web 3.31.3-88-g97db4f40f"
+ return output.split()[1]
+ return None
diff --git a/testing/web-platform/tests/tools/wpt/commands.json b/testing/web-platform/tests/tools/wpt/commands.json
new file mode 100644
index 0000000000..41304a0122
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/commands.json
@@ -0,0 +1,94 @@
+{
+ "run": {
+ "path": "run.py",
+ "script": "run",
+ "parser": "create_parser",
+ "help": "Run tests in a browser",
+ "virtualenv": true,
+ "requirements": [
+ "../manifest/requirements.txt",
+ "../wptrunner/requirements.txt"
+ ],
+ "conditional_requirements": {
+ "commandline_flag": {
+ "enable_webtransport_h3": [
+ "../webtransport/requirements.txt"
+ ]
+ }
+ }
+ },
+ "create": {
+ "path": "create.py",
+ "script": "run",
+ "parser": "get_parser",
+ "help": "Create a new wpt test"
+ },
+ "update-expectations": {
+ "path": "update.py",
+ "script": "update_expectations",
+ "parser": "create_parser_update",
+ "help": "Update expectations files from raw logs.",
+ "virtualenv": true,
+ "requirements": [
+ "../wptrunner/requirements.txt"
+ ]
+ },
+ "files-changed": {
+ "path": "testfiles.py",
+ "script": "run_changed_files",
+ "parser": "get_parser",
+ "help": "Get a list of files that have changed",
+ "virtualenv": false
+ },
+ "tests-affected": {
+ "path": "testfiles.py",
+ "script": "run_tests_affected",
+ "parser": "get_parser_affected",
+ "help": "Get a list of tests affected by changes",
+ "virtualenv": false
+ },
+ "install": {
+ "path": "install.py",
+ "script": "run",
+ "parser": "get_parser",
+ "help": "Install browser components",
+ "virtualenv": true,
+ "requirements": [
+ "requirements_install.txt"
+ ]
+ },
+ "branch-point": {
+ "path": "testfiles.py",
+ "script": "display_branch_point",
+ "parser": null,
+ "help": "Print branch point from master",
+ "virtualenv": false
+ },
+ "rev-list": {
+ "path": "revlist.py",
+ "script": "run_rev_list",
+ "parser": "get_parser",
+ "help": "List tagged revisions at regular intervals",
+ "virtualenv": false
+ },
+ "install-android-emulator": {
+ "path": "android.py",
+ "script": "run_install",
+ "parser": "get_parser_install",
+ "help": "Setup the x86 android emulator",
+ "virtualenv": true,
+ "requirements": [
+ "requirements.txt"
+ ]
+ },
+ "start-android-emulator": {
+ "path": "android.py",
+ "script": "run_start",
+ "parser": "get_parser_start",
+ "help": "Start the x86 android emulator",
+ "virtualenv": true,
+ "requirements": [
+ "requirements.txt"
+ ]
+ }
+}
diff --git a/testing/web-platform/tests/tools/wpt/create.py b/testing/web-platform/tests/tools/wpt/create.py
new file mode 100644
index 0000000000..27a23ca901
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/create.py
@@ -0,0 +1,133 @@
+# mypy: allow-untyped-defs
+
+import subprocess
+import os
+
+here = os.path.dirname(__file__)
+
+template_prefix = """<!doctype html>
+%(documentElement)s<meta charset=utf-8>
+"""
+template_long_timeout = "<meta name=timeout content=long>\n"
+
+template_body_th = """<title></title>
+<script src=/resources/testharness.js></script>
+<script src=/resources/testharnessreport.js></script>
+<script>
+
+</script>
+"""
+
+template_body_reftest = """<title></title>
+<link rel=%(match)s href=%(ref)s>
+"""
+
+template_body_reftest_wait = """<script src="/common/reftest-wait.js"></script>
+"""
+
+def get_parser():
+ import argparse
+ p = argparse.ArgumentParser()
+ p.add_argument("--no-editor", action="store_true",
+ help="Don't try to open the test in an editor")
+ p.add_argument("-e", "--editor", action="store", help="Editor to use")
+ p.add_argument("--long-timeout", action="store_true",
+ help="Test should be given a long timeout (typically 60s rather than 10s, but varies depending on environment)")
+ p.add_argument("--overwrite", action="store_true",
+ help="Allow overwriting an existing test file")
+ p.add_argument("-r", "--reftest", action="store_true",
+ help="Create a reftest rather than a testharness (js) test"),
+ p.add_argument("-m", "--reference", dest="ref", help="Path to the reference file")
+ p.add_argument("--mismatch", action="store_true",
+ help="Create a mismatch reftest")
+ p.add_argument("--wait", action="store_true",
+ help="Create a reftest that waits until takeScreenshot() is called")
+ p.add_argument("--tests-root", action="store", default=os.path.join(here, "..", ".."),
+ help="Path to the root of the wpt directory")
+ p.add_argument("path", action="store", help="Path to the test file")
+ return p
+
+
+
+def rel_path(path, tests_root):
+ if path is None:
+ return
+
+ abs_path = os.path.normpath(os.path.abspath(path))
+ return os.path.relpath(abs_path, tests_root)
+
+
+def run(_venv, **kwargs):
+ path = rel_path(kwargs["path"], kwargs["tests_root"])
+ ref_path = rel_path(kwargs["ref"], kwargs["tests_root"])
+
+ if kwargs["ref"]:
+ kwargs["reftest"] = True
+
+ if ".." in path:
+ print("""Test path %s is not under wpt root.""" % path)
+ return 1
+
+ if ref_path and ".." in ref_path:
+ print("""Reference path %s is not under wpt root""" % ref_path)
+ return 1
+
+
+ if os.path.exists(path) and not kwargs["overwrite"]:
+ print("Test path already exists, pass --overwrite to replace")
+ return 1
+
+ if kwargs["mismatch"] and not kwargs["reftest"]:
+ print("--mismatch only makes sense for a reftest")
+ return 1
+
+ if kwargs["wait"] and not kwargs["reftest"]:
+ print("--wait only makes sense for a reftest")
+ return 1
+
+ args = {"documentElement": "<html class=reftest-wait>\n" if kwargs["wait"] else ""}
+ template = template_prefix % args
+ if kwargs["long_timeout"]:
+ template += template_long_timeout
+
+ if kwargs["reftest"]:
+ args = {"match": "match" if not kwargs["mismatch"] else "mismatch",
+ "ref": os.path.relpath(ref_path, path) if kwargs["ref"] else '""'}
+ template += template_body_reftest % args
+ if kwargs["wait"]:
+ template += template_body_reftest_wait
+ else:
+ template += template_body_th
+ try:
+ os.makedirs(os.path.dirname(path))
+ except OSError:
+ pass
+ with open(path, "w") as f:
+ f.write(template)
+
+ ref_path = kwargs["ref"]
+ if ref_path and not os.path.exists(ref_path):
+ with open(ref_path, "w") as f:
+ f.write(template_prefix % {"documentElement": ""})
+
+ if kwargs["no_editor"]:
+ editor = None
+ elif kwargs["editor"]:
+ editor = kwargs["editor"]
+ elif "VISUAL" in os.environ:
+ editor = os.environ["VISUAL"]
+ elif "EDITOR" in os.environ:
+ editor = os.environ["EDITOR"]
+ else:
+ editor = None
+
+ proc = None
+ if editor:
+ if ref_path:
+ path = f"{path} {ref_path}"
+ proc = subprocess.Popen(f"{editor} {path}", shell=True)
+ else:
+ print("Created test %s" % path)
+
+ if proc:
+ proc.wait()
diff --git a/testing/web-platform/tests/tools/wpt/install.py b/testing/web-platform/tests/tools/wpt/install.py
new file mode 100644
index 0000000000..821ce86f97
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/install.py
@@ -0,0 +1,120 @@
+# mypy: allow-untyped-defs
+
+import argparse
+from . import browser
+
+latest_channels = {
+ 'android_weblayer': 'dev',
+ 'android_webview': 'dev',
+ 'firefox': 'nightly',
+ 'chrome': 'nightly',
+ 'chrome_android': 'dev',
+ 'chromium': 'nightly',
+ 'edgechromium': 'dev',
+ 'safari': 'preview',
+ 'servo': 'nightly',
+ 'webkitgtk_minibrowser': 'nightly'
+}
+
+channel_by_name = {
+ 'stable': 'stable',
+ 'release': 'stable',
+ 'beta': 'beta',
+ 'dev': 'dev',
+ 'canary': 'canary',
+ 'nightly': latest_channels,
+ 'preview': latest_channels,
+ 'experimental': latest_channels,
+}
+
+channel_args = argparse.ArgumentParser(add_help=False)
+channel_args.add_argument('--channel', choices=channel_by_name.keys(),
+ default='nightly', action='store',
+ help='''
+Name of browser release channel (default: nightly). "stable" and "release" are
+synonyms for the latest browser stable release; "beta" is the beta release;
+"dev" is only meaningful for Chrome (i.e. Chrome Dev); "nightly",
+"experimental", and "preview" are all synonyms for the latest available
+development or trunk release. (For WebDriver installs, we attempt to select an
+appropriate, compatible version for the latest browser release on the selected
+channel.) This flag overrides --browser-channel.''')
+
+
+def get_parser():
+ parser = argparse.ArgumentParser(
+ parents=[channel_args],
+ description="Install a given browser or webdriver frontend.")
+ parser.add_argument('browser', choices=['firefox', 'chrome', 'chromium', 'servo', 'safari'],
+ help='name of web browser product')
+ parser.add_argument('component', choices=['browser', 'webdriver'],
+ help='name of component')
+ parser.add_argument('--download-only', action="store_true",
+ help="Download the selected component but don't install it")
+ parser.add_argument('--rename', action="store", default=None,
+ help="Filename, excluding extension for downloaded archive "
+ "(only with --download-only)")
+ parser.add_argument('-d', '--destination',
+ help='filesystem directory to place the component')
+ parser.add_argument('--revision', default=None,
+ help='Chromium revision to install from snapshots')
+ return parser
+
+
+def get_channel(browser, channel):
+ channel = channel_by_name[channel]
+ if isinstance(channel, dict):
+ channel = channel.get(browser)
+ return channel
+
+
+def run(venv, **kwargs):
+ import logging
+ logger = logging.getLogger("install")
+
+ browser = kwargs["browser"]
+ destination = kwargs["destination"]
+ channel = get_channel(browser, kwargs["channel"])
+
+ if channel != kwargs["channel"]:
+ logger.info("Interpreting channel '%s' as '%s'", kwargs["channel"], channel)
+
+ if destination is None:
+ if venv:
+ if kwargs["component"] == "browser":
+ destination = venv.path
+ else:
+ destination = venv.bin_path
+ else:
+ raise argparse.ArgumentError(None,
+ "No --destination argument, and no default for the environment")
+
+ if kwargs["revision"] is not None and browser != "chromium":
+ raise argparse.ArgumentError(None, "--revision flag cannot be used for non-Chromium browsers.")
+
+ install(browser, kwargs["component"], destination, channel, logger=logger,
+ download_only=kwargs["download_only"], rename=kwargs["rename"],
+ revision=kwargs["revision"])
+
+
+def install(name, component, destination, channel="nightly", logger=None, download_only=False,
+ rename=None, revision=None):
+ if logger is None:
+ import logging
+ logger = logging.getLogger("install")
+
+ prefix = "download" if download_only else "install"
+ suffix = "_webdriver" if component == 'webdriver' else ""
+
+ method = prefix + suffix
+
+ browser_cls = getattr(browser, name.title())
+ logger.info('Now installing %s %s...', name, component)
+ kwargs = {}
+ if download_only and rename:
+ kwargs["rename"] = rename
+ if revision:
+ kwargs["revision"] = revision
+
+ path = getattr(browser_cls(logger), method)(dest=destination, channel=channel, **kwargs)
+ if path:
+ logger.info('Binary %s as %s', "downloaded" if download_only else "installed", path)
diff --git a/testing/web-platform/tests/tools/wpt/markdown.py b/testing/web-platform/tests/tools/wpt/markdown.py
new file mode 100644
index 0000000000..e1d8c4ebfe
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/markdown.py
@@ -0,0 +1,44 @@
+# mypy: allow-untyped-defs
+
+from functools import reduce
+
+def format_comment_title(product):
+ """Produce a Markdown-formatted string based on a given "product"--a string
+ containing a browser identifier optionally followed by a colon and a
+ release channel. (For example: "firefox" or "chrome:dev".) The generated
+ title string is used both to create new comments and to locate (and
+ subsequently update) previously-submitted comments."""
+ parts = product.split(":")
+ title = parts[0].title()
+
+ if len(parts) > 1:
+ title += " (%s)" % parts[1]
+
+ return "# %s #" % title
+
+
+def markdown_adjust(s):
+ """Escape problematic markdown sequences."""
+ s = s.replace('\t', '\\t')
+ s = s.replace('\n', '\\n')
+ s = s.replace('\r', '\\r')
+ s = s.replace('`', '')
+ s = s.replace('|', '\\|')
+ return s
+
+
+def table(headings, data, log):
+ """Create and log data to specified logger in tabular format."""
+ cols = range(len(headings))
+ assert all(len(item) == len(cols) for item in data)
+ max_widths = reduce(lambda prev, cur: [(len(cur[i]) + 2)
+ if (len(cur[i]) + 2) > prev[i]
+ else prev[i]
+ for i in cols],
+ data,
+ [len(item) + 2 for item in headings])
+ log("|%s|" % "|".join(item.center(max_widths[i]) for i, item in enumerate(headings)))
+ log("|%s|" % "|".join("-" * max_widths[i] for i in cols))
+ for row in data:
+ log("|%s|" % "|".join(" %s" % row[i].ljust(max_widths[i] - 1) for i in cols))
+ log("")
diff --git a/testing/web-platform/tests/tools/wpt/paths b/testing/web-platform/tests/tools/wpt/paths
new file mode 100644
index 0000000000..7e9ae837ec
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/paths
@@ -0,0 +1,7 @@
+docs/
+tools/ci/
+tools/docker/
+tools/lint/
+tools/manifest/
+tools/serve/
+tools/wpt/
diff --git a/testing/web-platform/tests/tools/wpt/requirements.txt b/testing/web-platform/tests/tools/wpt/requirements.txt
new file mode 100644
index 0000000000..a743bbe341
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/requirements.txt
@@ -0,0 +1 @@
+requests==2.27.1
diff --git a/testing/web-platform/tests/tools/wpt/requirements_install.txt b/testing/web-platform/tests/tools/wpt/requirements_install.txt
new file mode 100644
index 0000000000..5db7bce788
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/requirements_install.txt
@@ -0,0 +1 @@
+mozinstall==2.0.1
diff --git a/testing/web-platform/tests/tools/wpt/revlist.py b/testing/web-platform/tests/tools/wpt/revlist.py
new file mode 100644
index 0000000000..e9fea30522
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/revlist.py
@@ -0,0 +1,107 @@
+import argparse
+import os
+import time
+from typing import Any, Iterator, Tuple
+
+from tools.wpt.testfiles import get_git_cmd
+
+here = os.path.dirname(__file__)
+wpt_root = os.path.abspath(os.path.join(here, os.pardir, os.pardir))
+
+
+def calculate_cutoff_date(until: int, epoch: int, offset: int) -> int:
+ return (((until - offset) // epoch) * epoch) + offset
+
+
+def parse_epoch(string: str) -> int:
+ UNIT_DICT = {"h": 3600, "d": 86400, "w": 604800}
+ base = string[:-1]
+ unit = string[-1:]
+ if base.isdigit() and unit in UNIT_DICT:
+ return int(base) * UNIT_DICT[unit]
+ raise argparse.ArgumentTypeError('must be digits followed by h/d/w')
+
+
+def get_tagged_revisions(pattern: str) -> Iterator[Tuple[str, str, int]]:
+ '''
+ Iterates the tagged revisions as (tag name, commit sha, committer date) tuples.
+ '''
+ git = get_git_cmd(wpt_root)
+ args = [
+ pattern,
+ '--sort=-committerdate',
+ '--format=%(refname:lstrip=2) %(objectname) %(committerdate:raw)',
+ '--count=100000'
+ ]
+ ref_list = git("for-each-ref", *args) # type: ignore
+ for line in ref_list.splitlines():
+ if not line:
+ continue
+ tag, commit, date, _ = line.split(" ")
+ date = int(date)
+ yield tag, commit, date
+
+
+def get_epoch_revisions(epoch: int, until: int, max_count: int) -> Iterator[str]:
+ # Set an offset to start to count the the weekly epoch from
+ # Monday 00:00:00. This is particularly important for the weekly epoch
+ # because fix the start of the epoch to Monday. This offset is calculated
+ # from Thursday, 1 January 1970 0:00:00 to Monday, 5 January 1970 0:00:00
+ epoch_offset = 345600
+ count = 0
+
+ # Iterates the tagged revisions in descending order finding the more
+ # recent commit still older than a "cutoff_date" value.
+ # When a commit is found "cutoff_date" is set to a new value multiplier of
+ # "epoch" but still below of the date of the current commit found.
+ # This needed to deal with intervals where no candidates were found
+ # for the current "epoch" and the next candidate found is yet below
+ # the lower values of the interval (it is the case of J and I for the
+ # interval between Wed and Tue, in the example). The algorithm fix
+ # the next "cutoff_date" value based on the date value of the current one
+ # skipping the intermediate values.
+ # The loop ends once we reached the required number of revisions to return
+ # or the are no more tagged revisions or the cutoff_date reach zero.
+ #
+ # Fri Sat Sun Mon Tue Wed Thu Fri Sat
+ # | | | | | | | | |
+ # -A---B-C---DEF---G---H--IJ----------K-----L-M----N--O--
+ # ^
+ # now
+ # Expected result: N,M,K,J,H,G,F,C,A
+
+ cutoff_date = calculate_cutoff_date(until, epoch, epoch_offset)
+ for _, commit, date in get_tagged_revisions("refs/tags/merge_pr_*"):
+ if count >= max_count:
+ return
+ if date < cutoff_date:
+ yield commit
+ count += 1
+ cutoff_date = calculate_cutoff_date(date, epoch, epoch_offset)
+
+
+def get_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--epoch",
+ default="1d",
+ type=parse_epoch,
+ help="regular interval of time selected to get the "
+ "tagged revisions. Valid values are digits "
+ "followed by h/d/w (e.x. 9h, 9d, 9w ...) where "
+ "the mimimun selectable interval is one hour "
+ "(1h)")
+ parser.add_argument("--max-count",
+ default=1,
+ type=int,
+ help="maximum number of revisions to be returned by "
+ "the command")
+ return parser
+
+
+def run_rev_list(**kwargs: Any) -> None:
+ # "epoch_threshold" is a safety margin. After this time it is fine to
+ # assume that any tags are created and pushed.
+ epoch_threshold = 600
+ until = int(time.time()) - epoch_threshold
+ for line in get_epoch_revisions(kwargs["epoch"], until, kwargs["max_count"]):
+ print(line)
diff --git a/testing/web-platform/tests/tools/wpt/run.py b/testing/web-platform/tests/tools/wpt/run.py
new file mode 100644
index 0000000000..468bea3c51
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/run.py
@@ -0,0 +1,873 @@
+# mypy: allow-untyped-defs
+
+import argparse
+import os
+import platform
+import sys
+from distutils.spawn import find_executable
+from typing import ClassVar, Tuple, Type
+
+wpt_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir))
+sys.path.insert(0, os.path.abspath(os.path.join(wpt_root, "tools")))
+
+from . import browser, install, testfiles
+from ..serve import serve
+
+logger = None
+
+
+class WptrunError(Exception):
+ pass
+
+
+class WptrunnerHelpAction(argparse.Action):
+ def __init__(self,
+ option_strings,
+ dest=argparse.SUPPRESS,
+ default=argparse.SUPPRESS,
+ help=None):
+ super().__init__(
+ option_strings=option_strings,
+ dest=dest,
+ default=default,
+ nargs=0,
+ help=help)
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ from wptrunner import wptcommandline
+ wptparser = wptcommandline.create_parser()
+ wptparser.usage = parser.usage
+ wptparser.print_help()
+ parser.exit()
+
+
+def create_parser():
+ from wptrunner import wptcommandline
+
+ parser = argparse.ArgumentParser(add_help=False, parents=[install.channel_args])
+ parser.add_argument("product", action="store",
+ help="Browser to run tests in")
+ parser.add_argument("--affected", action="store", default=None,
+ help="Run affected tests since revish")
+ parser.add_argument("--yes", "-y", dest="prompt", action="store_false", default=True,
+ help="Don't prompt before installing components")
+ parser.add_argument("--install-browser", action="store_true",
+ help="Install the browser from the release channel specified by --channel "
+ "(or the nightly channel by default).")
+ parser.add_argument("--install-webdriver", action="store_true",
+ help="Install WebDriver from the release channel specified by --channel "
+ "(or the nightly channel by default).")
+ parser._add_container_actions(wptcommandline.create_parser())
+ return parser
+
+
+def exit(msg=None):
+ if msg:
+ logger.critical(msg)
+ sys.exit(1)
+ else:
+ sys.exit(0)
+
+
+def args_general(kwargs):
+
+ def set_if_none(name, value):
+ if kwargs.get(name) is None:
+ kwargs[name] = value
+ logger.info("Set %s to %s" % (name, value))
+
+ set_if_none("tests_root", wpt_root)
+ set_if_none("metadata_root", wpt_root)
+ set_if_none("manifest_update", True)
+ set_if_none("manifest_download", True)
+
+ if kwargs["ssl_type"] in (None, "pregenerated"):
+ cert_root = os.path.join(wpt_root, "tools", "certs")
+ if kwargs["ca_cert_path"] is None:
+ kwargs["ca_cert_path"] = os.path.join(cert_root, "cacert.pem")
+
+ if kwargs["host_key_path"] is None:
+ kwargs["host_key_path"] = os.path.join(cert_root, "web-platform.test.key")
+
+ if kwargs["host_cert_path"] is None:
+ kwargs["host_cert_path"] = os.path.join(cert_root, "web-platform.test.pem")
+ elif kwargs["ssl_type"] == "openssl":
+ if not find_executable(kwargs["openssl_binary"]):
+ if os.uname()[0] == "Windows":
+ raise WptrunError("""OpenSSL binary not found. If you need HTTPS tests, install OpenSSL from
+
+https://slproweb.com/products/Win32OpenSSL.html
+
+Ensuring that libraries are added to /bin and add the resulting bin directory to
+your PATH.
+
+Otherwise run with --ssl-type=none""")
+ else:
+ raise WptrunError("""OpenSSL not found. If you don't need HTTPS support run with --ssl-type=none,
+otherwise install OpenSSL and ensure that it's on your $PATH.""")
+
+
+def check_environ(product):
+ if product not in ("android_weblayer", "android_webview", "chrome",
+ "chrome_android", "chrome_ios", "content_shell",
+ "firefox", "firefox_android", "servo"):
+ config_builder = serve.build_config(os.path.join(wpt_root, "config.json"))
+ # Override the ports to avoid looking for free ports
+ config_builder.ssl = {"type": "none"}
+ config_builder.ports = {"http": [8000]}
+
+ is_windows = platform.uname()[0] == "Windows"
+
+ with config_builder as config:
+ expected_hosts = set(config.domains_set)
+ if is_windows:
+ expected_hosts.update(config.not_domains_set)
+
+ missing_hosts = set(expected_hosts)
+ if is_windows:
+ hosts_path = r"%s\System32\drivers\etc\hosts" % os.environ.get(
+ "SystemRoot", r"C:\Windows")
+ else:
+ hosts_path = "/etc/hosts"
+
+ if os.path.abspath(os.curdir) == wpt_root:
+ wpt_path = "wpt"
+ else:
+ wpt_path = os.path.join(wpt_root, "wpt")
+
+ with open(hosts_path) as f:
+ for line in f:
+ line = line.split("#", 1)[0].strip()
+ parts = line.split()
+ hosts = parts[1:]
+ for host in hosts:
+ missing_hosts.discard(host)
+ if missing_hosts:
+ if is_windows:
+ message = """Missing hosts file configuration. Run
+
+python %s make-hosts-file | Out-File %s -Encoding ascii -Append
+
+in PowerShell with Administrator privileges.""" % (wpt_path, hosts_path)
+ else:
+ message = """Missing hosts file configuration. Run
+
+%s make-hosts-file | sudo tee -a %s""" % ("./wpt" if wpt_path == "wpt" else wpt_path,
+ hosts_path)
+ raise WptrunError(message)
+
+
+class BrowserSetup:
+ name = None # type: ClassVar[str]
+ browser_cls = None # type: ClassVar[Type[browser.Browser]]
+
+ def __init__(self, venv, prompt=True):
+ self.browser = self.browser_cls(logger)
+ self.venv = venv
+ self.prompt = prompt
+
+ def prompt_install(self, component):
+ if not self.prompt:
+ return True
+ while True:
+ resp = input("Download and install %s [Y/n]? " % component).strip().lower()
+ if not resp or resp == "y":
+ return True
+ elif resp == "n":
+ return False
+
+ def install(self, channel=None):
+ if self.prompt_install(self.name):
+ return self.browser.install(self.venv.path, channel)
+
+ def install_requirements(self):
+ if not self.venv.skip_virtualenv_setup and self.browser.requirements:
+ self.venv.install_requirements(os.path.join(
+ wpt_root, "tools", "wptrunner", self.browser.requirements))
+
+
+ def setup(self, kwargs):
+ self.setup_kwargs(kwargs)
+
+
+def safe_unsetenv(env_var):
+ """Safely remove an environment variable.
+
+ Python3 does not support os.unsetenv in Windows for python<3.9, so we better
+ remove the variable directly from os.environ.
+ """
+ try:
+ del os.environ[env_var]
+ except KeyError:
+ pass
+
+
+class Firefox(BrowserSetup):
+ name = "firefox"
+ browser_cls = browser.Firefox
+
+ def setup_kwargs(self, kwargs):
+ if kwargs["binary"] is None:
+ if kwargs["browser_channel"] is None:
+ kwargs["browser_channel"] = "nightly"
+ logger.info("No browser channel specified. Running nightly instead.")
+
+ binary = self.browser.find_binary(self.venv.path,
+ kwargs["browser_channel"])
+ if binary is None:
+ raise WptrunError("""Firefox binary not found on $PATH.
+
+Install Firefox or use --binary to set the binary path""")
+ kwargs["binary"] = binary
+
+ if kwargs["certutil_binary"] is None and kwargs["ssl_type"] != "none":
+ certutil = self.browser.find_certutil()
+
+ if certutil is None:
+ # Can't download this for now because it's missing the libnss3 library
+ logger.info("""Can't find certutil, certificates will not be checked.
+Consider installing certutil via your OS package manager or directly.""")
+ else:
+ logger.info("Using certutil %s" % certutil)
+
+ kwargs["certutil_binary"] = certutil
+
+ if kwargs["webdriver_binary"] is None and "wdspec" in kwargs["test_types"]:
+ webdriver_binary = None
+ if not kwargs["install_webdriver"]:
+ webdriver_binary = self.browser.find_webdriver()
+
+ if webdriver_binary is None:
+ install = self.prompt_install("geckodriver")
+
+ if install:
+ logger.info("Downloading geckodriver")
+ webdriver_binary = self.browser.install_webdriver(
+ dest=self.venv.bin_path,
+ channel=kwargs["browser_channel"],
+ browser_binary=kwargs["binary"])
+ else:
+ logger.info("Using webdriver binary %s" % webdriver_binary)
+
+ if webdriver_binary:
+ kwargs["webdriver_binary"] = webdriver_binary
+ else:
+ logger.info("Unable to find or install geckodriver, skipping wdspec tests")
+ kwargs["test_types"].remove("wdspec")
+
+ if kwargs["prefs_root"] is None:
+ prefs_root = self.browser.install_prefs(kwargs["binary"],
+ self.venv.path,
+ channel=kwargs["browser_channel"])
+ kwargs["prefs_root"] = prefs_root
+
+ if kwargs["headless"] is None and not kwargs["debug_test"]:
+ kwargs["headless"] = True
+ logger.info("Running in headless mode, pass --no-headless to disable")
+
+ # Turn off Firefox WebRTC ICE logging on WPT (turned on by mozrunner)
+ safe_unsetenv('R_LOG_LEVEL')
+ safe_unsetenv('R_LOG_DESTINATION')
+ safe_unsetenv('R_LOG_VERBOSE')
+
+ # Allow WebRTC tests to call getUserMedia.
+ kwargs["extra_prefs"].append("media.navigator.streams.fake=true")
+
+
+class FirefoxAndroid(BrowserSetup):
+ name = "firefox_android"
+ browser_cls = browser.FirefoxAndroid
+
+ def setup_kwargs(self, kwargs):
+ from . import android
+ import mozdevice
+
+ # We don't support multiple channels for android yet
+ if kwargs["browser_channel"] is None:
+ kwargs["browser_channel"] = "nightly"
+
+ if kwargs["prefs_root"] is None:
+ prefs_root = self.browser.install_prefs(kwargs["binary"],
+ self.venv.path,
+ channel=kwargs["browser_channel"])
+ kwargs["prefs_root"] = prefs_root
+
+ if kwargs["package_name"] is None:
+ kwargs["package_name"] = "org.mozilla.geckoview.test_runner"
+ app = kwargs["package_name"]
+
+ if not kwargs["device_serial"]:
+ kwargs["device_serial"] = ["emulator-5554"]
+
+ for device_serial in kwargs["device_serial"]:
+ if device_serial.startswith("emulator-"):
+ # We're running on an emulator so ensure that's set up
+ emulator = android.install(logger,
+ reinstall=False,
+ no_prompt=not self.prompt,
+ device_serial=device_serial)
+ android.start(logger,
+ emulator=emulator,
+ reinstall=False,
+ device_serial=device_serial)
+
+ if "ADB_PATH" not in os.environ:
+ adb_path = os.path.join(android.get_sdk_path(None),
+ "platform-tools",
+ "adb")
+ os.environ["ADB_PATH"] = adb_path
+ adb_path = os.environ["ADB_PATH"]
+
+ for device_serial in kwargs["device_serial"]:
+ device = mozdevice.ADBDeviceFactory(adb=adb_path,
+ device=device_serial)
+
+ if self.browser.apk_path:
+ device.uninstall_app(app)
+ device.install_app(self.browser.apk_path)
+ elif not device.is_app_installed(app):
+ raise WptrunError("app %s not installed on device %s" %
+ (app, device_serial))
+
+
+class Chrome(BrowserSetup):
+ name = "chrome"
+ browser_cls = browser.Chrome # type: ClassVar[Type[browser.ChromeChromiumBase]]
+ experimental_channels = ("dev", "canary", "nightly") # type: ClassVar[Tuple[str, ...]]
+
+ def setup_kwargs(self, kwargs):
+ browser_channel = kwargs["browser_channel"]
+ if kwargs["binary"] is None:
+ binary = self.browser.find_binary(venv_path=self.venv.path, channel=browser_channel)
+ if binary:
+ kwargs["binary"] = binary
+ else:
+ raise WptrunError(f"Unable to locate {self.name.capitalize()} binary")
+
+ if kwargs["mojojs_path"]:
+ kwargs["enable_mojojs"] = True
+ logger.info("--mojojs-path is provided, enabling MojoJS")
+ else:
+ path = self.browser.install_mojojs(dest=self.venv.path,
+ browser_binary=kwargs["binary"])
+ if path:
+ kwargs["mojojs_path"] = path
+ kwargs["enable_mojojs"] = True
+ logger.info(f"MojoJS enabled automatically (mojojs_path: {path})")
+ else:
+ kwargs["enable_mojojs"] = False
+ logger.info("MojoJS is disabled for this run.")
+
+ if kwargs["webdriver_binary"] is None:
+ webdriver_binary = None
+ if not kwargs["install_webdriver"]:
+ webdriver_binary = self.browser.find_webdriver(self.venv.bin_path)
+ if webdriver_binary and not self.browser.webdriver_supports_browser(
+ webdriver_binary, kwargs["binary"], browser_channel):
+ webdriver_binary = None
+
+ if webdriver_binary is None:
+ install = self.prompt_install("chromedriver")
+
+ if install:
+ webdriver_binary = self.browser.install_webdriver(
+ dest=self.venv.bin_path,
+ channel=browser_channel,
+ browser_binary=kwargs["binary"],
+ )
+ else:
+ logger.info("Using webdriver binary %s" % webdriver_binary)
+
+ if webdriver_binary:
+ kwargs["webdriver_binary"] = webdriver_binary
+ else:
+ raise WptrunError("Unable to locate or install matching ChromeDriver binary")
+ if browser_channel in self.experimental_channels:
+ # HACK(Hexcles): work around https://github.com/web-platform-tests/wpt/issues/16448
+ kwargs["webdriver_args"].append("--disable-build-check")
+ if kwargs["enable_experimental"] is None:
+ logger.info(
+ "Automatically turning on experimental features for Chrome Dev/Canary or Chromium trunk")
+ kwargs["enable_experimental"] = True
+ if kwargs["enable_webtransport_h3"] is None:
+ # To start the WebTransport over HTTP/3 test server.
+ kwargs["enable_webtransport_h3"] = True
+ if os.getenv("TASKCLUSTER_ROOT_URL"):
+ # We are on Taskcluster, where our Docker container does not have
+ # enough capabilities to run Chrome with sandboxing. (gh-20133)
+ kwargs["binary_args"].append("--no-sandbox")
+
+
+class ContentShell(BrowserSetup):
+ name = "content_shell"
+ browser_cls = browser.ContentShell
+ experimental_channels = ("dev", "canary", "nightly")
+
+ def setup_kwargs(self, kwargs):
+ browser_channel = kwargs["browser_channel"]
+ if kwargs["binary"] is None:
+ binary = self.browser.find_binary(venv_path=self.venv.path, channel=browser_channel)
+ if binary:
+ kwargs["binary"] = binary
+ else:
+ raise WptrunError(f"Unable to locate {self.name.capitalize()} binary")
+
+ if kwargs["mojojs_path"]:
+ kwargs["enable_mojojs"] = True
+ logger.info("--mojojs-path is provided, enabling MojoJS")
+ elif kwargs["enable_mojojs"]:
+ logger.warning(f"Cannot install MojoJS for {self.name}, "
+ "which does not return version information. "
+ "Provide '--mojojs-path' explicitly instead.")
+ logger.warning("MojoJS is disabled for this run.")
+
+ kwargs["enable_webtransport_h3"] = True
+
+
+class Chromium(Chrome):
+ name = "chromium"
+ browser_cls = browser.Chromium # type: ClassVar[Type[browser.ChromeChromiumBase]]
+ experimental_channels = ("nightly",)
+
+
+class ChromeAndroidBase(BrowserSetup):
+ experimental_channels = ("dev", "canary")
+
+ def setup_kwargs(self, kwargs):
+ if kwargs.get("device_serial"):
+ self.browser.device_serial = kwargs["device_serial"]
+ if kwargs.get("adb_binary"):
+ self.browser.adb_binary = kwargs["adb_binary"]
+ browser_channel = kwargs["browser_channel"]
+ if kwargs["package_name"] is None:
+ kwargs["package_name"] = self.browser.find_binary(
+ channel=browser_channel)
+ if kwargs["webdriver_binary"] is None:
+ webdriver_binary = None
+ if not kwargs["install_webdriver"]:
+ webdriver_binary = self.browser.find_webdriver()
+
+ if webdriver_binary is None:
+ install = self.prompt_install("chromedriver")
+
+ if install:
+ logger.info("Downloading chromedriver")
+ webdriver_binary = self.browser.install_webdriver(
+ dest=self.venv.bin_path,
+ channel=browser_channel,
+ browser_binary=kwargs["package_name"],
+ )
+ else:
+ logger.info("Using webdriver binary %s" % webdriver_binary)
+
+ if webdriver_binary:
+ kwargs["webdriver_binary"] = webdriver_binary
+ else:
+ raise WptrunError("Unable to locate or install chromedriver binary")
+
+
+class ChromeAndroid(ChromeAndroidBase):
+ name = "chrome_android"
+ browser_cls = browser.ChromeAndroid
+
+ def setup_kwargs(self, kwargs):
+ super().setup_kwargs(kwargs)
+ if kwargs["browser_channel"] in self.experimental_channels:
+ # HACK(Hexcles): work around https://github.com/web-platform-tests/wpt/issues/16448
+ kwargs["webdriver_args"].append("--disable-build-check")
+ if kwargs["enable_experimental"] is None:
+ logger.info("Automatically turning on experimental features for Chrome Dev/Canary")
+ kwargs["enable_experimental"] = True
+
+
+class ChromeiOS(BrowserSetup):
+ name = "chrome_ios"
+ browser_cls = browser.ChromeiOS
+
+ def setup_kwargs(self, kwargs):
+ if kwargs["webdriver_binary"] is None:
+ raise WptrunError("Unable to locate or install chromedriver binary")
+
+
+class AndroidWeblayer(ChromeAndroidBase):
+ name = "android_weblayer"
+ browser_cls = browser.AndroidWeblayer
+
+ def setup_kwargs(self, kwargs):
+ super().setup_kwargs(kwargs)
+ if kwargs["browser_channel"] in self.experimental_channels and kwargs["enable_experimental"] is None:
+ logger.info("Automatically turning on experimental features for WebLayer Dev/Canary")
+ kwargs["enable_experimental"] = True
+
+
+class AndroidWebview(ChromeAndroidBase):
+ name = "android_webview"
+ browser_cls = browser.AndroidWebview
+
+
+class Opera(BrowserSetup):
+ name = "opera"
+ browser_cls = browser.Opera
+
+ def setup_kwargs(self, kwargs):
+ if kwargs["webdriver_binary"] is None:
+ webdriver_binary = None
+ if not kwargs["install_webdriver"]:
+ webdriver_binary = self.browser.find_webdriver()
+
+ if webdriver_binary is None:
+ install = self.prompt_install("operadriver")
+
+ if install:
+ logger.info("Downloading operadriver")
+ webdriver_binary = self.browser.install_webdriver(
+ dest=self.venv.bin_path,
+ channel=kwargs["browser_channel"])
+ else:
+ logger.info("Using webdriver binary %s" % webdriver_binary)
+
+ if webdriver_binary:
+ kwargs["webdriver_binary"] = webdriver_binary
+ else:
+ raise WptrunError("Unable to locate or install operadriver binary")
+
+
+class EdgeChromium(BrowserSetup):
+ name = "MicrosoftEdge"
+ browser_cls = browser.EdgeChromium
+
+ def setup_kwargs(self, kwargs):
+ browser_channel = kwargs["browser_channel"]
+ if kwargs["binary"] is None:
+ binary = self.browser.find_binary(channel=browser_channel)
+ if binary:
+ logger.info("Using Edge binary %s" % binary)
+ kwargs["binary"] = binary
+ else:
+ raise WptrunError("Unable to locate Edge binary")
+
+ if kwargs["webdriver_binary"] is None:
+ webdriver_binary = None
+ if not kwargs["install_webdriver"]:
+ webdriver_binary = self.browser.find_webdriver()
+ if (webdriver_binary and not self.browser.webdriver_supports_browser(
+ webdriver_binary, kwargs["binary"])):
+ webdriver_binary = None
+
+ if webdriver_binary is None:
+ install = self.prompt_install("msedgedriver")
+
+ if install:
+ logger.info("Downloading msedgedriver")
+ webdriver_binary = self.browser.install_webdriver(
+ dest=self.venv.bin_path,
+ channel=browser_channel)
+ else:
+ logger.info("Using webdriver binary %s" % webdriver_binary)
+
+ if webdriver_binary:
+ kwargs["webdriver_binary"] = webdriver_binary
+ else:
+ raise WptrunError("Unable to locate or install msedgedriver binary")
+ if browser_channel in ("dev", "canary") and kwargs["enable_experimental"] is None:
+ logger.info("Automatically turning on experimental features for Edge Dev/Canary")
+ kwargs["enable_experimental"] = True
+
+
+class Edge(BrowserSetup):
+ name = "edge"
+ browser_cls = browser.Edge
+
+ def install(self, channel=None):
+ raise NotImplementedError
+
+ def setup_kwargs(self, kwargs):
+ if kwargs["webdriver_binary"] is None:
+ webdriver_binary = self.browser.find_webdriver()
+
+ if webdriver_binary is None:
+ raise WptrunError("""Unable to find WebDriver and we aren't yet clever enough to work out which
+version to download. Please go to the following URL and install the correct
+version for your Edge/Windows release somewhere on the %PATH%:
+
+https://developer.microsoft.com/en-us/microsoft-edge/tools/webdriver/
+""")
+ kwargs["webdriver_binary"] = webdriver_binary
+
+
+class EdgeWebDriver(Edge):
+ name = "edge_webdriver"
+ browser_cls = browser.EdgeWebDriver
+
+
+class InternetExplorer(BrowserSetup):
+ name = "ie"
+ browser_cls = browser.InternetExplorer
+
+ def install(self, channel=None):
+ raise NotImplementedError
+
+ def setup_kwargs(self, kwargs):
+ if kwargs["webdriver_binary"] is None:
+ webdriver_binary = self.browser.find_webdriver()
+
+ if webdriver_binary is None:
+ raise WptrunError("""Unable to find WebDriver and we aren't yet clever enough to work out which
+version to download. Please go to the following URL and install the driver for Internet Explorer
+somewhere on the %PATH%:
+
+https://selenium-release.storage.googleapis.com/index.html
+""")
+ kwargs["webdriver_binary"] = webdriver_binary
+
+
+class Safari(BrowserSetup):
+ name = "safari"
+ browser_cls = browser.Safari
+
+ def install(self, channel=None):
+ raise NotImplementedError
+
+ def setup_kwargs(self, kwargs):
+ if kwargs["webdriver_binary"] is None:
+ webdriver_binary = self.browser.find_webdriver(channel=kwargs["browser_channel"])
+
+ if webdriver_binary is None:
+ raise WptrunError("Unable to locate safaridriver binary")
+
+ kwargs["webdriver_binary"] = webdriver_binary
+
+
+class Sauce(BrowserSetup):
+ name = "sauce"
+ browser_cls = browser.Sauce
+
+ def install(self, channel=None):
+ raise NotImplementedError
+
+ def setup_kwargs(self, kwargs):
+ if kwargs["sauce_browser"] is None:
+ raise WptrunError("Missing required argument --sauce-browser")
+ if kwargs["sauce_version"] is None:
+ raise WptrunError("Missing required argument --sauce-version")
+ kwargs["test_types"] = ["testharness", "reftest"]
+
+
+class Servo(BrowserSetup):
+ name = "servo"
+ browser_cls = browser.Servo
+
+ def install(self, channel=None):
+ if self.prompt_install(self.name):
+ return self.browser.install(self.venv.path)
+
+ def setup_kwargs(self, kwargs):
+ if kwargs["binary"] is None:
+ binary = self.browser.find_binary(self.venv.path, None)
+
+ if binary is None:
+ raise WptrunError("Unable to find servo binary in PATH")
+ kwargs["binary"] = binary
+
+
+class ServoWebDriver(Servo):
+ name = "servodriver"
+ browser_cls = browser.ServoWebDriver
+
+
+class WebKit(BrowserSetup):
+ name = "webkit"
+ browser_cls = browser.WebKit
+
+ def install(self, channel=None):
+ raise NotImplementedError
+
+ def setup_kwargs(self, kwargs):
+ pass
+
+
+class WebKitGTKMiniBrowser(BrowserSetup):
+ name = "webkitgtk_minibrowser"
+ browser_cls = browser.WebKitGTKMiniBrowser
+
+ def install(self, channel=None):
+ if self.prompt_install(self.name):
+ return self.browser.install(self.venv.path, channel, self.prompt)
+
+ def setup_kwargs(self, kwargs):
+ if kwargs["binary"] is None:
+ binary = self.browser.find_binary(
+ venv_path=self.venv.path, channel=kwargs["browser_channel"])
+
+ if binary is None:
+ raise WptrunError("Unable to find MiniBrowser binary")
+ kwargs["binary"] = binary
+
+ if kwargs["webdriver_binary"] is None:
+ webdriver_binary = self.browser.find_webdriver(
+ venv_path=self.venv.path, channel=kwargs["browser_channel"])
+
+ if webdriver_binary is None:
+ raise WptrunError("Unable to find WebKitWebDriver in PATH")
+ kwargs["webdriver_binary"] = webdriver_binary
+
+
+class Epiphany(BrowserSetup):
+ name = "epiphany"
+ browser_cls = browser.Epiphany
+
+ def install(self, channel=None):
+ raise NotImplementedError
+
+ def setup_kwargs(self, kwargs):
+ if kwargs["binary"] is None:
+ binary = self.browser.find_binary()
+
+ if binary is None:
+ raise WptrunError("Unable to find epiphany in PATH")
+ kwargs["binary"] = binary
+
+ if kwargs["webdriver_binary"] is None:
+ webdriver_binary = self.browser.find_webdriver()
+
+ if webdriver_binary is None:
+ raise WptrunError("Unable to find WebKitWebDriver in PATH")
+ kwargs["webdriver_binary"] = webdriver_binary
+
+
+product_setup = {
+ "android_weblayer": AndroidWeblayer,
+ "android_webview": AndroidWebview,
+ "firefox": Firefox,
+ "firefox_android": FirefoxAndroid,
+ "chrome": Chrome,
+ "chrome_android": ChromeAndroid,
+ "chrome_ios": ChromeiOS,
+ "chromium": Chromium,
+ "content_shell": ContentShell,
+ "edgechromium": EdgeChromium,
+ "edge": Edge,
+ "edge_webdriver": EdgeWebDriver,
+ "ie": InternetExplorer,
+ "safari": Safari,
+ "servo": Servo,
+ "servodriver": ServoWebDriver,
+ "sauce": Sauce,
+ "opera": Opera,
+ "webkit": WebKit,
+ "webkitgtk_minibrowser": WebKitGTKMiniBrowser,
+ "epiphany": Epiphany,
+}
+
+
+def setup_logging(kwargs, default_config=None, formatter_defaults=None):
+ import mozlog
+ from wptrunner import wptrunner
+
+ global logger
+
+ # Use the grouped formatter by default where mozlog 3.9+ is installed
+ if default_config is None:
+ if hasattr(mozlog.formatters, "GroupingFormatter"):
+ default_formatter = "grouped"
+ else:
+ default_formatter = "mach"
+ default_config = {default_formatter: sys.stdout}
+ wptrunner.setup_logging(kwargs, default_config, formatter_defaults=formatter_defaults)
+ logger = wptrunner.logger
+ return logger
+
+
+def setup_wptrunner(venv, **kwargs):
+ from wptrunner import wptcommandline
+
+ kwargs = kwargs.copy()
+
+ kwargs["product"] = kwargs["product"].replace("-", "_")
+
+ check_environ(kwargs["product"])
+ args_general(kwargs)
+
+ if kwargs["product"] not in product_setup:
+ raise WptrunError("Unsupported product %s" % kwargs["product"])
+
+ setup_cls = product_setup[kwargs["product"]](venv, kwargs["prompt"])
+ setup_cls.install_requirements()
+
+ affected_revish = kwargs.get("affected")
+ if affected_revish is not None:
+ files_changed, _ = testfiles.files_changed(
+ affected_revish, include_uncommitted=True, include_new=True)
+ # TODO: Perhaps use wptrunner.testloader.ManifestLoader here
+ # and remove the manifest-related code from testfiles.
+ # https://github.com/web-platform-tests/wpt/issues/14421
+ tests_changed, tests_affected = testfiles.affected_testfiles(
+ files_changed, manifest_path=kwargs.get("manifest_path"), manifest_update=kwargs["manifest_update"])
+ test_list = tests_changed | tests_affected
+ logger.info("Identified %s affected tests" % len(test_list))
+ test_list = [os.path.relpath(item, wpt_root) for item in test_list]
+ kwargs["test_list"] += test_list
+ kwargs["default_exclude"] = True
+
+ if kwargs["install_browser"] and not kwargs["channel"]:
+ logger.info("--install-browser is given but --channel is not set, default to nightly channel")
+ kwargs["channel"] = "nightly"
+
+ if kwargs["channel"]:
+ channel = install.get_channel(kwargs["product"], kwargs["channel"])
+ if channel is not None:
+ if channel != kwargs["channel"]:
+ logger.info("Interpreting channel '%s' as '%s'" % (kwargs["channel"],
+ channel))
+ kwargs["browser_channel"] = channel
+ else:
+ logger.info("Valid channels for %s not known; using argument unmodified" %
+ kwargs["product"])
+ kwargs["browser_channel"] = kwargs["channel"]
+
+ if kwargs["install_browser"]:
+ logger.info("Installing browser")
+ kwargs["binary"] = setup_cls.install(channel=channel)
+
+ setup_cls.setup(kwargs)
+
+ # Remove kwargs we handle here
+ wptrunner_kwargs = kwargs.copy()
+ for kwarg in ["affected",
+ "install_browser",
+ "install_webdriver",
+ "channel",
+ "prompt"]:
+ del wptrunner_kwargs[kwarg]
+
+ wptcommandline.check_args(wptrunner_kwargs)
+
+ wptrunner_path = os.path.join(wpt_root, "tools", "wptrunner")
+
+ if not venv.skip_virtualenv_setup:
+ venv.install_requirements(os.path.join(wptrunner_path, "requirements.txt"))
+
+ # Only update browser_version if it was not given as a command line
+ # argument, so that it can be overridden on the command line.
+ if not wptrunner_kwargs["browser_version"]:
+ wptrunner_kwargs["browser_version"] = setup_cls.browser.version(
+ binary=wptrunner_kwargs.get("binary") or wptrunner_kwargs.get("package_name"),
+ webdriver_binary=wptrunner_kwargs.get("webdriver_binary"),
+ )
+
+ return wptrunner_kwargs
+
+
+def run(venv, **kwargs):
+ setup_logging(kwargs)
+
+ wptrunner_kwargs = setup_wptrunner(venv, **kwargs)
+
+ rv = run_single(venv, **wptrunner_kwargs) > 0
+
+ return rv
+
+
+def run_single(venv, **kwargs):
+ from wptrunner import wptrunner
+ return wptrunner.start(**kwargs)
diff --git a/testing/web-platform/tests/tools/wpt/testfiles.py b/testing/web-platform/tests/tools/wpt/testfiles.py
new file mode 100644
index 0000000000..172ad201fc
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/testfiles.py
@@ -0,0 +1,442 @@
+import argparse
+import logging
+import os
+import re
+import subprocess
+import sys
+
+from collections import OrderedDict
+
+try:
+ from ..manifest import manifest
+ from ..manifest.utils import git as get_git_cmd
+except ValueError:
+ # if we're not within the tools package, the above is an import from above
+ # the top-level which raises ValueError, so reimport it with an absolute
+ # reference
+ #
+ # note we need both because depending on caller we may/may not have the
+ # paths set up correctly to handle both and MYPY has no knowledge of our
+ # sys.path magic
+ from manifest import manifest # type: ignore
+ from manifest.utils import git as get_git_cmd # type: ignore
+
+MYPY = False
+if MYPY:
+ # MYPY is set to True when run under Mypy.
+ from typing import Any
+ from typing import Dict
+ from typing import Iterable
+ from typing import List
+ from typing import Optional
+ from typing import Pattern
+ from typing import Sequence
+ from typing import Set
+ from typing import Text
+ from typing import Tuple
+
+DEFAULT_IGNORE_RULERS = ("resources/testharness*", "resources/testdriver*")
+
+here = os.path.dirname(__file__)
+wpt_root = os.path.abspath(os.path.join(here, os.pardir, os.pardir))
+
+logger = logging.getLogger()
+
+
+def display_branch_point():
+ # type: () -> None
+ print(branch_point())
+
+
+def branch_point():
+ # type: () -> Optional[Text]
+ git = get_git_cmd(wpt_root)
+ if git is None:
+ raise Exception("git not found")
+
+ if (os.environ.get("GITHUB_PULL_REQUEST", "false") == "false" and
+ os.environ.get("GITHUB_BRANCH") == "master"):
+ # For builds on the master branch just return the HEAD commit
+ return git("rev-parse", "HEAD")
+ elif os.environ.get("GITHUB_PULL_REQUEST", "false") != "false":
+ # This is a PR, so the base branch is in GITHUB_BRANCH
+ base_branch = os.environ.get("GITHUB_BRANCH")
+ assert base_branch, "GITHUB_BRANCH environment variable is defined"
+ branch_point = git("merge-base", "HEAD", base_branch) # type: Optional[Text]
+ else:
+ # Otherwise we aren't on a PR, so we try to find commits that are only in the
+ # current branch c.f.
+ # http://stackoverflow.com/questions/13460152/find-first-ancestor-commit-in-another-branch
+
+ # parse HEAD into an object ref
+ head = git("rev-parse", "HEAD")
+
+ # get everything in refs/heads and refs/remotes that doesn't include HEAD
+ not_heads = [item for item in git("rev-parse", "--not", "--branches", "--remotes").split("\n")
+ if item and item != "^%s" % head]
+
+ # get all commits on HEAD but not reachable from anything in not_heads
+ cmd = ["git", "rev-list", "--topo-order", "--parents", "--stdin", "HEAD"]
+ proc = subprocess.Popen(cmd,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ cwd=wpt_root)
+ commits_bytes, _ = proc.communicate(b"\n".join(item.encode("ascii") for item in not_heads))
+ if proc.returncode != 0:
+ raise subprocess.CalledProcessError(proc.returncode,
+ cmd,
+ commits_bytes)
+
+ commit_parents = OrderedDict() # type: Dict[Text, List[Text]]
+ commits = commits_bytes.decode("ascii")
+ if commits:
+ for line in commits.split("\n"):
+ line_commits = line.split(" ")
+ commit_parents[line_commits[0]] = line_commits[1:]
+
+ branch_point = None
+
+ # if there are any commits, take the first parent that is not in commits
+ for commit, parents in commit_parents.items():
+ for parent in parents:
+ if parent not in commit_parents:
+ branch_point = parent
+ break
+
+ if branch_point:
+ break
+
+ # if we had any commits, we should now have a branch point
+ assert branch_point or not commit_parents
+
+ # The above heuristic will fail in the following cases:
+ #
+ # - The current branch has fallen behind the remote version
+ # - Changes on the current branch were rebased and therefore do not exist on any
+ # other branch. This will result in the selection of a commit that is earlier
+ # in the history than desired (as determined by calculating the later of the
+ # branch point and the merge base)
+ #
+ # In either case, fall back to using the merge base as the branch point.
+ merge_base = git("merge-base", "HEAD", "origin/master")
+ if (branch_point is None or
+ (branch_point != merge_base and
+ not git("log", "--oneline", f"{merge_base}..{branch_point}").strip())):
+ logger.debug("Using merge-base as the branch point")
+ branch_point = merge_base
+ else:
+ logger.debug("Using first commit on another branch as the branch point")
+
+ logger.debug("Branch point from master: %s" % branch_point)
+ if branch_point:
+ branch_point = branch_point.strip()
+ return branch_point
+
+
+def compile_ignore_rule(rule):
+ # type: (Text) -> Pattern[Text]
+ rule = rule.replace(os.path.sep, "/")
+ parts = rule.split("/")
+ re_parts = []
+ for part in parts:
+ if part.endswith("**"):
+ re_parts.append(re.escape(part[:-2]) + ".*")
+ elif part.endswith("*"):
+ re_parts.append(re.escape(part[:-1]) + "[^/]*")
+ else:
+ re_parts.append(re.escape(part))
+ return re.compile("^%s$" % "/".join(re_parts))
+
+
+def repo_files_changed(revish, include_uncommitted=False, include_new=False):
+ # type: (Text, bool, bool) -> Set[Text]
+ git = get_git_cmd(wpt_root)
+ if git is None:
+ raise Exception("git not found")
+
+ if "..." in revish:
+ raise Exception(f"... not supported when finding files changed (revish: {revish!r}")
+
+ if ".." in revish:
+ # ".." isn't treated as a range for git-diff; what we want is
+ # everything reachable from B but not A, and git diff A...B
+ # gives us that (via the merge-base)
+ revish = revish.replace("..", "...")
+
+ files_list = git("diff", "--no-renames", "--name-only", "-z", revish).split("\0")
+ assert not files_list[-1], f"final item should be empty, got: {files_list[-1]!r}"
+ files = set(files_list[:-1])
+
+ if include_uncommitted:
+ entries = git("status", "-z").split("\0")
+ assert not entries[-1]
+ entries = entries[:-1]
+ for item in entries:
+ status, path = item.split(" ", 1)
+ if status == "??" and not include_new:
+ continue
+ else:
+ if not os.path.isdir(path):
+ files.add(path)
+ else:
+ for dirpath, dirnames, filenames in os.walk(path):
+ for filename in filenames:
+ files.add(os.path.join(dirpath, filename))
+
+ return files
+
+
+def exclude_ignored(files, ignore_rules):
+ # type: (Iterable[Text], Optional[Sequence[Text]]) -> Tuple[List[Text], List[Text]]
+ if ignore_rules is None:
+ ignore_rules = DEFAULT_IGNORE_RULERS
+ compiled_ignore_rules = [compile_ignore_rule(item) for item in set(ignore_rules)]
+
+ changed = []
+ ignored = []
+ for item in sorted(files):
+ fullpath = os.path.join(wpt_root, item)
+ rule_path = item.replace(os.path.sep, "/")
+ for rule in compiled_ignore_rules:
+ if rule.match(rule_path):
+ ignored.append(fullpath)
+ break
+ else:
+ changed.append(fullpath)
+
+ return changed, ignored
+
+
+def files_changed(revish, # type: Text
+ ignore_rules=None, # type: Optional[Sequence[Text]]
+ include_uncommitted=False, # type: bool
+ include_new=False # type: bool
+ ):
+ # type: (...) -> Tuple[List[Text], List[Text]]
+ """Find files changed in certain revisions.
+
+ The function passes `revish` directly to `git diff`, so `revish` can have a
+ variety of forms; see `git diff --help` for details. Files in the diff that
+ are matched by `ignore_rules` are excluded.
+ """
+ files = repo_files_changed(revish,
+ include_uncommitted=include_uncommitted,
+ include_new=include_new)
+ if not files:
+ return [], []
+
+ return exclude_ignored(files, ignore_rules)
+
+
+def _in_repo_root(full_path):
+ # type: (Text) -> bool
+ rel_path = os.path.relpath(full_path, wpt_root)
+ path_components = rel_path.split(os.sep)
+ return len(path_components) < 2
+
+
+def load_manifest(manifest_path=None, manifest_update=True):
+ # type: (Optional[Text], bool) -> manifest.Manifest
+ if manifest_path is None:
+ manifest_path = os.path.join(wpt_root, "MANIFEST.json")
+ return manifest.load_and_update(wpt_root, manifest_path, "/",
+ update=manifest_update)
+
+
+def affected_testfiles(files_changed, # type: Iterable[Text]
+ skip_dirs=None, # type: Optional[Set[Text]]
+ manifest_path=None, # type: Optional[Text]
+ manifest_update=True # type: bool
+ ):
+ # type: (...) -> Tuple[Set[Text], Set[Text]]
+ """Determine and return list of test files that reference changed files."""
+ if skip_dirs is None:
+ skip_dirs = {"conformance-checkers", "docs", "tools"}
+ affected_testfiles = set()
+ # Exclude files that are in the repo root, because
+ # they are not part of any test.
+ files_changed = [f for f in files_changed if not _in_repo_root(f)]
+ nontests_changed = set(files_changed)
+ wpt_manifest = load_manifest(manifest_path, manifest_update)
+
+ test_types = ["crashtest", "print-reftest", "reftest", "testharness", "wdspec"]
+ support_files = {os.path.join(wpt_root, path)
+ for _, path, _ in wpt_manifest.itertypes("support")}
+ wdspec_test_files = {os.path.join(wpt_root, path)
+ for _, path, _ in wpt_manifest.itertypes("wdspec")}
+ test_files = {os.path.join(wpt_root, path)
+ for _, path, _ in wpt_manifest.itertypes(*test_types)}
+
+ interface_dir = os.path.join(wpt_root, 'interfaces')
+ interfaces_files = {os.path.join(wpt_root, 'interfaces', filename)
+ for filename in os.listdir(interface_dir)}
+
+ interfaces_changed = interfaces_files.intersection(nontests_changed)
+ nontests_changed = nontests_changed.intersection(support_files)
+
+ tests_changed = {item for item in files_changed if item in test_files}
+
+ nontest_changed_paths = set()
+ rewrites = {"/resources/webidl2/lib/webidl2.js": "/resources/WebIDLParser.js"} # type: Dict[Text, Text]
+ for full_path in nontests_changed:
+ rel_path = os.path.relpath(full_path, wpt_root)
+ path_components = rel_path.split(os.sep)
+ top_level_subdir = path_components[0]
+ if top_level_subdir in skip_dirs:
+ continue
+ repo_path = "/" + os.path.relpath(full_path, wpt_root).replace(os.path.sep, "/")
+ if repo_path in rewrites:
+ repo_path = rewrites[repo_path]
+ full_path = os.path.join(wpt_root, repo_path[1:].replace("/", os.path.sep))
+ nontest_changed_paths.add((full_path, repo_path))
+
+ interfaces_changed_names = [os.path.splitext(os.path.basename(interface))[0]
+ for interface in interfaces_changed]
+
+ def affected_by_wdspec(test):
+ # type: (Text) -> bool
+ affected = False
+ if test in wdspec_test_files:
+ for support_full_path, _ in nontest_changed_paths:
+ # parent of support file or of "support" directory
+ parent = os.path.dirname(support_full_path)
+ if os.path.basename(parent) == "support":
+ parent = os.path.dirname(parent)
+ relpath = os.path.relpath(test, parent)
+ if not relpath.startswith(os.pardir):
+ # testfile is in subtree of support file
+ affected = True
+ break
+ return affected
+
+ def affected_by_interfaces(file_contents):
+ # type: (Text) -> bool
+ if len(interfaces_changed_names) > 0:
+ if 'idlharness.js' in file_contents:
+ for interface in interfaces_changed_names:
+ regex = '[\'"]' + interface + '(\\.idl)?[\'"]'
+ if re.search(regex, file_contents):
+ return True
+ return False
+
+ for root, dirs, fnames in os.walk(wpt_root):
+ # Walk top_level_subdir looking for test files containing either the
+ # relative filepath or absolute filepath to the changed files.
+ if root == wpt_root:
+ for dir_name in skip_dirs:
+ dirs.remove(dir_name)
+ for fname in fnames:
+ test_full_path = os.path.join(root, fname)
+ # Skip any file that's not a test file.
+ if test_full_path not in test_files:
+ continue
+ if affected_by_wdspec(test_full_path):
+ affected_testfiles.add(test_full_path)
+ continue
+
+ with open(test_full_path, "rb") as fh:
+ raw_file_contents = fh.read() # type: bytes
+ if raw_file_contents.startswith(b"\xfe\xff"):
+ file_contents = raw_file_contents.decode("utf-16be", "replace") # type: Text
+ elif raw_file_contents.startswith(b"\xff\xfe"):
+ file_contents = raw_file_contents.decode("utf-16le", "replace")
+ else:
+ file_contents = raw_file_contents.decode("utf8", "replace")
+ for full_path, repo_path in nontest_changed_paths:
+ rel_path = os.path.relpath(full_path, root).replace(os.path.sep, "/")
+ if rel_path in file_contents or repo_path in file_contents or affected_by_interfaces(file_contents):
+ affected_testfiles.add(test_full_path)
+ continue
+
+ return tests_changed, affected_testfiles
+
+
+def get_parser():
+ # type: () -> argparse.ArgumentParser
+ parser = argparse.ArgumentParser()
+ parser.add_argument("revish", default=None, help="Commits to consider. Defaults to the "
+ "commits on the current branch", nargs="?")
+ parser.add_argument("--ignore-rule", action="append",
+ help="Override the rules for paths to exclude from lists of changes. "
+ "Rules are paths relative to the test root, with * before a separator "
+ "or the end matching anything other than a path separator and ** in that "
+ "position matching anything. This flag can be used multiple times for "
+ "multiple rules. Specifying this flag overrides the default: " +
+ ", ".join(DEFAULT_IGNORE_RULERS))
+ parser.add_argument("--modified", action="store_true",
+ help="Include files under version control that have been "
+ "modified or staged")
+ parser.add_argument("--new", action="store_true",
+ help="Include files in the worktree that are not in version control")
+ parser.add_argument("--show-type", action="store_true",
+ help="Print the test type along with each affected test")
+ parser.add_argument("--null", action="store_true",
+ help="Separate items with a null byte")
+ return parser
+
+
+def get_parser_affected():
+ # type: () -> argparse.ArgumentParser
+ parser = get_parser()
+ parser.add_argument("--metadata",
+ dest="metadata_root",
+ action="store",
+ default=wpt_root,
+ help="Directory that will contain MANIFEST.json")
+ return parser
+
+
+def get_revish(**kwargs):
+ # type: (**Any) -> Text
+ revish = kwargs.get("revish")
+ if revish is None:
+ revish = "%s..HEAD" % branch_point()
+ return revish.strip()
+
+
+def run_changed_files(**kwargs):
+ # type: (**Any) -> None
+ revish = get_revish(**kwargs)
+ changed, _ = files_changed(revish,
+ kwargs["ignore_rule"],
+ include_uncommitted=kwargs["modified"],
+ include_new=kwargs["new"])
+
+ separator = "\0" if kwargs["null"] else "\n"
+
+ for item in sorted(changed):
+ line = os.path.relpath(item, wpt_root) + separator
+ sys.stdout.write(line)
+
+
+def run_tests_affected(**kwargs):
+ # type: (**Any) -> None
+ revish = get_revish(**kwargs)
+ changed, _ = files_changed(revish,
+ kwargs["ignore_rule"],
+ include_uncommitted=kwargs["modified"],
+ include_new=kwargs["new"])
+ manifest_path = os.path.join(kwargs["metadata_root"], "MANIFEST.json")
+ tests_changed, dependents = affected_testfiles(
+ changed,
+ {"conformance-checkers", "docs", "tools"},
+ manifest_path=manifest_path
+ )
+
+ message = "{path}"
+ if kwargs["show_type"]:
+ wpt_manifest = load_manifest(manifest_path)
+ message = "{path}\t{item_type}"
+
+ message += "\0" if kwargs["null"] else "\n"
+
+ for item in sorted(tests_changed | dependents):
+ results = {
+ "path": os.path.relpath(item, wpt_root)
+ }
+ if kwargs["show_type"]:
+ item_types = {i.item_type for i in wpt_manifest.iterpath(results["path"])}
+ if len(item_types) != 1:
+ item_types = {" ".join(item_types)}
+ results["item_type"] = item_types.pop()
+ sys.stdout.write(message.format(**results))
diff --git a/testing/web-platform/tests/tools/wpt/tests/__init__.py b/testing/web-platform/tests/tools/wpt/tests/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/__init__.py
diff --git a/testing/web-platform/tests/tools/wpt/tests/latest_mozilla_central.txt b/testing/web-platform/tests/tools/wpt/tests/latest_mozilla_central.txt
new file mode 100644
index 0000000000..7078a36b0c
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/latest_mozilla_central.txt
@@ -0,0 +1,20834 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <title>Directory Listing: /pub/firefox/nightly/latest-mozilla-central/</title>
+ </head>
+ <body>
+ <h1>Index of /pub/firefox/nightly/latest-mozilla-central/</h1>
+ <table>
+ <tr>
+ <th>Type</th>
+ <th>Name</th>
+ <th>Size</th>
+ <th>Last Modified</th>
+ </tr>
+
+ <tr>
+ <td>Dir</td>
+ <td><a href="/pub/firefox/nightly/">..</a></td>
+ <td></td>
+ <td></td>
+ </tr>
+
+
+ <tr>
+ <td>Dir</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/mar-tools/">mar-tools/</a></td>
+ <td></td>
+ <td></td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/Firefox%20Installer.en-US.exe">Firefox Installer.en-US.exe</a></td>
+ <td>269K</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/README">README</a></td>
+ <td>82</td>
+ <td>17-Nov-2015 10:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.langpack.xpi">firefox-57.0a1.en-US.langpack.xpi</a></td>
+ <td>424K</td>
+ <td>21-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.awsy.tests.zip">firefox-57.0a1.en-US.linux-i686.awsy.tests.zip</a></td>
+ <td>14K</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.checksums">firefox-57.0a1.en-US.linux-i686.checksums</a></td>
+ <td>8K</td>
+ <td>21-Sep-2017 12:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.checksums.asc">firefox-57.0a1.en-US.linux-i686.checksums.asc</a></td>
+ <td>836</td>
+ <td>21-Sep-2017 12:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.common.tests.zip">firefox-57.0a1.en-US.linux-i686.common.tests.zip</a></td>
+ <td>45M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.complete.mar">firefox-57.0a1.en-US.linux-i686.complete.mar</a></td>
+ <td>47M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.cppunittest.tests.zip">firefox-57.0a1.en-US.linux-i686.cppunittest.tests.zip</a></td>
+ <td>13M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.crashreporter-symbols.zip">firefox-57.0a1.en-US.linux-i686.crashreporter-symbols.zip</a></td>
+ <td>108M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.mochitest.tests.zip">firefox-57.0a1.en-US.linux-i686.mochitest.tests.zip</a></td>
+ <td>73M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.mozinfo.json">firefox-57.0a1.en-US.linux-i686.mozinfo.json</a></td>
+ <td>871</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.reftest.tests.zip">firefox-57.0a1.en-US.linux-i686.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.talos.tests.zip">firefox-57.0a1.en-US.linux-i686.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>21-Sep-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.tar.bz2">firefox-57.0a1.en-US.linux-i686.tar.bz2</a></td>
+ <td>60M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.tar.bz2.asc">firefox-57.0a1.en-US.linux-i686.tar.bz2.asc</a></td>
+ <td>836</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.test_packages.json">firefox-57.0a1.en-US.linux-i686.test_packages.json</a></td>
+ <td>1K</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.txt">firefox-57.0a1.en-US.linux-i686.txt</a></td>
+ <td>99</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.web-platform.tests.tar.gz">firefox-57.0a1.en-US.linux-i686.web-platform.tests.tar.gz</a></td>
+ <td>49M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686.xpcshell.tests.zip">firefox-57.0a1.en-US.linux-i686.xpcshell.tests.zip</a></td>
+ <td>10M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-i686_info.txt">firefox-57.0a1.en-US.linux-i686_info.txt</a></td>
+ <td>23</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.awsy.tests.zip">firefox-57.0a1.en-US.linux-x86_64.awsy.tests.zip</a></td>
+ <td>14K</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.checksums">firefox-57.0a1.en-US.linux-x86_64.checksums</a></td>
+ <td>8K</td>
+ <td>21-Sep-2017 12:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.checksums.asc">firefox-57.0a1.en-US.linux-x86_64.checksums.asc</a></td>
+ <td>836</td>
+ <td>21-Sep-2017 12:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.common.tests.zip">firefox-57.0a1.en-US.linux-x86_64.common.tests.zip</a></td>
+ <td>52M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.complete.mar">firefox-57.0a1.en-US.linux-x86_64.complete.mar</a></td>
+ <td>47M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.cppunittest.tests.zip">firefox-57.0a1.en-US.linux-x86_64.cppunittest.tests.zip</a></td>
+ <td>13M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.crashreporter-symbols.zip">firefox-57.0a1.en-US.linux-x86_64.crashreporter-symbols.zip</a></td>
+ <td>103M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.json">firefox-57.0a1.en-US.linux-x86_64.json</a></td>
+ <td>877</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.mochitest.tests.zip">firefox-57.0a1.en-US.linux-x86_64.mochitest.tests.zip</a></td>
+ <td>73M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.mozinfo.json">firefox-57.0a1.en-US.linux-x86_64.mozinfo.json</a></td>
+ <td>876</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.reftest.tests.zip">firefox-57.0a1.en-US.linux-x86_64.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.talos.tests.zip">firefox-57.0a1.en-US.linux-x86_64.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.tar.bz2">firefox-57.0a1.en-US.linux-x86_64.tar.bz2</a></td>
+ <td>59M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.tar.bz2.asc">firefox-57.0a1.en-US.linux-x86_64.tar.bz2.asc</a></td>
+ <td>836</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.test_packages.json">firefox-57.0a1.en-US.linux-x86_64.test_packages.json</a></td>
+ <td>1K</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.txt">firefox-57.0a1.en-US.linux-x86_64.txt</a></td>
+ <td>99</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.web-platform.tests.tar.gz">firefox-57.0a1.en-US.linux-x86_64.web-platform.tests.tar.gz</a></td>
+ <td>49M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64.xpcshell.tests.zip">firefox-57.0a1.en-US.linux-x86_64.xpcshell.tests.zip</a></td>
+ <td>10M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.linux-x86_64_info.txt">firefox-57.0a1.en-US.linux-x86_64_info.txt</a></td>
+ <td>23</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.awsy.tests.zip">firefox-57.0a1.en-US.mac.awsy.tests.zip</a></td>
+ <td>14K</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.checksums">firefox-57.0a1.en-US.mac.checksums</a></td>
+ <td>7K</td>
+ <td>21-Sep-2017 11:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.checksums.asc">firefox-57.0a1.en-US.mac.checksums.asc</a></td>
+ <td>836</td>
+ <td>21-Sep-2017 11:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.common.tests.zip">firefox-57.0a1.en-US.mac.common.tests.zip</a></td>
+ <td>35M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.complete.mar">firefox-57.0a1.en-US.mac.complete.mar</a></td>
+ <td>46M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.cppunittest.tests.zip">firefox-57.0a1.en-US.mac.cppunittest.tests.zip</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.crashreporter-symbols.zip">firefox-57.0a1.en-US.mac.crashreporter-symbols.zip</a></td>
+ <td>118M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.dmg">firefox-57.0a1.en-US.mac.dmg</a></td>
+ <td>63M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.json">firefox-57.0a1.en-US.mac.json</a></td>
+ <td>1K</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.mochitest.tests.zip">firefox-57.0a1.en-US.mac.mochitest.tests.zip</a></td>
+ <td>72M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.mozinfo.json">firefox-57.0a1.en-US.mac.mozinfo.json</a></td>
+ <td>877</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.reftest.tests.zip">firefox-57.0a1.en-US.mac.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.talos.tests.zip">firefox-57.0a1.en-US.mac.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.test_packages.json">firefox-57.0a1.en-US.mac.test_packages.json</a></td>
+ <td>1K</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.txt">firefox-57.0a1.en-US.mac.txt</a></td>
+ <td>99</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.web-platform.tests.tar.gz">firefox-57.0a1.en-US.mac.web-platform.tests.tar.gz</a></td>
+ <td>49M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac.xpcshell.tests.zip">firefox-57.0a1.en-US.mac.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.mac_info.txt">firefox-57.0a1.en-US.mac_info.txt</a></td>
+ <td>23</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.awsy.tests.zip">firefox-57.0a1.en-US.win32.awsy.tests.zip</a></td>
+ <td>14K</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.checksums">firefox-57.0a1.en-US.win32.checksums</a></td>
+ <td>8K</td>
+ <td>21-Sep-2017 13:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.checksums.asc">firefox-57.0a1.en-US.win32.checksums.asc</a></td>
+ <td>836</td>
+ <td>21-Sep-2017 13:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.common.tests.zip">firefox-57.0a1.en-US.win32.common.tests.zip</a></td>
+ <td>38M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.complete.mar">firefox-57.0a1.en-US.win32.complete.mar</a></td>
+ <td>38M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.cppunittest.tests.zip">firefox-57.0a1.en-US.win32.cppunittest.tests.zip</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.crashreporter-symbols.zip">firefox-57.0a1.en-US.win32.crashreporter-symbols.zip</a></td>
+ <td>39M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.installer-stub.exe">firefox-57.0a1.en-US.win32.installer-stub.exe</a></td>
+ <td>288K</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.installer.exe">firefox-57.0a1.en-US.win32.installer.exe</a></td>
+ <td>36M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.json">firefox-57.0a1.en-US.win32.json</a></td>
+ <td>832</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.mochitest.tests.zip">firefox-57.0a1.en-US.win32.mochitest.tests.zip</a></td>
+ <td>72M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.mozinfo.json">firefox-57.0a1.en-US.win32.mozinfo.json</a></td>
+ <td>844</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.reftest.tests.zip">firefox-57.0a1.en-US.win32.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.talos.tests.zip">firefox-57.0a1.en-US.win32.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.test_packages.json">firefox-57.0a1.en-US.win32.test_packages.json</a></td>
+ <td>1K</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.txt">firefox-57.0a1.en-US.win32.txt</a></td>
+ <td>100</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.web-platform.tests.tar.gz">firefox-57.0a1.en-US.win32.web-platform.tests.tar.gz</a></td>
+ <td>49M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.xpcshell.tests.zip">firefox-57.0a1.en-US.win32.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32.zip">firefox-57.0a1.en-US.win32.zip</a></td>
+ <td>52M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win32_info.txt">firefox-57.0a1.en-US.win32_info.txt</a></td>
+ <td>23</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.awsy.tests.zip">firefox-57.0a1.en-US.win64.awsy.tests.zip</a></td>
+ <td>14K</td>
+ <td>21-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.checksums">firefox-57.0a1.en-US.win64.checksums</a></td>
+ <td>7K</td>
+ <td>21-Sep-2017 13:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.checksums.asc">firefox-57.0a1.en-US.win64.checksums.asc</a></td>
+ <td>836</td>
+ <td>21-Sep-2017 13:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.common.tests.zip">firefox-57.0a1.en-US.win64.common.tests.zip</a></td>
+ <td>38M</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.complete.mar">firefox-57.0a1.en-US.win64.complete.mar</a></td>
+ <td>41M</td>
+ <td>21-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.cppunittest.tests.zip">firefox-57.0a1.en-US.win64.cppunittest.tests.zip</a></td>
+ <td>9M</td>
+ <td>21-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.crashreporter-symbols.zip">firefox-57.0a1.en-US.win64.crashreporter-symbols.zip</a></td>
+ <td>34M</td>
+ <td>21-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.installer.exe">firefox-57.0a1.en-US.win64.installer.exe</a></td>
+ <td>38M</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.json">firefox-57.0a1.en-US.win64.json</a></td>
+ <td>834</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.mochitest.tests.zip">firefox-57.0a1.en-US.win64.mochitest.tests.zip</a></td>
+ <td>72M</td>
+ <td>21-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.mozinfo.json">firefox-57.0a1.en-US.win64.mozinfo.json</a></td>
+ <td>847</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.reftest.tests.zip">firefox-57.0a1.en-US.win64.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>21-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.talos.tests.zip">firefox-57.0a1.en-US.win64.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.test_packages.json">firefox-57.0a1.en-US.win64.test_packages.json</a></td>
+ <td>1K</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.txt">firefox-57.0a1.en-US.win64.txt</a></td>
+ <td>100</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.web-platform.tests.tar.gz">firefox-57.0a1.en-US.win64.web-platform.tests.tar.gz</a></td>
+ <td>49M</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.xpcshell.tests.zip">firefox-57.0a1.en-US.win64.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>21-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64.zip">firefox-57.0a1.en-US.win64.zip</a></td>
+ <td>56M</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-57.0a1.en-US.win64_info.txt">firefox-57.0a1.en-US.win64_info.txt</a></td>
+ <td>23</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.langpack.xpi">firefox-58.0a1.en-US.langpack.xpi</a></td>
+ <td>433K</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.awsy.tests.zip">firefox-58.0a1.en-US.linux-i686.awsy.tests.zip</a></td>
+ <td>16K</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.checksums">firefox-58.0a1.en-US.linux-i686.checksums</a></td>
+ <td>8K</td>
+ <td>13-Nov-2017 00:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.checksums.asc">firefox-58.0a1.en-US.linux-i686.checksums.asc</a></td>
+ <td>836</td>
+ <td>13-Nov-2017 00:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.common.tests.zip">firefox-58.0a1.en-US.linux-i686.common.tests.zip</a></td>
+ <td>47M</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.complete.mar">firefox-58.0a1.en-US.linux-i686.complete.mar</a></td>
+ <td>43M</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.cppunittest.tests.zip">firefox-58.0a1.en-US.linux-i686.cppunittest.tests.zip</a></td>
+ <td>11M</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.crashreporter-symbols.zip">firefox-58.0a1.en-US.linux-i686.crashreporter-symbols.zip</a></td>
+ <td>121M</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.json">firefox-58.0a1.en-US.linux-i686.json</a></td>
+ <td>911</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.mochitest.tests.zip">firefox-58.0a1.en-US.linux-i686.mochitest.tests.zip</a></td>
+ <td>73M</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.mozinfo.json">firefox-58.0a1.en-US.linux-i686.mozinfo.json</a></td>
+ <td>871</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.reftest.tests.zip">firefox-58.0a1.en-US.linux-i686.reftest.tests.zip</a></td>
+ <td>57M</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.talos.tests.zip">firefox-58.0a1.en-US.linux-i686.talos.tests.zip</a></td>
+ <td>17M</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.tar.bz2">firefox-58.0a1.en-US.linux-i686.tar.bz2</a></td>
+ <td>56M</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.tar.bz2.asc">firefox-58.0a1.en-US.linux-i686.tar.bz2.asc</a></td>
+ <td>836</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.test_packages.json">firefox-58.0a1.en-US.linux-i686.test_packages.json</a></td>
+ <td>1K</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.txt">firefox-58.0a1.en-US.linux-i686.txt</a></td>
+ <td>99</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.web-platform.tests.tar.gz">firefox-58.0a1.en-US.linux-i686.web-platform.tests.tar.gz</a></td>
+ <td>46M</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686.xpcshell.tests.zip">firefox-58.0a1.en-US.linux-i686.xpcshell.tests.zip</a></td>
+ <td>10M</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-i686_info.txt">firefox-58.0a1.en-US.linux-i686_info.txt</a></td>
+ <td>23</td>
+ <td>13-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.awsy.tests.zip">firefox-58.0a1.en-US.linux-x86_64.awsy.tests.zip</a></td>
+ <td>16K</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.checksums">firefox-58.0a1.en-US.linux-x86_64.checksums</a></td>
+ <td>8K</td>
+ <td>13-Nov-2017 00:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.checksums.asc">firefox-58.0a1.en-US.linux-x86_64.checksums.asc</a></td>
+ <td>836</td>
+ <td>13-Nov-2017 00:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.common.tests.zip">firefox-58.0a1.en-US.linux-x86_64.common.tests.zip</a></td>
+ <td>55M</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.complete.mar">firefox-58.0a1.en-US.linux-x86_64.complete.mar</a></td>
+ <td>47M</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.cppunittest.tests.zip">firefox-58.0a1.en-US.linux-x86_64.cppunittest.tests.zip</a></td>
+ <td>13M</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.crashreporter-symbols.zip">firefox-58.0a1.en-US.linux-x86_64.crashreporter-symbols.zip</a></td>
+ <td>105M</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.json">firefox-58.0a1.en-US.linux-x86_64.json</a></td>
+ <td>877</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.mochitest.tests.zip">firefox-58.0a1.en-US.linux-x86_64.mochitest.tests.zip</a></td>
+ <td>73M</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.mozinfo.json">firefox-58.0a1.en-US.linux-x86_64.mozinfo.json</a></td>
+ <td>876</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.reftest.tests.zip">firefox-58.0a1.en-US.linux-x86_64.reftest.tests.zip</a></td>
+ <td>57M</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.talos.tests.zip">firefox-58.0a1.en-US.linux-x86_64.talos.tests.zip</a></td>
+ <td>17M</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.tar.bz2">firefox-58.0a1.en-US.linux-x86_64.tar.bz2</a></td>
+ <td>60M</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.tar.bz2.asc">firefox-58.0a1.en-US.linux-x86_64.tar.bz2.asc</a></td>
+ <td>836</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.test_packages.json">firefox-58.0a1.en-US.linux-x86_64.test_packages.json</a></td>
+ <td>1K</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.txt">firefox-58.0a1.en-US.linux-x86_64.txt</a></td>
+ <td>99</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.web-platform.tests.tar.gz">firefox-58.0a1.en-US.linux-x86_64.web-platform.tests.tar.gz</a></td>
+ <td>46M</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64.xpcshell.tests.zip">firefox-58.0a1.en-US.linux-x86_64.xpcshell.tests.zip</a></td>
+ <td>10M</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.linux-x86_64_info.txt">firefox-58.0a1.en-US.linux-x86_64_info.txt</a></td>
+ <td>23</td>
+ <td>13-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.awsy.tests.zip">firefox-58.0a1.en-US.mac.awsy.tests.zip</a></td>
+ <td>16K</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.checksums">firefox-58.0a1.en-US.mac.checksums</a></td>
+ <td>7K</td>
+ <td>12-Nov-2017 23:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.checksums.asc">firefox-58.0a1.en-US.mac.checksums.asc</a></td>
+ <td>836</td>
+ <td>12-Nov-2017 23:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.common.tests.zip">firefox-58.0a1.en-US.mac.common.tests.zip</a></td>
+ <td>36M</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.complete.mar">firefox-58.0a1.en-US.mac.complete.mar</a></td>
+ <td>47M</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.cppunittest.tests.zip">firefox-58.0a1.en-US.mac.cppunittest.tests.zip</a></td>
+ <td>8M</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.crashreporter-symbols.zip">firefox-58.0a1.en-US.mac.crashreporter-symbols.zip</a></td>
+ <td>118M</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.dmg">firefox-58.0a1.en-US.mac.dmg</a></td>
+ <td>63M</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.json">firefox-58.0a1.en-US.mac.json</a></td>
+ <td>1K</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.mochitest.tests.zip">firefox-58.0a1.en-US.mac.mochitest.tests.zip</a></td>
+ <td>72M</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.mozinfo.json">firefox-58.0a1.en-US.mac.mozinfo.json</a></td>
+ <td>877</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.reftest.tests.zip">firefox-58.0a1.en-US.mac.reftest.tests.zip</a></td>
+ <td>57M</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.talos.tests.zip">firefox-58.0a1.en-US.mac.talos.tests.zip</a></td>
+ <td>17M</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.test_packages.json">firefox-58.0a1.en-US.mac.test_packages.json</a></td>
+ <td>1K</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.txt">firefox-58.0a1.en-US.mac.txt</a></td>
+ <td>99</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.web-platform.tests.tar.gz">firefox-58.0a1.en-US.mac.web-platform.tests.tar.gz</a></td>
+ <td>46M</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac.xpcshell.tests.zip">firefox-58.0a1.en-US.mac.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.mac_info.txt">firefox-58.0a1.en-US.mac_info.txt</a></td>
+ <td>23</td>
+ <td>12-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.awsy.tests.zip">firefox-58.0a1.en-US.win32.awsy.tests.zip</a></td>
+ <td>16K</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.checksums">firefox-58.0a1.en-US.win32.checksums</a></td>
+ <td>8K</td>
+ <td>13-Nov-2017 00:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.checksums.asc">firefox-58.0a1.en-US.win32.checksums.asc</a></td>
+ <td>836</td>
+ <td>13-Nov-2017 00:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.common.tests.zip">firefox-58.0a1.en-US.win32.common.tests.zip</a></td>
+ <td>38M</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.complete.mar">firefox-58.0a1.en-US.win32.complete.mar</a></td>
+ <td>39M</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.cppunittest.tests.zip">firefox-58.0a1.en-US.win32.cppunittest.tests.zip</a></td>
+ <td>8M</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.crashreporter-symbols.zip">firefox-58.0a1.en-US.win32.crashreporter-symbols.zip</a></td>
+ <td>39M</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.installer-stub.exe">firefox-58.0a1.en-US.win32.installer-stub.exe</a></td>
+ <td>269K</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.installer.exe">firefox-58.0a1.en-US.win32.installer.exe</a></td>
+ <td>37M</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.json">firefox-58.0a1.en-US.win32.json</a></td>
+ <td>846</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.mochitest.tests.zip">firefox-58.0a1.en-US.win32.mochitest.tests.zip</a></td>
+ <td>72M</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.mozinfo.json">firefox-58.0a1.en-US.win32.mozinfo.json</a></td>
+ <td>844</td>
+ <td>13-Nov-2017 00:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.reftest.tests.zip">firefox-58.0a1.en-US.win32.reftest.tests.zip</a></td>
+ <td>57M</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.talos.tests.zip">firefox-58.0a1.en-US.win32.talos.tests.zip</a></td>
+ <td>17M</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.test_packages.json">firefox-58.0a1.en-US.win32.test_packages.json</a></td>
+ <td>1K</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.txt">firefox-58.0a1.en-US.win32.txt</a></td>
+ <td>100</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.web-platform.tests.tar.gz">firefox-58.0a1.en-US.win32.web-platform.tests.tar.gz</a></td>
+ <td>46M</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.xpcshell.tests.zip">firefox-58.0a1.en-US.win32.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32.zip">firefox-58.0a1.en-US.win32.zip</a></td>
+ <td>54M</td>
+ <td>13-Nov-2017 00:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win32_info.txt">firefox-58.0a1.en-US.win32_info.txt</a></td>
+ <td>23</td>
+ <td>13-Nov-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.awsy.tests.zip">firefox-58.0a1.en-US.win64.awsy.tests.zip</a></td>
+ <td>16K</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.checksums">firefox-58.0a1.en-US.win64.checksums</a></td>
+ <td>7K</td>
+ <td>13-Nov-2017 00:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.checksums.asc">firefox-58.0a1.en-US.win64.checksums.asc</a></td>
+ <td>836</td>
+ <td>13-Nov-2017 00:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.common.tests.zip">firefox-58.0a1.en-US.win64.common.tests.zip</a></td>
+ <td>38M</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.complete.mar">firefox-58.0a1.en-US.win64.complete.mar</a></td>
+ <td>42M</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.cppunittest.tests.zip">firefox-58.0a1.en-US.win64.cppunittest.tests.zip</a></td>
+ <td>9M</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.crashreporter-symbols.zip">firefox-58.0a1.en-US.win64.crashreporter-symbols.zip</a></td>
+ <td>34M</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.installer.exe">firefox-58.0a1.en-US.win64.installer.exe</a></td>
+ <td>39M</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.json">firefox-58.0a1.en-US.win64.json</a></td>
+ <td>856</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.mochitest.tests.zip">firefox-58.0a1.en-US.win64.mochitest.tests.zip</a></td>
+ <td>72M</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.mozinfo.json">firefox-58.0a1.en-US.win64.mozinfo.json</a></td>
+ <td>847</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.reftest.tests.zip">firefox-58.0a1.en-US.win64.reftest.tests.zip</a></td>
+ <td>57M</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.talos.tests.zip">firefox-58.0a1.en-US.win64.talos.tests.zip</a></td>
+ <td>17M</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.test_packages.json">firefox-58.0a1.en-US.win64.test_packages.json</a></td>
+ <td>1K</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.txt">firefox-58.0a1.en-US.win64.txt</a></td>
+ <td>100</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.web-platform.tests.tar.gz">firefox-58.0a1.en-US.win64.web-platform.tests.tar.gz</a></td>
+ <td>46M</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.xpcshell.tests.zip">firefox-58.0a1.en-US.win64.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64.zip">firefox-58.0a1.en-US.win64.zip</a></td>
+ <td>58M</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-58.0a1.en-US.win64_info.txt">firefox-58.0a1.en-US.win64_info.txt</a></td>
+ <td>23</td>
+ <td>13-Nov-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.langpack.xpi">firefox-59.0a1.en-US.langpack.xpi</a></td>
+ <td>431K</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.awsy.tests.zip">firefox-59.0a1.en-US.linux-i686.awsy.tests.zip</a></td>
+ <td>15K</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.checksums">firefox-59.0a1.en-US.linux-i686.checksums</a></td>
+ <td>8K</td>
+ <td>22-Jan-2018 12:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.checksums.asc">firefox-59.0a1.en-US.linux-i686.checksums.asc</a></td>
+ <td>836</td>
+ <td>22-Jan-2018 12:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.common.tests.zip">firefox-59.0a1.en-US.linux-i686.common.tests.zip</a></td>
+ <td>54M</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.complete.mar">firefox-59.0a1.en-US.linux-i686.complete.mar</a></td>
+ <td>44M</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.cppunittest.tests.zip">firefox-59.0a1.en-US.linux-i686.cppunittest.tests.zip</a></td>
+ <td>11M</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.crashreporter-symbols.zip">firefox-59.0a1.en-US.linux-i686.crashreporter-symbols.zip</a></td>
+ <td>104M</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.json">firefox-59.0a1.en-US.linux-i686.json</a></td>
+ <td>866</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.mochitest.tests.zip">firefox-59.0a1.en-US.linux-i686.mochitest.tests.zip</a></td>
+ <td>74M</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.mozinfo.json">firefox-59.0a1.en-US.linux-i686.mozinfo.json</a></td>
+ <td>897</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.reftest.tests.zip">firefox-59.0a1.en-US.linux-i686.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.talos.tests.zip">firefox-59.0a1.en-US.linux-i686.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.tar.bz2">firefox-59.0a1.en-US.linux-i686.tar.bz2</a></td>
+ <td>56M</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.tar.bz2.asc">firefox-59.0a1.en-US.linux-i686.tar.bz2.asc</a></td>
+ <td>836</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.test_packages.json">firefox-59.0a1.en-US.linux-i686.test_packages.json</a></td>
+ <td>1K</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.txt">firefox-59.0a1.en-US.linux-i686.txt</a></td>
+ <td>99</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.web-platform.tests.tar.gz">firefox-59.0a1.en-US.linux-i686.web-platform.tests.tar.gz</a></td>
+ <td>47M</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686.xpcshell.tests.zip">firefox-59.0a1.en-US.linux-i686.xpcshell.tests.zip</a></td>
+ <td>10M</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-i686_info.txt">firefox-59.0a1.en-US.linux-i686_info.txt</a></td>
+ <td>23</td>
+ <td>22-Jan-2018 11:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.awsy.tests.zip">firefox-59.0a1.en-US.linux-x86_64.awsy.tests.zip</a></td>
+ <td>15K</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.checksums">firefox-59.0a1.en-US.linux-x86_64.checksums</a></td>
+ <td>8K</td>
+ <td>22-Jan-2018 11:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.checksums.asc">firefox-59.0a1.en-US.linux-x86_64.checksums.asc</a></td>
+ <td>836</td>
+ <td>22-Jan-2018 11:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.common.tests.zip">firefox-59.0a1.en-US.linux-x86_64.common.tests.zip</a></td>
+ <td>55M</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.complete.mar">firefox-59.0a1.en-US.linux-x86_64.complete.mar</a></td>
+ <td>48M</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.cppunittest.tests.zip">firefox-59.0a1.en-US.linux-x86_64.cppunittest.tests.zip</a></td>
+ <td>13M</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.crashreporter-symbols.zip">firefox-59.0a1.en-US.linux-x86_64.crashreporter-symbols.zip</a></td>
+ <td>91M</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.json">firefox-59.0a1.en-US.linux-x86_64.json</a></td>
+ <td>832</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.mochitest.tests.zip">firefox-59.0a1.en-US.linux-x86_64.mochitest.tests.zip</a></td>
+ <td>74M</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.mozinfo.json">firefox-59.0a1.en-US.linux-x86_64.mozinfo.json</a></td>
+ <td>902</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.reftest.tests.zip">firefox-59.0a1.en-US.linux-x86_64.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.talos.tests.zip">firefox-59.0a1.en-US.linux-x86_64.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.tar.bz2">firefox-59.0a1.en-US.linux-x86_64.tar.bz2</a></td>
+ <td>60M</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.tar.bz2.asc">firefox-59.0a1.en-US.linux-x86_64.tar.bz2.asc</a></td>
+ <td>836</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.test_packages.json">firefox-59.0a1.en-US.linux-x86_64.test_packages.json</a></td>
+ <td>1K</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.txt">firefox-59.0a1.en-US.linux-x86_64.txt</a></td>
+ <td>99</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.web-platform.tests.tar.gz">firefox-59.0a1.en-US.linux-x86_64.web-platform.tests.tar.gz</a></td>
+ <td>47M</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64.xpcshell.tests.zip">firefox-59.0a1.en-US.linux-x86_64.xpcshell.tests.zip</a></td>
+ <td>10M</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.linux-x86_64_info.txt">firefox-59.0a1.en-US.linux-x86_64_info.txt</a></td>
+ <td>23</td>
+ <td>22-Jan-2018 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.awsy.tests.zip">firefox-59.0a1.en-US.mac.awsy.tests.zip</a></td>
+ <td>15K</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.checksums">firefox-59.0a1.en-US.mac.checksums</a></td>
+ <td>7K</td>
+ <td>22-Jan-2018 11:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.checksums.asc">firefox-59.0a1.en-US.mac.checksums.asc</a></td>
+ <td>836</td>
+ <td>22-Jan-2018 11:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.common.tests.zip">firefox-59.0a1.en-US.mac.common.tests.zip</a></td>
+ <td>34M</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.complete.mar">firefox-59.0a1.en-US.mac.complete.mar</a></td>
+ <td>47M</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.cppunittest.tests.zip">firefox-59.0a1.en-US.mac.cppunittest.tests.zip</a></td>
+ <td>9M</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.crashreporter-symbols.zip">firefox-59.0a1.en-US.mac.crashreporter-symbols.zip</a></td>
+ <td>102M</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.dmg">firefox-59.0a1.en-US.mac.dmg</a></td>
+ <td>64M</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.json">firefox-59.0a1.en-US.mac.json</a></td>
+ <td>1K</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.mochitest.tests.zip">firefox-59.0a1.en-US.mac.mochitest.tests.zip</a></td>
+ <td>74M</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.mozinfo.json">firefox-59.0a1.en-US.mac.mozinfo.json</a></td>
+ <td>903</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.reftest.tests.zip">firefox-59.0a1.en-US.mac.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.talos.tests.zip">firefox-59.0a1.en-US.mac.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.test_packages.json">firefox-59.0a1.en-US.mac.test_packages.json</a></td>
+ <td>1K</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.txt">firefox-59.0a1.en-US.mac.txt</a></td>
+ <td>99</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.web-platform.tests.tar.gz">firefox-59.0a1.en-US.mac.web-platform.tests.tar.gz</a></td>
+ <td>47M</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac.xpcshell.tests.zip">firefox-59.0a1.en-US.mac.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.mac_info.txt">firefox-59.0a1.en-US.mac_info.txt</a></td>
+ <td>23</td>
+ <td>22-Jan-2018 11:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.awsy.tests.zip">firefox-59.0a1.en-US.win32.awsy.tests.zip</a></td>
+ <td>15K</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.checksums">firefox-59.0a1.en-US.win32.checksums</a></td>
+ <td>8K</td>
+ <td>22-Jan-2018 12:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.checksums.asc">firefox-59.0a1.en-US.win32.checksums.asc</a></td>
+ <td>836</td>
+ <td>22-Jan-2018 12:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.common.tests.zip">firefox-59.0a1.en-US.win32.common.tests.zip</a></td>
+ <td>36M</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.complete.mar">firefox-59.0a1.en-US.win32.complete.mar</a></td>
+ <td>40M</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.cppunittest.tests.zip">firefox-59.0a1.en-US.win32.cppunittest.tests.zip</a></td>
+ <td>8M</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.crashreporter-symbols.zip">firefox-59.0a1.en-US.win32.crashreporter-symbols.zip</a></td>
+ <td>32M</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.installer-stub.exe">firefox-59.0a1.en-US.win32.installer-stub.exe</a></td>
+ <td>269K</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.installer.exe">firefox-59.0a1.en-US.win32.installer.exe</a></td>
+ <td>37M</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.json">firefox-59.0a1.en-US.win32.json</a></td>
+ <td>846</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.mochitest.tests.zip">firefox-59.0a1.en-US.win32.mochitest.tests.zip</a></td>
+ <td>74M</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.mozinfo.json">firefox-59.0a1.en-US.win32.mozinfo.json</a></td>
+ <td>870</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.reftest.tests.zip">firefox-59.0a1.en-US.win32.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.talos.tests.zip">firefox-59.0a1.en-US.win32.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.test_packages.json">firefox-59.0a1.en-US.win32.test_packages.json</a></td>
+ <td>1K</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.txt">firefox-59.0a1.en-US.win32.txt</a></td>
+ <td>99</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.web-platform.tests.tar.gz">firefox-59.0a1.en-US.win32.web-platform.tests.tar.gz</a></td>
+ <td>47M</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.xpcshell.tests.zip">firefox-59.0a1.en-US.win32.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32.zip">firefox-59.0a1.en-US.win32.zip</a></td>
+ <td>55M</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win32_info.txt">firefox-59.0a1.en-US.win32_info.txt</a></td>
+ <td>23</td>
+ <td>22-Jan-2018 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.awsy.tests.zip">firefox-59.0a1.en-US.win64.awsy.tests.zip</a></td>
+ <td>15K</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.checksums">firefox-59.0a1.en-US.win64.checksums</a></td>
+ <td>7K</td>
+ <td>22-Jan-2018 12:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.checksums.asc">firefox-59.0a1.en-US.win64.checksums.asc</a></td>
+ <td>836</td>
+ <td>22-Jan-2018 12:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.common.tests.zip">firefox-59.0a1.en-US.win64.common.tests.zip</a></td>
+ <td>37M</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.complete.mar">firefox-59.0a1.en-US.win64.complete.mar</a></td>
+ <td>43M</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.cppunittest.tests.zip">firefox-59.0a1.en-US.win64.cppunittest.tests.zip</a></td>
+ <td>9M</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.crashreporter-symbols.zip">firefox-59.0a1.en-US.win64.crashreporter-symbols.zip</a></td>
+ <td>27M</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.installer.exe">firefox-59.0a1.en-US.win64.installer.exe</a></td>
+ <td>40M</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.json">firefox-59.0a1.en-US.win64.json</a></td>
+ <td>856</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.mochitest.tests.zip">firefox-59.0a1.en-US.win64.mochitest.tests.zip</a></td>
+ <td>74M</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.mozinfo.json">firefox-59.0a1.en-US.win64.mozinfo.json</a></td>
+ <td>873</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.reftest.tests.zip">firefox-59.0a1.en-US.win64.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.talos.tests.zip">firefox-59.0a1.en-US.win64.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.test_packages.json">firefox-59.0a1.en-US.win64.test_packages.json</a></td>
+ <td>1K</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.txt">firefox-59.0a1.en-US.win64.txt</a></td>
+ <td>99</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.web-platform.tests.tar.gz">firefox-59.0a1.en-US.win64.web-platform.tests.tar.gz</a></td>
+ <td>47M</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.xpcshell.tests.zip">firefox-59.0a1.en-US.win64.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64.zip">firefox-59.0a1.en-US.win64.zip</a></td>
+ <td>59M</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-59.0a1.en-US.win64_info.txt">firefox-59.0a1.en-US.win64_info.txt</a></td>
+ <td>23</td>
+ <td>22-Jan-2018 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.langpack.xpi">firefox-60.0a1.en-US.langpack.xpi</a></td>
+ <td>434K</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.awsy.tests.zip">firefox-60.0a1.en-US.linux-i686.awsy.tests.zip</a></td>
+ <td>15K</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.checksums">firefox-60.0a1.en-US.linux-i686.checksums</a></td>
+ <td>8K</td>
+ <td>15-Feb-2018 12:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.checksums.asc">firefox-60.0a1.en-US.linux-i686.checksums.asc</a></td>
+ <td>836</td>
+ <td>15-Feb-2018 12:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.common.tests.zip">firefox-60.0a1.en-US.linux-i686.common.tests.zip</a></td>
+ <td>53M</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.complete.mar">firefox-60.0a1.en-US.linux-i686.complete.mar</a></td>
+ <td>44M</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.cppunittest.tests.zip">firefox-60.0a1.en-US.linux-i686.cppunittest.tests.zip</a></td>
+ <td>11M</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.crashreporter-symbols.zip">firefox-60.0a1.en-US.linux-i686.crashreporter-symbols.zip</a></td>
+ <td>104M</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.json">firefox-60.0a1.en-US.linux-i686.json</a></td>
+ <td>850</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.mochitest.tests.zip">firefox-60.0a1.en-US.linux-i686.mochitest.tests.zip</a></td>
+ <td>75M</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.mozinfo.json">firefox-60.0a1.en-US.linux-i686.mozinfo.json</a></td>
+ <td>897</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.reftest.tests.zip">firefox-60.0a1.en-US.linux-i686.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.talos.tests.zip">firefox-60.0a1.en-US.linux-i686.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.tar.bz2">firefox-60.0a1.en-US.linux-i686.tar.bz2</a></td>
+ <td>56M</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.tar.bz2.asc">firefox-60.0a1.en-US.linux-i686.tar.bz2.asc</a></td>
+ <td>836</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.test_packages.json">firefox-60.0a1.en-US.linux-i686.test_packages.json</a></td>
+ <td>1K</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.txt">firefox-60.0a1.en-US.linux-i686.txt</a></td>
+ <td>99</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.web-platform.tests.tar.gz">firefox-60.0a1.en-US.linux-i686.web-platform.tests.tar.gz</a></td>
+ <td>48M</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686.xpcshell.tests.zip">firefox-60.0a1.en-US.linux-i686.xpcshell.tests.zip</a></td>
+ <td>10M</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-i686_info.txt">firefox-60.0a1.en-US.linux-i686_info.txt</a></td>
+ <td>23</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.awsy.tests.zip">firefox-60.0a1.en-US.linux-x86_64.awsy.tests.zip</a></td>
+ <td>15K</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.checksums">firefox-60.0a1.en-US.linux-x86_64.checksums</a></td>
+ <td>8K</td>
+ <td>15-Feb-2018 12:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.checksums.asc">firefox-60.0a1.en-US.linux-x86_64.checksums.asc</a></td>
+ <td>836</td>
+ <td>15-Feb-2018 12:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.common.tests.zip">firefox-60.0a1.en-US.linux-x86_64.common.tests.zip</a></td>
+ <td>54M</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.complete.mar">firefox-60.0a1.en-US.linux-x86_64.complete.mar</a></td>
+ <td>48M</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.cppunittest.tests.zip">firefox-60.0a1.en-US.linux-x86_64.cppunittest.tests.zip</a></td>
+ <td>13M</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.crashreporter-symbols.zip">firefox-60.0a1.en-US.linux-x86_64.crashreporter-symbols.zip</a></td>
+ <td>91M</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.json">firefox-60.0a1.en-US.linux-x86_64.json</a></td>
+ <td>816</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.mochitest.tests.zip">firefox-60.0a1.en-US.linux-x86_64.mochitest.tests.zip</a></td>
+ <td>75M</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.mozinfo.json">firefox-60.0a1.en-US.linux-x86_64.mozinfo.json</a></td>
+ <td>902</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.reftest.tests.zip">firefox-60.0a1.en-US.linux-x86_64.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.talos.tests.zip">firefox-60.0a1.en-US.linux-x86_64.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.tar.bz2">firefox-60.0a1.en-US.linux-x86_64.tar.bz2</a></td>
+ <td>60M</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.tar.bz2.asc">firefox-60.0a1.en-US.linux-x86_64.tar.bz2.asc</a></td>
+ <td>836</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.test_packages.json">firefox-60.0a1.en-US.linux-x86_64.test_packages.json</a></td>
+ <td>1K</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.txt">firefox-60.0a1.en-US.linux-x86_64.txt</a></td>
+ <td>99</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.web-platform.tests.tar.gz">firefox-60.0a1.en-US.linux-x86_64.web-platform.tests.tar.gz</a></td>
+ <td>48M</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64.xpcshell.tests.zip">firefox-60.0a1.en-US.linux-x86_64.xpcshell.tests.zip</a></td>
+ <td>10M</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.linux-x86_64_info.txt">firefox-60.0a1.en-US.linux-x86_64_info.txt</a></td>
+ <td>23</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.awsy.tests.zip">firefox-60.0a1.en-US.mac.awsy.tests.zip</a></td>
+ <td>15K</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.checksums">firefox-60.0a1.en-US.mac.checksums</a></td>
+ <td>7K</td>
+ <td>15-Feb-2018 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.checksums.asc">firefox-60.0a1.en-US.mac.checksums.asc</a></td>
+ <td>836</td>
+ <td>15-Feb-2018 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.common.tests.zip">firefox-60.0a1.en-US.mac.common.tests.zip</a></td>
+ <td>34M</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.complete.mar">firefox-60.0a1.en-US.mac.complete.mar</a></td>
+ <td>48M</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.cppunittest.tests.zip">firefox-60.0a1.en-US.mac.cppunittest.tests.zip</a></td>
+ <td>9M</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.crashreporter-symbols.zip">firefox-60.0a1.en-US.mac.crashreporter-symbols.zip</a></td>
+ <td>117M</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.dmg">firefox-60.0a1.en-US.mac.dmg</a></td>
+ <td>65M</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.json">firefox-60.0a1.en-US.mac.json</a></td>
+ <td>1K</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.mochitest.tests.zip">firefox-60.0a1.en-US.mac.mochitest.tests.zip</a></td>
+ <td>74M</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.mozinfo.json">firefox-60.0a1.en-US.mac.mozinfo.json</a></td>
+ <td>903</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.reftest.tests.zip">firefox-60.0a1.en-US.mac.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.talos.tests.zip">firefox-60.0a1.en-US.mac.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.test_packages.json">firefox-60.0a1.en-US.mac.test_packages.json</a></td>
+ <td>1K</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.txt">firefox-60.0a1.en-US.mac.txt</a></td>
+ <td>99</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.web-platform.tests.tar.gz">firefox-60.0a1.en-US.mac.web-platform.tests.tar.gz</a></td>
+ <td>48M</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac.xpcshell.tests.zip">firefox-60.0a1.en-US.mac.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.mac_info.txt">firefox-60.0a1.en-US.mac_info.txt</a></td>
+ <td>23</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.awsy.tests.zip">firefox-60.0a1.en-US.win32.awsy.tests.zip</a></td>
+ <td>15K</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.checksums">firefox-60.0a1.en-US.win32.checksums</a></td>
+ <td>8K</td>
+ <td>15-Feb-2018 13:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.checksums.asc">firefox-60.0a1.en-US.win32.checksums.asc</a></td>
+ <td>836</td>
+ <td>15-Feb-2018 13:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.common.tests.zip">firefox-60.0a1.en-US.win32.common.tests.zip</a></td>
+ <td>36M</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.complete.mar">firefox-60.0a1.en-US.win32.complete.mar</a></td>
+ <td>40M</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.cppunittest.tests.zip">firefox-60.0a1.en-US.win32.cppunittest.tests.zip</a></td>
+ <td>8M</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.crashreporter-symbols.zip">firefox-60.0a1.en-US.win32.crashreporter-symbols.zip</a></td>
+ <td>32M</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.installer-stub.exe">firefox-60.0a1.en-US.win32.installer-stub.exe</a></td>
+ <td>269K</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.installer.exe">firefox-60.0a1.en-US.win32.installer.exe</a></td>
+ <td>37M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.json">firefox-60.0a1.en-US.win32.json</a></td>
+ <td>830</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.mochitest.tests.zip">firefox-60.0a1.en-US.win32.mochitest.tests.zip</a></td>
+ <td>74M</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.mozinfo.json">firefox-60.0a1.en-US.win32.mozinfo.json</a></td>
+ <td>870</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.reftest.tests.zip">firefox-60.0a1.en-US.win32.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.talos.tests.zip">firefox-60.0a1.en-US.win32.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.test_packages.json">firefox-60.0a1.en-US.win32.test_packages.json</a></td>
+ <td>1K</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.txt">firefox-60.0a1.en-US.win32.txt</a></td>
+ <td>99</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.web-platform.tests.tar.gz">firefox-60.0a1.en-US.win32.web-platform.tests.tar.gz</a></td>
+ <td>48M</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.xpcshell.tests.zip">firefox-60.0a1.en-US.win32.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32.zip">firefox-60.0a1.en-US.win32.zip</a></td>
+ <td>55M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win32_info.txt">firefox-60.0a1.en-US.win32_info.txt</a></td>
+ <td>23</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.awsy.tests.zip">firefox-60.0a1.en-US.win64.awsy.tests.zip</a></td>
+ <td>15K</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.checksums">firefox-60.0a1.en-US.win64.checksums</a></td>
+ <td>7K</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.checksums.asc">firefox-60.0a1.en-US.win64.checksums.asc</a></td>
+ <td>836</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.common.tests.zip">firefox-60.0a1.en-US.win64.common.tests.zip</a></td>
+ <td>37M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.complete.mar">firefox-60.0a1.en-US.win64.complete.mar</a></td>
+ <td>43M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.cppunittest.tests.zip">firefox-60.0a1.en-US.win64.cppunittest.tests.zip</a></td>
+ <td>9M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.crashreporter-symbols.zip">firefox-60.0a1.en-US.win64.crashreporter-symbols.zip</a></td>
+ <td>28M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.installer.exe">firefox-60.0a1.en-US.win64.installer.exe</a></td>
+ <td>40M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.json">firefox-60.0a1.en-US.win64.json</a></td>
+ <td>840</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.mochitest.tests.zip">firefox-60.0a1.en-US.win64.mochitest.tests.zip</a></td>
+ <td>74M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.mozinfo.json">firefox-60.0a1.en-US.win64.mozinfo.json</a></td>
+ <td>873</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.reftest.tests.zip">firefox-60.0a1.en-US.win64.reftest.tests.zip</a></td>
+ <td>58M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.talos.tests.zip">firefox-60.0a1.en-US.win64.talos.tests.zip</a></td>
+ <td>13M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.test_packages.json">firefox-60.0a1.en-US.win64.test_packages.json</a></td>
+ <td>1K</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.txt">firefox-60.0a1.en-US.win64.txt</a></td>
+ <td>99</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.web-platform.tests.tar.gz">firefox-60.0a1.en-US.win64.web-platform.tests.tar.gz</a></td>
+ <td>48M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.xpcshell.tests.zip">firefox-60.0a1.en-US.win64.xpcshell.tests.zip</a></td>
+ <td>9M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64.zip">firefox-60.0a1.en-US.win64.zip</a></td>
+ <td>59M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-60.0a1.en-US.win64_info.txt">firefox-60.0a1.en-US.win64_info.txt</a></td>
+ <td>23</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-i686-en-US-20170917100334-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-linux-i686-en-US-20170917100334-20170920220431.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 00:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-i686-en-US-20170917220255-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-linux-i686-en-US-20170917220255-20170920220431.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 00:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-i686-en-US-20170917220255-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-linux-i686-en-US-20170917220255-20170921100141.partial.mar</a></td>
+ <td>9M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-i686-en-US-20170918100059-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-linux-i686-en-US-20170918100059-20170920220431.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 00:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-i686-en-US-20170918100059-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-linux-i686-en-US-20170918100059-20170921100141.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-i686-en-US-20170918220054-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-linux-i686-en-US-20170918220054-20170920220431.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 00:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-i686-en-US-20170918220054-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-linux-i686-en-US-20170918220054-20170921100141.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-i686-en-US-20170920220431-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-linux-i686-en-US-20170920220431-20170921100141.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Sep-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170917100334-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170917100334-20170919100405.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Sep-2017 14:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170917220255-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170917220255-20170919100405.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Sep-2017 14:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170917220255-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170917220255-20170919220202.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 00:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918100059-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918100059-20170919100405.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Sep-2017 14:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918100059-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918100059-20170919220202.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 00:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918100059-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918100059-20170920100426.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 12:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918220054-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918220054-20170919100405.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Sep-2017 14:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918220054-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918220054-20170919220202.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 00:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918220054-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918220054-20170920100426.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 12:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918220054-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170918220054-20170920220431.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 00:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919100405-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919100405-20170919220202.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 00:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919100405-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919100405-20170920100426.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 12:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919100405-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919100405-20170920220431.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919100405-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919100405-20170921100141.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919220202-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919220202-20170920100426.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Sep-2017 12:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919220202-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919220202-20170920220431.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Sep-2017 00:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919220202-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170919220202-20170921100141.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170920100426-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170920100426-20170920220431.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Sep-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170920100426-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170920100426-20170921100141.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Sep-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170920220431-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-linux-x86_64-en-US-20170920220431-20170921100141.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170917100334-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170917100334-20170919100405.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Sep-2017 12:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170917220255-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170917220255-20170919100405.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Sep-2017 12:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170917220255-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170917220255-20170919220202.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Sep-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170918100059-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170918100059-20170919100405.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Sep-2017 12:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170918100059-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170918100059-20170919220202.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Sep-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170918100059-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170918100059-20170920100426.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Sep-2017 11:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170918220054-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170918220054-20170919100405.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Sep-2017 12:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170918220054-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170918220054-20170919220202.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Sep-2017 23:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170918220054-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170918220054-20170920100426.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Sep-2017 11:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170918220054-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170918220054-20170920220431.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Sep-2017 23:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170919100405-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170919100405-20170919220202.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Sep-2017 23:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170919100405-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170919100405-20170920100426.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Sep-2017 11:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170919100405-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170919100405-20170920220431.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Sep-2017 23:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170919100405-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170919100405-20170921100141.partial.mar</a></td>
+ <td>4M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170919220202-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170919220202-20170920100426.partial.mar</a></td>
+ <td>3M</td>
+ <td>20-Sep-2017 11:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170919220202-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170919220202-20170920220431.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Sep-2017 23:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170919220202-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170919220202-20170921100141.partial.mar</a></td>
+ <td>4M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170920100426-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170920100426-20170920220431.partial.mar</a></td>
+ <td>3M</td>
+ <td>20-Sep-2017 23:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170920100426-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170920100426-20170921100141.partial.mar</a></td>
+ <td>4M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-mac-en-US-20170920220431-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-mac-en-US-20170920220431-20170921100141.partial.mar</a></td>
+ <td>3M</td>
+ <td>21-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170917100334-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170917100334-20170919100405.partial.mar</a></td>
+ <td>6M</td>
+ <td>19-Sep-2017 15:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170917220255-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170917220255-20170919100405.partial.mar</a></td>
+ <td>5M</td>
+ <td>19-Sep-2017 15:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170917220255-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170917220255-20170919220202.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Sep-2017 00:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170918100059-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170918100059-20170919100405.partial.mar</a></td>
+ <td>5M</td>
+ <td>19-Sep-2017 15:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170918100059-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170918100059-20170919220202.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Sep-2017 00:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170918100059-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170918100059-20170920100426.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Sep-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170918220054-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170918220054-20170919100405.partial.mar</a></td>
+ <td>6M</td>
+ <td>19-Sep-2017 15:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170918220054-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170918220054-20170919220202.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Sep-2017 00:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170918220054-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170918220054-20170920100426.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Sep-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170918220054-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170918220054-20170920220431.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Sep-2017 00:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170919100405-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170919100405-20170919220202.partial.mar</a></td>
+ <td>5M</td>
+ <td>20-Sep-2017 00:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170919100405-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170919100405-20170920100426.partial.mar</a></td>
+ <td>5M</td>
+ <td>20-Sep-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170919100405-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170919100405-20170920220431.partial.mar</a></td>
+ <td>5M</td>
+ <td>21-Sep-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170919100405-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170919100405-20170921100141.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170919220202-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170919220202-20170920100426.partial.mar</a></td>
+ <td>5M</td>
+ <td>20-Sep-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170919220202-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170919220202-20170920220431.partial.mar</a></td>
+ <td>5M</td>
+ <td>21-Sep-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170919220202-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170919220202-20170921100141.partial.mar</a></td>
+ <td>5M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170920100426-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170920100426-20170920220431.partial.mar</a></td>
+ <td>5M</td>
+ <td>21-Sep-2017 00:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170920100426-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170920100426-20170921100141.partial.mar</a></td>
+ <td>5M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win32-en-US-20170920220431-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-win32-en-US-20170920220431-20170921100141.partial.mar</a></td>
+ <td>5M</td>
+ <td>21-Sep-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170917100334-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170917100334-20170919100405.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Sep-2017 15:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170917220255-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170917220255-20170919100405.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Sep-2017 15:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170917220255-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170917220255-20170919220202.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 00:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170918100059-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170918100059-20170919100405.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Sep-2017 15:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170918100059-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170918100059-20170919220202.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 00:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170918100059-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170918100059-20170920100426.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 12:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170918220054-20170919100405.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170918220054-20170919100405.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Sep-2017 15:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170918220054-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170918220054-20170919220202.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 00:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170918220054-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170918220054-20170920100426.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 12:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170918220054-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170918220054-20170920220431.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Sep-2017 01:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170919100405-20170919220202.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170919100405-20170919220202.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 00:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170919100405-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170919100405-20170920100426.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Sep-2017 12:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170919100405-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170919100405-20170920220431.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Sep-2017 01:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170919100405-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170919100405-20170921100141.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170919220202-20170920100426.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170919220202-20170920100426.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Sep-2017 12:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170919220202-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170919220202-20170920220431.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Sep-2017 01:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170919220202-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170919220202-20170921100141.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170920100426-20170920220431.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170920100426-20170920220431.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Sep-2017 01:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170920100426-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170920100426-20170921100141.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-57.0a1-win64-en-US-20170920220431-20170921100141.partial.mar">firefox-mozilla-central-57.0a1-win64-en-US-20170920220431-20170921100141.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170918100059-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170918100059-20170921220243.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 00:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170918220054-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170918220054-20170921220243.partial.mar</a></td>
+ <td>8M</td>
+ <td>22-Sep-2017 00:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170918220054-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170918220054-20170922100051.partial.mar</a></td>
+ <td>9M</td>
+ <td>22-Sep-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170920220431-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170920220431-20170921220243.partial.mar</a></td>
+ <td>8M</td>
+ <td>22-Sep-2017 00:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170920220431-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170920220431-20170922100051.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170920220431-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170920220431-20170922220129.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Sep-2017 00:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921100141-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921100141-20170921220243.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 00:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921100141-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921100141-20170922100051.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921100141-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921100141-20170922220129.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Sep-2017 00:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921100141-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921100141-20170923100045.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Sep-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921220243-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921220243-20170922100051.partial.mar</a></td>
+ <td>8M</td>
+ <td>22-Sep-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921220243-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921220243-20170922220129.partial.mar</a></td>
+ <td>8M</td>
+ <td>23-Sep-2017 00:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921220243-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921220243-20170923100045.partial.mar</a></td>
+ <td>8M</td>
+ <td>23-Sep-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921220243-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170921220243-20170923220337.partial.mar</a></td>
+ <td>8M</td>
+ <td>24-Sep-2017 01:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922100051-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922100051-20170922220129.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Sep-2017 00:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922100051-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922100051-20170923100045.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Sep-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922100051-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922100051-20170923220337.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Sep-2017 01:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922100051-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922100051-20170924100550.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Sep-2017 13:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922220129-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922220129-20170923100045.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Sep-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922220129-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922220129-20170923220337.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Sep-2017 01:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922220129-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922220129-20170924100550.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Sep-2017 13:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922220129-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170922220129-20170924220116.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 01:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923100045-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923100045-20170923220337.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Sep-2017 01:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923100045-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923100045-20170924100550.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Sep-2017 13:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923100045-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923100045-20170924220116.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 01:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923100045-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923100045-20170925100307.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923220337-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923220337-20170924100550.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Sep-2017 13:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923220337-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923220337-20170924220116.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 01:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923220337-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923220337-20170925100307.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923220337-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170923220337-20170925220207.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Sep-2017 00:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924100550-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924100550-20170924220116.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 01:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924100550-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924100550-20170925100307.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924100550-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924100550-20170925220207.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Sep-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924100550-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924100550-20170926100259.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 12:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924220116-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924220116-20170925100307.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924220116-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924220116-20170925220207.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Sep-2017 00:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924220116-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924220116-20170926100259.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 12:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924220116-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170924220116-20170926220106.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Sep-2017 00:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925100307-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925100307-20170925220207.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Sep-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925100307-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925100307-20170926100259.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 12:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925100307-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925100307-20170926220106.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Sep-2017 00:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925100307-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925100307-20170927100120.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Sep-2017 12:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925220207-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925220207-20170926100259.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 12:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925220207-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925220207-20170926220106.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Sep-2017 00:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925220207-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925220207-20170927100120.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Sep-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925220207-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170925220207-20170928100123.partial.mar</a></td>
+ <td>9M</td>
+ <td>28-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926100259-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926100259-20170926220106.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Sep-2017 00:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926100259-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926100259-20170927100120.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Sep-2017 12:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926100259-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926100259-20170928100123.partial.mar</a></td>
+ <td>9M</td>
+ <td>28-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926100259-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926100259-20170928220658.partial.mar</a></td>
+ <td>8M</td>
+ <td>29-Sep-2017 00:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926220106-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926220106-20170927100120.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Sep-2017 12:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926220106-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926220106-20170928100123.partial.mar</a></td>
+ <td>8M</td>
+ <td>28-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926220106-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926220106-20170928220658.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 00:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926220106-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170926220106-20170929100122.partial.mar</a></td>
+ <td>8M</td>
+ <td>29-Sep-2017 12:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170927100120-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170927100120-20170928100123.partial.mar</a></td>
+ <td>8M</td>
+ <td>28-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170927100120-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170927100120-20170928220658.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 00:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170927100120-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170927100120-20170929100122.partial.mar</a></td>
+ <td>8M</td>
+ <td>29-Sep-2017 12:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170927100120-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170927100120-20170929220356.partial.mar</a></td>
+ <td>8M</td>
+ <td>30-Sep-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928100123-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928100123-20170928220658.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 00:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928100123-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928100123-20170929100122.partial.mar</a></td>
+ <td>8M</td>
+ <td>29-Sep-2017 12:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928100123-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928100123-20170929220356.partial.mar</a></td>
+ <td>9M</td>
+ <td>30-Sep-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928100123-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928100123-20170930100302.partial.mar</a></td>
+ <td>9M</td>
+ <td>30-Sep-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928220658-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928220658-20170929100122.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 12:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928220658-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928220658-20170929220356.partial.mar</a></td>
+ <td>8M</td>
+ <td>30-Sep-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928220658-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928220658-20170930100302.partial.mar</a></td>
+ <td>8M</td>
+ <td>30-Sep-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928220658-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170928220658-20170930220116.partial.mar</a></td>
+ <td>8M</td>
+ <td>01-Oct-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929100122-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929100122-20170929220356.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Sep-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929100122-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929100122-20170930100302.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Sep-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929100122-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929100122-20170930220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929100122-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929100122-20171001100335.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929220356-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929220356-20170930100302.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Sep-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929220356-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929220356-20170930220116.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Oct-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929220356-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929220356-20171001100335.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Oct-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929220356-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170929220356-20171001220301.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930100302-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930100302-20170930220116.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Oct-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930100302-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930100302-20171001100335.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Oct-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930100302-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930100302-20171001220301.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930100302-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930100302-20171002100134.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930220116-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930220116-20171001100335.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Oct-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930220116-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930220116-20171001220301.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930220116-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930220116-20171002100134.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930220116-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20170930220116-20171002220204.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Oct-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001100335-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001100335-20171001220301.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001100335-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001100335-20171002100134.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001100335-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001100335-20171002220204.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Oct-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001100335-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001100335-20171003100226.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 12:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001220301-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001220301-20171002100134.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001220301-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001220301-20171002220204.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Oct-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001220301-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001220301-20171003100226.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 12:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001220301-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171001220301-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002100134-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002100134-20171002220204.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Oct-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002100134-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002100134-20171003100226.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Oct-2017 12:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002100134-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002100134-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002100134-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002100134-20171004100049.partial.mar</a></td>
+ <td>10M</td>
+ <td>04-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002220204-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002220204-20171003100226.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 12:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002220204-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002220204-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002220204-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002220204-20171004100049.partial.mar</a></td>
+ <td>10M</td>
+ <td>04-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002220204-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171002220204-20171004220309.partial.mar</a></td>
+ <td>11M</td>
+ <td>05-Oct-2017 00:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003100226-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003100226-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003100226-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003100226-20171004100049.partial.mar</a></td>
+ <td>10M</td>
+ <td>04-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003100226-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003100226-20171004220309.partial.mar</a></td>
+ <td>11M</td>
+ <td>05-Oct-2017 00:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003100226-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003100226-20171005100211.partial.mar</a></td>
+ <td>11M</td>
+ <td>05-Oct-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003220138-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003220138-20171004100049.partial.mar</a></td>
+ <td>9M</td>
+ <td>04-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003220138-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003220138-20171004220309.partial.mar</a></td>
+ <td>10M</td>
+ <td>05-Oct-2017 00:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003220138-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003220138-20171005100211.partial.mar</a></td>
+ <td>10M</td>
+ <td>05-Oct-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003220138-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171003220138-20171005220204.partial.mar</a></td>
+ <td>11M</td>
+ <td>06-Oct-2017 00:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004100049-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004100049-20171004220309.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004100049-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004100049-20171005100211.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Oct-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004100049-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004100049-20171005220204.partial.mar</a></td>
+ <td>8M</td>
+ <td>06-Oct-2017 00:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004100049-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004100049-20171006100327.partial.mar</a></td>
+ <td>9M</td>
+ <td>06-Oct-2017 12:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004220309-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004220309-20171005100211.partial.mar</a></td>
+ <td>7M</td>
+ <td>05-Oct-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004220309-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004220309-20171005220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Oct-2017 00:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004220309-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004220309-20171006100327.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Oct-2017 12:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004220309-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171004220309-20171006220306.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005100211-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005100211-20171005220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Oct-2017 00:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005100211-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005100211-20171006100327.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Oct-2017 12:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005100211-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005100211-20171006220306.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005100211-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005100211-20171007100142.partial.mar</a></td>
+ <td>8M</td>
+ <td>07-Oct-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005220204-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005220204-20171006100327.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Oct-2017 12:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005220204-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005220204-20171006220306.partial.mar</a></td>
+ <td>8M</td>
+ <td>07-Oct-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005220204-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005220204-20171007100142.partial.mar</a></td>
+ <td>9M</td>
+ <td>07-Oct-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005220204-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171005220204-20171007220156.partial.mar</a></td>
+ <td>9M</td>
+ <td>08-Oct-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006100327-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006100327-20171006220306.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006100327-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006100327-20171007100142.partial.mar</a></td>
+ <td>8M</td>
+ <td>07-Oct-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006100327-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006100327-20171007220156.partial.mar</a></td>
+ <td>8M</td>
+ <td>08-Oct-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006100327-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006100327-20171008131700.partial.mar</a></td>
+ <td>9M</td>
+ <td>08-Oct-2017 15:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006220306-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006220306-20171007100142.partial.mar</a></td>
+ <td>8M</td>
+ <td>07-Oct-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006220306-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006220306-20171007220156.partial.mar</a></td>
+ <td>8M</td>
+ <td>08-Oct-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006220306-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006220306-20171008131700.partial.mar</a></td>
+ <td>8M</td>
+ <td>08-Oct-2017 15:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006220306-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171006220306-20171008220130.partial.mar</a></td>
+ <td>9M</td>
+ <td>09-Oct-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007100142-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007100142-20171007220156.partial.mar</a></td>
+ <td>5M</td>
+ <td>08-Oct-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007100142-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007100142-20171008131700.partial.mar</a></td>
+ <td>7M</td>
+ <td>08-Oct-2017 15:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007100142-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007100142-20171008220130.partial.mar</a></td>
+ <td>9M</td>
+ <td>09-Oct-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007100142-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007100142-20171009100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>09-Oct-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007220156-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007220156-20171008131700.partial.mar</a></td>
+ <td>6M</td>
+ <td>08-Oct-2017 15:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007220156-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007220156-20171008220130.partial.mar</a></td>
+ <td>9M</td>
+ <td>09-Oct-2017 01:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007220156-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007220156-20171009100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>09-Oct-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007220156-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171007220156-20171009220104.partial.mar</a></td>
+ <td>9M</td>
+ <td>10-Oct-2017 00:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008131700-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008131700-20171008220130.partial.mar</a></td>
+ <td>8M</td>
+ <td>09-Oct-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008131700-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008131700-20171009100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>09-Oct-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008131700-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008131700-20171009220104.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 00:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008131700-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008131700-20171010100200.partial.mar</a></td>
+ <td>9M</td>
+ <td>10-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008220130-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008220130-20171009100134.partial.mar</a></td>
+ <td>9M</td>
+ <td>09-Oct-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008220130-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008220130-20171009220104.partial.mar</a></td>
+ <td>9M</td>
+ <td>10-Oct-2017 00:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008220130-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008220130-20171010100200.partial.mar</a></td>
+ <td>10M</td>
+ <td>10-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008220130-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171008220130-20171010220102.partial.mar</a></td>
+ <td>10M</td>
+ <td>11-Oct-2017 00:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009100134-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009100134-20171009220104.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 00:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009100134-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009100134-20171010100200.partial.mar</a></td>
+ <td>9M</td>
+ <td>10-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009100134-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009100134-20171010220102.partial.mar</a></td>
+ <td>9M</td>
+ <td>11-Oct-2017 00:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009100134-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009100134-20171011100133.partial.mar</a></td>
+ <td>9M</td>
+ <td>11-Oct-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009220104-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009220104-20171010100200.partial.mar</a></td>
+ <td>7M</td>
+ <td>10-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009220104-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009220104-20171010220102.partial.mar</a></td>
+ <td>8M</td>
+ <td>11-Oct-2017 00:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009220104-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009220104-20171011100133.partial.mar</a></td>
+ <td>8M</td>
+ <td>11-Oct-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009220104-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171009220104-20171011220113.partial.mar</a></td>
+ <td>9M</td>
+ <td>12-Oct-2017 00:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010100200-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010100200-20171010220102.partial.mar</a></td>
+ <td>7M</td>
+ <td>11-Oct-2017 00:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010100200-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010100200-20171011100133.partial.mar</a></td>
+ <td>8M</td>
+ <td>11-Oct-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010100200-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010100200-20171011220113.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 00:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010100200-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010100200-20171012100228.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010100200-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010100200-20171012105833.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 15:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010220102-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010220102-20171011100133.partial.mar</a></td>
+ <td>7M</td>
+ <td>11-Oct-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010220102-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010220102-20171011220113.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 00:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010220102-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010220102-20171012100228.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010220102-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171010220102-20171012105833.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 15:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011100133-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011100133-20171011220113.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 00:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011100133-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011100133-20171012100228.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011100133-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011100133-20171012105833.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 15:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011100133-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011100133-20171012220111.partial.mar</a></td>
+ <td>9M</td>
+ <td>13-Oct-2017 00:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011220113-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011220113-20171012100228.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011220113-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011220113-20171012105833.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 15:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011220113-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011220113-20171012220111.partial.mar</a></td>
+ <td>9M</td>
+ <td>13-Oct-2017 00:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011220113-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171011220113-20171013100112.partial.mar</a></td>
+ <td>9M</td>
+ <td>13-Oct-2017 12:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012100228-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012100228-20171012220111.partial.mar</a></td>
+ <td>8M</td>
+ <td>13-Oct-2017 00:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012100228-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012100228-20171013100112.partial.mar</a></td>
+ <td>9M</td>
+ <td>13-Oct-2017 12:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012100228-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012100228-20171013220204.partial.mar</a></td>
+ <td>9M</td>
+ <td>14-Oct-2017 01:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012105833-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012105833-20171012220111.partial.mar</a></td>
+ <td>8M</td>
+ <td>13-Oct-2017 00:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012105833-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012105833-20171013100112.partial.mar</a></td>
+ <td>9M</td>
+ <td>13-Oct-2017 12:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012105833-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012105833-20171013220204.partial.mar</a></td>
+ <td>9M</td>
+ <td>14-Oct-2017 01:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012105833-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012105833-20171014100219.partial.mar</a></td>
+ <td>11M</td>
+ <td>14-Oct-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012220111-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012220111-20171013100112.partial.mar</a></td>
+ <td>9M</td>
+ <td>13-Oct-2017 12:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012220111-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012220111-20171013220204.partial.mar</a></td>
+ <td>9M</td>
+ <td>14-Oct-2017 01:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012220111-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012220111-20171014100219.partial.mar</a></td>
+ <td>10M</td>
+ <td>14-Oct-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012220111-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171012220111-20171014220542.partial.mar</a></td>
+ <td>11M</td>
+ <td>15-Oct-2017 01:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013100112-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013100112-20171013220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>14-Oct-2017 01:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013100112-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013100112-20171014100219.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013100112-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013100112-20171014220542.partial.mar</a></td>
+ <td>8M</td>
+ <td>15-Oct-2017 01:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013100112-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013100112-20171015100127.partial.mar</a></td>
+ <td>8M</td>
+ <td>15-Oct-2017 14:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013220204-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013220204-20171014100219.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013220204-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013220204-20171014220542.partial.mar</a></td>
+ <td>8M</td>
+ <td>15-Oct-2017 01:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013220204-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013220204-20171015100127.partial.mar</a></td>
+ <td>9M</td>
+ <td>15-Oct-2017 14:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013220204-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171013220204-20171015220106.partial.mar</a></td>
+ <td>9M</td>
+ <td>16-Oct-2017 01:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014100219-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014100219-20171014220542.partial.mar</a></td>
+ <td>6M</td>
+ <td>15-Oct-2017 01:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014100219-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014100219-20171015100127.partial.mar</a></td>
+ <td>6M</td>
+ <td>15-Oct-2017 14:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014100219-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014100219-20171015220106.partial.mar</a></td>
+ <td>7M</td>
+ <td>16-Oct-2017 01:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014100219-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014100219-20171016100113.partial.mar</a></td>
+ <td>7M</td>
+ <td>16-Oct-2017 12:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014220542-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014220542-20171015100127.partial.mar</a></td>
+ <td>5M</td>
+ <td>15-Oct-2017 14:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014220542-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014220542-20171015220106.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 01:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014220542-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014220542-20171016100113.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 12:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014220542-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171014220542-20171016220427.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 01:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015100127-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015100127-20171015220106.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 01:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015100127-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015100127-20171016100113.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 12:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015100127-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015100127-20171016220427.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 01:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015100127-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015100127-20171017100127.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015220106-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015220106-20171016100113.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 12:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015220106-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015220106-20171016220427.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 01:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015220106-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015220106-20171017100127.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015220106-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171015220106-20171017141229.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 17:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016100113-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016100113-20171016220427.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 01:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016100113-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016100113-20171017100127.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016100113-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016100113-20171017141229.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 17:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016100113-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016100113-20171017220415.partial.mar</a></td>
+ <td>8M</td>
+ <td>18-Oct-2017 01:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016220427-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016220427-20171017100127.partial.mar</a></td>
+ <td>9M</td>
+ <td>17-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016220427-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016220427-20171017141229.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 17:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016220427-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016220427-20171017220415.partial.mar</a></td>
+ <td>9M</td>
+ <td>18-Oct-2017 01:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016220427-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171016220427-20171018100140.partial.mar</a></td>
+ <td>9M</td>
+ <td>18-Oct-2017 12:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017100127-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017100127-20171017141229.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 17:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017100127-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017100127-20171017220415.partial.mar</a></td>
+ <td>7M</td>
+ <td>18-Oct-2017 01:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017100127-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017100127-20171018100140.partial.mar</a></td>
+ <td>7M</td>
+ <td>18-Oct-2017 12:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017100127-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017100127-20171018220049.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017141229-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017141229-20171017220415.partial.mar</a></td>
+ <td>7M</td>
+ <td>18-Oct-2017 01:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017141229-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017141229-20171018100140.partial.mar</a></td>
+ <td>7M</td>
+ <td>18-Oct-2017 12:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017141229-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017141229-20171018220049.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017141229-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017141229-20171019100107.partial.mar</a></td>
+ <td>8M</td>
+ <td>19-Oct-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017220415-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017220415-20171018100140.partial.mar</a></td>
+ <td>7M</td>
+ <td>18-Oct-2017 12:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017220415-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017220415-20171018220049.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017220415-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017220415-20171019100107.partial.mar</a></td>
+ <td>8M</td>
+ <td>19-Oct-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017220415-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171017220415-20171019222141.partial.mar</a></td>
+ <td>9M</td>
+ <td>20-Oct-2017 01:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018100140-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018100140-20171018220049.partial.mar</a></td>
+ <td>5M</td>
+ <td>19-Oct-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018100140-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018100140-20171019100107.partial.mar</a></td>
+ <td>8M</td>
+ <td>19-Oct-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018100140-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018100140-20171019222141.partial.mar</a></td>
+ <td>9M</td>
+ <td>20-Oct-2017 01:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018100140-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018100140-20171020100426.partial.mar</a></td>
+ <td>8M</td>
+ <td>20-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018220049-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018220049-20171019100107.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018220049-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018220049-20171019222141.partial.mar</a></td>
+ <td>9M</td>
+ <td>20-Oct-2017 01:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018220049-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018220049-20171020100426.partial.mar</a></td>
+ <td>8M</td>
+ <td>20-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018220049-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171018220049-20171020221129.partial.mar</a></td>
+ <td>9M</td>
+ <td>21-Oct-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019100107-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019100107-20171019222141.partial.mar</a></td>
+ <td>8M</td>
+ <td>20-Oct-2017 01:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019100107-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019100107-20171020100426.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019100107-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019100107-20171020221129.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Oct-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019100107-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019100107-20171021100029.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019222141-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019222141-20171020100426.partial.mar</a></td>
+ <td>8M</td>
+ <td>20-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019222141-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019222141-20171020221129.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019222141-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019222141-20171021100029.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019222141-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171019222141-20171021220121.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 02:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020100426-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020100426-20171020221129.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Oct-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020100426-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020100426-20171021100029.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020100426-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020100426-20171021220121.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 02:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020100426-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020100426-20171022100058.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020221129-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020221129-20171021100029.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020221129-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020221129-20171021220121.partial.mar</a></td>
+ <td>8M</td>
+ <td>22-Oct-2017 02:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020221129-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020221129-20171022100058.partial.mar</a></td>
+ <td>8M</td>
+ <td>22-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020221129-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171020221129-20171022220103.partial.mar</a></td>
+ <td>8M</td>
+ <td>23-Oct-2017 00:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021100029-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021100029-20171021220121.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 02:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021100029-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021100029-20171022100058.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021100029-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021100029-20171022220103.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 00:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021100029-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021100029-20171023100252.partial.mar</a></td>
+ <td>8M</td>
+ <td>23-Oct-2017 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021220121-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021220121-20171022100058.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021220121-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021220121-20171022220103.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Oct-2017 00:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021220121-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021220121-20171023100252.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021220121-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171021220121-20171023220222.partial.mar</a></td>
+ <td>13M</td>
+ <td>24-Oct-2017 01:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022100058-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022100058-20171022220103.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Oct-2017 00:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022100058-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022100058-20171023100252.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022100058-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022100058-20171023220222.partial.mar</a></td>
+ <td>13M</td>
+ <td>24-Oct-2017 01:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022100058-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022100058-20171024100135.partial.mar</a></td>
+ <td>13M</td>
+ <td>24-Oct-2017 12:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022220103-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022220103-20171023100252.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022220103-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022220103-20171023220222.partial.mar</a></td>
+ <td>13M</td>
+ <td>24-Oct-2017 01:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022220103-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022220103-20171024100135.partial.mar</a></td>
+ <td>13M</td>
+ <td>24-Oct-2017 12:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022220103-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171022220103-20171024220325.partial.mar</a></td>
+ <td>13M</td>
+ <td>25-Oct-2017 00:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023100252-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023100252-20171023220222.partial.mar</a></td>
+ <td>13M</td>
+ <td>24-Oct-2017 01:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023100252-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023100252-20171024100135.partial.mar</a></td>
+ <td>13M</td>
+ <td>24-Oct-2017 12:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023100252-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023100252-20171024220325.partial.mar</a></td>
+ <td>13M</td>
+ <td>25-Oct-2017 00:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023100252-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023100252-20171025100449.partial.mar</a></td>
+ <td>13M</td>
+ <td>25-Oct-2017 12:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023220222-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023220222-20171024100135.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Oct-2017 12:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023220222-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023220222-20171024220325.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Oct-2017 00:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023220222-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023220222-20171025100449.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Oct-2017 12:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023220222-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171023220222-20171025230440.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Oct-2017 02:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024100135-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024100135-20171024220325.partial.mar</a></td>
+ <td>4M</td>
+ <td>25-Oct-2017 00:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024100135-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024100135-20171025100449.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Oct-2017 12:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024100135-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024100135-20171025230440.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Oct-2017 02:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024100135-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024100135-20171026100047.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024220325-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024220325-20171025100449.partial.mar</a></td>
+ <td>4M</td>
+ <td>25-Oct-2017 12:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024220325-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024220325-20171025230440.partial.mar</a></td>
+ <td>4M</td>
+ <td>26-Oct-2017 02:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024220325-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024220325-20171026100047.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024220325-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171024220325-20171026221945.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Oct-2017 07:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025100449-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025100449-20171025230440.partial.mar</a></td>
+ <td>3M</td>
+ <td>26-Oct-2017 02:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025100449-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025100449-20171026100047.partial.mar</a></td>
+ <td>4M</td>
+ <td>26-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025100449-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025100449-20171026221945.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Oct-2017 07:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025100449-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025100449-20171027100103.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Oct-2017 15:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025230440-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025230440-20171026100047.partial.mar</a></td>
+ <td>4M</td>
+ <td>26-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025230440-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025230440-20171026221945.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Oct-2017 07:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025230440-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025230440-20171027100103.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Oct-2017 15:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025230440-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171025230440-20171027220059.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Oct-2017 02:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026100047-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026100047-20171026221945.partial.mar</a></td>
+ <td>4M</td>
+ <td>27-Oct-2017 07:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026100047-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026100047-20171027100103.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Oct-2017 14:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026100047-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026100047-20171027220059.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Oct-2017 02:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026100047-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026100047-20171028100423.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Oct-2017 13:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026221945-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026221945-20171027100103.partial.mar</a></td>
+ <td>4M</td>
+ <td>27-Oct-2017 14:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026221945-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026221945-20171027220059.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Oct-2017 02:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026221945-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026221945-20171028100423.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Oct-2017 13:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026221945-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171026221945-20171028220326.partial.mar</a></td>
+ <td>5M</td>
+ <td>29-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027100103-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027100103-20171027220059.partial.mar</a></td>
+ <td>4M</td>
+ <td>28-Oct-2017 02:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027100103-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027100103-20171028100423.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Oct-2017 13:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027100103-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027100103-20171028220326.partial.mar</a></td>
+ <td>4M</td>
+ <td>29-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027100103-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027100103-20171029102300.partial.mar</a></td>
+ <td>4M</td>
+ <td>29-Oct-2017 14:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027220059-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027220059-20171028100423.partial.mar</a></td>
+ <td>4M</td>
+ <td>28-Oct-2017 13:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027220059-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027220059-20171028220326.partial.mar</a></td>
+ <td>4M</td>
+ <td>29-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027220059-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027220059-20171029102300.partial.mar</a></td>
+ <td>4M</td>
+ <td>29-Oct-2017 14:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027220059-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171027220059-20171029220112.partial.mar</a></td>
+ <td>4M</td>
+ <td>30-Oct-2017 03:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028100423-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028100423-20171028220326.partial.mar</a></td>
+ <td>4M</td>
+ <td>29-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028100423-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028100423-20171029102300.partial.mar</a></td>
+ <td>4M</td>
+ <td>29-Oct-2017 14:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028100423-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028100423-20171029220112.partial.mar</a></td>
+ <td>4M</td>
+ <td>30-Oct-2017 03:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028100423-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028100423-20171030103605.partial.mar</a></td>
+ <td>4M</td>
+ <td>30-Oct-2017 20:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028220326-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028220326-20171029102300.partial.mar</a></td>
+ <td>1M</td>
+ <td>29-Oct-2017 14:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028220326-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028220326-20171029220112.partial.mar</a></td>
+ <td>2M</td>
+ <td>30-Oct-2017 03:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028220326-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028220326-20171030103605.partial.mar</a></td>
+ <td>4M</td>
+ <td>30-Oct-2017 20:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028220326-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028220326-20171031220132.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 03:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028220326-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171028220326-20171031235118.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 10:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029102300-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029102300-20171029220112.partial.mar</a></td>
+ <td>1M</td>
+ <td>30-Oct-2017 03:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029102300-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029102300-20171030103605.partial.mar</a></td>
+ <td>4M</td>
+ <td>30-Oct-2017 20:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029102300-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029102300-20171031220132.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 03:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029102300-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029102300-20171031235118.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 10:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029220112-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029220112-20171030103605.partial.mar</a></td>
+ <td>4M</td>
+ <td>30-Oct-2017 20:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029220112-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029220112-20171031220132.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 03:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029220112-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029220112-20171031235118.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 10:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029220112-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171029220112-20171101104430.partial.mar</a></td>
+ <td>38M</td>
+ <td>01-Nov-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171030103605-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171030103605-20171031220132.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 03:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171030103605-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171030103605-20171031235118.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 10:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171030103605-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171030103605-20171101104430.partial.mar</a></td>
+ <td>38M</td>
+ <td>01-Nov-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171030103605-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171030103605-20171101220120.partial.mar</a></td>
+ <td>38M</td>
+ <td>02-Nov-2017 00:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031220132-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031220132-20171101104430.partial.mar</a></td>
+ <td>38M</td>
+ <td>01-Nov-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031220132-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031220132-20171101220120.partial.mar</a></td>
+ <td>38M</td>
+ <td>02-Nov-2017 00:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031220132-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031220132-20171102100041.partial.mar</a></td>
+ <td>5M</td>
+ <td>02-Nov-2017 12:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031235118-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031235118-20171101104430.partial.mar</a></td>
+ <td>38M</td>
+ <td>01-Nov-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031235118-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031235118-20171101220120.partial.mar</a></td>
+ <td>38M</td>
+ <td>02-Nov-2017 00:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031235118-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031235118-20171102100041.partial.mar</a></td>
+ <td>5M</td>
+ <td>02-Nov-2017 12:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031235118-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171031235118-20171102222620.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Nov-2017 00:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101104430-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101104430-20171101220120.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Nov-2017 00:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101104430-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101104430-20171102100041.partial.mar</a></td>
+ <td>25M</td>
+ <td>02-Nov-2017 12:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101104430-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101104430-20171102222620.partial.mar</a></td>
+ <td>25M</td>
+ <td>03-Nov-2017 00:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101104430-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101104430-20171103100331.partial.mar</a></td>
+ <td>25M</td>
+ <td>03-Nov-2017 12:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101220120-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101220120-20171102100041.partial.mar</a></td>
+ <td>24M</td>
+ <td>02-Nov-2017 12:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101220120-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101220120-20171102222620.partial.mar</a></td>
+ <td>25M</td>
+ <td>03-Nov-2017 00:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101220120-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101220120-20171103100331.partial.mar</a></td>
+ <td>24M</td>
+ <td>03-Nov-2017 12:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101220120-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171101220120-20171103220715.partial.mar</a></td>
+ <td>24M</td>
+ <td>04-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102100041-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102100041-20171102222620.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Nov-2017 00:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102100041-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102100041-20171103100331.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Nov-2017 12:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102100041-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102100041-20171103220715.partial.mar</a></td>
+ <td>5M</td>
+ <td>04-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102100041-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102100041-20171104100412.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Nov-2017 12:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102222620-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102222620-20171103100331.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Nov-2017 12:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102222620-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102222620-20171103220715.partial.mar</a></td>
+ <td>4M</td>
+ <td>04-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102222620-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102222620-20171104100412.partial.mar</a></td>
+ <td>5M</td>
+ <td>04-Nov-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102222620-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171102222620-20171104220420.partial.mar</a></td>
+ <td>5M</td>
+ <td>05-Nov-2017 00:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103100331-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103100331-20171103220715.partial.mar</a></td>
+ <td>3M</td>
+ <td>04-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103100331-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103100331-20171104100412.partial.mar</a></td>
+ <td>5M</td>
+ <td>04-Nov-2017 12:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103100331-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103100331-20171104220420.partial.mar</a></td>
+ <td>5M</td>
+ <td>05-Nov-2017 00:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103100331-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103100331-20171105100353.partial.mar</a></td>
+ <td>5M</td>
+ <td>05-Nov-2017 12:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103220715-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103220715-20171104100412.partial.mar</a></td>
+ <td>5M</td>
+ <td>04-Nov-2017 12:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103220715-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103220715-20171104220420.partial.mar</a></td>
+ <td>5M</td>
+ <td>05-Nov-2017 00:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103220715-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103220715-20171105100353.partial.mar</a></td>
+ <td>5M</td>
+ <td>05-Nov-2017 12:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103220715-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171103220715-20171105220721.partial.mar</a></td>
+ <td>5M</td>
+ <td>06-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104100412-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104100412-20171104220420.partial.mar</a></td>
+ <td>3M</td>
+ <td>05-Nov-2017 00:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104100412-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104100412-20171105100353.partial.mar</a></td>
+ <td>3M</td>
+ <td>05-Nov-2017 12:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104100412-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104100412-20171105220721.partial.mar</a></td>
+ <td>3M</td>
+ <td>06-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104100412-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104100412-20171106100122.partial.mar</a></td>
+ <td>4M</td>
+ <td>06-Nov-2017 11:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104220420-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104220420-20171105100353.partial.mar</a></td>
+ <td>46K</td>
+ <td>05-Nov-2017 12:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104220420-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104220420-20171105220721.partial.mar</a></td>
+ <td>469K</td>
+ <td>06-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104220420-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171104220420-20171106100122.partial.mar</a></td>
+ <td>3M</td>
+ <td>06-Nov-2017 11:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171105100353-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171105100353-20171105220721.partial.mar</a></td>
+ <td>466K</td>
+ <td>06-Nov-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171105100353-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171105100353-20171106100122.partial.mar</a></td>
+ <td>3M</td>
+ <td>06-Nov-2017 11:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-i686-en-US-20171105220721-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-linux-i686-en-US-20171105220721-20171106100122.partial.mar</a></td>
+ <td>3M</td>
+ <td>06-Nov-2017 11:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170919220202-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170919220202-20170921220243.partial.mar</a></td>
+ <td>8M</td>
+ <td>22-Sep-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170920100426-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170920100426-20170921220243.partial.mar</a></td>
+ <td>8M</td>
+ <td>22-Sep-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170920100426-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170920100426-20170922100051.partial.mar</a></td>
+ <td>8M</td>
+ <td>22-Sep-2017 12:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170920220431-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170920220431-20170921220243.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170920220431-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170920220431-20170922100051.partial.mar</a></td>
+ <td>8M</td>
+ <td>22-Sep-2017 12:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170920220431-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170920220431-20170922220129.partial.mar</a></td>
+ <td>8M</td>
+ <td>23-Sep-2017 01:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921100141-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921100141-20170921220243.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Sep-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921100141-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921100141-20170922100051.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 12:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921100141-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921100141-20170922220129.partial.mar</a></td>
+ <td>8M</td>
+ <td>23-Sep-2017 01:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921100141-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921100141-20170923100045.partial.mar</a></td>
+ <td>8M</td>
+ <td>23-Sep-2017 13:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921220243-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921220243-20170922100051.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 12:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921220243-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921220243-20170922220129.partial.mar</a></td>
+ <td>8M</td>
+ <td>23-Sep-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921220243-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921220243-20170923100045.partial.mar</a></td>
+ <td>8M</td>
+ <td>23-Sep-2017 13:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921220243-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170921220243-20170923220337.partial.mar</a></td>
+ <td>8M</td>
+ <td>24-Sep-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922100051-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922100051-20170922220129.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Sep-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922100051-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922100051-20170923100045.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Sep-2017 13:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922100051-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922100051-20170923220337.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Sep-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922100051-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922100051-20170924100550.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Sep-2017 13:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922220129-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922220129-20170923100045.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Sep-2017 13:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922220129-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922220129-20170923220337.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Sep-2017 00:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922220129-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922220129-20170924100550.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Sep-2017 13:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922220129-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170922220129-20170924220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923100045-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923100045-20170923220337.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Sep-2017 00:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923100045-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923100045-20170924100550.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Sep-2017 13:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923100045-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923100045-20170924220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923100045-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923100045-20170925100307.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923220337-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923220337-20170924100550.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Sep-2017 13:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923220337-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923220337-20170924220116.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923220337-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923220337-20170925100307.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923220337-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170923220337-20170925220207.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924100550-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924100550-20170924220116.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 01:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924100550-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924100550-20170925100307.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924100550-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924100550-20170925220207.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Sep-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924100550-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924100550-20170926100259.partial.mar</a></td>
+ <td>8M</td>
+ <td>26-Sep-2017 12:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924220116-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924220116-20170925100307.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924220116-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924220116-20170925220207.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924220116-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924220116-20170926100259.partial.mar</a></td>
+ <td>8M</td>
+ <td>26-Sep-2017 12:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924220116-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170924220116-20170926220106.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Sep-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925100307-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925100307-20170925220207.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Sep-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925100307-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925100307-20170926100259.partial.mar</a></td>
+ <td>8M</td>
+ <td>26-Sep-2017 12:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925100307-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925100307-20170926220106.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Sep-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925100307-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925100307-20170928100123.partial.mar</a></td>
+ <td>9M</td>
+ <td>28-Sep-2017 12:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925220207-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925220207-20170926100259.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 12:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925220207-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925220207-20170926220106.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Sep-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925220207-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925220207-20170928100123.partial.mar</a></td>
+ <td>9M</td>
+ <td>28-Sep-2017 12:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925220207-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170925220207-20170928220658.partial.mar</a></td>
+ <td>9M</td>
+ <td>29-Sep-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926100259-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926100259-20170926220106.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Sep-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926100259-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926100259-20170928100123.partial.mar</a></td>
+ <td>9M</td>
+ <td>28-Sep-2017 12:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926100259-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926100259-20170928220658.partial.mar</a></td>
+ <td>8M</td>
+ <td>29-Sep-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926100259-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926100259-20170929100122.partial.mar</a></td>
+ <td>9M</td>
+ <td>29-Sep-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926220106-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926220106-20170928100123.partial.mar</a></td>
+ <td>8M</td>
+ <td>28-Sep-2017 12:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926220106-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926220106-20170928220658.partial.mar</a></td>
+ <td>8M</td>
+ <td>29-Sep-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926220106-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926220106-20170929100122.partial.mar</a></td>
+ <td>8M</td>
+ <td>29-Sep-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926220106-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170926220106-20170929220356.partial.mar</a></td>
+ <td>9M</td>
+ <td>30-Sep-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928100123-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928100123-20170928220658.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928100123-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928100123-20170929100122.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928100123-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928100123-20170929220356.partial.mar</a></td>
+ <td>8M</td>
+ <td>30-Sep-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928100123-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928100123-20170930100302.partial.mar</a></td>
+ <td>8M</td>
+ <td>30-Sep-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928220658-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928220658-20170929100122.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928220658-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928220658-20170929220356.partial.mar</a></td>
+ <td>8M</td>
+ <td>30-Sep-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928220658-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928220658-20170930100302.partial.mar</a></td>
+ <td>8M</td>
+ <td>30-Sep-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928220658-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170928220658-20170930220116.partial.mar</a></td>
+ <td>8M</td>
+ <td>01-Oct-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929100122-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929100122-20170929220356.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Sep-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929100122-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929100122-20170930100302.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Sep-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929100122-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929100122-20170930220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929100122-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929100122-20171001100335.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929220356-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929220356-20170930100302.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Sep-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929220356-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929220356-20170930220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929220356-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929220356-20171001100335.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929220356-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170929220356-20171001220301.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Oct-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930100302-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930100302-20170930220116.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Oct-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930100302-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930100302-20171001100335.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930100302-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930100302-20171001220301.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Oct-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930100302-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930100302-20171002100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Oct-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930220116-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930220116-20171001100335.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Oct-2017 12:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930220116-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930220116-20171001220301.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930220116-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930220116-20171002100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Oct-2017 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930220116-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20170930220116-20171002220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001100335-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001100335-20171001220301.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001100335-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001100335-20171002100134.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 12:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001100335-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001100335-20171002220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001100335-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001100335-20171003100226.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001220301-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001220301-20171002100134.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 12:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001220301-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001220301-20171002220204.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Oct-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001220301-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001220301-20171003100226.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001220301-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171001220301-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 00:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002100134-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002100134-20171002220204.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Oct-2017 00:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002100134-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002100134-20171003100226.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002100134-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002100134-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 00:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002100134-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002100134-20171004220309.partial.mar</a></td>
+ <td>11M</td>
+ <td>05-Oct-2017 00:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002220204-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002220204-20171003100226.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002220204-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002220204-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 00:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002220204-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002220204-20171004220309.partial.mar</a></td>
+ <td>11M</td>
+ <td>05-Oct-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002220204-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171002220204-20171005100211.partial.mar</a></td>
+ <td>11M</td>
+ <td>05-Oct-2017 12:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003100226-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003100226-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 00:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003100226-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003100226-20171004220309.partial.mar</a></td>
+ <td>10M</td>
+ <td>05-Oct-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003100226-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003100226-20171005100211.partial.mar</a></td>
+ <td>11M</td>
+ <td>05-Oct-2017 12:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003100226-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003100226-20171005220204.partial.mar</a></td>
+ <td>11M</td>
+ <td>06-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003220138-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003220138-20171004220309.partial.mar</a></td>
+ <td>10M</td>
+ <td>05-Oct-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003220138-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003220138-20171005100211.partial.mar</a></td>
+ <td>10M</td>
+ <td>05-Oct-2017 12:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003220138-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003220138-20171005220204.partial.mar</a></td>
+ <td>11M</td>
+ <td>06-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003220138-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171003220138-20171006100327.partial.mar</a></td>
+ <td>10M</td>
+ <td>06-Oct-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171004220309-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171004220309-20171005100211.partial.mar</a></td>
+ <td>6M</td>
+ <td>05-Oct-2017 12:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171004220309-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171004220309-20171005220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171004220309-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171004220309-20171006100327.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Oct-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171004220309-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171004220309-20171006220306.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 00:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005100211-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005100211-20171005220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005100211-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005100211-20171006100327.partial.mar</a></td>
+ <td>5M</td>
+ <td>06-Oct-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005100211-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005100211-20171006220306.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 00:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005100211-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005100211-20171007100142.partial.mar</a></td>
+ <td>9M</td>
+ <td>07-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005220204-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005220204-20171006100327.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Oct-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005220204-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005220204-20171006220306.partial.mar</a></td>
+ <td>8M</td>
+ <td>07-Oct-2017 00:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005220204-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005220204-20171007100142.partial.mar</a></td>
+ <td>8M</td>
+ <td>07-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005220204-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171005220204-20171007220156.partial.mar</a></td>
+ <td>9M</td>
+ <td>08-Oct-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006100327-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006100327-20171006220306.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 00:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006100327-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006100327-20171007100142.partial.mar</a></td>
+ <td>9M</td>
+ <td>07-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006100327-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006100327-20171007220156.partial.mar</a></td>
+ <td>9M</td>
+ <td>08-Oct-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006100327-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006100327-20171008131700.partial.mar</a></td>
+ <td>9M</td>
+ <td>08-Oct-2017 15:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006220306-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006220306-20171007100142.partial.mar</a></td>
+ <td>8M</td>
+ <td>07-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006220306-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006220306-20171007220156.partial.mar</a></td>
+ <td>8M</td>
+ <td>08-Oct-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006220306-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006220306-20171008131700.partial.mar</a></td>
+ <td>8M</td>
+ <td>08-Oct-2017 15:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006220306-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171006220306-20171008220130.partial.mar</a></td>
+ <td>8M</td>
+ <td>09-Oct-2017 00:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007100142-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007100142-20171007220156.partial.mar</a></td>
+ <td>6M</td>
+ <td>08-Oct-2017 00:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007100142-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007100142-20171008131700.partial.mar</a></td>
+ <td>7M</td>
+ <td>08-Oct-2017 15:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007100142-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007100142-20171008220130.partial.mar</a></td>
+ <td>7M</td>
+ <td>09-Oct-2017 00:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007100142-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007100142-20171009100134.partial.mar</a></td>
+ <td>8M</td>
+ <td>09-Oct-2017 12:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007220156-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007220156-20171008131700.partial.mar</a></td>
+ <td>7M</td>
+ <td>08-Oct-2017 15:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007220156-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007220156-20171008220130.partial.mar</a></td>
+ <td>6M</td>
+ <td>09-Oct-2017 00:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007220156-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007220156-20171009100134.partial.mar</a></td>
+ <td>8M</td>
+ <td>09-Oct-2017 12:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007220156-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171007220156-20171009220104.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 00:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008131700-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008131700-20171008220130.partial.mar</a></td>
+ <td>5M</td>
+ <td>09-Oct-2017 00:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008131700-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008131700-20171009100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>09-Oct-2017 12:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008131700-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008131700-20171009220104.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 00:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008131700-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008131700-20171010100200.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 12:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008220130-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008220130-20171009100134.partial.mar</a></td>
+ <td>8M</td>
+ <td>09-Oct-2017 12:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008220130-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008220130-20171009220104.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 00:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008220130-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008220130-20171010100200.partial.mar</a></td>
+ <td>9M</td>
+ <td>10-Oct-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008220130-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171008220130-20171010220102.partial.mar</a></td>
+ <td>9M</td>
+ <td>11-Oct-2017 00:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009100134-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009100134-20171009220104.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 00:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009100134-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009100134-20171010100200.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009100134-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009100134-20171010220102.partial.mar</a></td>
+ <td>9M</td>
+ <td>11-Oct-2017 00:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009100134-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009100134-20171011100133.partial.mar</a></td>
+ <td>9M</td>
+ <td>11-Oct-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009220104-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009220104-20171010100200.partial.mar</a></td>
+ <td>7M</td>
+ <td>10-Oct-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009220104-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009220104-20171010220102.partial.mar</a></td>
+ <td>8M</td>
+ <td>11-Oct-2017 00:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009220104-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009220104-20171011100133.partial.mar</a></td>
+ <td>8M</td>
+ <td>11-Oct-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009220104-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171009220104-20171011220113.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 00:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010100200-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010100200-20171010220102.partial.mar</a></td>
+ <td>7M</td>
+ <td>11-Oct-2017 00:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010100200-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010100200-20171011100133.partial.mar</a></td>
+ <td>8M</td>
+ <td>11-Oct-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010100200-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010100200-20171011220113.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 00:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010100200-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010100200-20171012100228.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010100200-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010100200-20171012105833.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 15:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010220102-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010220102-20171011100133.partial.mar</a></td>
+ <td>7M</td>
+ <td>11-Oct-2017 17:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010220102-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010220102-20171011220113.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 00:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010220102-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010220102-20171012100228.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010220102-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171010220102-20171012105833.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 15:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011100133-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011100133-20171011220113.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 00:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011100133-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011100133-20171012100228.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011100133-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011100133-20171012105833.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 15:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011100133-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011100133-20171012220111.partial.mar</a></td>
+ <td>8M</td>
+ <td>13-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011220113-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011220113-20171012100228.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011220113-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011220113-20171012105833.partial.mar</a></td>
+ <td>6M</td>
+ <td>12-Oct-2017 15:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011220113-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011220113-20171012220111.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011220113-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171011220113-20171013100112.partial.mar</a></td>
+ <td>9M</td>
+ <td>13-Oct-2017 12:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012100228-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012100228-20171012220111.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012100228-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012100228-20171013100112.partial.mar</a></td>
+ <td>9M</td>
+ <td>13-Oct-2017 12:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012100228-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012100228-20171013220204.partial.mar</a></td>
+ <td>9M</td>
+ <td>14-Oct-2017 00:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012105833-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012105833-20171012220111.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 00:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012105833-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012105833-20171013100112.partial.mar</a></td>
+ <td>9M</td>
+ <td>13-Oct-2017 12:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012105833-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012105833-20171013220204.partial.mar</a></td>
+ <td>9M</td>
+ <td>14-Oct-2017 00:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012105833-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012105833-20171014100219.partial.mar</a></td>
+ <td>10M</td>
+ <td>14-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012220111-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012220111-20171013100112.partial.mar</a></td>
+ <td>8M</td>
+ <td>13-Oct-2017 12:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012220111-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012220111-20171013220204.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 00:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012220111-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012220111-20171014100219.partial.mar</a></td>
+ <td>10M</td>
+ <td>14-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012220111-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171012220111-20171014220542.partial.mar</a></td>
+ <td>10M</td>
+ <td>15-Oct-2017 01:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013100112-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013100112-20171013220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>14-Oct-2017 00:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013100112-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013100112-20171014100219.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013100112-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013100112-20171014220542.partial.mar</a></td>
+ <td>9M</td>
+ <td>15-Oct-2017 01:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013100112-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013100112-20171015100127.partial.mar</a></td>
+ <td>9M</td>
+ <td>15-Oct-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013220204-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013220204-20171014100219.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013220204-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013220204-20171014220542.partial.mar</a></td>
+ <td>9M</td>
+ <td>15-Oct-2017 01:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013220204-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013220204-20171015100127.partial.mar</a></td>
+ <td>8M</td>
+ <td>15-Oct-2017 13:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013220204-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171013220204-20171015220106.partial.mar</a></td>
+ <td>8M</td>
+ <td>16-Oct-2017 01:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014100219-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014100219-20171014220542.partial.mar</a></td>
+ <td>7M</td>
+ <td>15-Oct-2017 01:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014100219-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014100219-20171015100127.partial.mar</a></td>
+ <td>6M</td>
+ <td>15-Oct-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014100219-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014100219-20171015220106.partial.mar</a></td>
+ <td>7M</td>
+ <td>16-Oct-2017 01:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014100219-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014100219-20171016100113.partial.mar</a></td>
+ <td>7M</td>
+ <td>16-Oct-2017 12:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014220542-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014220542-20171015100127.partial.mar</a></td>
+ <td>6M</td>
+ <td>15-Oct-2017 13:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014220542-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014220542-20171015220106.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 01:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014220542-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014220542-20171016100113.partial.mar</a></td>
+ <td>7M</td>
+ <td>16-Oct-2017 12:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014220542-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171014220542-20171016220427.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 01:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015100127-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015100127-20171015220106.partial.mar</a></td>
+ <td>5M</td>
+ <td>16-Oct-2017 01:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015100127-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015100127-20171016100113.partial.mar</a></td>
+ <td>7M</td>
+ <td>16-Oct-2017 12:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015100127-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015100127-20171016220427.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 01:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015100127-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015100127-20171017100127.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 12:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015220106-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015220106-20171016100113.partial.mar</a></td>
+ <td>7M</td>
+ <td>16-Oct-2017 12:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015220106-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015220106-20171016220427.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 01:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015220106-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015220106-20171017100127.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 12:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015220106-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171015220106-20171017141229.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 17:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016100113-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016100113-20171016220427.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 01:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016100113-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016100113-20171017100127.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 12:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016100113-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016100113-20171017141229.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 17:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016100113-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016100113-20171017220415.partial.mar</a></td>
+ <td>8M</td>
+ <td>18-Oct-2017 01:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016220427-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016220427-20171017100127.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 12:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016220427-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016220427-20171017141229.partial.mar</a></td>
+ <td>8M</td>
+ <td>17-Oct-2017 17:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016220427-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016220427-20171017220415.partial.mar</a></td>
+ <td>9M</td>
+ <td>18-Oct-2017 01:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016220427-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171016220427-20171018100140.partial.mar</a></td>
+ <td>8M</td>
+ <td>18-Oct-2017 12:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017100127-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017100127-20171017141229.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 17:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017100127-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017100127-20171017220415.partial.mar</a></td>
+ <td>8M</td>
+ <td>18-Oct-2017 01:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017100127-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017100127-20171018100140.partial.mar</a></td>
+ <td>8M</td>
+ <td>18-Oct-2017 12:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017100127-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017100127-20171018220049.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 01:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017141229-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017141229-20171017220415.partial.mar</a></td>
+ <td>8M</td>
+ <td>18-Oct-2017 01:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017141229-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017141229-20171018100140.partial.mar</a></td>
+ <td>8M</td>
+ <td>18-Oct-2017 12:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017141229-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017141229-20171018220049.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 01:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017141229-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017141229-20171019100107.partial.mar</a></td>
+ <td>8M</td>
+ <td>19-Oct-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017220415-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017220415-20171018100140.partial.mar</a></td>
+ <td>7M</td>
+ <td>18-Oct-2017 12:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017220415-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017220415-20171018220049.partial.mar</a></td>
+ <td>8M</td>
+ <td>19-Oct-2017 01:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017220415-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017220415-20171019100107.partial.mar</a></td>
+ <td>8M</td>
+ <td>19-Oct-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017220415-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171017220415-20171019222141.partial.mar</a></td>
+ <td>9M</td>
+ <td>20-Oct-2017 01:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018100140-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018100140-20171018220049.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 01:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018100140-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018100140-20171019100107.partial.mar</a></td>
+ <td>8M</td>
+ <td>19-Oct-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018100140-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018100140-20171019222141.partial.mar</a></td>
+ <td>8M</td>
+ <td>20-Oct-2017 01:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018100140-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018100140-20171020100426.partial.mar</a></td>
+ <td>8M</td>
+ <td>20-Oct-2017 12:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018220049-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018220049-20171019100107.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018220049-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018220049-20171019222141.partial.mar</a></td>
+ <td>8M</td>
+ <td>20-Oct-2017 01:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018220049-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018220049-20171020100426.partial.mar</a></td>
+ <td>8M</td>
+ <td>20-Oct-2017 12:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018220049-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171018220049-20171020221129.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019100107-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019100107-20171019222141.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Oct-2017 01:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019100107-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019100107-20171020100426.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Oct-2017 12:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019100107-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019100107-20171020221129.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019100107-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019100107-20171021100029.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019222141-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019222141-20171020100426.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Oct-2017 12:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019222141-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019222141-20171020221129.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019222141-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019222141-20171021100029.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019222141-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171019222141-20171021220121.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 02:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020100426-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020100426-20171020221129.partial.mar</a></td>
+ <td>8M</td>
+ <td>21-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020100426-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020100426-20171021100029.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020100426-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020100426-20171021220121.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 02:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020100426-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020100426-20171022100058.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020221129-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020221129-20171021100029.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020221129-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020221129-20171021220121.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 02:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020221129-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020221129-20171022100058.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020221129-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171020221129-20171022220103.partial.mar</a></td>
+ <td>8M</td>
+ <td>23-Oct-2017 00:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021100029-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021100029-20171021220121.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Oct-2017 02:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021100029-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021100029-20171022100058.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021100029-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021100029-20171022220103.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 00:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021100029-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021100029-20171023100252.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 12:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021220121-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021220121-20171022100058.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Oct-2017 12:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021220121-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021220121-20171022220103.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 00:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021220121-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021220121-20171023100252.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 12:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021220121-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171021220121-20171023220222.partial.mar</a></td>
+ <td>8M</td>
+ <td>24-Oct-2017 00:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022100058-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022100058-20171022220103.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 00:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022100058-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022100058-20171023100252.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 12:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022100058-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022100058-20171023220222.partial.mar</a></td>
+ <td>8M</td>
+ <td>24-Oct-2017 00:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022100058-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022100058-20171024100135.partial.mar</a></td>
+ <td>8M</td>
+ <td>24-Oct-2017 12:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022220103-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022220103-20171023100252.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 12:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022220103-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022220103-20171023220222.partial.mar</a></td>
+ <td>8M</td>
+ <td>24-Oct-2017 00:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022220103-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022220103-20171024100135.partial.mar</a></td>
+ <td>8M</td>
+ <td>24-Oct-2017 12:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022220103-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171022220103-20171024220325.partial.mar</a></td>
+ <td>9M</td>
+ <td>25-Oct-2017 00:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023100252-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023100252-20171023220222.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Oct-2017 00:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023100252-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023100252-20171024100135.partial.mar</a></td>
+ <td>8M</td>
+ <td>24-Oct-2017 12:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023100252-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023100252-20171024220325.partial.mar</a></td>
+ <td>8M</td>
+ <td>25-Oct-2017 00:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023100252-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023100252-20171025100449.partial.mar</a></td>
+ <td>8M</td>
+ <td>25-Oct-2017 12:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023220222-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023220222-20171024100135.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Oct-2017 12:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023220222-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023220222-20171024220325.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Oct-2017 00:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023220222-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023220222-20171025100449.partial.mar</a></td>
+ <td>8M</td>
+ <td>25-Oct-2017 12:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023220222-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171023220222-20171025230440.partial.mar</a></td>
+ <td>8M</td>
+ <td>26-Oct-2017 02:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024100135-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024100135-20171024220325.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Oct-2017 00:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024100135-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024100135-20171025100449.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Oct-2017 12:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024100135-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024100135-20171025230440.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 02:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024100135-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024100135-20171026100047.partial.mar</a></td>
+ <td>8M</td>
+ <td>26-Oct-2017 14:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024220325-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024220325-20171025100449.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Oct-2017 12:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024220325-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024220325-20171025230440.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 02:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024220325-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024220325-20171026100047.partial.mar</a></td>
+ <td>8M</td>
+ <td>26-Oct-2017 14:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024220325-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171024220325-20171026221945.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Oct-2017 02:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025100449-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025100449-20171025230440.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 02:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025100449-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025100449-20171026100047.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 14:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025100449-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025100449-20171026221945.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Oct-2017 02:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025100449-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025100449-20171027100103.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Oct-2017 15:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025230440-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025230440-20171026100047.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 14:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025230440-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025230440-20171026221945.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Oct-2017 02:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025230440-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025230440-20171027100103.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 15:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025230440-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171025230440-20171027220059.partial.mar</a></td>
+ <td>8M</td>
+ <td>28-Oct-2017 02:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026100047-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026100047-20171026221945.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 02:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026100047-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026100047-20171027100103.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 15:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026100047-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026100047-20171027220059.partial.mar</a></td>
+ <td>8M</td>
+ <td>28-Oct-2017 02:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026100047-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026100047-20171028100423.partial.mar</a></td>
+ <td>8M</td>
+ <td>28-Oct-2017 14:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026221945-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026221945-20171027100103.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 15:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026221945-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026221945-20171027220059.partial.mar</a></td>
+ <td>7M</td>
+ <td>28-Oct-2017 02:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026221945-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026221945-20171028100423.partial.mar</a></td>
+ <td>8M</td>
+ <td>28-Oct-2017 14:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026221945-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171026221945-20171028220326.partial.mar</a></td>
+ <td>8M</td>
+ <td>29-Oct-2017 01:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027100103-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027100103-20171027220059.partial.mar</a></td>
+ <td>7M</td>
+ <td>28-Oct-2017 02:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027100103-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027100103-20171028100423.partial.mar</a></td>
+ <td>7M</td>
+ <td>28-Oct-2017 14:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027100103-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027100103-20171028220326.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Oct-2017 01:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027100103-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027100103-20171029102300.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Oct-2017 14:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027220059-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027220059-20171028100423.partial.mar</a></td>
+ <td>7M</td>
+ <td>28-Oct-2017 14:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027220059-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027220059-20171028220326.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Oct-2017 01:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027220059-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027220059-20171029102300.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Oct-2017 14:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027220059-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171027220059-20171029220112.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 03:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028100423-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028100423-20171028220326.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Oct-2017 01:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028100423-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028100423-20171029102300.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Oct-2017 14:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028100423-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028100423-20171029220112.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 03:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028100423-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028100423-20171030103605.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 19:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028220326-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028220326-20171029102300.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Oct-2017 14:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028220326-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028220326-20171029220112.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 03:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028220326-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028220326-20171030103605.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 19:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028220326-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028220326-20171031220132.partial.mar</a></td>
+ <td>8M</td>
+ <td>01-Nov-2017 03:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028220326-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171028220326-20171031235118.partial.mar</a></td>
+ <td>8M</td>
+ <td>01-Nov-2017 10:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029102300-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029102300-20171029220112.partial.mar</a></td>
+ <td>5M</td>
+ <td>30-Oct-2017 03:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029102300-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029102300-20171030103605.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 19:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029102300-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029102300-20171031220132.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 03:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029102300-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029102300-20171031235118.partial.mar</a></td>
+ <td>8M</td>
+ <td>01-Nov-2017 10:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029220112-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029220112-20171030103605.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 19:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029220112-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029220112-20171031220132.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 03:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029220112-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029220112-20171031235118.partial.mar</a></td>
+ <td>8M</td>
+ <td>01-Nov-2017 10:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029220112-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171029220112-20171101104430.partial.mar</a></td>
+ <td>8M</td>
+ <td>01-Nov-2017 16:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171030103605-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171030103605-20171031220132.partial.mar</a></td>
+ <td>8M</td>
+ <td>01-Nov-2017 03:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171030103605-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171030103605-20171031235118.partial.mar</a></td>
+ <td>8M</td>
+ <td>01-Nov-2017 10:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171030103605-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171030103605-20171101104430.partial.mar</a></td>
+ <td>8M</td>
+ <td>01-Nov-2017 16:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171030103605-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171030103605-20171101220120.partial.mar</a></td>
+ <td>9M</td>
+ <td>02-Nov-2017 00:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031220132-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031220132-20171101104430.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 16:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031220132-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031220132-20171101220120.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 00:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031220132-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031220132-20171102222620.partial.mar</a></td>
+ <td>8M</td>
+ <td>03-Nov-2017 00:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031235118-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031235118-20171101104430.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 16:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031235118-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031235118-20171101220120.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 00:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031235118-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031235118-20171102222620.partial.mar</a></td>
+ <td>8M</td>
+ <td>03-Nov-2017 00:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031235118-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171031235118-20171103100331.partial.mar</a></td>
+ <td>8M</td>
+ <td>03-Nov-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101104430-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101104430-20171101220120.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 00:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101104430-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101104430-20171102222620.partial.mar</a></td>
+ <td>8M</td>
+ <td>03-Nov-2017 00:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101104430-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101104430-20171103100331.partial.mar</a></td>
+ <td>8M</td>
+ <td>03-Nov-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101104430-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101104430-20171103220715.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Nov-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101220120-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101220120-20171102222620.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 00:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101220120-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101220120-20171103100331.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101220120-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101220120-20171103220715.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Nov-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101220120-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171101220120-20171104100412.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Nov-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171102222620-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171102222620-20171103100331.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171102222620-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171102222620-20171103220715.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Nov-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171102222620-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171102222620-20171104100412.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Nov-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171102222620-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171102222620-20171104220420.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Nov-2017 23:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103100331-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103100331-20171103220715.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Nov-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103100331-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103100331-20171104100412.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Nov-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103100331-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103100331-20171104220420.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Nov-2017 23:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103100331-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103100331-20171105100353.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Nov-2017 12:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103220715-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103220715-20171104100412.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Nov-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103220715-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103220715-20171104220420.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Nov-2017 23:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103220715-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103220715-20171105100353.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Nov-2017 12:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103220715-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171103220715-20171105220721.partial.mar</a></td>
+ <td>8M</td>
+ <td>06-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104100412-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104100412-20171104220420.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Nov-2017 23:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104100412-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104100412-20171105100353.partial.mar</a></td>
+ <td>6M</td>
+ <td>05-Nov-2017 12:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104100412-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104100412-20171105220721.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104100412-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104100412-20171106100122.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Nov-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104220420-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104220420-20171105100353.partial.mar</a></td>
+ <td>6M</td>
+ <td>05-Nov-2017 12:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104220420-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104220420-20171105220721.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104220420-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171104220420-20171106100122.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Nov-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171105100353-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171105100353-20171105220721.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Nov-2017 00:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171105100353-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171105100353-20171106100122.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Nov-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171105220721-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-linux-x86_64-en-US-20171105220721-20171106100122.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Nov-2017 12:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170919220202-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170919220202-20170921220243.partial.mar</a></td>
+ <td>4M</td>
+ <td>21-Sep-2017 23:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170920100426-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170920100426-20170921220243.partial.mar</a></td>
+ <td>4M</td>
+ <td>21-Sep-2017 23:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170920100426-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170920100426-20170922100051.partial.mar</a></td>
+ <td>4M</td>
+ <td>22-Sep-2017 11:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170920220431-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170920220431-20170921220243.partial.mar</a></td>
+ <td>4M</td>
+ <td>21-Sep-2017 23:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170920220431-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170920220431-20170922100051.partial.mar</a></td>
+ <td>4M</td>
+ <td>22-Sep-2017 11:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170920220431-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170920220431-20170922220129.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Sep-2017 23:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170921100141-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170921100141-20170921220243.partial.mar</a></td>
+ <td>3M</td>
+ <td>21-Sep-2017 23:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170921100141-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170921100141-20170922100051.partial.mar</a></td>
+ <td>4M</td>
+ <td>22-Sep-2017 11:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170921100141-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170921100141-20170922220129.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Sep-2017 23:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170921100141-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170921100141-20170923100045.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Sep-2017 11:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170921220243-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170921220243-20170922100051.partial.mar</a></td>
+ <td>4M</td>
+ <td>22-Sep-2017 11:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170921220243-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170921220243-20170922220129.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Sep-2017 23:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170921220243-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170921220243-20170923100045.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Sep-2017 11:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170921220243-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170921220243-20170923220337.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Sep-2017 23:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170922100051-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170922100051-20170922220129.partial.mar</a></td>
+ <td>4M</td>
+ <td>22-Sep-2017 23:34</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170922100051-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170922100051-20170923100045.partial.mar</a></td>
+ <td>4M</td>
+ <td>23-Sep-2017 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170922100051-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170922100051-20170923220337.partial.mar</a></td>
+ <td>4M</td>
+ <td>23-Sep-2017 23:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170922100051-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170922100051-20170924100550.partial.mar</a></td>
+ <td>4M</td>
+ <td>24-Sep-2017 12:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170922220129-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170922220129-20170923100045.partial.mar</a></td>
+ <td>3M</td>
+ <td>23-Sep-2017 11:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170922220129-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170922220129-20170923220337.partial.mar</a></td>
+ <td>3M</td>
+ <td>23-Sep-2017 23:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170922220129-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170922220129-20170924100550.partial.mar</a></td>
+ <td>3M</td>
+ <td>24-Sep-2017 12:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170922220129-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170922220129-20170924220116.partial.mar</a></td>
+ <td>3M</td>
+ <td>24-Sep-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170923100045-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170923100045-20170923220337.partial.mar</a></td>
+ <td>1M</td>
+ <td>23-Sep-2017 23:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170923100045-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170923100045-20170924100550.partial.mar</a></td>
+ <td>2M</td>
+ <td>24-Sep-2017 12:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170923100045-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170923100045-20170924220116.partial.mar</a></td>
+ <td>3M</td>
+ <td>24-Sep-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170923100045-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170923100045-20170925100307.partial.mar</a></td>
+ <td>4M</td>
+ <td>25-Sep-2017 11:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170923220337-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170923220337-20170924100550.partial.mar</a></td>
+ <td>2M</td>
+ <td>24-Sep-2017 12:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170923220337-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170923220337-20170924220116.partial.mar</a></td>
+ <td>3M</td>
+ <td>24-Sep-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170923220337-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170923220337-20170925100307.partial.mar</a></td>
+ <td>3M</td>
+ <td>25-Sep-2017 11:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170923220337-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170923220337-20170925220207.partial.mar</a></td>
+ <td>4M</td>
+ <td>25-Sep-2017 23:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170924100550-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170924100550-20170924220116.partial.mar</a></td>
+ <td>3M</td>
+ <td>24-Sep-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170924100550-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170924100550-20170925100307.partial.mar</a></td>
+ <td>3M</td>
+ <td>25-Sep-2017 11:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170924100550-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170924100550-20170925220207.partial.mar</a></td>
+ <td>3M</td>
+ <td>25-Sep-2017 23:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170924100550-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170924100550-20170926100259.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Sep-2017 11:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170924220116-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170924220116-20170925100307.partial.mar</a></td>
+ <td>3M</td>
+ <td>25-Sep-2017 11:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170924220116-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170924220116-20170925220207.partial.mar</a></td>
+ <td>3M</td>
+ <td>25-Sep-2017 23:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170924220116-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170924220116-20170926100259.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Sep-2017 11:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170924220116-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170924220116-20170926220106.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Sep-2017 23:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170925100307-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170925100307-20170925220207.partial.mar</a></td>
+ <td>1M</td>
+ <td>25-Sep-2017 23:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170925100307-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170925100307-20170926100259.partial.mar</a></td>
+ <td>4M</td>
+ <td>26-Sep-2017 11:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170925100307-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170925100307-20170926220106.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Sep-2017 23:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170925100307-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170925100307-20170927100120.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170925220207-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170925220207-20170926100259.partial.mar</a></td>
+ <td>4M</td>
+ <td>26-Sep-2017 11:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170925220207-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170925220207-20170926220106.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Sep-2017 23:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170925220207-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170925220207-20170927100120.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170925220207-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170925220207-20170928100123.partial.mar</a></td>
+ <td>6M</td>
+ <td>28-Sep-2017 11:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170926100259-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170926100259-20170926220106.partial.mar</a></td>
+ <td>3M</td>
+ <td>26-Sep-2017 23:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170926100259-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170926100259-20170927100120.partial.mar</a></td>
+ <td>4M</td>
+ <td>27-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170926100259-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170926100259-20170928100123.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Sep-2017 11:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170926100259-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170926100259-20170928220658.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Sep-2017 23:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170926220106-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170926220106-20170927100120.partial.mar</a></td>
+ <td>4M</td>
+ <td>27-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170926220106-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170926220106-20170928100123.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Sep-2017 11:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170926220106-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170926220106-20170928220658.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Sep-2017 23:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170926220106-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170926220106-20170929100122.partial.mar</a></td>
+ <td>5M</td>
+ <td>29-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170927100120-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170927100120-20170928100123.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Sep-2017 11:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170927100120-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170927100120-20170928220658.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Sep-2017 23:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170927100120-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170927100120-20170929100122.partial.mar</a></td>
+ <td>5M</td>
+ <td>29-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170927100120-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170927100120-20170929220356.partial.mar</a></td>
+ <td>5M</td>
+ <td>29-Sep-2017 23:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170928100123-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170928100123-20170928220658.partial.mar</a></td>
+ <td>3M</td>
+ <td>28-Sep-2017 23:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170928100123-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170928100123-20170929100122.partial.mar</a></td>
+ <td>4M</td>
+ <td>29-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170928100123-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170928100123-20170929220356.partial.mar</a></td>
+ <td>5M</td>
+ <td>29-Sep-2017 23:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170928100123-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170928100123-20170930100302.partial.mar</a></td>
+ <td>5M</td>
+ <td>30-Sep-2017 11:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170928220658-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170928220658-20170929100122.partial.mar</a></td>
+ <td>4M</td>
+ <td>29-Sep-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170928220658-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170928220658-20170929220356.partial.mar</a></td>
+ <td>5M</td>
+ <td>29-Sep-2017 23:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170928220658-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170928220658-20170930100302.partial.mar</a></td>
+ <td>5M</td>
+ <td>30-Sep-2017 11:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170928220658-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170928220658-20170930220116.partial.mar</a></td>
+ <td>5M</td>
+ <td>30-Sep-2017 23:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170929100122-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170929100122-20170929220356.partial.mar</a></td>
+ <td>4M</td>
+ <td>29-Sep-2017 23:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170929100122-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170929100122-20170930100302.partial.mar</a></td>
+ <td>4M</td>
+ <td>30-Sep-2017 11:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170929100122-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170929100122-20170930220116.partial.mar</a></td>
+ <td>4M</td>
+ <td>30-Sep-2017 23:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170929100122-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170929100122-20171001100335.partial.mar</a></td>
+ <td>4M</td>
+ <td>01-Oct-2017 11:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170929220356-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170929220356-20170930100302.partial.mar</a></td>
+ <td>2M</td>
+ <td>30-Sep-2017 11:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170929220356-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170929220356-20170930220116.partial.mar</a></td>
+ <td>3M</td>
+ <td>30-Sep-2017 23:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170929220356-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170929220356-20171001100335.partial.mar</a></td>
+ <td>3M</td>
+ <td>01-Oct-2017 11:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170929220356-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170929220356-20171001220301.partial.mar</a></td>
+ <td>3M</td>
+ <td>01-Oct-2017 23:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170930100302-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170930100302-20170930220116.partial.mar</a></td>
+ <td>1006K</td>
+ <td>30-Sep-2017 23:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170930100302-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170930100302-20171001100335.partial.mar</a></td>
+ <td>3M</td>
+ <td>01-Oct-2017 11:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170930100302-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170930100302-20171001220301.partial.mar</a></td>
+ <td>3M</td>
+ <td>01-Oct-2017 23:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170930100302-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170930100302-20171002100134.partial.mar</a></td>
+ <td>4M</td>
+ <td>02-Oct-2017 11:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170930220116-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170930220116-20171001100335.partial.mar</a></td>
+ <td>3M</td>
+ <td>01-Oct-2017 11:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170930220116-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170930220116-20171001220301.partial.mar</a></td>
+ <td>3M</td>
+ <td>01-Oct-2017 23:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170930220116-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170930220116-20171002100134.partial.mar</a></td>
+ <td>4M</td>
+ <td>02-Oct-2017 11:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20170930220116-20171002223859.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20170930220116-20171002223859.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Oct-2017 00:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171001100335-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171001100335-20171001220301.partial.mar</a></td>
+ <td>2M</td>
+ <td>01-Oct-2017 23:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171001100335-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171001100335-20171002100134.partial.mar</a></td>
+ <td>3M</td>
+ <td>02-Oct-2017 11:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171001100335-20171002223859.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171001100335-20171002223859.partial.mar</a></td>
+ <td>3M</td>
+ <td>03-Oct-2017 00:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171001100335-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171001100335-20171003100226.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Oct-2017 11:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171001220301-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171001220301-20171002100134.partial.mar</a></td>
+ <td>3M</td>
+ <td>02-Oct-2017 11:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171001220301-20171002223859.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171001220301-20171002223859.partial.mar</a></td>
+ <td>3M</td>
+ <td>03-Oct-2017 00:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171001220301-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171001220301-20171003100226.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Oct-2017 11:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171001220301-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171001220301-20171003220138.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Oct-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171002100134-20171002223859.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171002100134-20171002223859.partial.mar</a></td>
+ <td>1M</td>
+ <td>03-Oct-2017 00:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171002100134-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171002100134-20171003100226.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Oct-2017 11:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171002100134-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171002100134-20171003220138.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Oct-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171002100134-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171002100134-20171004100049.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Oct-2017 11:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171002223859-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171002223859-20171003100226.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Oct-2017 11:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171002223859-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171002223859-20171003220138.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Oct-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171002223859-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171002223859-20171004100049.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Oct-2017 11:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171002223859-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171002223859-20171004220309.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Oct-2017 23:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171003100226-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171003100226-20171003220138.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Oct-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171003100226-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171003100226-20171004100049.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Oct-2017 11:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171003100226-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171003100226-20171004220309.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 23:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171003100226-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171003100226-20171005100211.partial.mar</a></td>
+ <td>7M</td>
+ <td>05-Oct-2017 11:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171003220138-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171003220138-20171004100049.partial.mar</a></td>
+ <td>5M</td>
+ <td>04-Oct-2017 11:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171003220138-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171003220138-20171004220309.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 23:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171003220138-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171003220138-20171005100211.partial.mar</a></td>
+ <td>7M</td>
+ <td>05-Oct-2017 11:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171003220138-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171003220138-20171005220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>05-Oct-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171004100049-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171004100049-20171004220309.partial.mar</a></td>
+ <td>5M</td>
+ <td>04-Oct-2017 23:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171004100049-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171004100049-20171005100211.partial.mar</a></td>
+ <td>5M</td>
+ <td>05-Oct-2017 11:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171004100049-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171004100049-20171005220204.partial.mar</a></td>
+ <td>5M</td>
+ <td>05-Oct-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171004100049-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171004100049-20171006100327.partial.mar</a></td>
+ <td>5M</td>
+ <td>06-Oct-2017 11:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171004220309-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171004220309-20171005100211.partial.mar</a></td>
+ <td>4M</td>
+ <td>05-Oct-2017 11:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171004220309-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171004220309-20171005220204.partial.mar</a></td>
+ <td>4M</td>
+ <td>05-Oct-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171004220309-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171004220309-20171006100327.partial.mar</a></td>
+ <td>4M</td>
+ <td>06-Oct-2017 11:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171004220309-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171004220309-20171006220306.partial.mar</a></td>
+ <td>5M</td>
+ <td>06-Oct-2017 23:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171005100211-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171005100211-20171005220204.partial.mar</a></td>
+ <td>103K</td>
+ <td>05-Oct-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171005100211-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171005100211-20171006100327.partial.mar</a></td>
+ <td>109K</td>
+ <td>06-Oct-2017 11:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171005100211-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171005100211-20171006220306.partial.mar</a></td>
+ <td>4M</td>
+ <td>06-Oct-2017 23:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171005100211-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171005100211-20171007100142.partial.mar</a></td>
+ <td>5M</td>
+ <td>07-Oct-2017 11:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171005220204-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171005220204-20171006100327.partial.mar</a></td>
+ <td>108K</td>
+ <td>06-Oct-2017 11:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171005220204-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171005220204-20171006220306.partial.mar</a></td>
+ <td>4M</td>
+ <td>06-Oct-2017 23:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171005220204-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171005220204-20171007100142.partial.mar</a></td>
+ <td>5M</td>
+ <td>07-Oct-2017 11:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171005220204-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171005220204-20171007220156.partial.mar</a></td>
+ <td>5M</td>
+ <td>07-Oct-2017 23:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171006100327-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171006100327-20171006220306.partial.mar</a></td>
+ <td>4M</td>
+ <td>06-Oct-2017 23:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171006100327-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171006100327-20171007100142.partial.mar</a></td>
+ <td>5M</td>
+ <td>07-Oct-2017 11:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171006100327-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171006100327-20171007220156.partial.mar</a></td>
+ <td>5M</td>
+ <td>07-Oct-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171006100327-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171006100327-20171008131700.partial.mar</a></td>
+ <td>5M</td>
+ <td>08-Oct-2017 14:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171006220306-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171006220306-20171007100142.partial.mar</a></td>
+ <td>4M</td>
+ <td>07-Oct-2017 11:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171006220306-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171006220306-20171007220156.partial.mar</a></td>
+ <td>4M</td>
+ <td>07-Oct-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171006220306-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171006220306-20171008131700.partial.mar</a></td>
+ <td>4M</td>
+ <td>08-Oct-2017 14:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171006220306-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171006220306-20171008220130.partial.mar</a></td>
+ <td>4M</td>
+ <td>08-Oct-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171007100142-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171007100142-20171007220156.partial.mar</a></td>
+ <td>1M</td>
+ <td>07-Oct-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171007100142-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171007100142-20171008131700.partial.mar</a></td>
+ <td>3M</td>
+ <td>08-Oct-2017 14:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171007100142-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171007100142-20171008220130.partial.mar</a></td>
+ <td>3M</td>
+ <td>08-Oct-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171007100142-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171007100142-20171009100134.partial.mar</a></td>
+ <td>4M</td>
+ <td>09-Oct-2017 11:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171007220156-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171007220156-20171008131700.partial.mar</a></td>
+ <td>3M</td>
+ <td>08-Oct-2017 14:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171007220156-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171007220156-20171008220130.partial.mar</a></td>
+ <td>3M</td>
+ <td>08-Oct-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171007220156-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171007220156-20171009100134.partial.mar</a></td>
+ <td>4M</td>
+ <td>09-Oct-2017 11:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171007220156-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171007220156-20171009220104.partial.mar</a></td>
+ <td>4M</td>
+ <td>09-Oct-2017 23:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171008131700-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171008131700-20171008220130.partial.mar</a></td>
+ <td>1M</td>
+ <td>08-Oct-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171008131700-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171008131700-20171009100134.partial.mar</a></td>
+ <td>3M</td>
+ <td>09-Oct-2017 11:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171008131700-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171008131700-20171009220104.partial.mar</a></td>
+ <td>4M</td>
+ <td>09-Oct-2017 23:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171008131700-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171008131700-20171010100200.partial.mar</a></td>
+ <td>5M</td>
+ <td>10-Oct-2017 11:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171008220130-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171008220130-20171009100134.partial.mar</a></td>
+ <td>3M</td>
+ <td>09-Oct-2017 11:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171008220130-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171008220130-20171009220104.partial.mar</a></td>
+ <td>4M</td>
+ <td>09-Oct-2017 23:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171008220130-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171008220130-20171010100200.partial.mar</a></td>
+ <td>5M</td>
+ <td>10-Oct-2017 11:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171008220130-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171008220130-20171010220102.partial.mar</a></td>
+ <td>6M</td>
+ <td>10-Oct-2017 23:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171009100134-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171009100134-20171009220104.partial.mar</a></td>
+ <td>4M</td>
+ <td>09-Oct-2017 23:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171009100134-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171009100134-20171010100200.partial.mar</a></td>
+ <td>5M</td>
+ <td>10-Oct-2017 11:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171009100134-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171009100134-20171010220102.partial.mar</a></td>
+ <td>5M</td>
+ <td>10-Oct-2017 23:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171009100134-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171009100134-20171011100133.partial.mar</a></td>
+ <td>6M</td>
+ <td>11-Oct-2017 18:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171009220104-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171009220104-20171010100200.partial.mar</a></td>
+ <td>4M</td>
+ <td>10-Oct-2017 11:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171009220104-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171009220104-20171010220102.partial.mar</a></td>
+ <td>5M</td>
+ <td>10-Oct-2017 23:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171009220104-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171009220104-20171011100133.partial.mar</a></td>
+ <td>5M</td>
+ <td>11-Oct-2017 18:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171009220104-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171009220104-20171011220113.partial.mar</a></td>
+ <td>6M</td>
+ <td>11-Oct-2017 23:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171010100200-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171010100200-20171010220102.partial.mar</a></td>
+ <td>4M</td>
+ <td>10-Oct-2017 23:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171010100200-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171010100200-20171011100133.partial.mar</a></td>
+ <td>5M</td>
+ <td>11-Oct-2017 18:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171010100200-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171010100200-20171011220113.partial.mar</a></td>
+ <td>5M</td>
+ <td>11-Oct-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171010100200-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171010100200-20171012100228.partial.mar</a></td>
+ <td>6M</td>
+ <td>12-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171010100200-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171010100200-20171012105833.partial.mar</a></td>
+ <td>6M</td>
+ <td>12-Oct-2017 13:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171010220102-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171010220102-20171011100133.partial.mar</a></td>
+ <td>4M</td>
+ <td>11-Oct-2017 18:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171010220102-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171010220102-20171011220113.partial.mar</a></td>
+ <td>5M</td>
+ <td>11-Oct-2017 23:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171010220102-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171010220102-20171012100228.partial.mar</a></td>
+ <td>5M</td>
+ <td>12-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171010220102-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171010220102-20171012105833.partial.mar</a></td>
+ <td>5M</td>
+ <td>12-Oct-2017 13:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171011100133-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171011100133-20171011220113.partial.mar</a></td>
+ <td>4M</td>
+ <td>11-Oct-2017 23:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171011100133-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171011100133-20171012100228.partial.mar</a></td>
+ <td>4M</td>
+ <td>12-Oct-2017 11:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171011100133-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171011100133-20171012105833.partial.mar</a></td>
+ <td>4M</td>
+ <td>12-Oct-2017 13:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171011100133-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171011100133-20171012220111.partial.mar</a></td>
+ <td>5M</td>
+ <td>12-Oct-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171011220113-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171011220113-20171012100228.partial.mar</a></td>
+ <td>4M</td>
+ <td>12-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171011220113-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171011220113-20171012105833.partial.mar</a></td>
+ <td>3M</td>
+ <td>12-Oct-2017 13:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171011220113-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171011220113-20171012220111.partial.mar</a></td>
+ <td>4M</td>
+ <td>12-Oct-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171011220113-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171011220113-20171013100112.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 11:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171012100228-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171012100228-20171012220111.partial.mar</a></td>
+ <td>3M</td>
+ <td>12-Oct-2017 23:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171012100228-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171012100228-20171013100112.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 11:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171012100228-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171012100228-20171013220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 23:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171012105833-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171012105833-20171012220111.partial.mar</a></td>
+ <td>4M</td>
+ <td>12-Oct-2017 23:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171012105833-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171012105833-20171013100112.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 11:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171012105833-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171012105833-20171013220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 23:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171012105833-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171012105833-20171014100219.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 11:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171012220111-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171012220111-20171013100112.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 11:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171012220111-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171012220111-20171013220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 23:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171012220111-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171012220111-20171014100219.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 11:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171012220111-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171012220111-20171014220542.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 23:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171013100112-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171013100112-20171013220204.partial.mar</a></td>
+ <td>4M</td>
+ <td>13-Oct-2017 23:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171013100112-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171013100112-20171014100219.partial.mar</a></td>
+ <td>5M</td>
+ <td>14-Oct-2017 11:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171013100112-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171013100112-20171014220542.partial.mar</a></td>
+ <td>6M</td>
+ <td>14-Oct-2017 23:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171013100112-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171013100112-20171015100127.partial.mar</a></td>
+ <td>6M</td>
+ <td>15-Oct-2017 12:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171013220204-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171013220204-20171014100219.partial.mar</a></td>
+ <td>5M</td>
+ <td>14-Oct-2017 11:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171013220204-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171013220204-20171014220542.partial.mar</a></td>
+ <td>5M</td>
+ <td>14-Oct-2017 23:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171013220204-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171013220204-20171015100127.partial.mar</a></td>
+ <td>6M</td>
+ <td>15-Oct-2017 12:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171013220204-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171013220204-20171015220106.partial.mar</a></td>
+ <td>5M</td>
+ <td>15-Oct-2017 23:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171014100219-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171014100219-20171014220542.partial.mar</a></td>
+ <td>3M</td>
+ <td>14-Oct-2017 23:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171014100219-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171014100219-20171015100127.partial.mar</a></td>
+ <td>3M</td>
+ <td>15-Oct-2017 12:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171014100219-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171014100219-20171015220106.partial.mar</a></td>
+ <td>3M</td>
+ <td>15-Oct-2017 23:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171014100219-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171014100219-20171016100113.partial.mar</a></td>
+ <td>4M</td>
+ <td>16-Oct-2017 11:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171014220542-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171014220542-20171015100127.partial.mar</a></td>
+ <td>3M</td>
+ <td>15-Oct-2017 12:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171014220542-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171014220542-20171015220106.partial.mar</a></td>
+ <td>3M</td>
+ <td>15-Oct-2017 23:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171014220542-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171014220542-20171016100113.partial.mar</a></td>
+ <td>4M</td>
+ <td>16-Oct-2017 11:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171014220542-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171014220542-20171016220427.partial.mar</a></td>
+ <td>4M</td>
+ <td>16-Oct-2017 23:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171015100127-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171015100127-20171015220106.partial.mar</a></td>
+ <td>2M</td>
+ <td>15-Oct-2017 23:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171015100127-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171015100127-20171016100113.partial.mar</a></td>
+ <td>3M</td>
+ <td>16-Oct-2017 11:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171015100127-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171015100127-20171016220427.partial.mar</a></td>
+ <td>3M</td>
+ <td>16-Oct-2017 23:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171015100127-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171015100127-20171017100127.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 11:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171015220106-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171015220106-20171016100113.partial.mar</a></td>
+ <td>3M</td>
+ <td>16-Oct-2017 11:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171015220106-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171015220106-20171016220427.partial.mar</a></td>
+ <td>3M</td>
+ <td>16-Oct-2017 23:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171015220106-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171015220106-20171017100127.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 11:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171015220106-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171015220106-20171017141229.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 15:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171016100113-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171016100113-20171016220427.partial.mar</a></td>
+ <td>103K</td>
+ <td>16-Oct-2017 23:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171016100113-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171016100113-20171017100127.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 11:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171016100113-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171016100113-20171017141229.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 15:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171016100113-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171016100113-20171017220415.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 23:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171016220427-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171016220427-20171017100127.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 11:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171016220427-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171016220427-20171017141229.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 15:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171016220427-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171016220427-20171017220415.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 23:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171016220427-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171016220427-20171018100140.partial.mar</a></td>
+ <td>5M</td>
+ <td>18-Oct-2017 11:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017100127-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017100127-20171017141229.partial.mar</a></td>
+ <td>115K</td>
+ <td>17-Oct-2017 15:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017100127-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017100127-20171017220415.partial.mar</a></td>
+ <td>4M</td>
+ <td>17-Oct-2017 23:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017100127-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017100127-20171018100140.partial.mar</a></td>
+ <td>4M</td>
+ <td>18-Oct-2017 11:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017100127-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017100127-20171018220049.partial.mar</a></td>
+ <td>4M</td>
+ <td>18-Oct-2017 23:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017141229-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017141229-20171017220415.partial.mar</a></td>
+ <td>4M</td>
+ <td>17-Oct-2017 23:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017141229-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017141229-20171018100140.partial.mar</a></td>
+ <td>4M</td>
+ <td>18-Oct-2017 11:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017141229-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017141229-20171018220049.partial.mar</a></td>
+ <td>4M</td>
+ <td>18-Oct-2017 23:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017141229-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017141229-20171019100107.partial.mar</a></td>
+ <td>5M</td>
+ <td>19-Oct-2017 11:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017220415-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017220415-20171018100140.partial.mar</a></td>
+ <td>3M</td>
+ <td>18-Oct-2017 11:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017220415-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017220415-20171018220049.partial.mar</a></td>
+ <td>3M</td>
+ <td>18-Oct-2017 23:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017220415-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017220415-20171019100107.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Oct-2017 11:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171017220415-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171017220415-20171019222141.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Oct-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171018100140-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171018100140-20171018220049.partial.mar</a></td>
+ <td>998K</td>
+ <td>18-Oct-2017 23:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171018100140-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171018100140-20171019100107.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Oct-2017 11:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171018100140-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171018100140-20171019222141.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Oct-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171018100140-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171018100140-20171020100426.partial.mar</a></td>
+ <td>5M</td>
+ <td>20-Oct-2017 11:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171018220049-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171018220049-20171019100107.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Oct-2017 11:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171018220049-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171018220049-20171019222141.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Oct-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171018220049-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171018220049-20171020100426.partial.mar</a></td>
+ <td>5M</td>
+ <td>20-Oct-2017 11:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171018220049-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171018220049-20171020221129.partial.mar</a></td>
+ <td>5M</td>
+ <td>20-Oct-2017 23:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171019100107-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171019100107-20171019222141.partial.mar</a></td>
+ <td>3M</td>
+ <td>20-Oct-2017 00:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171019100107-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171019100107-20171020100426.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Oct-2017 11:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171019100107-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171019100107-20171020221129.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Oct-2017 23:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171019100107-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171019100107-20171021100029.partial.mar</a></td>
+ <td>4M</td>
+ <td>21-Oct-2017 11:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171019222141-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171019222141-20171020100426.partial.mar</a></td>
+ <td>3M</td>
+ <td>20-Oct-2017 11:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171019222141-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171019222141-20171020221129.partial.mar</a></td>
+ <td>4M</td>
+ <td>20-Oct-2017 23:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171019222141-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171019222141-20171021100029.partial.mar</a></td>
+ <td>4M</td>
+ <td>21-Oct-2017 11:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171019222141-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171019222141-20171021220121.partial.mar</a></td>
+ <td>4M</td>
+ <td>22-Oct-2017 00:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171020100426-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171020100426-20171020221129.partial.mar</a></td>
+ <td>3M</td>
+ <td>20-Oct-2017 23:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171020100426-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171020100426-20171021100029.partial.mar</a></td>
+ <td>3M</td>
+ <td>21-Oct-2017 11:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171020100426-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171020100426-20171021220121.partial.mar</a></td>
+ <td>3M</td>
+ <td>22-Oct-2017 00:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171020100426-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171020100426-20171022100058.partial.mar</a></td>
+ <td>4M</td>
+ <td>22-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171020221129-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171020221129-20171021100029.partial.mar</a></td>
+ <td>3M</td>
+ <td>21-Oct-2017 11:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171020221129-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171020221129-20171021220121.partial.mar</a></td>
+ <td>3M</td>
+ <td>22-Oct-2017 00:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171020221129-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171020221129-20171022100058.partial.mar</a></td>
+ <td>3M</td>
+ <td>22-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171020221129-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171020221129-20171022220103.partial.mar</a></td>
+ <td>3M</td>
+ <td>22-Oct-2017 23:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171021100029-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171021100029-20171021220121.partial.mar</a></td>
+ <td>1M</td>
+ <td>22-Oct-2017 00:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171021100029-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171021100029-20171022100058.partial.mar</a></td>
+ <td>3M</td>
+ <td>22-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171021100029-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171021100029-20171022220103.partial.mar</a></td>
+ <td>3M</td>
+ <td>22-Oct-2017 23:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171021100029-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171021100029-20171023100252.partial.mar</a></td>
+ <td>4M</td>
+ <td>23-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171021220121-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171021220121-20171022100058.partial.mar</a></td>
+ <td>3M</td>
+ <td>22-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171021220121-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171021220121-20171022220103.partial.mar</a></td>
+ <td>3M</td>
+ <td>22-Oct-2017 23:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171021220121-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171021220121-20171023100252.partial.mar</a></td>
+ <td>4M</td>
+ <td>23-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171021220121-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171021220121-20171023220222.partial.mar</a></td>
+ <td>4M</td>
+ <td>23-Oct-2017 23:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171022100058-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171022100058-20171022220103.partial.mar</a></td>
+ <td>1M</td>
+ <td>22-Oct-2017 23:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171022100058-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171022100058-20171023100252.partial.mar</a></td>
+ <td>3M</td>
+ <td>23-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171022100058-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171022100058-20171023220222.partial.mar</a></td>
+ <td>4M</td>
+ <td>23-Oct-2017 23:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171022100058-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171022100058-20171024100135.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Oct-2017 11:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171022220103-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171022220103-20171023100252.partial.mar</a></td>
+ <td>3M</td>
+ <td>23-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171022220103-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171022220103-20171023220222.partial.mar</a></td>
+ <td>4M</td>
+ <td>23-Oct-2017 23:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171022220103-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171022220103-20171024100135.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Oct-2017 11:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171022220103-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171022220103-20171024220325.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Oct-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171023100252-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171023100252-20171023220222.partial.mar</a></td>
+ <td>3M</td>
+ <td>23-Oct-2017 23:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171023100252-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171023100252-20171024100135.partial.mar</a></td>
+ <td>4M</td>
+ <td>24-Oct-2017 11:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171023100252-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171023100252-20171024220325.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Oct-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171023100252-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171023100252-20171025100449.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171023220222-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171023220222-20171024100135.partial.mar</a></td>
+ <td>4M</td>
+ <td>24-Oct-2017 11:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171023220222-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171023220222-20171024220325.partial.mar</a></td>
+ <td>4M</td>
+ <td>24-Oct-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171023220222-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171023220222-20171025100449.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171023220222-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171023220222-20171025230440.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Oct-2017 01:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171024100135-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171024100135-20171024220325.partial.mar</a></td>
+ <td>4M</td>
+ <td>24-Oct-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171024100135-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171024100135-20171025100449.partial.mar</a></td>
+ <td>4M</td>
+ <td>25-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171024100135-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171024100135-20171025230440.partial.mar</a></td>
+ <td>4M</td>
+ <td>26-Oct-2017 01:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171024100135-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171024100135-20171026100047.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171024220325-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171024220325-20171025100449.partial.mar</a></td>
+ <td>4M</td>
+ <td>25-Oct-2017 11:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171024220325-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171024220325-20171025230440.partial.mar</a></td>
+ <td>4M</td>
+ <td>26-Oct-2017 01:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171024220325-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171024220325-20171026100047.partial.mar</a></td>
+ <td>4M</td>
+ <td>26-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171024220325-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171024220325-20171026221945.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Oct-2017 00:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171025100449-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171025100449-20171025230440.partial.mar</a></td>
+ <td>3M</td>
+ <td>26-Oct-2017 01:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171025100449-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171025100449-20171026100047.partial.mar</a></td>
+ <td>4M</td>
+ <td>26-Oct-2017 12:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171025100449-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171025100449-20171026221945.partial.mar</a></td>
+ <td>4M</td>
+ <td>27-Oct-2017 00:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171025100449-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171025100449-20171027100103.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171025230440-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171025230440-20171026100047.partial.mar</a></td>
+ <td>4M</td>
+ <td>26-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171025230440-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171025230440-20171026221945.partial.mar</a></td>
+ <td>4M</td>
+ <td>27-Oct-2017 00:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171025230440-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171025230440-20171027100103.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171025230440-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171025230440-20171027220059.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Oct-2017 00:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171026100047-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171026100047-20171026221945.partial.mar</a></td>
+ <td>3M</td>
+ <td>27-Oct-2017 00:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171026100047-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171026100047-20171027100103.partial.mar</a></td>
+ <td>4M</td>
+ <td>27-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171026100047-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171026100047-20171027220059.partial.mar</a></td>
+ <td>4M</td>
+ <td>28-Oct-2017 00:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171026100047-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171026100047-20171028100423.partial.mar</a></td>
+ <td>5M</td>
+ <td>28-Oct-2017 13:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171026221945-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171026221945-20171027100103.partial.mar</a></td>
+ <td>4M</td>
+ <td>27-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171026221945-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171026221945-20171027220059.partial.mar</a></td>
+ <td>4M</td>
+ <td>28-Oct-2017 00:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171026221945-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171026221945-20171028100423.partial.mar</a></td>
+ <td>4M</td>
+ <td>28-Oct-2017 13:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171026221945-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171026221945-20171028220326.partial.mar</a></td>
+ <td>4M</td>
+ <td>28-Oct-2017 23:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171027100103-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171027100103-20171027220059.partial.mar</a></td>
+ <td>4M</td>
+ <td>28-Oct-2017 00:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171027100103-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171027100103-20171028100423.partial.mar</a></td>
+ <td>4M</td>
+ <td>28-Oct-2017 13:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171027100103-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171027100103-20171028220326.partial.mar</a></td>
+ <td>4M</td>
+ <td>28-Oct-2017 23:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171027100103-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171027100103-20171029102300.partial.mar</a></td>
+ <td>4M</td>
+ <td>29-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171027220059-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171027220059-20171028100423.partial.mar</a></td>
+ <td>3M</td>
+ <td>28-Oct-2017 13:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171027220059-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171027220059-20171028220326.partial.mar</a></td>
+ <td>4M</td>
+ <td>28-Oct-2017 23:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171027220059-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171027220059-20171029102300.partial.mar</a></td>
+ <td>4M</td>
+ <td>29-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171027220059-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171027220059-20171029220112.partial.mar</a></td>
+ <td>4M</td>
+ <td>30-Oct-2017 02:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171028100423-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171028100423-20171028220326.partial.mar</a></td>
+ <td>3M</td>
+ <td>28-Oct-2017 23:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171028100423-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171028100423-20171029102300.partial.mar</a></td>
+ <td>3M</td>
+ <td>29-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171028100423-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171028100423-20171029220112.partial.mar</a></td>
+ <td>3M</td>
+ <td>30-Oct-2017 02:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171028100423-20171030100132.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171028100423-20171030100132.partial.mar</a></td>
+ <td>3M</td>
+ <td>30-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171028100423-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171028100423-20171030103605.partial.mar</a></td>
+ <td>4M</td>
+ <td>30-Oct-2017 18:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171028220326-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171028220326-20171029102300.partial.mar</a></td>
+ <td>1M</td>
+ <td>29-Oct-2017 12:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171028220326-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171028220326-20171029220112.partial.mar</a></td>
+ <td>2M</td>
+ <td>30-Oct-2017 02:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171028220326-20171030100132.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171028220326-20171030100132.partial.mar</a></td>
+ <td>2M</td>
+ <td>30-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171028220326-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171028220326-20171030103605.partial.mar</a></td>
+ <td>3M</td>
+ <td>30-Oct-2017 18:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171029102300-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171029102300-20171029220112.partial.mar</a></td>
+ <td>2M</td>
+ <td>30-Oct-2017 02:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171029102300-20171030100132.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171029102300-20171030100132.partial.mar</a></td>
+ <td>2M</td>
+ <td>30-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171029102300-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171029102300-20171030103605.partial.mar</a></td>
+ <td>3M</td>
+ <td>30-Oct-2017 18:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171029102300-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171029102300-20171031220132.partial.mar</a></td>
+ <td>4M</td>
+ <td>01-Nov-2017 01:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171029102300-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171029102300-20171031235118.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 10:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171029220112-20171030100132.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171029220112-20171030100132.partial.mar</a></td>
+ <td>100K</td>
+ <td>30-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171029220112-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171029220112-20171030103605.partial.mar</a></td>
+ <td>3M</td>
+ <td>30-Oct-2017 18:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171029220112-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171029220112-20171031220132.partial.mar</a></td>
+ <td>4M</td>
+ <td>01-Nov-2017 01:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171029220112-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171029220112-20171031235118.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 10:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171030100132-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171030100132-20171031220132.partial.mar</a></td>
+ <td>4M</td>
+ <td>01-Nov-2017 01:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171030100132-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171030100132-20171031235118.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 10:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171030100132-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171030100132-20171101104430.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 16:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171030103605-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171030103605-20171031220132.partial.mar</a></td>
+ <td>4M</td>
+ <td>01-Nov-2017 01:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171030103605-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171030103605-20171031235118.partial.mar</a></td>
+ <td>4M</td>
+ <td>01-Nov-2017 10:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171030103605-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171030103605-20171101104430.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 16:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171030103605-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171030103605-20171101220120.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Nov-2017 23:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171031220132-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171031220132-20171101104430.partial.mar</a></td>
+ <td>4M</td>
+ <td>01-Nov-2017 16:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171031220132-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171031220132-20171101220120.partial.mar</a></td>
+ <td>4M</td>
+ <td>01-Nov-2017 23:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171031220132-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171031220132-20171102100041.partial.mar</a></td>
+ <td>5M</td>
+ <td>02-Nov-2017 11:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171031235118-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171031235118-20171101104430.partial.mar</a></td>
+ <td>3M</td>
+ <td>01-Nov-2017 16:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171031235118-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171031235118-20171101220120.partial.mar</a></td>
+ <td>4M</td>
+ <td>01-Nov-2017 23:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171031235118-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171031235118-20171102100041.partial.mar</a></td>
+ <td>4M</td>
+ <td>02-Nov-2017 11:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171031235118-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171031235118-20171102222620.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Nov-2017 00:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171101104430-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171101104430-20171101220120.partial.mar</a></td>
+ <td>4M</td>
+ <td>01-Nov-2017 23:22</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171101104430-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171101104430-20171102100041.partial.mar</a></td>
+ <td>4M</td>
+ <td>02-Nov-2017 11:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171101104430-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171101104430-20171102222620.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Nov-2017 00:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171101104430-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171101104430-20171103100331.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Nov-2017 11:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171101220120-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171101220120-20171102100041.partial.mar</a></td>
+ <td>3M</td>
+ <td>02-Nov-2017 11:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171101220120-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171101220120-20171102222620.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Nov-2017 00:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171101220120-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171101220120-20171103100331.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Nov-2017 11:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171101220120-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171101220120-20171103220715.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Nov-2017 23:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171102100041-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171102100041-20171102222620.partial.mar</a></td>
+ <td>3M</td>
+ <td>03-Nov-2017 00:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171102100041-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171102100041-20171103100331.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Nov-2017 11:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171102100041-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171102100041-20171103220715.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Nov-2017 23:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171102100041-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171102100041-20171104100412.partial.mar</a></td>
+ <td>5M</td>
+ <td>04-Nov-2017 11:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171102222620-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171102222620-20171103100331.partial.mar</a></td>
+ <td>3M</td>
+ <td>03-Nov-2017 11:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171102222620-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171102222620-20171103220715.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Nov-2017 23:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171102222620-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171102222620-20171104100412.partial.mar</a></td>
+ <td>5M</td>
+ <td>04-Nov-2017 11:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171102222620-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171102222620-20171104220420.partial.mar</a></td>
+ <td>5M</td>
+ <td>04-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171103100331-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171103100331-20171103220715.partial.mar</a></td>
+ <td>3M</td>
+ <td>03-Nov-2017 23:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171103100331-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171103100331-20171104100412.partial.mar</a></td>
+ <td>4M</td>
+ <td>04-Nov-2017 11:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171103100331-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171103100331-20171104220420.partial.mar</a></td>
+ <td>5M</td>
+ <td>04-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171103100331-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171103100331-20171105100353.partial.mar</a></td>
+ <td>4M</td>
+ <td>05-Nov-2017 11:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171103220715-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171103220715-20171104100412.partial.mar</a></td>
+ <td>4M</td>
+ <td>04-Nov-2017 11:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171103220715-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171103220715-20171104220420.partial.mar</a></td>
+ <td>4M</td>
+ <td>04-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171103220715-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171103220715-20171105100353.partial.mar</a></td>
+ <td>4M</td>
+ <td>05-Nov-2017 11:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171103220715-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171103220715-20171105220721.partial.mar</a></td>
+ <td>4M</td>
+ <td>05-Nov-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171104100412-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171104100412-20171104220420.partial.mar</a></td>
+ <td>3M</td>
+ <td>04-Nov-2017 23:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171104100412-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171104100412-20171105100353.partial.mar</a></td>
+ <td>3M</td>
+ <td>05-Nov-2017 11:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171104100412-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171104100412-20171105220721.partial.mar</a></td>
+ <td>3M</td>
+ <td>05-Nov-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171104100412-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171104100412-20171106100122.partial.mar</a></td>
+ <td>3M</td>
+ <td>06-Nov-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171104220420-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171104220420-20171105100353.partial.mar</a></td>
+ <td>104K</td>
+ <td>05-Nov-2017 11:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171104220420-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171104220420-20171105220721.partial.mar</a></td>
+ <td>1M</td>
+ <td>05-Nov-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171104220420-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171104220420-20171106100122.partial.mar</a></td>
+ <td>3M</td>
+ <td>06-Nov-2017 11:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171105100353-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171105100353-20171105220721.partial.mar</a></td>
+ <td>1M</td>
+ <td>05-Nov-2017 23:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171105100353-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171105100353-20171106100122.partial.mar</a></td>
+ <td>3M</td>
+ <td>06-Nov-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-mac-en-US-20171105220721-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-mac-en-US-20171105220721-20171106100122.partial.mar</a></td>
+ <td>3M</td>
+ <td>06-Nov-2017 11:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170919220202-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170919220202-20170921220243.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Sep-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170920100426-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170920100426-20170921220243.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Sep-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170920100426-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170920100426-20170922100051.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Sep-2017 13:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170920220431-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170920220431-20170921220243.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Sep-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170920220431-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170920220431-20170922100051.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Sep-2017 13:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170920220431-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170920220431-20170922220129.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Sep-2017 01:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170921100141-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170921100141-20170921220243.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Sep-2017 01:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170921100141-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170921100141-20170922100051.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Sep-2017 13:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170921100141-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170921100141-20170922220129.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Sep-2017 01:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170921100141-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170921100141-20170923100045.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Sep-2017 13:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170921220243-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170921220243-20170922100051.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Sep-2017 13:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170921220243-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170921220243-20170922220129.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Sep-2017 01:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170921220243-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170921220243-20170923100045.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Sep-2017 13:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170921220243-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170921220243-20170923220337.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Sep-2017 01:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170922100051-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170922100051-20170922220129.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Sep-2017 01:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170922100051-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170922100051-20170923100045.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Sep-2017 13:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170922100051-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170922100051-20170923220337.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Sep-2017 01:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170922100051-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170922100051-20170924100550.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Sep-2017 14:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170922220129-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170922220129-20170923100045.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Sep-2017 13:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170922220129-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170922220129-20170923220337.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Sep-2017 01:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170922220129-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170922220129-20170924100550.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Sep-2017 14:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170922220129-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170922220129-20170924220116.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Sep-2017 02:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170923100045-20170923220337.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170923100045-20170923220337.partial.mar</a></td>
+ <td>4M</td>
+ <td>24-Sep-2017 01:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170923100045-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170923100045-20170924100550.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Sep-2017 14:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170923100045-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170923100045-20170924220116.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Sep-2017 02:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170923100045-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170923100045-20170925100307.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Sep-2017 12:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170923220337-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170923220337-20170924100550.partial.mar</a></td>
+ <td>5M</td>
+ <td>24-Sep-2017 14:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170923220337-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170923220337-20170924220116.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Sep-2017 02:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170923220337-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170923220337-20170925100307.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Sep-2017 12:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170923220337-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170923220337-20170925220207.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Sep-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170924100550-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170924100550-20170924220116.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Sep-2017 02:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170924100550-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170924100550-20170925100307.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Sep-2017 12:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170924100550-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170924100550-20170925220207.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Sep-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170924100550-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170924100550-20170926100259.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Sep-2017 13:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170924220116-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170924220116-20170925100307.partial.mar</a></td>
+ <td>5M</td>
+ <td>25-Sep-2017 12:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170924220116-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170924220116-20170925220207.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Sep-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170924220116-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170924220116-20170926100259.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Sep-2017 13:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170924220116-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170924220116-20170926220106.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Sep-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170925100307-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170925100307-20170925220207.partial.mar</a></td>
+ <td>4M</td>
+ <td>26-Sep-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170925100307-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170925100307-20170926100259.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Sep-2017 13:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170925100307-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170925100307-20170926220106.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Sep-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170925100307-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170925100307-20170927100120.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Sep-2017 13:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170925220207-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170925220207-20170926100259.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Sep-2017 13:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170925220207-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170925220207-20170926220106.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Sep-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170925220207-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170925220207-20170927100120.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Sep-2017 13:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170925220207-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170925220207-20170928100123.partial.mar</a></td>
+ <td>6M</td>
+ <td>28-Sep-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170926100259-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170926100259-20170926220106.partial.mar</a></td>
+ <td>5M</td>
+ <td>27-Sep-2017 01:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170926100259-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170926100259-20170927100120.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Sep-2017 13:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170926100259-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170926100259-20170928100123.partial.mar</a></td>
+ <td>6M</td>
+ <td>28-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170926100259-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170926100259-20170928220658.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170926220106-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170926220106-20170927100120.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Sep-2017 13:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170926220106-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170926220106-20170928100123.partial.mar</a></td>
+ <td>6M</td>
+ <td>28-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170926220106-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170926220106-20170928220658.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170926220106-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170926220106-20170929100122.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Sep-2017 13:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170927100120-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170927100120-20170928100123.partial.mar</a></td>
+ <td>6M</td>
+ <td>28-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170927100120-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170927100120-20170928220658.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170927100120-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170927100120-20170929100122.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Sep-2017 13:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170927100120-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170927100120-20170929220356.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170928100123-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170928100123-20170928220658.partial.mar</a></td>
+ <td>5M</td>
+ <td>29-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170928100123-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170928100123-20170929100122.partial.mar</a></td>
+ <td>5M</td>
+ <td>29-Sep-2017 13:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170928100123-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170928100123-20170929220356.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170928100123-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170928100123-20170930100302.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Sep-2017 13:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170928220658-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170928220658-20170929100122.partial.mar</a></td>
+ <td>5M</td>
+ <td>29-Sep-2017 13:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170928220658-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170928220658-20170929220356.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170928220658-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170928220658-20170930100302.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Sep-2017 13:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170928220658-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170928220658-20170930220116.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Oct-2017 01:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170929100122-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170929100122-20170929220356.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170929100122-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170929100122-20170930100302.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Sep-2017 13:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170929100122-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170929100122-20170930220116.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Oct-2017 01:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170929100122-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170929100122-20171001100335.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170929220356-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170929220356-20170930100302.partial.mar</a></td>
+ <td>5M</td>
+ <td>30-Sep-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170929220356-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170929220356-20170930220116.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Oct-2017 01:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170929220356-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170929220356-20171001100335.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Oct-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170929220356-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170929220356-20171001220301.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170930100302-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170930100302-20170930220116.partial.mar</a></td>
+ <td>4M</td>
+ <td>01-Oct-2017 01:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170930100302-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170930100302-20171001100335.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Oct-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170930100302-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170930100302-20171001220301.partial.mar</a></td>
+ <td>5M</td>
+ <td>02-Oct-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170930100302-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170930100302-20171002100134.partial.mar</a></td>
+ <td>5M</td>
+ <td>02-Oct-2017 13:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170930220116-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170930220116-20171001100335.partial.mar</a></td>
+ <td>5M</td>
+ <td>01-Oct-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170930220116-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170930220116-20171001220301.partial.mar</a></td>
+ <td>5M</td>
+ <td>02-Oct-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170930220116-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170930220116-20171002100134.partial.mar</a></td>
+ <td>5M</td>
+ <td>02-Oct-2017 13:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20170930220116-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20170930220116-20171002220204.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171001100335-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171001100335-20171001220301.partial.mar</a></td>
+ <td>4M</td>
+ <td>02-Oct-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171001100335-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171001100335-20171002100134.partial.mar</a></td>
+ <td>5M</td>
+ <td>02-Oct-2017 13:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171001100335-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171001100335-20171002220204.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171001100335-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171001100335-20171003100226.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Oct-2017 12:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171001220301-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171001220301-20171002100134.partial.mar</a></td>
+ <td>5M</td>
+ <td>02-Oct-2017 13:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171001220301-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171001220301-20171002220204.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171001220301-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171001220301-20171003100226.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Oct-2017 12:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171001220301-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171001220301-20171003220138.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Oct-2017 00:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171002100134-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171002100134-20171002220204.partial.mar</a></td>
+ <td>4M</td>
+ <td>03-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171002100134-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171002100134-20171003100226.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Oct-2017 12:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171002100134-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171002100134-20171003220138.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Oct-2017 00:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171002100134-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171002100134-20171004100049.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 12:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171002220204-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171002220204-20171003100226.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Oct-2017 12:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171002220204-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171002220204-20171003220138.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Oct-2017 00:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171002220204-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171002220204-20171004100049.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 12:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171002220204-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171002220204-20171004220309.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Oct-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171003100226-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171003100226-20171003220138.partial.mar</a></td>
+ <td>5M</td>
+ <td>04-Oct-2017 00:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171003100226-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171003100226-20171004100049.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 12:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171003100226-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171003100226-20171004220309.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Oct-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171003100226-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171003100226-20171005100211.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Oct-2017 12:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171003220138-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171003220138-20171004100049.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 12:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171003220138-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171003220138-20171004220309.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Oct-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171003220138-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171003220138-20171005100211.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Oct-2017 12:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171003220138-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171003220138-20171005220204.partial.mar</a></td>
+ <td>8M</td>
+ <td>06-Oct-2017 01:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171004100049-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171004100049-20171004220309.partial.mar</a></td>
+ <td>6M</td>
+ <td>05-Oct-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171004100049-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171004100049-20171005100211.partial.mar</a></td>
+ <td>6M</td>
+ <td>05-Oct-2017 12:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171004100049-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171004100049-20171005220204.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Oct-2017 01:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171004100049-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171004100049-20171006100327.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Oct-2017 13:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171004220309-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171004220309-20171005100211.partial.mar</a></td>
+ <td>5M</td>
+ <td>05-Oct-2017 12:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171004220309-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171004220309-20171005220204.partial.mar</a></td>
+ <td>5M</td>
+ <td>06-Oct-2017 01:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171004220309-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171004220309-20171006100327.partial.mar</a></td>
+ <td>5M</td>
+ <td>06-Oct-2017 13:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171004220309-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171004220309-20171006220306.partial.mar</a></td>
+ <td>6M</td>
+ <td>07-Oct-2017 00:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171005100211-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171005100211-20171005220204.partial.mar</a></td>
+ <td>4M</td>
+ <td>06-Oct-2017 01:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171005100211-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171005100211-20171006100327.partial.mar</a></td>
+ <td>4M</td>
+ <td>06-Oct-2017 13:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171005100211-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171005100211-20171006220306.partial.mar</a></td>
+ <td>5M</td>
+ <td>07-Oct-2017 00:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171005100211-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171005100211-20171007100142.partial.mar</a></td>
+ <td>6M</td>
+ <td>07-Oct-2017 12:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171005220204-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171005220204-20171006100327.partial.mar</a></td>
+ <td>4M</td>
+ <td>06-Oct-2017 13:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171005220204-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171005220204-20171006220306.partial.mar</a></td>
+ <td>6M</td>
+ <td>07-Oct-2017 00:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171005220204-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171005220204-20171007100142.partial.mar</a></td>
+ <td>6M</td>
+ <td>07-Oct-2017 12:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171005220204-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171005220204-20171007220156.partial.mar</a></td>
+ <td>6M</td>
+ <td>08-Oct-2017 00:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171006100327-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171006100327-20171006220306.partial.mar</a></td>
+ <td>6M</td>
+ <td>07-Oct-2017 00:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171006100327-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171006100327-20171007100142.partial.mar</a></td>
+ <td>6M</td>
+ <td>07-Oct-2017 12:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171006100327-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171006100327-20171007220156.partial.mar</a></td>
+ <td>6M</td>
+ <td>08-Oct-2017 00:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171006100327-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171006100327-20171008131700.partial.mar</a></td>
+ <td>6M</td>
+ <td>08-Oct-2017 16:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171006220306-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171006220306-20171007100142.partial.mar</a></td>
+ <td>6M</td>
+ <td>07-Oct-2017 12:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171006220306-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171006220306-20171007220156.partial.mar</a></td>
+ <td>6M</td>
+ <td>08-Oct-2017 00:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171006220306-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171006220306-20171008131700.partial.mar</a></td>
+ <td>6M</td>
+ <td>08-Oct-2017 16:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171006220306-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171006220306-20171008220130.partial.mar</a></td>
+ <td>6M</td>
+ <td>09-Oct-2017 00:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171007100142-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171007100142-20171007220156.partial.mar</a></td>
+ <td>4M</td>
+ <td>08-Oct-2017 00:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171007100142-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171007100142-20171008131700.partial.mar</a></td>
+ <td>5M</td>
+ <td>08-Oct-2017 16:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171007100142-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171007100142-20171008220130.partial.mar</a></td>
+ <td>5M</td>
+ <td>09-Oct-2017 00:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171007100142-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171007100142-20171009100134.partial.mar</a></td>
+ <td>5M</td>
+ <td>09-Oct-2017 12:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171007220156-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171007220156-20171008131700.partial.mar</a></td>
+ <td>5M</td>
+ <td>08-Oct-2017 16:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171007220156-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171007220156-20171008220130.partial.mar</a></td>
+ <td>5M</td>
+ <td>09-Oct-2017 00:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171007220156-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171007220156-20171009100134.partial.mar</a></td>
+ <td>5M</td>
+ <td>09-Oct-2017 12:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171007220156-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171007220156-20171009220104.partial.mar</a></td>
+ <td>6M</td>
+ <td>10-Oct-2017 01:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171008131700-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171008131700-20171008220130.partial.mar</a></td>
+ <td>4M</td>
+ <td>09-Oct-2017 00:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171008131700-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171008131700-20171009100134.partial.mar</a></td>
+ <td>5M</td>
+ <td>09-Oct-2017 12:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171008131700-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171008131700-20171009220104.partial.mar</a></td>
+ <td>6M</td>
+ <td>10-Oct-2017 01:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171008131700-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171008131700-20171010100200.partial.mar</a></td>
+ <td>6M</td>
+ <td>10-Oct-2017 13:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171008220130-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171008220130-20171009100134.partial.mar</a></td>
+ <td>5M</td>
+ <td>09-Oct-2017 12:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171008220130-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171008220130-20171009220104.partial.mar</a></td>
+ <td>6M</td>
+ <td>10-Oct-2017 01:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171008220130-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171008220130-20171010100200.partial.mar</a></td>
+ <td>6M</td>
+ <td>10-Oct-2017 13:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171008220130-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171008220130-20171010220102.partial.mar</a></td>
+ <td>7M</td>
+ <td>11-Oct-2017 01:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171009100134-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171009100134-20171009220104.partial.mar</a></td>
+ <td>6M</td>
+ <td>10-Oct-2017 01:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171009100134-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171009100134-20171010100200.partial.mar</a></td>
+ <td>6M</td>
+ <td>10-Oct-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171009100134-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171009100134-20171010220102.partial.mar</a></td>
+ <td>7M</td>
+ <td>11-Oct-2017 01:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171009100134-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171009100134-20171011100133.partial.mar</a></td>
+ <td>8M</td>
+ <td>11-Oct-2017 17:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171009220104-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171009220104-20171010100200.partial.mar</a></td>
+ <td>6M</td>
+ <td>10-Oct-2017 13:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171009220104-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171009220104-20171010220102.partial.mar</a></td>
+ <td>6M</td>
+ <td>11-Oct-2017 01:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171009220104-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171009220104-20171011100133.partial.mar</a></td>
+ <td>7M</td>
+ <td>11-Oct-2017 17:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171009220104-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171009220104-20171011220113.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 01:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171010100200-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171010100200-20171010220102.partial.mar</a></td>
+ <td>6M</td>
+ <td>11-Oct-2017 01:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171010100200-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171010100200-20171011100133.partial.mar</a></td>
+ <td>7M</td>
+ <td>11-Oct-2017 17:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171010100200-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171010100200-20171011220113.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 01:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171010100200-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171010100200-20171012100228.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 14:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171010100200-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171010100200-20171012105833.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 15:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171010220102-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171010220102-20171011100133.partial.mar</a></td>
+ <td>6M</td>
+ <td>11-Oct-2017 17:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171010220102-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171010220102-20171011220113.partial.mar</a></td>
+ <td>6M</td>
+ <td>12-Oct-2017 01:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171010220102-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171010220102-20171012100228.partial.mar</a></td>
+ <td>6M</td>
+ <td>12-Oct-2017 14:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171010220102-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171010220102-20171012105833.partial.mar</a></td>
+ <td>6M</td>
+ <td>12-Oct-2017 15:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171011100133-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171011100133-20171011220113.partial.mar</a></td>
+ <td>5M</td>
+ <td>12-Oct-2017 01:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171011100133-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171011100133-20171012100228.partial.mar</a></td>
+ <td>6M</td>
+ <td>12-Oct-2017 14:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171011100133-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171011100133-20171012105833.partial.mar</a></td>
+ <td>6M</td>
+ <td>12-Oct-2017 15:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171011100133-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171011100133-20171012220111.partial.mar</a></td>
+ <td>6M</td>
+ <td>13-Oct-2017 00:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171011220113-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171011220113-20171012100228.partial.mar</a></td>
+ <td>5M</td>
+ <td>12-Oct-2017 14:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171011220113-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171011220113-20171012105833.partial.mar</a></td>
+ <td>5M</td>
+ <td>12-Oct-2017 15:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171011220113-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171011220113-20171012220111.partial.mar</a></td>
+ <td>6M</td>
+ <td>13-Oct-2017 00:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171011220113-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171011220113-20171013100112.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 13:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171012100228-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171012100228-20171012220111.partial.mar</a></td>
+ <td>6M</td>
+ <td>13-Oct-2017 00:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171012100228-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171012100228-20171013100112.partial.mar</a></td>
+ <td>6M</td>
+ <td>13-Oct-2017 13:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171012100228-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171012100228-20171013220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>14-Oct-2017 01:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171012105833-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171012105833-20171012220111.partial.mar</a></td>
+ <td>6M</td>
+ <td>13-Oct-2017 00:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171012105833-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171012105833-20171013100112.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 13:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171012105833-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171012105833-20171013220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>14-Oct-2017 01:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171012105833-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171012105833-20171014100219.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 12:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171012220111-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171012220111-20171013100112.partial.mar</a></td>
+ <td>6M</td>
+ <td>13-Oct-2017 13:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171012220111-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171012220111-20171013220204.partial.mar</a></td>
+ <td>6M</td>
+ <td>14-Oct-2017 01:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171012220111-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171012220111-20171014100219.partial.mar</a></td>
+ <td>7M</td>
+ <td>14-Oct-2017 12:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171012220111-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171012220111-20171014220542.partial.mar</a></td>
+ <td>8M</td>
+ <td>15-Oct-2017 01:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171013100112-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171013100112-20171013220204.partial.mar</a></td>
+ <td>5M</td>
+ <td>14-Oct-2017 01:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171013100112-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171013100112-20171014100219.partial.mar</a></td>
+ <td>7M</td>
+ <td>14-Oct-2017 12:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171013100112-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171013100112-20171014220542.partial.mar</a></td>
+ <td>7M</td>
+ <td>15-Oct-2017 01:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171013100112-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171013100112-20171015100127.partial.mar</a></td>
+ <td>7M</td>
+ <td>15-Oct-2017 14:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171013220204-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171013220204-20171014100219.partial.mar</a></td>
+ <td>6M</td>
+ <td>14-Oct-2017 12:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171013220204-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171013220204-20171014220542.partial.mar</a></td>
+ <td>7M</td>
+ <td>15-Oct-2017 01:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171013220204-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171013220204-20171015100127.partial.mar</a></td>
+ <td>7M</td>
+ <td>15-Oct-2017 14:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171013220204-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171013220204-20171015220106.partial.mar</a></td>
+ <td>7M</td>
+ <td>16-Oct-2017 02:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171014100219-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171014100219-20171014220542.partial.mar</a></td>
+ <td>6M</td>
+ <td>15-Oct-2017 01:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171014100219-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171014100219-20171015100127.partial.mar</a></td>
+ <td>6M</td>
+ <td>15-Oct-2017 14:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171014100219-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171014100219-20171015220106.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 02:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171014100219-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171014100219-20171016100113.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 14:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171014220542-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171014220542-20171015100127.partial.mar</a></td>
+ <td>5M</td>
+ <td>15-Oct-2017 14:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171014220542-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171014220542-20171015220106.partial.mar</a></td>
+ <td>5M</td>
+ <td>16-Oct-2017 02:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171014220542-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171014220542-20171016100113.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 14:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171014220542-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171014220542-20171016220427.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 01:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171015100127-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171015100127-20171015220106.partial.mar</a></td>
+ <td>4M</td>
+ <td>16-Oct-2017 02:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171015100127-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171015100127-20171016100113.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 14:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171015100127-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171015100127-20171016220427.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 01:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171015100127-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171015100127-20171017100127.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 13:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171015220106-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171015220106-20171016100113.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 14:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171015220106-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171015220106-20171016220427.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 01:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171015220106-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171015220106-20171017100127.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 13:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171015220106-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171015220106-20171017141229.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 18:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171016100113-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171016100113-20171016220427.partial.mar</a></td>
+ <td>4M</td>
+ <td>17-Oct-2017 01:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171016100113-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171016100113-20171017100127.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 13:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171016100113-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171016100113-20171017141229.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 18:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171016100113-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171016100113-20171017220415.partial.mar</a></td>
+ <td>6M</td>
+ <td>18-Oct-2017 01:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171016220427-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171016220427-20171017100127.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 13:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171016220427-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171016220427-20171017141229.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 18:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171016220427-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171016220427-20171017220415.partial.mar</a></td>
+ <td>6M</td>
+ <td>18-Oct-2017 01:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171016220427-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171016220427-20171018100140.partial.mar</a></td>
+ <td>6M</td>
+ <td>18-Oct-2017 12:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017100127-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017100127-20171017141229.partial.mar</a></td>
+ <td>4M</td>
+ <td>17-Oct-2017 18:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017100127-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017100127-20171017220415.partial.mar</a></td>
+ <td>5M</td>
+ <td>18-Oct-2017 01:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017100127-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017100127-20171018100140.partial.mar</a></td>
+ <td>5M</td>
+ <td>18-Oct-2017 12:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017100127-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017100127-20171018220049.partial.mar</a></td>
+ <td>5M</td>
+ <td>19-Oct-2017 01:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017141229-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017141229-20171017220415.partial.mar</a></td>
+ <td>5M</td>
+ <td>18-Oct-2017 01:10</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017141229-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017141229-20171018100140.partial.mar</a></td>
+ <td>6M</td>
+ <td>18-Oct-2017 12:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017141229-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017141229-20171018220049.partial.mar</a></td>
+ <td>5M</td>
+ <td>19-Oct-2017 01:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017141229-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017141229-20171019100107.partial.mar</a></td>
+ <td>6M</td>
+ <td>19-Oct-2017 13:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017220415-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017220415-20171018100140.partial.mar</a></td>
+ <td>5M</td>
+ <td>18-Oct-2017 12:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017220415-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017220415-20171018220049.partial.mar</a></td>
+ <td>5M</td>
+ <td>19-Oct-2017 01:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017220415-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017220415-20171019100107.partial.mar</a></td>
+ <td>6M</td>
+ <td>19-Oct-2017 13:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171017220415-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171017220415-20171019222141.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Oct-2017 01:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171018100140-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171018100140-20171018220049.partial.mar</a></td>
+ <td>4M</td>
+ <td>19-Oct-2017 01:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171018100140-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171018100140-20171019100107.partial.mar</a></td>
+ <td>6M</td>
+ <td>19-Oct-2017 13:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171018100140-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171018100140-20171019222141.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Oct-2017 01:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171018100140-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171018100140-20171020100426.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Oct-2017 13:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171018220049-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171018220049-20171019100107.partial.mar</a></td>
+ <td>6M</td>
+ <td>19-Oct-2017 13:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171018220049-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171018220049-20171019222141.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Oct-2017 01:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171018220049-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171018220049-20171020100426.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Oct-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171018220049-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171018220049-20171020221129.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Oct-2017 01:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171019100107-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171019100107-20171019222141.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Oct-2017 01:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171019100107-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171019100107-20171020100426.partial.mar</a></td>
+ <td>5M</td>
+ <td>20-Oct-2017 13:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171019100107-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171019100107-20171020221129.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Oct-2017 01:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171019100107-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171019100107-20171021100029.partial.mar</a></td>
+ <td>5M</td>
+ <td>21-Oct-2017 13:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171019222141-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171019222141-20171020100426.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Oct-2017 13:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171019222141-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171019222141-20171020221129.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Oct-2017 01:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171019222141-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171019222141-20171021100029.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Oct-2017 13:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171019222141-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171019222141-20171021220121.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Oct-2017 02:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171020100426-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171020100426-20171020221129.partial.mar</a></td>
+ <td>5M</td>
+ <td>21-Oct-2017 01:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171020100426-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171020100426-20171021100029.partial.mar</a></td>
+ <td>5M</td>
+ <td>21-Oct-2017 13:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171020100426-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171020100426-20171021220121.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Oct-2017 02:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171020100426-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171020100426-20171022100058.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171020221129-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171020221129-20171021100029.partial.mar</a></td>
+ <td>5M</td>
+ <td>21-Oct-2017 13:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171020221129-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171020221129-20171021220121.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Oct-2017 02:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171020221129-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171020221129-20171022100058.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171020221129-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171020221129-20171022220103.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Oct-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171021100029-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171021100029-20171021220121.partial.mar</a></td>
+ <td>4M</td>
+ <td>22-Oct-2017 02:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171021100029-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171021100029-20171022100058.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171021100029-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171021100029-20171022220103.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Oct-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171021100029-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171021100029-20171023100252.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Oct-2017 13:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171021220121-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171021220121-20171022100058.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171021220121-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171021220121-20171022220103.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Oct-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171021220121-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171021220121-20171023100252.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Oct-2017 13:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171021220121-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171021220121-20171023220222.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Oct-2017 00:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171022100058-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171022100058-20171022220103.partial.mar</a></td>
+ <td>4M</td>
+ <td>23-Oct-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171022100058-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171022100058-20171023100252.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Oct-2017 13:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171022100058-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171022100058-20171023220222.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Oct-2017 00:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171022100058-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171022100058-20171024100135.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Oct-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171022220103-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171022220103-20171023100252.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Oct-2017 13:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171022220103-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171022220103-20171023220222.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Oct-2017 00:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171022220103-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171022220103-20171024100135.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Oct-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171022220103-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171022220103-20171024220325.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Oct-2017 00:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171023100252-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171023100252-20171023220222.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Oct-2017 00:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171023100252-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171023100252-20171024100135.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Oct-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171023100252-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171023100252-20171024220325.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Oct-2017 00:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171023100252-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171023100252-20171025100449.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Oct-2017 13:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171023220222-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171023220222-20171024100135.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Oct-2017 12:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171023220222-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171023220222-20171024220325.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Oct-2017 00:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171023220222-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171023220222-20171025100449.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Oct-2017 13:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171023220222-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171023220222-20171025230440.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Oct-2017 02:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171024100135-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171024100135-20171024220325.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Oct-2017 00:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171024100135-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171024100135-20171025100449.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Oct-2017 13:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171024100135-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171024100135-20171025230440.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Oct-2017 02:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171024100135-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171024100135-20171026100047.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 15:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171024220325-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171024220325-20171025100449.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Oct-2017 13:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171024220325-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171024220325-20171025230440.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Oct-2017 02:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171024220325-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171024220325-20171026100047.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 15:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171024220325-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171024220325-20171026221945.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 02:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171025100449-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171025100449-20171025230440.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Oct-2017 02:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171025100449-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171025100449-20171026100047.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Oct-2017 15:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171025100449-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171025100449-20171026221945.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 02:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171025100449-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171025100449-20171027100103.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 15:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171025230440-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171025230440-20171026100047.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Oct-2017 15:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171025230440-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171025230440-20171026221945.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 02:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171025230440-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171025230440-20171027100103.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 15:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171025230440-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171025230440-20171027220059.partial.mar</a></td>
+ <td>14M</td>
+ <td>28-Oct-2017 03:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171026100047-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171026100047-20171026221945.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Oct-2017 02:35</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171026100047-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171026100047-20171027100103.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Oct-2017 15:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171026100047-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171026100047-20171027220059.partial.mar</a></td>
+ <td>14M</td>
+ <td>28-Oct-2017 03:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171026100047-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171026100047-20171028100423.partial.mar</a></td>
+ <td>14M</td>
+ <td>28-Oct-2017 15:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171026221945-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171026221945-20171027100103.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Oct-2017 15:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171026221945-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171026221945-20171027220059.partial.mar</a></td>
+ <td>14M</td>
+ <td>28-Oct-2017 03:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171026221945-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171026221945-20171028100423.partial.mar</a></td>
+ <td>14M</td>
+ <td>28-Oct-2017 15:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171026221945-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171026221945-20171028220326.partial.mar</a></td>
+ <td>14M</td>
+ <td>29-Oct-2017 01:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171027100103-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171027100103-20171027220059.partial.mar</a></td>
+ <td>14M</td>
+ <td>28-Oct-2017 03:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171027100103-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171027100103-20171028100423.partial.mar</a></td>
+ <td>14M</td>
+ <td>28-Oct-2017 15:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171027100103-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171027100103-20171028220326.partial.mar</a></td>
+ <td>14M</td>
+ <td>29-Oct-2017 01:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171027100103-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171027100103-20171029102300.partial.mar</a></td>
+ <td>14M</td>
+ <td>29-Oct-2017 14:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171027220059-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171027220059-20171028100423.partial.mar</a></td>
+ <td>6M</td>
+ <td>28-Oct-2017 15:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171027220059-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171027220059-20171028220326.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Oct-2017 01:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171027220059-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171027220059-20171029102300.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Oct-2017 14:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171027220059-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171027220059-20171029220112.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Oct-2017 04:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171028100423-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171028100423-20171028220326.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Oct-2017 01:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171028100423-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171028100423-20171029102300.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Oct-2017 14:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171028100423-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171028100423-20171029220112.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Oct-2017 04:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171028100423-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171028100423-20171030103605.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Oct-2017 20:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171028220326-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171028220326-20171029102300.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Oct-2017 14:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171028220326-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171028220326-20171029220112.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Oct-2017 04:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171028220326-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171028220326-20171030103605.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Oct-2017 20:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171028220326-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171028220326-20171031220132.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Nov-2017 09:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171028220326-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171028220326-20171031235118.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Nov-2017 10:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171029102300-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171029102300-20171029220112.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Oct-2017 04:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171029102300-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171029102300-20171030103605.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Oct-2017 20:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171029102300-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171029102300-20171031220132.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Nov-2017 09:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171029102300-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171029102300-20171031235118.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Nov-2017 10:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171029220112-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171029220112-20171030103605.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Oct-2017 20:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171029220112-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171029220112-20171031220132.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Nov-2017 09:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171029220112-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171029220112-20171031235118.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Nov-2017 10:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171029220112-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171029220112-20171101104430.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 16:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171030103605-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171030103605-20171031220132.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Nov-2017 09:54</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171030103605-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171030103605-20171031235118.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Nov-2017 10:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171030103605-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171030103605-20171101104430.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Nov-2017 16:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171030103605-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171030103605-20171101220120.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171031220132-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171031220132-20171101104430.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Nov-2017 16:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171031220132-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171031220132-20171101220120.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Nov-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171031220132-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171031220132-20171102100041.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Nov-2017 12:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171031235118-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171031235118-20171101104430.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Nov-2017 16:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171031235118-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171031235118-20171101220120.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Nov-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171031235118-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171031235118-20171102100041.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Nov-2017 12:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171031235118-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171031235118-20171102222620.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 01:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171101104430-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171101104430-20171101220120.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Nov-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171101104430-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171101104430-20171102100041.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Nov-2017 12:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171101104430-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171101104430-20171102222620.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 01:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171101104430-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171101104430-20171103100331.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 12:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171101220120-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171101220120-20171102100041.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Nov-2017 12:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171101220120-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171101220120-20171102222620.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Nov-2017 01:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171101220120-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171101220120-20171103100331.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Nov-2017 12:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171101220120-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171101220120-20171103220715.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Nov-2017 00:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171102100041-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171102100041-20171102222620.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Nov-2017 01:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171102100041-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171102100041-20171103100331.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Nov-2017 12:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171102100041-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171102100041-20171103220715.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Nov-2017 00:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171102100041-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171102100041-20171104100412.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Nov-2017 12:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171102222620-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171102222620-20171103100331.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Nov-2017 12:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171102222620-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171102222620-20171103220715.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Nov-2017 00:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171102222620-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171102222620-20171104100412.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Nov-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171102222620-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171102222620-20171104220420.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Nov-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171103100331-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171103100331-20171103220715.partial.mar</a></td>
+ <td>6M</td>
+ <td>04-Nov-2017 00:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171103100331-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171103100331-20171104100412.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Nov-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171103100331-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171103100331-20171104220420.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Nov-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171103100331-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171103100331-20171105100353.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Nov-2017 12:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171103220715-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171103220715-20171104100412.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Nov-2017 12:45</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171103220715-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171103220715-20171104220420.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Nov-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171103220715-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171103220715-20171105100353.partial.mar</a></td>
+ <td>8M</td>
+ <td>05-Nov-2017 12:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171103220715-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171103220715-20171105220721.partial.mar</a></td>
+ <td>8M</td>
+ <td>06-Nov-2017 00:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171104100412-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171104100412-20171104220420.partial.mar</a></td>
+ <td>7M</td>
+ <td>05-Nov-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171104100412-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171104100412-20171105100353.partial.mar</a></td>
+ <td>7M</td>
+ <td>05-Nov-2017 12:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171104100412-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171104100412-20171105220721.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Nov-2017 00:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171104100412-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171104100412-20171106100122.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Nov-2017 12:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171104220420-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171104220420-20171105100353.partial.mar</a></td>
+ <td>4M</td>
+ <td>05-Nov-2017 12:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171104220420-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171104220420-20171105220721.partial.mar</a></td>
+ <td>4M</td>
+ <td>06-Nov-2017 00:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171104220420-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171104220420-20171106100122.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Nov-2017 12:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171105100353-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171105100353-20171105220721.partial.mar</a></td>
+ <td>4M</td>
+ <td>06-Nov-2017 00:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171105100353-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171105100353-20171106100122.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Nov-2017 12:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win32-en-US-20171105220721-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-win32-en-US-20171105220721-20171106100122.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Nov-2017 12:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170919220202-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170919220202-20170921220243.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 01:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170920100426-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170920100426-20170921220243.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 01:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170920100426-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170920100426-20170922100051.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170920220431-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170920220431-20170921220243.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Sep-2017 01:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170920220431-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170920220431-20170922100051.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170920220431-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170920220431-20170922220129.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Sep-2017 02:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170921100141-20170921220243.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170921100141-20170921220243.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Sep-2017 01:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170921100141-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170921100141-20170922100051.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170921100141-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170921100141-20170922220129.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Sep-2017 02:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170921100141-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170921100141-20170923100045.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Sep-2017 13:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170921220243-20170922100051.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170921220243-20170922100051.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Sep-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170921220243-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170921220243-20170922220129.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Sep-2017 02:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170921220243-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170921220243-20170923100045.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Sep-2017 13:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170921220243-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170921220243-20170924100550.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Sep-2017 14:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170922100051-20170922220129.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170922100051-20170922220129.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Sep-2017 02:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170922100051-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170922100051-20170923100045.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Sep-2017 13:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170922100051-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170922100051-20170924100550.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Sep-2017 14:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170922100051-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170922100051-20170924220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Sep-2017 02:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170922220129-20170923100045.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170922220129-20170923100045.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Sep-2017 13:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170922220129-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170922220129-20170924100550.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Sep-2017 14:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170922220129-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170922220129-20170924220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Sep-2017 02:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170922220129-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170922220129-20170925100307.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Sep-2017 13:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170923100045-20170924100550.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170923100045-20170924100550.partial.mar</a></td>
+ <td>6M</td>
+ <td>24-Sep-2017 14:30</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170923100045-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170923100045-20170924220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Sep-2017 02:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170923100045-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170923100045-20170925100307.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Sep-2017 13:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170923100045-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170923100045-20170925220207.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 01:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170924100550-20170924220116.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170924100550-20170924220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Sep-2017 02:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170924100550-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170924100550-20170925100307.partial.mar</a></td>
+ <td>6M</td>
+ <td>25-Sep-2017 13:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170924100550-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170924100550-20170925220207.partial.mar</a></td>
+ <td>6M</td>
+ <td>26-Sep-2017 01:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170924100550-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170924100550-20170926100259.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170924220116-20170925100307.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170924220116-20170925100307.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Sep-2017 13:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170924220116-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170924220116-20170925220207.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 01:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170924220116-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170924220116-20170926100259.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170924220116-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170924220116-20170926220106.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170925100307-20170925220207.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170925100307-20170925220207.partial.mar</a></td>
+ <td>5M</td>
+ <td>26-Sep-2017 01:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170925100307-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170925100307-20170926100259.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 13:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170925100307-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170925100307-20170926220106.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170925100307-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170925100307-20170927100120.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Sep-2017 13:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170925220207-20170926100259.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170925220207-20170926100259.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Sep-2017 13:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170925220207-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170925220207-20170926220106.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170925220207-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170925220207-20170927100120.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Sep-2017 13:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170925220207-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170925220207-20170928100123.partial.mar</a></td>
+ <td>8M</td>
+ <td>28-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170926100259-20170926220106.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170926100259-20170926220106.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Sep-2017 01:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170926100259-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170926100259-20170927100120.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Sep-2017 13:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170926100259-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170926100259-20170928100123.partial.mar</a></td>
+ <td>7M</td>
+ <td>28-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170926100259-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170926100259-20170928220658.partial.mar</a></td>
+ <td>8M</td>
+ <td>29-Sep-2017 01:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170926220106-20170927100120.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170926220106-20170927100120.partial.mar</a></td>
+ <td>6M</td>
+ <td>27-Sep-2017 13:29</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170926220106-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170926220106-20170928100123.partial.mar</a></td>
+ <td>7M</td>
+ <td>28-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170926220106-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170926220106-20170928220658.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 01:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170926220106-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170926220106-20170929100122.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 13:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170927100120-20170928100123.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170927100120-20170928100123.partial.mar</a></td>
+ <td>7M</td>
+ <td>28-Sep-2017 13:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170927100120-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170927100120-20170928220658.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 01:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170927100120-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170927100120-20170929100122.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 13:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170927100120-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170927100120-20170929220356.partial.mar</a></td>
+ <td>8M</td>
+ <td>30-Sep-2017 01:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170928100123-20170928220658.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170928100123-20170928220658.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Sep-2017 01:24</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170928100123-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170928100123-20170929100122.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Sep-2017 13:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170928100123-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170928100123-20170929220356.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Sep-2017 01:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170928100123-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170928100123-20170930100302.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Sep-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170928220658-20170929100122.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170928220658-20170929100122.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Sep-2017 13:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170928220658-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170928220658-20170929220356.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Sep-2017 01:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170928220658-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170928220658-20170930100302.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Sep-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170928220658-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170928220658-20170930220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 01:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170929100122-20170929220356.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170929100122-20170929220356.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Sep-2017 01:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170929100122-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170929100122-20170930100302.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Sep-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170929100122-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170929100122-20170930220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 01:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170929100122-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170929100122-20171001100335.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 13:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170929220356-20170930100302.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170929220356-20170930100302.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Sep-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170929220356-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170929220356-20170930220116.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 01:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170929220356-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170929220356-20171001100335.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 13:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170929220356-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170929220356-20171001220301.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 01:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170930100302-20170930220116.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170930100302-20170930220116.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Oct-2017 01:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170930100302-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170930100302-20171001100335.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Oct-2017 13:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170930100302-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170930100302-20171001220301.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 01:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170930100302-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170930100302-20171002100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Oct-2017 13:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170930220116-20171001100335.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170930220116-20171001100335.partial.mar</a></td>
+ <td>6M</td>
+ <td>01-Oct-2017 13:03</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170930220116-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170930220116-20171001220301.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Oct-2017 01:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170930220116-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170930220116-20171002100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Oct-2017 13:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20170930220116-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20170930220116-20171002220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171001100335-20171001220301.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171001100335-20171001220301.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 01:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171001100335-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171001100335-20171002100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Oct-2017 13:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171001100335-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171001100335-20171002220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171001100335-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171001100335-20171003100226.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 12:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171001220301-20171002100134.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171001220301-20171002100134.partial.mar</a></td>
+ <td>6M</td>
+ <td>02-Oct-2017 13:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171001220301-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171001220301-20171002220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171001220301-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171001220301-20171003100226.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 12:49</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171001220301-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171001220301-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171002100134-20171002220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171002100134-20171002220204.partial.mar</a></td>
+ <td>5M</td>
+ <td>03-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171002100134-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171002100134-20171003100226.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Oct-2017 12:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171002100134-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171002100134-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171002100134-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171002100134-20171004100049.partial.mar</a></td>
+ <td>9M</td>
+ <td>04-Oct-2017 12:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171002220204-20171003100226.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171002220204-20171003100226.partial.mar</a></td>
+ <td>6M</td>
+ <td>03-Oct-2017 12:48</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171002220204-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171002220204-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171002220204-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171002220204-20171004100049.partial.mar</a></td>
+ <td>9M</td>
+ <td>04-Oct-2017 12:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171002220204-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171002220204-20171004220309.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Oct-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171003100226-20171003220138.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171003100226-20171003220138.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171003100226-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171003100226-20171004100049.partial.mar</a></td>
+ <td>9M</td>
+ <td>04-Oct-2017 12:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171003100226-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171003100226-20171004220309.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Oct-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171003100226-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171003100226-20171005100211.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Oct-2017 12:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171003220138-20171004100049.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171003220138-20171004100049.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Oct-2017 12:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171003220138-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171003220138-20171004220309.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Oct-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171003220138-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171003220138-20171005100211.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Oct-2017 12:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171003220138-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171003220138-20171005220204.partial.mar</a></td>
+ <td>9M</td>
+ <td>06-Oct-2017 01:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171004100049-20171004220309.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171004100049-20171004220309.partial.mar</a></td>
+ <td>7M</td>
+ <td>05-Oct-2017 01:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171004100049-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171004100049-20171005100211.partial.mar</a></td>
+ <td>7M</td>
+ <td>05-Oct-2017 12:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171004100049-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171004100049-20171005220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Oct-2017 01:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171004100049-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171004100049-20171006100327.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Oct-2017 13:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171004220309-20171005100211.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171004220309-20171005100211.partial.mar</a></td>
+ <td>6M</td>
+ <td>05-Oct-2017 12:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171004220309-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171004220309-20171005220204.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Oct-2017 01:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171004220309-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171004220309-20171006100327.partial.mar</a></td>
+ <td>6M</td>
+ <td>06-Oct-2017 13:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171004220309-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171004220309-20171006220306.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171005100211-20171005220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171005100211-20171005220204.partial.mar</a></td>
+ <td>5M</td>
+ <td>06-Oct-2017 01:26</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171005100211-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171005100211-20171006100327.partial.mar</a></td>
+ <td>5M</td>
+ <td>06-Oct-2017 13:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171005100211-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171005100211-20171006220306.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171005100211-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171005100211-20171007100142.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 12:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171005220204-20171006100327.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171005220204-20171006100327.partial.mar</a></td>
+ <td>5M</td>
+ <td>06-Oct-2017 13:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171005220204-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171005220204-20171006220306.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171005220204-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171005220204-20171007100142.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 12:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171005220204-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171005220204-20171007220156.partial.mar</a></td>
+ <td>8M</td>
+ <td>08-Oct-2017 00:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171006100327-20171006220306.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171006100327-20171006220306.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171006100327-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171006100327-20171007100142.partial.mar</a></td>
+ <td>7M</td>
+ <td>07-Oct-2017 12:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171006100327-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171006100327-20171007220156.partial.mar</a></td>
+ <td>8M</td>
+ <td>08-Oct-2017 00:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171006100327-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171006100327-20171008131700.partial.mar</a></td>
+ <td>7M</td>
+ <td>08-Oct-2017 16:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171006220306-20171007100142.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171006220306-20171007100142.partial.mar</a></td>
+ <td>8M</td>
+ <td>07-Oct-2017 12:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171006220306-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171006220306-20171007220156.partial.mar</a></td>
+ <td>7M</td>
+ <td>08-Oct-2017 00:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171006220306-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171006220306-20171008131700.partial.mar</a></td>
+ <td>8M</td>
+ <td>08-Oct-2017 16:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171006220306-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171006220306-20171008220130.partial.mar</a></td>
+ <td>8M</td>
+ <td>09-Oct-2017 01:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171007100142-20171007220156.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171007100142-20171007220156.partial.mar</a></td>
+ <td>6M</td>
+ <td>08-Oct-2017 00:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171007100142-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171007100142-20171008131700.partial.mar</a></td>
+ <td>7M</td>
+ <td>08-Oct-2017 16:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171007100142-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171007100142-20171008220130.partial.mar</a></td>
+ <td>7M</td>
+ <td>09-Oct-2017 01:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171007100142-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171007100142-20171009100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>09-Oct-2017 13:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171007220156-20171008131700.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171007220156-20171008131700.partial.mar</a></td>
+ <td>7M</td>
+ <td>08-Oct-2017 16:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171007220156-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171007220156-20171008220130.partial.mar</a></td>
+ <td>7M</td>
+ <td>09-Oct-2017 01:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171007220156-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171007220156-20171009100134.partial.mar</a></td>
+ <td>6M</td>
+ <td>09-Oct-2017 13:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171007220156-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171007220156-20171009220104.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 01:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171008131700-20171008220130.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171008131700-20171008220130.partial.mar</a></td>
+ <td>5M</td>
+ <td>09-Oct-2017 01:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171008131700-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171008131700-20171009100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>09-Oct-2017 13:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171008131700-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171008131700-20171009220104.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 01:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171008131700-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171008131700-20171010100200.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 13:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171008220130-20171009100134.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171008220130-20171009100134.partial.mar</a></td>
+ <td>7M</td>
+ <td>09-Oct-2017 13:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171008220130-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171008220130-20171009220104.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 01:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171008220130-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171008220130-20171010100200.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 13:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171008220130-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171008220130-20171010220102.partial.mar</a></td>
+ <td>9M</td>
+ <td>11-Oct-2017 01:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171009100134-20171009220104.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171009100134-20171009220104.partial.mar</a></td>
+ <td>7M</td>
+ <td>10-Oct-2017 01:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171009100134-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171009100134-20171010100200.partial.mar</a></td>
+ <td>8M</td>
+ <td>10-Oct-2017 13:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171009100134-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171009100134-20171010220102.partial.mar</a></td>
+ <td>8M</td>
+ <td>11-Oct-2017 01:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171009100134-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171009100134-20171011100133.partial.mar</a></td>
+ <td>9M</td>
+ <td>11-Oct-2017 17:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171009220104-20171010100200.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171009220104-20171010100200.partial.mar</a></td>
+ <td>7M</td>
+ <td>10-Oct-2017 13:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171009220104-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171009220104-20171010220102.partial.mar</a></td>
+ <td>7M</td>
+ <td>11-Oct-2017 01:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171009220104-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171009220104-20171011100133.partial.mar</a></td>
+ <td>8M</td>
+ <td>11-Oct-2017 17:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171009220104-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171009220104-20171011220113.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 01:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171010100200-20171010220102.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171010100200-20171010220102.partial.mar</a></td>
+ <td>7M</td>
+ <td>11-Oct-2017 01:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171010100200-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171010100200-20171011100133.partial.mar</a></td>
+ <td>8M</td>
+ <td>11-Oct-2017 17:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171010100200-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171010100200-20171011220113.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 01:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171010100200-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171010100200-20171012100228.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 14:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171010100200-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171010100200-20171012105833.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 16:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171010220102-20171011100133.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171010220102-20171011100133.partial.mar</a></td>
+ <td>7M</td>
+ <td>11-Oct-2017 17:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171010220102-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171010220102-20171011220113.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 01:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171010220102-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171010220102-20171012100228.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 14:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171010220102-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171010220102-20171012105833.partial.mar</a></td>
+ <td>8M</td>
+ <td>12-Oct-2017 16:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171011100133-20171011220113.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171011100133-20171011220113.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 01:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171011100133-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171011100133-20171012100228.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 14:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171011100133-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171011100133-20171012105833.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 16:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171011100133-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171011100133-20171012220111.partial.mar</a></td>
+ <td>8M</td>
+ <td>13-Oct-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171011220113-20171012100228.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171011220113-20171012100228.partial.mar</a></td>
+ <td>6M</td>
+ <td>12-Oct-2017 14:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171011220113-20171012105833.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171011220113-20171012105833.partial.mar</a></td>
+ <td>7M</td>
+ <td>12-Oct-2017 16:06</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171011220113-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171011220113-20171012220111.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171011220113-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171011220113-20171013100112.partial.mar</a></td>
+ <td>8M</td>
+ <td>13-Oct-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171012100228-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171012100228-20171012220111.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171012100228-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171012100228-20171013100112.partial.mar</a></td>
+ <td>8M</td>
+ <td>13-Oct-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171012100228-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171012100228-20171013220204.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 02:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171012105833-20171012220111.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171012105833-20171012220111.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 00:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171012105833-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171012105833-20171013100112.partial.mar</a></td>
+ <td>8M</td>
+ <td>13-Oct-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171012105833-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171012105833-20171013220204.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 02:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171012105833-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171012105833-20171014100219.partial.mar</a></td>
+ <td>9M</td>
+ <td>14-Oct-2017 13:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171012220111-20171013100112.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171012220111-20171013100112.partial.mar</a></td>
+ <td>7M</td>
+ <td>13-Oct-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171012220111-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171012220111-20171013220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>14-Oct-2017 02:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171012220111-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171012220111-20171014100219.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 13:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171012220111-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171012220111-20171014220542.partial.mar</a></td>
+ <td>8M</td>
+ <td>15-Oct-2017 02:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171013100112-20171013220204.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171013100112-20171013220204.partial.mar</a></td>
+ <td>7M</td>
+ <td>14-Oct-2017 02:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171013100112-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171013100112-20171014100219.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 13:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171013100112-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171013100112-20171014220542.partial.mar</a></td>
+ <td>8M</td>
+ <td>15-Oct-2017 02:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171013100112-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171013100112-20171015100127.partial.mar</a></td>
+ <td>8M</td>
+ <td>15-Oct-2017 15:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171013220204-20171014100219.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171013220204-20171014100219.partial.mar</a></td>
+ <td>8M</td>
+ <td>14-Oct-2017 13:11</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171013220204-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171013220204-20171014220542.partial.mar</a></td>
+ <td>8M</td>
+ <td>15-Oct-2017 02:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171013220204-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171013220204-20171015100127.partial.mar</a></td>
+ <td>8M</td>
+ <td>15-Oct-2017 15:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171013220204-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171013220204-20171015220106.partial.mar</a></td>
+ <td>8M</td>
+ <td>16-Oct-2017 02:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171014100219-20171014220542.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171014100219-20171014220542.partial.mar</a></td>
+ <td>6M</td>
+ <td>15-Oct-2017 02:15</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171014100219-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171014100219-20171015100127.partial.mar</a></td>
+ <td>7M</td>
+ <td>15-Oct-2017 15:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171014100219-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171014100219-20171015220106.partial.mar</a></td>
+ <td>7M</td>
+ <td>16-Oct-2017 02:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171014100219-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171014100219-20171016100113.partial.mar</a></td>
+ <td>7M</td>
+ <td>16-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171014220542-20171015100127.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171014220542-20171015100127.partial.mar</a></td>
+ <td>6M</td>
+ <td>15-Oct-2017 15:16</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171014220542-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171014220542-20171015220106.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 02:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171014220542-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171014220542-20171016100113.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171014220542-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171014220542-20171016220427.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 02:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171015100127-20171015220106.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171015100127-20171015220106.partial.mar</a></td>
+ <td>5M</td>
+ <td>16-Oct-2017 02:08</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171015100127-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171015100127-20171016100113.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171015100127-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171015100127-20171016220427.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 02:04</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171015100127-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171015100127-20171017100127.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171015220106-20171016100113.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171015220106-20171016100113.partial.mar</a></td>
+ <td>6M</td>
+ <td>16-Oct-2017 14:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171015220106-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171015220106-20171016220427.partial.mar</a></td>
+ <td>6M</td>
+ <td>17-Oct-2017 02:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171015220106-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171015220106-20171017100127.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171015220106-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171015220106-20171017141229.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 18:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171016100113-20171016220427.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171016100113-20171016220427.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 02:05</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171016100113-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171016100113-20171017100127.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171016100113-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171016100113-20171017141229.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 18:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171016100113-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171016100113-20171017220415.partial.mar</a></td>
+ <td>7M</td>
+ <td>18-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171016220427-20171017100127.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171016220427-20171017100127.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 13:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171016220427-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171016220427-20171017141229.partial.mar</a></td>
+ <td>7M</td>
+ <td>17-Oct-2017 18:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171016220427-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171016220427-20171017220415.partial.mar</a></td>
+ <td>7M</td>
+ <td>18-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171016220427-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171016220427-20171018100140.partial.mar</a></td>
+ <td>7M</td>
+ <td>18-Oct-2017 13:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017100127-20171017141229.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017100127-20171017141229.partial.mar</a></td>
+ <td>5M</td>
+ <td>17-Oct-2017 18:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017100127-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017100127-20171017220415.partial.mar</a></td>
+ <td>6M</td>
+ <td>18-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017100127-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017100127-20171018100140.partial.mar</a></td>
+ <td>6M</td>
+ <td>18-Oct-2017 13:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017100127-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017100127-20171018220049.partial.mar</a></td>
+ <td>6M</td>
+ <td>19-Oct-2017 01:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017141229-20171017220415.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017141229-20171017220415.partial.mar</a></td>
+ <td>6M</td>
+ <td>18-Oct-2017 01:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017141229-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017141229-20171018100140.partial.mar</a></td>
+ <td>6M</td>
+ <td>18-Oct-2017 13:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017141229-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017141229-20171018220049.partial.mar</a></td>
+ <td>6M</td>
+ <td>19-Oct-2017 01:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017141229-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017141229-20171019100107.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 13:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017220415-20171018100140.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017220415-20171018100140.partial.mar</a></td>
+ <td>6M</td>
+ <td>18-Oct-2017 13:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017220415-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017220415-20171018220049.partial.mar</a></td>
+ <td>6M</td>
+ <td>19-Oct-2017 01:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017220415-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017220415-20171019100107.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 13:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171017220415-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171017220415-20171019222141.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Oct-2017 02:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171018100140-20171018220049.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171018100140-20171018220049.partial.mar</a></td>
+ <td>5M</td>
+ <td>19-Oct-2017 01:25</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171018100140-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171018100140-20171019100107.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 13:52</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171018100140-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171018100140-20171019222141.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Oct-2017 02:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171018100140-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171018100140-20171020100426.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Oct-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171018220049-20171019100107.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171018220049-20171019100107.partial.mar</a></td>
+ <td>7M</td>
+ <td>19-Oct-2017 13:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171018220049-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171018220049-20171019222141.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Oct-2017 02:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171018220049-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171018220049-20171020100426.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Oct-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171018220049-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171018220049-20171020221129.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 01:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171019100107-20171019222141.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171019100107-20171019222141.partial.mar</a></td>
+ <td>6M</td>
+ <td>20-Oct-2017 02:33</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171019100107-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171019100107-20171020100426.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Oct-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171019100107-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171019100107-20171020221129.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 01:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171019100107-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171019100107-20171021100029.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171019222141-20171020100426.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171019222141-20171020100426.partial.mar</a></td>
+ <td>7M</td>
+ <td>20-Oct-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171019222141-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171019222141-20171020221129.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 01:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171019222141-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171019222141-20171021100029.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171019222141-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171019222141-20171021220121.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 02:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171020100426-20171020221129.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171020100426-20171020221129.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 01:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171020100426-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171020100426-20171021100029.partial.mar</a></td>
+ <td>6M</td>
+ <td>21-Oct-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171020100426-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171020100426-20171021220121.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Oct-2017 02:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171020100426-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171020100426-20171022100058.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 13:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171020221129-20171021100029.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171020221129-20171021100029.partial.mar</a></td>
+ <td>7M</td>
+ <td>21-Oct-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171020221129-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171020221129-20171021220121.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 02:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171020221129-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171020221129-20171022100058.partial.mar</a></td>
+ <td>7M</td>
+ <td>22-Oct-2017 13:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171020221129-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171020221129-20171022220103.partial.mar</a></td>
+ <td>7M</td>
+ <td>23-Oct-2017 01:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171021100029-20171021220121.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171021100029-20171021220121.partial.mar</a></td>
+ <td>5M</td>
+ <td>22-Oct-2017 02:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171021100029-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171021100029-20171022100058.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Oct-2017 13:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171021100029-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171021100029-20171022220103.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Oct-2017 01:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171021100029-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171021100029-20171023100252.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Oct-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171021220121-20171022100058.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171021220121-20171022100058.partial.mar</a></td>
+ <td>6M</td>
+ <td>22-Oct-2017 13:31</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171021220121-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171021220121-20171022220103.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Oct-2017 01:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171021220121-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171021220121-20171023100252.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Oct-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171021220121-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171021220121-20171023220222.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Oct-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171022100058-20171022220103.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171022100058-20171022220103.partial.mar</a></td>
+ <td>5M</td>
+ <td>23-Oct-2017 01:14</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171022100058-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171022100058-20171023100252.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Oct-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171022100058-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171022100058-20171023220222.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Oct-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171022100058-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171022100058-20171024100135.partial.mar</a></td>
+ <td>8M</td>
+ <td>24-Oct-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171022220103-20171023100252.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171022220103-20171023100252.partial.mar</a></td>
+ <td>6M</td>
+ <td>23-Oct-2017 13:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171022220103-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171022220103-20171023220222.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Oct-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171022220103-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171022220103-20171024100135.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Oct-2017 13:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171022220103-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171022220103-20171024220325.partial.mar</a></td>
+ <td>8M</td>
+ <td>25-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171023100252-20171023220222.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171023100252-20171023220222.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Oct-2017 00:38</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171023100252-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171023100252-20171024100135.partial.mar</a></td>
+ <td>8M</td>
+ <td>24-Oct-2017 13:18</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171023100252-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171023100252-20171024220325.partial.mar</a></td>
+ <td>8M</td>
+ <td>25-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171023100252-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171023100252-20171025100449.partial.mar</a></td>
+ <td>8M</td>
+ <td>25-Oct-2017 13:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171023220222-20171024100135.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171023220222-20171024100135.partial.mar</a></td>
+ <td>7M</td>
+ <td>24-Oct-2017 13:19</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171023220222-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171023220222-20171024220325.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171023220222-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171023220222-20171025100449.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Oct-2017 13:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171023220222-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171023220222-20171025230440.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 02:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171024100135-20171024220325.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171024100135-20171024220325.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Oct-2017 01:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171024100135-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171024100135-20171025100449.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Oct-2017 13:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171024100135-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171024100135-20171025230440.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 02:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171024100135-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171024100135-20171026100047.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 15:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171024220325-20171025100449.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171024220325-20171025100449.partial.mar</a></td>
+ <td>7M</td>
+ <td>25-Oct-2017 13:32</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171024220325-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171024220325-20171025230440.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 02:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171024220325-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171024220325-20171026100047.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 15:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171024220325-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171024220325-20171026221945.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 02:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171025100449-20171025230440.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171025100449-20171025230440.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 02:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171025100449-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171025100449-20171026100047.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 15:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171025100449-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171025100449-20171026221945.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 02:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171025100449-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171025100449-20171027100103.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Oct-2017 15:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171025230440-20171026100047.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171025230440-20171026100047.partial.mar</a></td>
+ <td>7M</td>
+ <td>26-Oct-2017 15:23</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171025230440-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171025230440-20171026221945.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 02:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171025230440-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171025230440-20171027100103.partial.mar</a></td>
+ <td>8M</td>
+ <td>27-Oct-2017 15:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171025230440-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171025230440-20171027220059.partial.mar</a></td>
+ <td>17M</td>
+ <td>28-Oct-2017 03:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171026100047-20171026221945.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171026100047-20171026221945.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 02:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171026100047-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171026100047-20171027100103.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 15:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171026100047-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171026100047-20171027220059.partial.mar</a></td>
+ <td>17M</td>
+ <td>28-Oct-2017 03:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171026100047-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171026100047-20171028100423.partial.mar</a></td>
+ <td>17M</td>
+ <td>28-Oct-2017 17:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171026221945-20171027100103.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171026221945-20171027100103.partial.mar</a></td>
+ <td>7M</td>
+ <td>27-Oct-2017 15:28</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171026221945-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171026221945-20171027220059.partial.mar</a></td>
+ <td>17M</td>
+ <td>28-Oct-2017 03:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171026221945-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171026221945-20171028100423.partial.mar</a></td>
+ <td>17M</td>
+ <td>28-Oct-2017 17:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171026221945-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171026221945-20171028220326.partial.mar</a></td>
+ <td>17M</td>
+ <td>29-Oct-2017 01:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171027100103-20171027220059.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171027100103-20171027220059.partial.mar</a></td>
+ <td>16M</td>
+ <td>28-Oct-2017 03:17</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171027100103-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171027100103-20171028100423.partial.mar</a></td>
+ <td>16M</td>
+ <td>28-Oct-2017 17:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171027100103-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171027100103-20171028220326.partial.mar</a></td>
+ <td>16M</td>
+ <td>29-Oct-2017 01:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171027100103-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171027100103-20171029102300.partial.mar</a></td>
+ <td>16M</td>
+ <td>29-Oct-2017 14:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171027220059-20171028100423.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171027220059-20171028100423.partial.mar</a></td>
+ <td>7M</td>
+ <td>28-Oct-2017 17:01</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171027220059-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171027220059-20171028220326.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Oct-2017 01:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171027220059-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171027220059-20171029102300.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Oct-2017 14:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171027220059-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171027220059-20171029220112.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 05:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171028100423-20171028220326.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171028100423-20171028220326.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Oct-2017 01:36</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171028100423-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171028100423-20171029102300.partial.mar</a></td>
+ <td>7M</td>
+ <td>29-Oct-2017 14:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171028100423-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171028100423-20171029220112.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 05:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171028100423-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171028100423-20171030103605.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 20:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171028220326-20171029102300.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171028220326-20171029102300.partial.mar</a></td>
+ <td>6M</td>
+ <td>29-Oct-2017 14:57</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171028220326-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171028220326-20171029220112.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Oct-2017 05:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171028220326-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171028220326-20171030103605.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 20:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171028220326-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171028220326-20171031220132.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 08:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171028220326-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171028220326-20171031235118.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 10:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171029102300-20171029220112.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171029102300-20171029220112.partial.mar</a></td>
+ <td>6M</td>
+ <td>30-Oct-2017 05:07</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171029102300-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171029102300-20171030103605.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 20:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171029102300-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171029102300-20171031220132.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 08:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171029102300-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171029102300-20171031235118.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 10:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171029220112-20171030103605.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171029220112-20171030103605.partial.mar</a></td>
+ <td>7M</td>
+ <td>30-Oct-2017 20:42</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171029220112-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171029220112-20171031220132.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 08:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171029220112-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171029220112-20171031235118.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 10:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171029220112-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171029220112-20171101104430.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 16:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171030103605-20171031220132.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171030103605-20171031220132.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 08:12</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171030103605-20171031235118.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171030103605-20171031235118.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 10:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171030103605-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171030103605-20171101104430.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 16:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171030103605-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171030103605-20171101220120.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 00:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171031220132-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171031220132-20171101104430.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 16:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171031220132-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171031220132-20171101220120.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 00:41</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171031220132-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171031220132-20171102100041.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 12:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171031235118-20171101104430.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171031235118-20171101104430.partial.mar</a></td>
+ <td>7M</td>
+ <td>01-Nov-2017 16:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171031235118-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171031235118-20171101220120.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 00:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171031235118-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171031235118-20171102100041.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 12:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171031235118-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171031235118-20171102222620.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 01:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171101104430-20171101220120.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171101104430-20171101220120.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 00:40</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171101104430-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171101104430-20171102100041.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 12:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171101104430-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171101104430-20171102222620.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 01:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171101104430-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171101104430-20171103100331.partial.mar</a></td>
+ <td>8M</td>
+ <td>03-Nov-2017 12:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171101220120-20171102100041.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171101220120-20171102100041.partial.mar</a></td>
+ <td>7M</td>
+ <td>02-Nov-2017 12:53</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171101220120-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171101220120-20171102222620.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 01:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171101220120-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171101220120-20171103100331.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 13:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171101220120-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171101220120-20171103220715.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Nov-2017 00:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171102100041-20171102222620.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171102100041-20171102222620.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 01:27</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171102100041-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171102100041-20171103100331.partial.mar</a></td>
+ <td>8M</td>
+ <td>03-Nov-2017 13:00</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171102100041-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171102100041-20171103220715.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Nov-2017 00:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171102100041-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171102100041-20171104100412.partial.mar</a></td>
+ <td>9M</td>
+ <td>04-Nov-2017 12:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171102222620-20171103100331.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171102222620-20171103100331.partial.mar</a></td>
+ <td>7M</td>
+ <td>03-Nov-2017 12:59</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171102222620-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171102222620-20171103220715.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Nov-2017 00:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171102222620-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171102222620-20171104100412.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Nov-2017 12:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171102222620-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171102222620-20171104220420.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Nov-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171103100331-20171103220715.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171103100331-20171103220715.partial.mar</a></td>
+ <td>7M</td>
+ <td>04-Nov-2017 00:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171103100331-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171103100331-20171104100412.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Nov-2017 12:46</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171103100331-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171103100331-20171104220420.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Nov-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171103100331-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171103100331-20171105100353.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Nov-2017 12:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171103220715-20171104100412.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171103220715-20171104100412.partial.mar</a></td>
+ <td>8M</td>
+ <td>04-Nov-2017 12:47</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171103220715-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171103220715-20171104220420.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Nov-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171103220715-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171103220715-20171105100353.partial.mar</a></td>
+ <td>9M</td>
+ <td>05-Nov-2017 12:43</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171103220715-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171103220715-20171105220721.partial.mar</a></td>
+ <td>9M</td>
+ <td>06-Nov-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171104100412-20171104220420.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171104100412-20171104220420.partial.mar</a></td>
+ <td>7M</td>
+ <td>05-Nov-2017 00:58</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171104100412-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171104100412-20171105100353.partial.mar</a></td>
+ <td>7M</td>
+ <td>05-Nov-2017 12:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171104100412-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171104100412-20171105220721.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Nov-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171104100412-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171104100412-20171106100122.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Nov-2017 12:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171104220420-20171105100353.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171104220420-20171105100353.partial.mar</a></td>
+ <td>5M</td>
+ <td>05-Nov-2017 12:44</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171104220420-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171104220420-20171105220721.partial.mar</a></td>
+ <td>5M</td>
+ <td>06-Nov-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171104220420-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171104220420-20171106100122.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Nov-2017 12:51</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171105100353-20171105220721.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171105100353-20171105220721.partial.mar</a></td>
+ <td>5M</td>
+ <td>06-Nov-2017 00:55</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171105100353-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171105100353-20171106100122.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Nov-2017 12:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/firefox-mozilla-central-58.0a1-win64-en-US-20171105220721-20171106100122.partial.mar">firefox-mozilla-central-58.0a1-win64-en-US-20171105220721-20171106100122.partial.mar</a></td>
+ <td>7M</td>
+ <td>06-Nov-2017 12:50</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/jsshell-linux-i686.zip">jsshell-linux-i686.zip</a></td>
+ <td>8M</td>
+ <td>15-Feb-2018 12:39</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/jsshell-linux-x86_64.zip">jsshell-linux-x86_64.zip</a></td>
+ <td>10M</td>
+ <td>15-Feb-2018 12:13</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/jsshell-mac.zip">jsshell-mac.zip</a></td>
+ <td>10M</td>
+ <td>15-Feb-2018 11:37</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/jsshell-mac64.zip">jsshell-mac64.zip</a></td>
+ <td>10M</td>
+ <td>13-Dec-2016 12:02</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/jsshell-win32.zip">jsshell-win32.zip</a></td>
+ <td>8M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/jsshell-win64.zip">jsshell-win64.zip</a></td>
+ <td>9M</td>
+ <td>15-Feb-2018 13:20</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/mozharness.zip">mozharness.zip</a></td>
+ <td>2M</td>
+ <td>15-Feb-2018 13:21</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/setup-stub.exe">setup-stub.exe</a></td>
+ <td>1M</td>
+ <td>26-Jul-2017 11:56</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/setup.exe">setup.exe</a></td>
+ <td>643K</td>
+ <td>26-Jul-2017 12:09</td>
+ </tr>
+
+
+
+ <tr>
+ <td>File</td>
+ <td><a href="/pub/firefox/nightly/latest-mozilla-central/toolchains.json">toolchains.json</a></td>
+ <td>1K</td>
+ <td>26-Jul-2017 12:09</td>
+ </tr>
+
+
+ </table>
+ </body>
+</html>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2018-05-17.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2018-05-17.html
new file mode 100644
index 0000000000..950b539de5
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2018-05-17.html
@@ -0,0 +1,30 @@
+<div class="column large-7 small-12 gutter padding-bottom-small">
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS and
+ experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/091-82859-20180516-222E2B66-E7F2-410F-AA71-A27B03AB84F6/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS High Sierra</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 10.13.</span
+ >
+ </li>
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/091-84914-20180516-222E2B66-E7F2-410F-AA71-A27B03AB84F6/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Sierra</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 10.12.6 or later.</span
+ >
+ </li>
+ <li class="document">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2018-09-19.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2018-09-19.html
new file mode 100644
index 0000000000..5c00c72219
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2018-09-19.html
@@ -0,0 +1,33 @@
+<div class="column large-7 small-12 gutter padding-bottom-small">
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS with
+ <a href="/safari/technology-preview/" class="nowrap"
+ >Safari Technology Preview</a
+ >
+ and experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/041-04649-20180910-76E7269A-B217-11E8-B40C-C08B7A641E38/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Mojave</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 10.14 beta.</span
+ >
+ </li>
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/041-04652-20180910-76E7269A-B217-11E8-B40C-C08B7A641E38/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS High Sierra</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 10.13.</span
+ >
+ </li>
+ <li class="document">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-06-04.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-06-04.html
new file mode 100644
index 0000000000..49ac12dad9
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-06-04.html
@@ -0,0 +1,33 @@
+<div class="column large-7 small-12 gutter padding-bottom-small">
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS with
+ <a href="/safari/technology-preview/" class="nowrap"
+ >Safari Technology Preview</a
+ >
+ and experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/001-09514-20200527-05f7a42c-d9a0-4a60-ba12-97f2145db993/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Catalina</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 10.15.</span
+ >
+ </li>
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/001-09573-20200527-5319cd41-1eb4-412a-817a-bf376957b539/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Mojave</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 10.14.</span
+ >
+ </li>
+ <li class="document">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-07-16.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-07-16.html
new file mode 100644
index 0000000000..2c376cc87a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-07-16.html
@@ -0,0 +1,36 @@
+<div class="column large-7 small-12 gutter padding-bottom-small">
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS with
+ <a href="/safari/technology-preview/" class="nowrap"
+ >Safari Technology Preview</a
+ >
+ and experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/001-22645-20200715-da14bc37-e5e6-4790-9e99-0b9d54293dd4/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Big Sur</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 11 beta.</span
+ ><span class="smaller lighter nowrap nowrap-small"
+ >Note: A known issue prevents this release of Safari Technology Preview
+ from working on DTK units.</span
+ >
+ </li>
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/001-26262-20200715-7968c880-7e9c-4ea5-9f90-f337c51066d8/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Catalina</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 10.15.</span
+ >
+ </li>
+ <li class="document">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-11-14.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-11-14.html
new file mode 100644
index 0000000000..a02d94d786
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2020-11-14.html
@@ -0,0 +1,33 @@
+<div class="column large-7 small-12 gutter padding-bottom-small">
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS with
+ <a href="/safari/technology-preview/" class="nowrap"
+ >Safari Technology Preview</a
+ >
+ and experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/001-62679-20201022-42e0d63a-527a-45af-beb1-02cd4095e341/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Big Sur</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 11 beta.</span
+ >
+ </li>
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/001-62651-20201022-6cd92e18-bfbe-48cc-9385-d84da8f3c24c/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Catalina</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 10.15.</span
+ >
+ </li>
+ <li class="document">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2021-06-08.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2021-06-08.html
new file mode 100644
index 0000000000..bfba2a7216
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2021-06-08.html
@@ -0,0 +1,33 @@
+<div class="column large-7 small-12 gutter padding-bottom-small">
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS with
+ <a href="/safari/technology-preview/" class="nowrap"
+ >Safari Technology Preview</a
+ >
+ and experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/071-45899-20210526-3fe7359c-0f20-4850-b6ec-da9b197119c2/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Big Sur</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 11.</span
+ >
+ </li>
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/071-44527-20210526-93430244-0334-4fae-878d-56502a656003/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Catalina</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 10.15.</span
+ >
+ </li>
+ <li class="document">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2021-10-28.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2021-10-28.html
new file mode 100644
index 0000000000..36f6b0e807
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2021-10-28.html
@@ -0,0 +1,31 @@
+<div class="column large-7 small-12 gutter padding-bottom-small">
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS with
+ <a href="/safari/technology-preview/" class="nowrap"
+ >Safari Technology Preview</a
+ >
+ and experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/002-26657-20211027-0354AC04-106E-4389-8084-861E45C1DC98/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Monterey</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS 12 beta.</span
+ >
+ </li>
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/002-26659-20211027-D948F693-7DCB-4C54-AA93-760F7DCB69D6/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Big Sur</a
+ ><br /><span class="smaller lighter">Requires macOS&nbsp;11.</span>
+ </li>
+ <li class="document">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-05-29.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-05-29.html
new file mode 100644
index 0000000000..b95b27b8ba
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-05-29.html
@@ -0,0 +1,44 @@
+<div class="callout">
+ <figure
+ class="app-icon large-icon safari-preview-icon"
+ aria-hidden="true"></figure>
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS with
+ <a href="/safari/technology-preview/" class="nowrap"
+ >Safari Technology Preview</a
+ >
+ and experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/012-08405-20220525-72BCCE23-C6E8-460A-851A-A29AC9C9BCF7/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Monterey</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS&nbsp;12.</span
+ >
+ </li>
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/012-08529-20220525-85875EC7-E4B8-4F5A-9571-85C51D6E381D/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS Big Sur</a
+ ><br /><span class="smaller lighter">Requires macOS&nbsp;11.</span>
+ </li>
+ <li class="document">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+ <div class="row gutter text-left">
+ <div class="column">
+ <p class="sosumi no-margin-bottom margin-top-small">Release</p>
+ <p class="smaller lighter no-margin">146</p>
+ </div>
+ <div class="column">
+ <p class="sosumi no-margin-bottom margin-top-small">Posted</p>
+ <p class="smaller lighter no-margin">May 25, 2022</p>
+ </div>
+ </div>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-06-22.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-06-22.html
new file mode 100644
index 0000000000..c019ff9330
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-06-22.html
@@ -0,0 +1,46 @@
+<div class="callout">
+ <figure
+ class="app-icon large-icon safari-preview-icon"
+ aria-hidden="true"></figure>
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS with
+ <a href="/safari/technology-preview/" class="nowrap"
+ >Safari Technology Preview</a
+ >
+ and experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/012-30324-20220621-99D72AEC-A0E2-4B48-8AC0-B567E3FD046B/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS&nbsp;Ventura</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS&nbsp;13 beta.</span
+ >
+ </li>
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/012-15389-20220621-FA8B8AC9-0442-432C-80B6-6016AB193FCA/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview for macOS&nbsp;Monterey</a
+ ><br /><span class="smaller lighter"
+ >Requires macOS&nbsp;12.3 or later.</span
+ >
+ </li>
+ <li class="document">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+ <div class="row gutter text-left">
+ <div class="column">
+ <p class="sosumi no-margin-bottom margin-top-small">Release</p>
+ <p class="smaller lighter no-margin">147</p>
+ </div>
+ <div class="column">
+ <p class="sosumi no-margin-bottom margin-top-small">Posted</p>
+ <p class="smaller lighter no-margin">June 21, 2022</p>
+ </div>
+ </div>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-07-05.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-07-05.html
new file mode 100644
index 0000000000..18bd2459ec
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-07-05.html
@@ -0,0 +1,37 @@
+<div class="callout">
+ <figure
+ class="app-icon large-icon safari-preview-icon"
+ aria-hidden="true"></figure>
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS with
+ <a href="/safari/technology-preview/" class="nowrap"
+ >Safari Technology Preview</a
+ >
+ and experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/012-32918-20220629-B3452905-0138-4CA9-A4E6-334B63585653/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview 148 for macOS&nbsp;Monterey</a
+ ><br /><span class="smaller lighter"
+ >Requires macOS&nbsp;12.3 or later.</span
+ >
+ </li>
+ <li class="document">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+ <div class="row gutter text-left">
+ <div class="column">
+ <p class="sosumi no-margin-bottom margin-top-small">Release</p>
+ <p class="smaller lighter no-margin">148</p>
+ </div>
+ <div class="column">
+ <p class="sosumi no-margin-bottom margin-top-small">Posted</p>
+ <p class="smaller lighter no-margin">June 29, 2022</p>
+ </div>
+ </div>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-07-07.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-07-07.html
new file mode 100644
index 0000000000..f73c9ad457
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-07-07.html
@@ -0,0 +1,46 @@
+<div class="callout">
+ <figure
+ class="app-icon large-icon safari-preview-icon"
+ aria-hidden="true"></figure>
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS with
+ <a href="/safari/technology-preview/" class="nowrap"
+ >Safari Technology Preview</a
+ >
+ and experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/012-38225-20220706-237860CD-5766-4F53-AAC7-1CE26023A959/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview<br />for macOS&nbsp;Ventura</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS&nbsp;13 beta&nbsp;3 or later.</span
+ >
+ </li>
+ <li class="dmg margin-top-small">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/012-32918-20220629-B3452905-0138-4CA9-A4E6-334B63585653/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview<br />for macOS&nbsp;Monterey</a
+ ><br /><span class="smaller lighter"
+ >Requires macOS&nbsp;12.3 or later.</span
+ >
+ </li>
+ <li class="document margin-top-small">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+ <div class="row gutter text-left">
+ <div class="column">
+ <p class="sosumi no-margin-bottom margin-top-small">Release</p>
+ <p class="smaller lighter no-margin">148</p>
+ </div>
+ <div class="column">
+ <p class="sosumi no-margin-bottom margin-top-small">Posted</p>
+ <p class="smaller lighter no-margin">June 29, 2022</p>
+ </div>
+ </div>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-08-25.html b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-08-25.html
new file mode 100644
index 0000000000..0f8bbe633e
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/safari-downloads/2022-08-25.html
@@ -0,0 +1,48 @@
+<div class="callout">
+ <figure
+ class="app-icon large-icon safari-preview-icon"
+ aria-hidden="true"
+ data-hires-status="pending"
+ ></figure>
+ <h4>Safari Technology Preview</h4>
+ <p class="margin-bottom-small">
+ Get a sneak peek at upcoming web technologies in macOS and iOS with
+ <a href="/safari/technology-preview/" class="nowrap"
+ >Safari Technology Preview</a
+ >
+ and experiment with these technologies in your websites and extensions.
+ </p>
+ <ul class="links small">
+ <li class="dmg" data-hires-status="pending">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/012-57606-20220824-F8A58F03-EAE7-4741-A1A4-4B13388819FD/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview<br />for macOS&nbsp;Ventura</a
+ ><br /><span class="smaller lighter nowrap nowrap-small"
+ >Requires macOS&nbsp;13 beta</span
+ >
+ </li>
+ <li class="dmg margin-top-small" data-hires-status="pending">
+ <a
+ class="inline"
+ href="https://secure-appldnld.apple.com/STP/012-56204-20220824-0BA2352E-A387-4BF9-964C-59A63B09E501/SafariTechnologyPreview.dmg"
+ >Safari Technology Preview<br />for macOS&nbsp;Monterey</a
+ ><br /><span class="smaller lighter"
+ >Requires macOS&nbsp;12.3 or later</span
+ >
+ </li>
+ <li class="document margin-top-small" data-hires-status="pending">
+ <a href="/safari/technology-preview/release-notes/">Release Notes</a>
+ </li>
+ </ul>
+ <div class="row gutter text-left">
+ <div class="column">
+ <p class="sosumi no-margin-bottom margin-top-small">Release</p>
+ <p class="smaller lighter no-margin">152</p>
+ </div>
+ <div class="column">
+ <p class="sosumi no-margin-bottom margin-top-small">Posted</p>
+ <p class="smaller lighter no-margin">August 24, 2022</p>
+ </div>
+ </div>
+</div>
diff --git a/testing/web-platform/tests/tools/wpt/tests/test_browser.py b/testing/web-platform/tests/tools/wpt/tests/test_browser.py
new file mode 100644
index 0000000000..d1d31e5099
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/test_browser.py
@@ -0,0 +1,386 @@
+# mypy: allow-untyped-defs
+
+import logging
+import os
+import inspect
+import requests
+import subprocess
+import sys
+from unittest import mock
+
+import pytest
+
+from packaging.specifiers import SpecifierSet
+from tools.wpt import browser
+
+
+logger = logging.getLogger()
+
+
+def test_all_browser_abc():
+ # Make sure all subclasses of Browser implement all abstract methods
+ # (except some known base classes). This is a basic sanity test in case
+ # we change the ABC interface of Browser as we only instantiate some
+ # products in unit tests.
+ classes = inspect.getmembers(browser)
+ for name, cls in classes:
+ if cls in (browser.Browser, browser.ChromeAndroidBase):
+ continue
+ if inspect.isclass(cls) and issubclass(cls, browser.Browser):
+ assert not inspect.isabstract(cls), "%s is abstract" % name
+
+
+def test_edgechromium_webdriver_supports_browser():
+ # EdgeDriver binary cannot be called.
+ edge = browser.EdgeChromium(logger)
+ edge.webdriver_version = mock.MagicMock(return_value=None)
+ assert not edge.webdriver_supports_browser('/usr/bin/edgedriver', '/usr/bin/edge')
+
+ # Browser binary cannot be called.
+ edge = browser.EdgeChromium(logger)
+ edge.webdriver_version = mock.MagicMock(return_value='70.0.1')
+ edge.version = mock.MagicMock(return_value=None)
+ assert edge.webdriver_supports_browser('/usr/bin/edgedriver', '/usr/bin/edge')
+
+ # Browser version matches.
+ edge = browser.EdgeChromium(logger)
+ edge.webdriver_version = mock.MagicMock(return_value='70.0.1')
+ edge.version = mock.MagicMock(return_value='70.1.5')
+ assert edge.webdriver_supports_browser('/usr/bin/edgedriver', '/usr/bin/edge')
+
+ # Browser version doesn't match.
+ edge = browser.EdgeChromium(logger)
+ edge.webdriver_version = mock.MagicMock(return_value='70.0.1')
+ edge.version = mock.MagicMock(return_value='69.0.1')
+ assert not edge.webdriver_supports_browser('/usr/bin/edgedriver', '/usr/bin/edge')
+
+
+# On Windows, webdriver_version directly calls _get_fileversion, so there is no
+# logic to test there.
+@pytest.mark.skipif(sys.platform.startswith('win'), reason='just uses _get_fileversion on Windows')
+@mock.patch('tools.wpt.browser.call')
+def test_edgechromium_webdriver_version(mocked_call):
+ edge = browser.EdgeChromium(logger)
+ webdriver_binary = '/usr/bin/edgedriver'
+
+ # Working cases.
+ mocked_call.return_value = 'Microsoft Edge WebDriver 84.0.4147.30'
+ assert edge.webdriver_version(webdriver_binary) == '84.0.4147.30'
+ mocked_call.return_value = 'Microsoft Edge WebDriver 87.0.1 (abcd1234-refs/branch-heads/4147@{#310})'
+ assert edge.webdriver_version(webdriver_binary) == '87.0.1'
+
+ # Various invalid version strings
+ mocked_call.return_value = 'Edge 84.0.4147.30 (dev)'
+ assert edge.webdriver_version(webdriver_binary) is None
+ mocked_call.return_value = 'Microsoft Edge WebDriver New 84.0.4147.30'
+ assert edge.webdriver_version(webdriver_binary) is None
+ mocked_call.return_value = ''
+ assert edge.webdriver_version(webdriver_binary) is None
+
+ # The underlying subprocess call throws.
+ mocked_call.side_effect = subprocess.CalledProcessError(5, 'cmd', output='Call failed')
+ assert edge.webdriver_version(webdriver_binary) is None
+
+
+def test_chrome_webdriver_supports_browser():
+ # ChromeDriver binary cannot be called.
+ chrome = browser.Chrome(logger)
+ chrome.webdriver_version = mock.MagicMock(return_value=None)
+ assert not chrome.webdriver_supports_browser('/usr/bin/chromedriver', '/usr/bin/chrome', 'stable')
+
+ # Browser binary cannot be called.
+ chrome = browser.Chrome(logger)
+ chrome.webdriver_version = mock.MagicMock(return_value='70.0.1')
+ chrome.version = mock.MagicMock(return_value=None)
+ assert chrome.webdriver_supports_browser('/usr/bin/chromedriver', '/usr/bin/chrome', 'stable')
+
+ # Browser version matches.
+ chrome = browser.Chrome(logger)
+ chrome.webdriver_version = mock.MagicMock(return_value='70.0.1')
+ chrome.version = mock.MagicMock(return_value='70.1.5')
+ assert chrome.webdriver_supports_browser('/usr/bin/chromedriver', '/usr/bin/chrome', 'stable')
+
+ # Browser version doesn't match.
+ chrome = browser.Chrome(logger)
+ chrome.webdriver_version = mock.MagicMock(return_value='70.0.1')
+ chrome.version = mock.MagicMock(return_value='69.0.1')
+ assert not chrome.webdriver_supports_browser('/usr/bin/chromedriver', '/usr/bin/chrome', 'stable')
+
+ # The dev channel switches between beta and ToT ChromeDriver, so is sometimes
+ # a version behind its ChromeDriver. As such, we accept browser version + 1 there.
+ chrome = browser.Chrome(logger)
+ chrome.webdriver_version = mock.MagicMock(return_value='70.0.1')
+ chrome.version = mock.MagicMock(return_value='70.1.0')
+ assert chrome.webdriver_supports_browser('/usr/bin/chromedriver', '/usr/bin/chrome', 'dev')
+ chrome.webdriver_version = mock.MagicMock(return_value='71.0.1')
+ assert chrome.webdriver_supports_browser('/usr/bin/chromedriver', '/usr/bin/chrome', 'dev')
+
+
+def test_chromium_webdriver_supports_browser():
+ # ChromeDriver binary cannot be called.
+ chromium = browser.Chromium(logger)
+ chromium.webdriver_version = mock.MagicMock(return_value=None)
+ assert not chromium.webdriver_supports_browser('/usr/bin/chromedriver', '/usr/bin/chrome')
+
+ # Browser binary cannot be called.
+ chromium = browser.Chromium(logger)
+ chromium.webdriver_version = mock.MagicMock(return_value='70.0.1')
+ chromium.version = mock.MagicMock(return_value=None)
+ assert chromium.webdriver_supports_browser('/usr/bin/chromedriver', '/usr/bin/chrome')
+
+ # Browser version matches.
+ chromium = browser.Chromium(logger)
+ chromium.webdriver_version = mock.MagicMock(return_value='70.0.1')
+ chromium.version = mock.MagicMock(return_value='70.0.1')
+ assert chromium.webdriver_supports_browser('/usr/bin/chromedriver', '/usr/bin/chrome')
+
+ # Browser version doesn't match.
+ chromium = browser.Chromium(logger)
+ chromium.webdriver_version = mock.MagicMock(return_value='70.0.1')
+ chromium.version = mock.MagicMock(return_value='69.0.1')
+ assert not chromium.webdriver_supports_browser('/usr/bin/chromedriver', '/usr/bin/chrome', 'stable')
+
+
+# On Windows, webdriver_version directly calls _get_fileversion, so there is no
+# logic to test there.
+@pytest.mark.skipif(sys.platform.startswith('win'), reason='just uses _get_fileversion on Windows')
+@mock.patch('tools.wpt.browser.call')
+def test_chrome_webdriver_version(mocked_call):
+ chrome = browser.Chrome(logger)
+ webdriver_binary = '/usr/bin/chromedriver'
+
+ # Working cases.
+ mocked_call.return_value = 'ChromeDriver 84.0.4147.30'
+ assert chrome.webdriver_version(webdriver_binary) == '84.0.4147.30'
+ mocked_call.return_value = 'ChromeDriver 87.0.1 (abcd1234-refs/branch-heads/4147@{#310})'
+ assert chrome.webdriver_version(webdriver_binary) == '87.0.1'
+
+ # Various invalid version strings
+ mocked_call.return_value = 'Chrome 84.0.4147.30 (dev)'
+ assert chrome.webdriver_version(webdriver_binary) is None
+ mocked_call.return_value = 'ChromeDriver New 84.0.4147.30'
+ assert chrome.webdriver_version(webdriver_binary) is None
+ mocked_call.return_value = ''
+ assert chrome.webdriver_version(webdriver_binary) is None
+
+ # The underlying subprocess call throws.
+ mocked_call.side_effect = subprocess.CalledProcessError(5, 'cmd', output='Call failed')
+ assert chrome.webdriver_version(webdriver_binary) is None
+
+
+@mock.patch('subprocess.check_output')
+def test_safari_version(mocked_check_output):
+ safari = browser.Safari(logger)
+
+ # Safari
+ mocked_check_output.return_value = b'Included with Safari 12.1 (14607.1.11)'
+ assert safari.version(webdriver_binary="safaridriver") == '12.1 (14607.1.11)'
+
+ # Safari Technology Preview
+ mocked_check_output.return_value = b'Included with Safari Technology Preview (Release 67, 13607.1.9.0.1)'
+ assert safari.version(webdriver_binary="safaridriver") == 'Technology Preview (Release 67, 13607.1.9.0.1)'
+
+@mock.patch('subprocess.check_output')
+def test_safari_version_errors(mocked_check_output):
+ safari = browser.Safari(logger)
+
+ # No webdriver_binary
+ assert safari.version() is None
+
+ # `safaridriver --version` return gibberish
+ mocked_check_output.return_value = b'gibberish'
+ assert safari.version(webdriver_binary="safaridriver") is None
+
+ # `safaridriver --version` fails (as it does for Safari <=12.0)
+ mocked_check_output.return_value = b'dummy'
+ mocked_check_output.side_effect = subprocess.CalledProcessError(1, 'cmd')
+ assert safari.version(webdriver_binary="safaridriver") is None
+
+
+@pytest.mark.parametrize(
+ "page_path",
+ sorted(
+ p.path
+ for p in os.scandir(os.path.join(os.path.dirname(__file__), "safari-downloads"))
+ if p.name.endswith(".html")
+ ),
+)
+@mock.patch("tools.wpt.browser.get")
+def test_safari_find_downloads_stp(mocked_get, page_path):
+ safari = browser.Safari(logger)
+
+ # Setup mock
+ response = requests.models.Response()
+ response.status_code = 200
+ response.encoding = "utf-8"
+ with open(page_path, "rb") as fp:
+ response._content = fp.read()
+ mocked_get.return_value = response
+
+ downloads = safari._find_downloads()
+
+ if page_path.endswith(
+ (
+ "2022-07-05.html",
+ )
+ ):
+ # occasionally STP is only shipped for a single OS version
+ assert len(downloads) == 1
+ else:
+ assert len(downloads) == 2
+
+
+@mock.patch("tools.wpt.browser.get")
+def test_safari_find_downloads_stp_20180517(mocked_get):
+ safari = browser.Safari(logger)
+ page_path = os.path.join(os.path.dirname(__file__), "safari-downloads", "2018-05-17.html")
+
+ # Setup mock
+ response = requests.models.Response()
+ response.status_code = 200
+ response.encoding = "utf-8"
+ with open(page_path, "rb") as fp:
+ response._content = fp.read()
+ mocked_get.return_value = response
+
+ downloads = safari._find_downloads()
+
+ assert len(downloads) == 2
+
+ assert downloads[0][0] == SpecifierSet("==10.13.*")
+ assert "10.12" not in downloads[0][0]
+ assert "10.13" in downloads[0][0]
+ assert "10.13.3" in downloads[0][0]
+ assert "10.14" not in downloads[0][0]
+
+ assert downloads[1][0] == SpecifierSet("~=10.12.6")
+ assert "10.12" not in downloads[1][0]
+ assert "10.12.6" in downloads[1][0]
+ assert "10.12.9" in downloads[1][0]
+ assert "10.13" not in downloads[1][0]
+
+
+@mock.patch("tools.wpt.browser.get")
+def test_safari_find_downloads_stp_20220529(mocked_get):
+ safari = browser.Safari(logger)
+ page_path = os.path.join(os.path.dirname(__file__), "safari-downloads", "2022-05-29.html")
+
+ # Setup mock
+ response = requests.models.Response()
+ response.status_code = 200
+ response.encoding = "utf-8"
+ with open(page_path, "rb") as fp:
+ response._content = fp.read()
+ mocked_get.return_value = response
+
+ downloads = safari._find_downloads()
+
+ assert len(downloads) == 2
+
+ assert downloads[0][0] == SpecifierSet("==12.*")
+ assert "11.4" not in downloads[0][0]
+ assert "12.0" in downloads[0][0]
+ assert "12.5" in downloads[0][0]
+ assert "13.0" not in downloads[0][0]
+
+ assert downloads[1][0] == SpecifierSet("==11.*")
+ assert "10.15.7" not in downloads[1][0]
+ assert "11.0.1" in downloads[1][0]
+ assert "11.3" in downloads[1][0]
+ assert "11.5" in downloads[1][0]
+ assert "12.0" not in downloads[1][0]
+
+
+@mock.patch("tools.wpt.browser.get")
+def test_safari_find_downloads_stp_20220707(mocked_get):
+ safari = browser.Safari(logger)
+ page_path = os.path.join(os.path.dirname(__file__), "safari-downloads", "2022-07-07.html")
+
+ # Setup mock
+ response = requests.models.Response()
+ response.status_code = 200
+ response.encoding = "utf-8"
+ with open(page_path, "rb") as fp:
+ response._content = fp.read()
+ mocked_get.return_value = response
+
+ downloads = safari._find_downloads()
+
+ assert len(downloads) == 2
+
+ assert downloads[0][0] == SpecifierSet("==13.*")
+ assert "12.4" not in downloads[0][0]
+ assert "13.0" in downloads[0][0]
+ assert "13.5" in downloads[0][0]
+ assert "14.0" not in downloads[0][0]
+
+ assert downloads[1][0] == SpecifierSet("~=12.3")
+ assert "11.5" not in downloads[1][0]
+ assert "12.2" not in downloads[1][0]
+ assert "12.3" in downloads[1][0]
+ assert "12.5" in downloads[1][0]
+ assert "13.0" not in downloads[1][0]
+
+
+@mock.patch('subprocess.check_output')
+def test_webkitgtk_minibrowser_version(mocked_check_output):
+ webkitgtk_minibrowser = browser.WebKitGTKMiniBrowser(logger)
+
+ # stable version
+ mocked_check_output.return_value = b'WebKitGTK 2.26.1\n'
+ assert webkitgtk_minibrowser.version(binary='MiniBrowser') == '2.26.1'
+
+ # nightly version
+ mocked_check_output.return_value = b'WebKitGTK 2.27.1 (r250823)\n'
+ assert webkitgtk_minibrowser.version(binary='MiniBrowser') == '2.27.1 (r250823)'
+
+@mock.patch('subprocess.check_output')
+def test_webkitgtk_minibrowser_version_errors(mocked_check_output):
+ webkitgtk_minibrowser = browser.WebKitGTKMiniBrowser(logger)
+
+ # No binary
+ assert webkitgtk_minibrowser.version() is None
+
+ # `MiniBrowser --version` return gibberish
+ mocked_check_output.return_value = b'gibberish'
+ assert webkitgtk_minibrowser.version(binary='MiniBrowser') is None
+
+ # `MiniBrowser --version` fails (as it does for MiniBrowser <= 2.26.0)
+ mocked_check_output.return_value = b'dummy'
+ mocked_check_output.side_effect = subprocess.CalledProcessError(1, 'cmd')
+ assert webkitgtk_minibrowser.version(binary='MiniBrowser') is None
+
+
+# The test below doesn't work on Windows because distutils find_binary()
+# on Windows only works if the binary name ends with a ".exe" suffix.
+# But, WebKitGTK itself doesn't support Windows, so lets skip the test.
+@pytest.mark.skipif(sys.platform.startswith('win'), reason='test not needed on Windows')
+@mock.patch('os.path.isfile')
+def test_webkitgtk_minibrowser_find_binary(mocked_os_path_isfile):
+ webkitgtk_minibrowser = browser.WebKitGTKMiniBrowser(logger)
+
+ # No MiniBrowser found
+ mocked_os_path_isfile.side_effect = lambda path: path == '/etc/passwd'
+ assert webkitgtk_minibrowser.find_binary() is None
+
+ # Found on the default Fedora path
+ fedora_minibrowser_path = '/usr/libexec/webkit2gtk-4.0/MiniBrowser'
+ mocked_os_path_isfile.side_effect = lambda path: path == fedora_minibrowser_path
+ assert webkitgtk_minibrowser.find_binary() == fedora_minibrowser_path
+
+ # Found on the default Debian path for AMD64 (gcc not available)
+ debian_minibrowser_path_amd64 = '/usr/lib/x86_64-linux-gnu/webkit2gtk-4.0/MiniBrowser'
+ mocked_os_path_isfile.side_effect = lambda path: path == debian_minibrowser_path_amd64
+ assert webkitgtk_minibrowser.find_binary() == debian_minibrowser_path_amd64
+
+ # Found on the default Debian path for AMD64 (gcc available but gives an error)
+ debian_minibrowser_path_amd64 = '/usr/lib/x86_64-linux-gnu/webkit2gtk-4.0/MiniBrowser'
+ mocked_os_path_isfile.side_effect = lambda path: path in [debian_minibrowser_path_amd64, '/usr/bin/gcc']
+ with mock.patch('subprocess.check_output', return_value = b'error', side_effect = subprocess.CalledProcessError(1, 'cmd')):
+ assert webkitgtk_minibrowser.find_binary() == debian_minibrowser_path_amd64
+
+ # Found on the default Debian path for ARM64 (gcc available)
+ debian_minibrowser_path_arm64 = '/usr/lib/aarch64-linux-gnu/webkit2gtk-4.0/MiniBrowser'
+ mocked_os_path_isfile.side_effect = lambda path: path in [debian_minibrowser_path_arm64, '/usr/bin/gcc']
+ with mock.patch('subprocess.check_output', return_value = b'aarch64-linux-gnu'):
+ assert webkitgtk_minibrowser.find_binary() == debian_minibrowser_path_arm64
diff --git a/testing/web-platform/tests/tools/wpt/tests/test_install.py b/testing/web-platform/tests/tools/wpt/tests/test_install.py
new file mode 100644
index 0000000000..2ee8f2bc0a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/test_install.py
@@ -0,0 +1,81 @@
+# mypy: allow-untyped-defs
+
+import logging
+import os
+import sys
+
+import pytest
+
+from tools.wpt import browser, utils, wpt
+
+
+@pytest.mark.slow
+@pytest.mark.remote_network
+def test_install_chromium():
+ venv_path = os.path.join(wpt.localpaths.repo_root, wpt.venv_dir())
+ channel = "nightly"
+ dest = os.path.join(wpt.localpaths.repo_root, wpt.venv_dir(), "browsers", channel)
+ if sys.platform == "win32":
+ chromium_path = os.path.join(dest, "chrome-win")
+ elif sys.platform == "darwin":
+ chromium_path = os.path.join(dest, "chrome-mac")
+ else:
+ chromium_path = os.path.join(dest, "chrome-linux")
+
+ if os.path.exists(chromium_path):
+ utils.rmtree(chromium_path)
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["install", "chromium", "browser"])
+ assert excinfo.value.code == 0
+ assert os.path.exists(chromium_path)
+
+ chromium = browser.Chromium(logging.getLogger("Chromium"))
+ binary = chromium.find_binary(venv_path, channel)
+ assert binary is not None and os.path.exists(binary)
+
+ utils.rmtree(chromium_path)
+
+
+@pytest.mark.slow
+@pytest.mark.remote_network
+def test_install_chrome():
+ with pytest.raises(NotImplementedError):
+ wpt.main(argv=["install", "chrome", "browser"])
+
+
+@pytest.mark.slow
+@pytest.mark.remote_network
+def test_install_chrome_chromedriver_by_version():
+ # This is not technically an integration test as we do not want to require Chrome Stable to run it.
+ chrome = browser.Chrome(logging.getLogger("Chrome"))
+ if sys.platform == "win32":
+ dest = os.path.join(wpt.localpaths.repo_root, wpt.venv_dir(), "Scripts")
+ chromedriver_path = os.path.join(dest, "chrome", "chromedriver.exe")
+ else:
+ dest = os.path.join(wpt.localpaths.repo_root, wpt.venv_dir(), "bin")
+ chromedriver_path = os.path.join(dest, "chrome", "chromedriver")
+ if os.path.exists(chromedriver_path):
+ os.unlink(chromedriver_path)
+ # This is a stable version.
+ binary_path = chrome.install_webdriver_by_version(dest=dest, version="84.0.4147.89")
+ assert binary_path == chromedriver_path
+ assert os.path.exists(chromedriver_path)
+ os.unlink(chromedriver_path)
+
+
+@pytest.mark.slow
+@pytest.mark.remote_network
+@pytest.mark.xfail(sys.platform == "win32",
+ reason="https://github.com/web-platform-tests/wpt/issues/17074")
+def test_install_firefox():
+ if sys.platform == "darwin":
+ fx_path = os.path.join(wpt.localpaths.repo_root, wpt.venv_dir(), "browsers", "nightly", "Firefox Nightly.app")
+ else:
+ fx_path = os.path.join(wpt.localpaths.repo_root, wpt.venv_dir(), "browsers", "nightly", "firefox")
+ if os.path.exists(fx_path):
+ utils.rmtree(fx_path)
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["install", "firefox", "browser", "--channel=nightly"])
+ assert excinfo.value.code == 0
+ assert os.path.exists(fx_path)
+ utils.rmtree(fx_path)
diff --git a/testing/web-platform/tests/tools/wpt/tests/test_markdown.py b/testing/web-platform/tests/tools/wpt/tests/test_markdown.py
new file mode 100644
index 0000000000..4f493826c1
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/test_markdown.py
@@ -0,0 +1,37 @@
+# mypy: allow-untyped-defs
+
+from tools.wpt import markdown
+
+def test_format_comment_title():
+ assert '# Browser #' == markdown.format_comment_title("browser")
+ assert '# Browser (channel) #' == markdown.format_comment_title("browser:channel")
+
+def test_markdown_adjust():
+ assert '\\t' == markdown.markdown_adjust('\t')
+ assert '\\r' == markdown.markdown_adjust('\r')
+ assert '\\n' == markdown.markdown_adjust('\n')
+ assert '' == markdown.markdown_adjust('`')
+ assert '\\|' == markdown.markdown_adjust('|')
+ assert '\\t\\r\\n\\|' == markdown.markdown_adjust('\t\r\n`|')
+
+result = ''
+def log(text):
+ global result
+ result += text
+
+def test_table():
+ global result
+ headings = ['h1','h2']
+ data = [['0', '1']]
+ markdown.table(headings, data, log)
+ assert ("| h1 | h2 |"
+ "|----|----|"
+ "| 0 | 1 |") == result
+
+ result = ''
+ data.append(['aaa', 'bb'])
+ markdown.table(headings, data, log)
+ assert ("| h1 | h2 |"
+ "|-----|----|"
+ "| 0 | 1 |"
+ "| aaa | bb |") == result
diff --git a/testing/web-platform/tests/tools/wpt/tests/test_revlist.py b/testing/web-platform/tests/tools/wpt/tests/test_revlist.py
new file mode 100644
index 0000000000..d5645868ef
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/test_revlist.py
@@ -0,0 +1,156 @@
+# mypy: allow-untyped-defs
+
+from unittest import mock
+
+from tools.wpt import revlist
+
+
+def test_calculate_cutoff_date():
+ assert revlist.calculate_cutoff_date(3601, 3600, 0) == 3600
+ assert revlist.calculate_cutoff_date(3600, 3600, 0) == 3600
+ assert revlist.calculate_cutoff_date(3599, 3600, 0) == 0
+ assert revlist.calculate_cutoff_date(3600, 3600, 1) == 1
+ assert revlist.calculate_cutoff_date(3600, 3600, -1) == 3599
+
+
+def test_parse_epoch():
+ assert revlist.parse_epoch("10h") == 36000
+ assert revlist.parse_epoch("10d") == 864000
+ assert revlist.parse_epoch("10w") == 6048000
+
+def check_revisions(tagged_revisions, expected_revisions):
+ for tagged, expected in zip(tagged_revisions, expected_revisions):
+ assert tagged == expected
+
+@mock.patch('subprocess.check_output')
+def test_get_epoch_revisions(mocked_check_output):
+ # check:
+ #
+ # * Several revisions in the same epoch offset (BC, DEF, HIJ, and LM)
+ # * Revision with a timestamp exactly equal to the epoch boundary (H)
+ # * Revision in non closed interval (O)
+ #
+ # mon tue wed thu fri sat sun mon thu wed
+ # | | | | | | | | |
+ # -A---B-C---DEF---G---H--IJ----------K-----L-M----N--O--
+ # ^
+ # until
+ # max_count: 5; epoch: 1d
+ # Expected result: N,M,K,J,G,F,C,A
+ epoch = 86400
+ until = 1188000 # Wednesday, 14 January 1970 18:00:00 UTC
+ mocked_check_output.return_value = b'''
+merge_pr_O O 1166400 _wed_
+merge_pr_N N 1080000 _tue_
+merge_pr_M M 1015200 _mon_
+merge_pr_L L 993600 _mon_
+merge_pr_K K 907200 _sun_
+merge_pr_J J 734400 _fri_
+merge_pr_I I 712800 _fri_
+merge_pr_H H 691200 _fri_
+merge_pr_G G 648000 _thu_
+merge_pr_F F 583200 _wed_
+merge_pr_E E 561600 _wed_
+merge_pr_D D 540000 _wed_
+merge_pr_C C 475200 _tue_
+merge_pr_B B 453600 _tue_
+merge_pr_A A 388800 _mon_
+'''
+ tagged_revisions = revlist.get_epoch_revisions(epoch, until, 8)
+ check_revisions(tagged_revisions, ['N', 'M', 'K', 'J', 'G', 'F', 'C', 'A'])
+ assert len(list(tagged_revisions)) == 0 # generator exhausted
+
+
+ # check: max_count with enough candidate items in the revision list
+ #
+ # mon tue wed thu fri sat sun mon
+ # | | | | | | |
+ # ------B-----C-----D----E-----F-----G------H---
+ # ^
+ # until
+ # max_count: 5; epoch: 1d
+ # Expected result: G,F,E,D,C
+ epoch = 86400
+ until = 1015200 # Monday, 12 January 1970 18:00:00 UTC
+ mocked_check_output.return_value = b'''
+merge_pr_H H 993600 _mon_
+merge_pr_G G 907200 _sun_
+merge_pr_F F 820800 _sat_
+merge_pr_E E 734400 _fri_
+merge_pr_D D 648000 _thu_
+merge_pr_C C 561600 _wed_
+merge_pr_B B 475200 _thu_
+'''
+ tagged_revisions = revlist.get_epoch_revisions(epoch, until, 5)
+ check_revisions(tagged_revisions, ['G', 'F', 'E', 'D', 'C'])
+ assert len(list(tagged_revisions)) == 0 # generator exhausted
+
+
+ # check: max_count with less returned candidates items than the needed
+ #
+ # mon tue wed thu fri sat sun mon
+ # | | | | | | |
+ # -----------------------------F-----G------H---
+ # ^
+ # until
+ # max_count: 5; epoch: 1d
+ # Expected result: G,F
+ epoch = 86400
+ until = 1015200 # Monday, 12 January 1970 18:00:00 UTC
+ mocked_check_output.return_value = b'''
+merge_pr_H H 993600 _mon_
+merge_pr_G G 907200 _sun_
+merge_pr_F F 820800 _sat_
+'''
+ tagged_revisions = revlist.get_epoch_revisions(epoch, until, 5)
+ check_revisions(tagged_revisions, ['G', 'F'])
+ assert len(list(tagged_revisions)) == 0 # generator exhausted
+
+
+ # check: initial until value is on an epoch boundary
+ #
+ # sud mon tue wed thu
+ # | | | |
+ # -F-G-----------------H
+ # ^
+ # until
+ # max_count: 3; epoch: 1d
+ # Expected result: G,F
+ # * H is skipped because because the epoch
+ # interval is defined as an right-open interval
+ # * G is included but in the Monday's interval
+ # * F is included because it is the unique candidate
+ # included in the Sunday's interval
+ epoch = 86400
+ until = 1296000 # Thursday, 15 January 1970 0:00:00 UTC
+ mocked_check_output.return_value = b'''
+merge_pr_H H 1296000 _wed_
+merge_pr_G G 950400 _mon_
+merge_pr_F F 921600 _sud_
+'''
+ tagged_revisions = revlist.get_epoch_revisions(epoch, until, 3)
+ check_revisions(tagged_revisions, ['G', 'F'])
+ assert len(list(tagged_revisions)) == 0 # generator exhausted
+
+
+ # check: until aligned with Monday, 5 January 1970 0:00:00 (345600)
+ # not with Thursday, 1 January 1970 0:00:00 (0)
+ #
+ # sud mon tue wed thu
+ # | | | |
+ # -F-G--------------H---
+ # ^
+ # until
+ # max_count: 1; epoch: 1w
+ # Expected result: F
+ epoch = 604800
+ moday = 950400 # Monday, 12 January 1970 00:00:00 UTC
+ until = moday + 345600 # 1296000. Thursday, 15 January 1970 0:00:00 UTC
+ mocked_check_output.return_value = b'''
+merge_pr_H H 1180800 _wed_
+merge_pr_G G 950400 _mon_
+merge_pr_F F 921600 _sud_
+'''
+ tagged_revisions = revlist.get_epoch_revisions(epoch, until, 1)
+ check_revisions(tagged_revisions, ['F'])
+ assert len(list(tagged_revisions)) == 0 # generator exhausted
diff --git a/testing/web-platform/tests/tools/wpt/tests/test_run.py b/testing/web-platform/tests/tools/wpt/tests/test_run.py
new file mode 100644
index 0000000000..f0e0d3c3ed
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/test_run.py
@@ -0,0 +1,76 @@
+# mypy: allow-untyped-defs
+
+import tempfile
+import shutil
+import sys
+from unittest import mock
+
+import pytest
+
+from tools.wpt import run
+from tools import localpaths # noqa: F401
+from wptrunner.browsers import product_list
+
+
+@pytest.fixture(scope="module")
+def venv():
+ from tools.wpt import virtualenv
+
+ class Virtualenv(virtualenv.Virtualenv):
+ def __init__(self):
+ self.path = tempfile.mkdtemp()
+ self.skip_virtualenv_setup = False
+
+ def create(self):
+ return
+
+ def activate(self):
+ return
+
+ def start(self):
+ return
+
+ def install(self, *requirements):
+ return
+
+ def install_requirements(self, requirements_path):
+ return
+
+ venv = Virtualenv()
+ yield venv
+
+ shutil.rmtree(venv.path)
+
+
+@pytest.fixture(scope="module")
+def logger():
+ run.setup_logging({})
+
+
+@pytest.mark.parametrize("platform", ["Windows", "Linux", "Darwin"])
+def test_check_environ_fail(platform):
+ m_open = mock.mock_open(read_data=b"")
+
+ with mock.patch.object(run, "open", m_open):
+ with mock.patch.object(run.platform, "uname",
+ return_value=(platform, "", "", "", "", "")):
+ with pytest.raises(run.WptrunError) as excinfo:
+ run.check_environ("foo")
+
+ assert "wpt make-hosts-file" in str(excinfo.value)
+
+
+@pytest.mark.parametrize("product", product_list)
+def test_setup_wptrunner(venv, logger, product):
+ if product == "firefox_android":
+ pytest.skip("Android emulator doesn't work on docker")
+ parser = run.create_parser()
+ kwargs = vars(parser.parse_args(["--channel=nightly", product]))
+ kwargs["prompt"] = False
+ # Hack to get a real existing path
+ kwargs["binary"] = sys.argv[0]
+ kwargs["webdriver_binary"] = sys.argv[0]
+ if kwargs["product"] == "sauce":
+ kwargs["sauce_browser"] = "firefox"
+ kwargs["sauce_version"] = "63"
+ run.setup_wptrunner(venv, **kwargs)
diff --git a/testing/web-platform/tests/tools/wpt/tests/test_testfiles.py b/testing/web-platform/tests/tools/wpt/tests/test_testfiles.py
new file mode 100644
index 0000000000..790ee70a63
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/test_testfiles.py
@@ -0,0 +1,71 @@
+# mypy: allow-untyped-defs
+
+import os.path
+from unittest.mock import patch
+
+from tools.manifest.manifest import Manifest
+from tools.wpt import testfiles
+
+
+def test_getrevish_kwarg():
+ assert testfiles.get_revish(revish="abcdef") == "abcdef"
+ assert testfiles.get_revish(revish="123456\n") == "123456"
+
+
+def test_getrevish_implicit():
+ with patch("tools.wpt.testfiles.branch_point", return_value="base"):
+ assert testfiles.get_revish() == "base..HEAD"
+
+
+def test_affected_testfiles():
+ manifest_json = {
+ "items": {
+ "crashtest": {
+ "a": {
+ "b": {
+ "c": {
+ "foo-crash.html": [
+ "acdefgh123456",
+ ["null", {}],
+ ]
+ }
+ }
+ }
+ }
+ },
+ "url_base": "/",
+ "version": 8,
+ }
+ manifest = Manifest.from_json("/", manifest_json)
+ with patch("tools.wpt.testfiles.load_manifest", return_value=manifest):
+ # Dependent affected tests are determined by walking the filesystem,
+ # which doesn't work in our test setup. We would need to refactor
+ # testfiles.affected_testfiles or have a more complex test setup to
+ # support testing those.
+ full_test_path = os.path.join(
+ testfiles.wpt_root, "a", "b", "c", "foo-crash.html")
+ tests_changed, _ = testfiles.affected_testfiles([full_test_path])
+ assert tests_changed == {full_test_path}
+
+
+def test_exclude_ignored():
+ default_ignored = [
+ "resources/testharness.js",
+ "resources/testharnessreport.js",
+ "resources/testdriver.js",
+ "resources/testdriver-vendor.js",
+ ]
+ default_ignored_abs = sorted(os.path.join(testfiles.wpt_root, x) for x in default_ignored)
+ default_changed = [
+ "foo/bar.html"
+ ]
+ default_changed_abs = sorted(os.path.join(testfiles.wpt_root, x) for x in default_changed)
+ files = default_ignored + default_changed
+
+ changed, ignored = testfiles.exclude_ignored(files, None)
+ assert sorted(changed) == default_changed_abs
+ assert sorted(ignored) == default_ignored_abs
+
+ changed, ignored = testfiles.exclude_ignored(files, [])
+ assert sorted(changed) == sorted(default_changed_abs + default_ignored_abs)
+ assert sorted(ignored) == []
diff --git a/testing/web-platform/tests/tools/wpt/tests/test_update_expectations.py b/testing/web-platform/tests/tools/wpt/tests/test_update_expectations.py
new file mode 100644
index 0000000000..a278cb1262
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/test_update_expectations.py
@@ -0,0 +1,130 @@
+# mypy: ignore-errors
+
+import json
+import os
+
+import pytest
+
+from tools.wpt import wpt
+from tools.wptrunner.wptrunner import manifestexpected
+from localpaths import repo_root
+
+@pytest.fixture
+def metadata_file(tmp_path):
+ created_files = []
+
+ def create_metadata(test_id, subtest_name, product, status="OK", subtest_status="PASS", channel="nightly"):
+ run_info = {
+ "os": "linux",
+ "processor": "x86_64",
+ "version": "Ubuntu 20.04",
+ "os_version": "20.04",
+ "bits": 64,
+ "linux_distro": "Ubuntu",
+ "product": product,
+ "debug": False,
+ "browser_version": "98.0.2",
+ "browser_channel": channel,
+ "verify": False,
+ "headless": True,
+ }
+
+ result = {
+ "test": test_id,
+ "subtests": [
+ {
+ "name": subtest_name,
+ "status": subtest_status,
+ "message": None,
+ "known_intermittent": []
+ }
+ ],
+ "status": status,
+ "message": None,
+ "duration": 555,
+ "known_intermittent": []
+ }
+
+ if status != "OK":
+ result["expected"] = "OK"
+
+ if subtest_status != "PASS":
+ result["subtests"][0]["expected"] = "PASS"
+
+ data = {
+ "time_start": 1648629686379,
+ "run_info": run_info,
+ "results": [result],
+ "time_end": 1648629698721
+ }
+
+ path = os.path.join(tmp_path, f"wptreport-{len(created_files)}.json")
+ with open(path, "w") as f:
+ json.dump(data, f)
+
+ created_files.append(path)
+ return run_info, path
+
+ yield create_metadata
+
+ for path in created_files:
+ os.unlink(path)
+
+
+def test_update(tmp_path, metadata_file):
+ # This has to be a real test so it's in the manifest
+ test_id = "/infrastructure/assumptions/cookie.html"
+ subtest_name = "cookies work in default browse settings"
+ test_path = os.path.join("infrastructure",
+ "assumptions",
+ "cookie.html")
+ run_info_firefox, path_firefox = metadata_file(test_id,
+ subtest_name,
+ "firefox",
+ subtest_status="FAIL",
+ channel="nightly")
+ run_info_chrome, path_chrome = metadata_file(test_id,
+ subtest_name,
+ "chrome",
+ status="ERROR",
+ subtest_status="NOTRUN",
+ channel="dev")
+
+ metadata_path = str(os.path.join(tmp_path, "metadata"))
+ os.makedirs(metadata_path)
+ wptreport_paths = [path_firefox, path_chrome]
+
+ update_properties = {"properties": ["product"]}
+ with open(os.path.join(metadata_path, "update_properties.json"), "w") as f:
+ json.dump(update_properties, f)
+
+ args = ["update-expectations",
+ "--manifest", os.path.join(repo_root, "MANIFEST.json"),
+ "--metadata", metadata_path,
+ "--log-mach-level", "debug"]
+ args += wptreport_paths
+
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=args)
+
+ assert excinfo.value.code == 0
+
+ expectation_path = os.path.join(metadata_path, test_path + ".ini")
+
+ assert os.path.exists(expectation_path)
+
+ firefox_expected = manifestexpected.get_manifest(metadata_path,
+ test_path,
+ "/",
+ run_info_firefox)
+ # Default expected isn't stored
+ with pytest.raises(KeyError):
+ assert firefox_expected.get_test(test_id).get("expected")
+ assert firefox_expected.get_test(test_id).get_subtest(subtest_name).expected == "FAIL"
+
+ chrome_expected = manifestexpected.get_manifest(metadata_path,
+ test_path,
+ "/",
+ run_info_chrome)
+ assert chrome_expected.get_test(test_id).expected == "ERROR"
+ assert chrome_expected.get_test(test_id).get_subtest(subtest_name).expected == "NOTRUN"
diff --git a/testing/web-platform/tests/tools/wpt/tests/test_wpt.py b/testing/web-platform/tests/tools/wpt/tests/test_wpt.py
new file mode 100644
index 0000000000..f5671f3743
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tests/test_wpt.py
@@ -0,0 +1,406 @@
+# mypy: allow-untyped-defs
+
+import errno
+import os
+import shutil
+import socket
+import subprocess
+import sys
+import tempfile
+import time
+
+from urllib.request import urlopen
+from urllib.error import URLError
+
+import pytest
+
+here = os.path.abspath(os.path.dirname(__file__))
+from tools.wpt import utils, wpt
+
+
+def is_port_8000_in_use():
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ s.bind(("127.0.0.1", 8000))
+ except OSError as e:
+ if e.errno == errno.EADDRINUSE:
+ return True
+ else:
+ raise e
+ finally:
+ s.close()
+ return False
+
+
+def get_persistent_manifest_path():
+ directory = ("~/meta" if os.environ.get('TRAVIS') == "true"
+ else wpt.localpaths.repo_root)
+ return os.path.join(directory, "MANIFEST.json")
+
+
+@pytest.fixture(scope="module", autouse=True)
+def init_manifest():
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["manifest", "--no-download",
+ "--path", get_persistent_manifest_path()])
+ assert excinfo.value.code == 0
+
+
+@pytest.fixture
+def manifest_dir():
+ try:
+ path = tempfile.mkdtemp()
+ shutil.copyfile(get_persistent_manifest_path(),
+ os.path.join(path, "MANIFEST.json"))
+ yield path
+ finally:
+ utils.rmtree(path)
+
+
+@pytest.fixture
+def temp_test():
+ os.makedirs("../../.tools-tests")
+ test_count = {"value": 0}
+
+ def make_test(body):
+ test_count["value"] += 1
+ test_name = ".tools-tests/%s.html" % test_count["value"]
+ test_path = "../../%s" % test_name
+
+ with open(test_path, "w") as handle:
+ handle.write("""
+ <!DOCTYPE html>
+ <script src="/resources/testharness.js"></script>
+ <script src="/resources/testharnessreport.js"></script>
+ <script>%s</script>
+ """ % body)
+
+ return test_name
+
+ yield make_test
+
+ utils.rmtree("../../.tools-tests")
+
+
+def test_missing():
+ with pytest.raises(SystemExit):
+ wpt.main(argv=["#missing-command"])
+
+
+def test_help():
+ # TODO: It seems like there's a bug in argparse that makes this argument order required
+ # should try to work around that
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["--help"])
+ assert excinfo.value.code == 0
+
+
+def test_load_commands():
+ commands = wpt.load_commands()
+ # The `wpt run` command has conditional requirements.
+ assert "conditional_requirements" in commands["run"]
+
+
+@pytest.mark.slow
+@pytest.mark.skipif(sys.platform == "win32",
+ reason="https://github.com/web-platform-tests/wpt/issues/28745")
+def test_list_tests(manifest_dir):
+ """The `--list-tests` option should not produce an error under normal
+ conditions."""
+
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["run", "--metadata", manifest_dir, "--list-tests",
+ "--channel", "dev", "--yes",
+ # Taskcluster machines do not have GPUs, so use software rendering via --enable-swiftshader.
+ "--enable-swiftshader",
+ "chrome", "/dom/nodes/Element-tagName.html"])
+ assert excinfo.value.code == 0
+
+
+@pytest.mark.slow
+def test_list_tests_missing_manifest(manifest_dir):
+ """The `--list-tests` option should not produce an error in the absence of
+ a test manifest file."""
+
+ os.remove(os.path.join(manifest_dir, "MANIFEST.json"))
+
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["run",
+ # This test triggers the creation of a new manifest
+ # file which is not necessary to ensure successful
+ # process completion. Specifying the current directory
+ # as the tests source via the --tests` option
+ # drastically reduces the time to execute the test.
+ "--tests", here,
+ "--metadata", manifest_dir,
+ "--list-tests",
+ "--yes",
+ "firefox", "/dom/nodes/Element-tagName.html"])
+
+ assert excinfo.value.code == 0
+
+
+@pytest.mark.slow
+def test_list_tests_invalid_manifest(manifest_dir):
+ """The `--list-tests` option should not produce an error in the presence of
+ a malformed test manifest file."""
+
+ manifest_filename = os.path.join(manifest_dir, "MANIFEST.json")
+
+ assert os.path.isfile(manifest_filename)
+
+ with open(manifest_filename, "a+") as handle:
+ handle.write("extra text which invalidates the file")
+
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["run",
+ # This test triggers the creation of a new manifest
+ # file which is not necessary to ensure successful
+ # process completion. Specifying the current directory
+ # as the tests source via the --tests` option
+ # drastically reduces the time to execute the test.
+ "--tests", here,
+ "--metadata", manifest_dir,
+ "--list-tests",
+ "--yes",
+ "firefox", "/dom/nodes/Element-tagName.html"])
+
+ assert excinfo.value.code == 0
+
+
+@pytest.mark.slow
+@pytest.mark.remote_network
+@pytest.mark.skipif(sys.platform == "win32",
+ reason="https://github.com/web-platform-tests/wpt/issues/28745")
+def test_run_zero_tests():
+ """A test execution describing zero tests should be reported as an error
+ even in the presence of the `--no-fail-on-unexpected` option."""
+ if is_port_8000_in_use():
+ pytest.skip("port 8000 already in use")
+
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["run", "--yes", "--no-pause", "--channel", "dev",
+ # Taskcluster machines do not have GPUs, so use software rendering via --enable-swiftshader.
+ "--enable-swiftshader",
+ "chrome", "/non-existent-dir/non-existent-file.html"])
+ assert excinfo.value.code != 0
+
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["run", "--yes", "--no-pause", "--no-fail-on-unexpected",
+ "--channel", "dev",
+ # Taskcluster machines do not have GPUs, so use software rendering via --enable-swiftshader.
+ "--enable-swiftshader",
+ "chrome", "/non-existent-dir/non-existent-file.html"])
+ assert excinfo.value.code != 0
+
+
+@pytest.mark.slow
+@pytest.mark.remote_network
+@pytest.mark.skipif(sys.platform == "win32",
+ reason="https://github.com/web-platform-tests/wpt/issues/28745")
+def test_run_failing_test():
+ """Failing tests should be reported with a non-zero exit status unless the
+ `--no-fail-on-unexpected` option has been specified."""
+ if is_port_8000_in_use():
+ pytest.skip("port 8000 already in use")
+ failing_test = "/infrastructure/expected-fail/failing-test.html"
+
+ assert os.path.isfile("../../%s" % failing_test)
+
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["run", "--yes", "--no-pause", "--channel", "dev",
+ # Taskcluster machines do not have GPUs, so use software rendering via --enable-swiftshader.
+ "--enable-swiftshader",
+ "chrome", failing_test])
+ assert excinfo.value.code != 0
+
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["run", "--yes", "--no-pause", "--no-fail-on-unexpected",
+ "--channel", "dev",
+ # Taskcluster machines do not have GPUs, so use software rendering via --enable-swiftshader.
+ "--enable-swiftshader",
+ "chrome", failing_test])
+ assert excinfo.value.code == 0
+
+
+@pytest.mark.slow
+@pytest.mark.remote_network
+@pytest.mark.skipif(sys.platform == "win32",
+ reason="https://github.com/web-platform-tests/wpt/issues/28745")
+def test_run_verify_unstable(temp_test):
+ """Unstable tests should be reported with a non-zero exit status. Stable
+ tests should be reported with a zero exit status."""
+ if is_port_8000_in_use():
+ pytest.skip("port 8000 already in use")
+ unstable_test = temp_test("""
+ test(function() {
+ if (localStorage.getItem('wpt-unstable-test-flag')) {
+ throw new Error();
+ }
+
+ localStorage.setItem('wpt-unstable-test-flag', 'x');
+ }, 'my test');
+ """)
+
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["run", "--yes", "--verify", "--channel", "dev",
+ # Taskcluster machines do not have GPUs, so use software rendering via --enable-swiftshader.
+ "--enable-swiftshader",
+ "chrome", unstable_test])
+ assert excinfo.value.code != 0
+
+ stable_test = temp_test("test(function() {}, 'my test');")
+
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["run", "--yes", "--verify", "--channel", "dev",
+ # Taskcluster machines do not have GPUs, so use software rendering via --enable-swiftshader.
+ "--enable-swiftshader",
+ "chrome", stable_test])
+ assert excinfo.value.code == 0
+
+
+def test_files_changed(capsys):
+ commit = "9047ac1d9f51b1e9faa4f9fad9c47d109609ab09"
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["files-changed", f"{commit}~..{commit}"])
+ assert excinfo.value.code == 0
+ out, err = capsys.readouterr()
+ expected = """html/browsers/offline/appcache/workers/appcache-worker.html
+html/browsers/offline/appcache/workers/resources/appcache-dedicated-worker-not-in-cache.js
+html/browsers/offline/appcache/workers/resources/appcache-shared-worker-not-in-cache.js
+html/browsers/offline/appcache/workers/resources/appcache-worker-data.py
+html/browsers/offline/appcache/workers/resources/appcache-worker-import.py
+html/browsers/offline/appcache/workers/resources/appcache-worker.manifest
+html/browsers/offline/appcache/workers/resources/appcache-worker.py
+""".replace("/", os.path.sep)
+ assert out == expected
+ assert err == ""
+
+
+def test_files_changed_null(capsys):
+ commit = "9047ac1d9f51b1e9faa4f9fad9c47d109609ab09"
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["files-changed", "--null", f"{commit}~..{commit}"])
+ assert excinfo.value.code == 0
+ out, err = capsys.readouterr()
+ expected = "\0".join(["html/browsers/offline/appcache/workers/appcache-worker.html",
+ "html/browsers/offline/appcache/workers/resources/appcache-dedicated-worker-not-in-cache.js",
+ "html/browsers/offline/appcache/workers/resources/appcache-shared-worker-not-in-cache.js",
+ "html/browsers/offline/appcache/workers/resources/appcache-worker-data.py",
+ "html/browsers/offline/appcache/workers/resources/appcache-worker-import.py",
+ "html/browsers/offline/appcache/workers/resources/appcache-worker.manifest",
+ "html/browsers/offline/appcache/workers/resources/appcache-worker.py",
+ ""]).replace("/", os.path.sep)
+ assert out == expected
+ assert err == ""
+
+
+def test_files_changed_ignore():
+ from tools.wpt.testfiles import exclude_ignored
+ files = ["resources/testharness.js", "resources/webidl2/index.js", "test/test.js"]
+ changed, ignored = exclude_ignored(files, ignore_rules=["resources/testharness*"])
+ assert changed == [os.path.join(wpt.wpt_root, item) for item in
+ ["resources/webidl2/index.js", "test/test.js"]]
+ assert ignored == [os.path.join(wpt.wpt_root, item) for item in
+ ["resources/testharness.js"]]
+
+
+def test_files_changed_ignore_rules():
+ from tools.wpt.testfiles import compile_ignore_rule
+ assert compile_ignore_rule("foo*bar*/baz").pattern == r"^foo\*bar[^/]*/baz$"
+ assert compile_ignore_rule("foo**bar**/baz").pattern == r"^foo\*\*bar.*/baz$"
+ assert compile_ignore_rule("foobar/baz/*").pattern == "^foobar/baz/[^/]*$"
+ assert compile_ignore_rule("foobar/baz/**").pattern == "^foobar/baz/.*$"
+
+
+@pytest.mark.slow # this updates the manifest
+@pytest.mark.xfail(sys.platform == "win32",
+ reason="Tests currently don't work on Windows for path reasons")
+@pytest.mark.skipif(sys.platform == "win32",
+ reason="https://github.com/web-platform-tests/wpt/issues/12934")
+def test_tests_affected(capsys, manifest_dir):
+ # This doesn't really work properly for random commits because we test the files in
+ # the current working directory for references to the changed files, not the ones at
+ # that specific commit. But we can at least test it returns something sensible.
+ # The test will fail if the file we assert is renamed, so we choose a stable one.
+ commit = "3a055e818218f548db240c316654f3cc1aeeb733"
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["tests-affected", "--metadata", manifest_dir, f"{commit}~..{commit}"])
+ assert excinfo.value.code == 0
+ out, err = capsys.readouterr()
+ assert "infrastructure/reftest-wait.html" in out
+
+
+@pytest.mark.slow # this updates the manifest
+@pytest.mark.xfail(sys.platform == "win32",
+ reason="Tests currently don't work on Windows for path reasons")
+@pytest.mark.skipif(sys.platform == "win32",
+ reason="https://github.com/web-platform-tests/wpt/issues/12934")
+def test_tests_affected_idlharness(capsys, manifest_dir):
+ commit = "47cea8c38b88c0ddd3854e4edec0c5b6f2697e62"
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["tests-affected", "--metadata", manifest_dir, f"{commit}~..{commit}"])
+ assert excinfo.value.code == 0
+ out, err = capsys.readouterr()
+ assert ("mst-content-hint/idlharness.window.js\n" +
+ "webrtc-encoded-transform/idlharness.https.window.js\n" +
+ "webrtc-identity/idlharness.https.window.js\n" +
+ "webrtc-stats/idlharness.window.js\n" +
+ "webrtc-stats/supported-stats.https.html\n" +
+ "webrtc/idlharness.https.window.js\n") == out
+
+
+@pytest.mark.slow # this updates the manifest
+@pytest.mark.xfail(sys.platform == "win32",
+ reason="Tests currently don't work on Windows for path reasons")
+@pytest.mark.skipif(sys.platform == "win32",
+ reason="https://github.com/web-platform-tests/wpt/issues/12934")
+def test_tests_affected_null(capsys, manifest_dir):
+ # This doesn't really work properly for random commits because we test the files in
+ # the current working directory for references to the changed files, not the ones at
+ # that specific commit. But we can at least test it returns something sensible.
+ # The test will fail if the file we assert is renamed, so we choose a stable one.
+ commit = "2614e3316f1d3d1a744ed3af088d19516552a5de"
+ with pytest.raises(SystemExit) as excinfo:
+ wpt.main(argv=["tests-affected", "--null", "--metadata", manifest_dir, f"{commit}~..{commit}"])
+ assert excinfo.value.code == 0
+ out, err = capsys.readouterr()
+
+ tests = out.split("\0")
+ assert "dom/idlharness.any.js" in tests
+ assert "xhr/idlharness.any.js" in tests
+
+
+@pytest.mark.slow
+@pytest.mark.skipif(sys.platform == "win32",
+ reason="no os.setsid/killpg to easily cleanup the process tree")
+def test_serve():
+ if is_port_8000_in_use():
+ pytest.skip("port 8000 already in use")
+
+ p = subprocess.Popen([os.path.join(wpt.localpaths.repo_root, "wpt"), "serve"],
+ preexec_fn=os.setsid)
+
+ start = time.time()
+ try:
+ while True:
+ if p.poll() is not None:
+ assert False, "server not running"
+ if time.time() - start > 60:
+ assert False, "server did not start responding within 60s"
+ try:
+ resp = urlopen("http://web-platform.test:8000")
+ print(resp)
+ except URLError:
+ print("URLError")
+ time.sleep(1)
+ else:
+ assert resp.code == 200
+ break
+ finally:
+ os.killpg(p.pid, 15)
+
+# The following commands are slow running and used implicitly in other CI
+# jobs, so we skip them here:
+# wpt manifest
+# wpt lint
diff --git a/testing/web-platform/tests/tools/wpt/tox.ini b/testing/web-platform/tests/tools/wpt/tox.ini
new file mode 100644
index 0000000000..eda300c3c8
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/tox.ini
@@ -0,0 +1,19 @@
+[tox]
+envlist = py36,py37,py38,py39,py310
+skipsdist=True
+skip_missing_interpreters = False
+
+[testenv]
+deps =
+ -r{toxinidir}/../requirements_pytest.txt
+ -r{toxinidir}/requirements.txt
+ -r{toxinidir}/../wptrunner/requirements.txt
+ -r{toxinidir}/../wptrunner/requirements_chromium.txt
+ -r{toxinidir}/../wptrunner/requirements_firefox.txt
+
+commands =
+ pytest {posargs}
+
+passenv =
+ DISPLAY
+ TASKCLUSTER_ROOT_URL
diff --git a/testing/web-platform/tests/tools/wpt/update.py b/testing/web-platform/tests/tools/wpt/update.py
new file mode 100644
index 0000000000..41faeac54f
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/update.py
@@ -0,0 +1,56 @@
+# mypy: allow-untyped-defs
+
+import os
+import sys
+
+from mozlog import commandline
+
+wpt_root = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir))
+sys.path.insert(0, os.path.abspath(os.path.join(wpt_root, "tools")))
+
+
+def manifest_update(test_paths):
+ from manifest import manifest # type: ignore
+ for url_base, paths in test_paths.items():
+ manifest.load_and_update(
+ paths["tests_path"],
+ paths["manifest_path"],
+ url_base)
+
+
+def create_parser_update():
+ from wptrunner import wptcommandline
+
+ return wptcommandline.create_parser_metadata_update()
+
+
+def update_expectations(_, **kwargs):
+ from wptrunner import metadata, wptcommandline
+
+ commandline.setup_logging("web-platform-tests",
+ kwargs,
+ {"mach": sys.stdout},
+ formatter_defaults=None)
+
+ if not kwargs["tests_root"]:
+ kwargs["tests_root"] = wpt_root
+
+ # This matches the manifest path we end up using in `wpt run`
+ if not kwargs["manifest_path"]:
+ kwargs["manifest_path"] = os.path.join(wpt_root, "MANIFEST.json")
+
+ kwargs = wptcommandline.check_args_metadata_update(kwargs)
+
+ update_properties = metadata.get_properties(properties_file=kwargs["properties_file"],
+ extra_properties=kwargs["extra_property"],
+ config=kwargs["config"],
+ product=kwargs["product"])
+
+ manifest_update(kwargs["test_paths"])
+ metadata.update_expected(kwargs["test_paths"],
+ kwargs["run_log"],
+ update_properties=update_properties,
+ full_update=False,
+ disable_intermittent=kwargs["update_intermittent"],
+ update_intermittent=kwargs["update_intermittent"],
+ remove_intermittent=kwargs["update_intermittent"])
diff --git a/testing/web-platform/tests/tools/wpt/utils.py b/testing/web-platform/tests/tools/wpt/utils.py
new file mode 100644
index 0000000000..b015b95e1a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/utils.py
@@ -0,0 +1,168 @@
+# mypy: allow-untyped-defs
+
+import errno
+import logging
+import os
+import sys
+import shutil
+import stat
+import subprocess
+import tarfile
+import time
+import zipfile
+from io import BytesIO
+from socket import error as SocketError # NOQA: N812
+from urllib.request import urlopen
+
+logger = logging.getLogger(__name__)
+
+
+def call(*args):
+ """Log terminal command, invoke it as a subprocess.
+
+ Returns a bytestring of the subprocess output if no error.
+ """
+ logger.debug(" ".join(args))
+ try:
+ return subprocess.check_output(args).decode('utf8')
+ except subprocess.CalledProcessError as e:
+ logger.critical("%s exited with return code %i" %
+ (e.cmd, e.returncode))
+ logger.critical(e.output)
+ raise
+
+
+def seekable(fileobj):
+ """Attempt to use file.seek on given file, with fallbacks."""
+ try:
+ fileobj.seek(fileobj.tell())
+ except Exception:
+ return BytesIO(fileobj.read())
+ else:
+ return fileobj
+
+
+def untar(fileobj, dest="."):
+ """Extract tar archive."""
+ logger.debug("untar")
+ fileobj = seekable(fileobj)
+ with tarfile.open(fileobj=fileobj) as tar_data:
+ tar_data.extractall(path=dest)
+
+
+def unzip(fileobj, dest=None, limit=None):
+ """Extract zip archive."""
+ logger.debug("unzip")
+ fileobj = seekable(fileobj)
+ with zipfile.ZipFile(fileobj) as zip_data:
+ for info in zip_data.infolist():
+ if limit is not None and info.filename not in limit:
+ continue
+ # external_attr has a size of 4 bytes and the info it contains depends on the system where the ZIP file was created.
+ # - If the Zipfile was created on an UNIX environment, then the 2 highest bytes represent UNIX permissions and file
+ # type bits (sys/stat.h st_mode entry on struct stat) and the lowest byte represents DOS FAT compatibility attributes
+ # (used mainly to store the directory bit).
+ # - If the ZipFile was created on a WIN/DOS environment then the lowest byte represents DOS FAT file attributes
+ # (those attributes are: directory bit, hidden bit, read-only bit, system-file bit, etc).
+ # More info at https://unix.stackexchange.com/a/14727 and https://forensicswiki.xyz/page/ZIP
+ # So, we can ignore the DOS FAT attributes because python ZipFile.extract() already takes care of creating the directories
+ # as needed (both on win and *nix) and the other DOS FAT attributes (hidden/read-only/system-file/etc) are not interesting
+ # here (not even on Windows, since we don't care about setting those extra attributes for our use case).
+ # So we do this:
+ # 1. When uncompressing on a Windows system we just call to extract().
+ # 2. When uncompressing on an Unix-like system we only take care of the attributes if the zip file was created on an
+ # Unix-like system, otherwise we don't have any info about the file permissions other than the DOS FAT attributes,
+ # which are useless here, so just call to extract() without setting any specific file permission in that case.
+ if info.create_system == 0 or sys.platform == 'win32':
+ zip_data.extract(info, path=dest)
+ else:
+ stat_st_mode = info.external_attr >> 16
+ info_dst_path = os.path.join(dest, info.filename)
+ if stat.S_ISLNK(stat_st_mode):
+ # Symlinks are stored in the ZIP file as text files that contain inside the target filename of the symlink.
+ # Recreate the symlink instead of calling extract() when an entry with the attribute stat.S_IFLNK is detected.
+ link_src_path = zip_data.read(info)
+ link_dst_dir = os.path.dirname(info_dst_path)
+ if not os.path.isdir(link_dst_dir):
+ os.makedirs(link_dst_dir)
+
+ # Remove existing link if exists.
+ if os.path.islink(info_dst_path):
+ os.unlink(info_dst_path)
+ os.symlink(link_src_path, info_dst_path)
+ else:
+ zip_data.extract(info, path=dest)
+ # Preserve bits 0-8 only: rwxrwxrwx (no sticky/setuid/setgid bits).
+ perm = stat_st_mode & 0x1FF
+ os.chmod(info_dst_path, perm)
+
+
+def get(url):
+ """Issue GET request to a given URL and return the response."""
+ import requests
+
+ logger.debug("GET %s" % url)
+ resp = requests.get(url, stream=True)
+ resp.raise_for_status()
+ return resp
+
+
+def get_download_to_descriptor(fd, url, max_retries=5):
+ """Download an URL in chunks and saves it to a file descriptor (truncating it)
+ It doesn't close the descriptor, but flushes it on success.
+ It retries the download in case of ECONNRESET up to max_retries.
+ This function is meant to download big files directly to the disk without
+ caching the whole file in memory.
+ """
+ if max_retries < 1:
+ max_retries = 1
+ wait = 2
+ for current_retry in range(1, max_retries+1):
+ try:
+ logger.info("Downloading %s Try %d/%d" % (url, current_retry, max_retries))
+ resp = urlopen(url)
+ # We may come here in a retry, ensure to truncate fd before start writing.
+ fd.seek(0)
+ fd.truncate(0)
+ while True:
+ chunk = resp.read(16*1024)
+ if not chunk:
+ break # Download finished
+ fd.write(chunk)
+ fd.flush()
+ # Success
+ return
+ except SocketError as e:
+ if current_retry < max_retries and e.errno == errno.ECONNRESET:
+ # Retry
+ logger.error("Connection reset by peer. Retrying after %ds..." % wait)
+ time.sleep(wait)
+ wait *= 2
+ else:
+ # Maximum retries or unknown error
+ raise
+
+def rmtree(path):
+ # This works around two issues:
+ # 1. Cannot delete read-only files owned by us (e.g. files extracted from tarballs)
+ # 2. On Windows, we sometimes just need to retry in case the file handler
+ # hasn't been fully released (a common issue).
+ def handle_remove_readonly(func, path, exc):
+ excvalue = exc[1]
+ if func in (os.rmdir, os.remove, os.unlink) and excvalue.errno == errno.EACCES:
+ os.chmod(path, stat.S_IRWXU | stat.S_IRWXG | stat.S_IRWXO) # 0777
+ func(path)
+ else:
+ raise
+
+ return shutil.rmtree(path, onerror=handle_remove_readonly)
+
+
+def sha256sum(file_path):
+ """Computes the SHA256 hash sum of a file"""
+ from hashlib import sha256
+ hash = sha256()
+ with open(file_path, 'rb') as f:
+ for chunk in iter(lambda: f.read(4096), b''):
+ hash.update(chunk)
+ return hash.hexdigest()
diff --git a/testing/web-platform/tests/tools/wpt/virtualenv.py b/testing/web-platform/tests/tools/wpt/virtualenv.py
new file mode 100644
index 0000000000..05ca52244c
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/virtualenv.py
@@ -0,0 +1,137 @@
+# mypy: allow-untyped-defs
+
+import os
+import shutil
+import sys
+import logging
+from distutils.spawn import find_executable
+
+# The `pkg_resources` module is provided by `setuptools`, which is itself a
+# dependency of `virtualenv`. Tolerate its absence so that this module may be
+# evaluated when that module is not available. Because users may not recognize
+# the `pkg_resources` module by name, raise a more descriptive error if it is
+# referenced during execution.
+try:
+ import pkg_resources as _pkg_resources
+ get_pkg_resources = lambda: _pkg_resources
+except ImportError:
+ def get_pkg_resources():
+ raise ValueError("The Python module `virtualenv` is not installed.")
+
+from tools.wpt.utils import call
+
+logger = logging.getLogger(__name__)
+
+class Virtualenv:
+ def __init__(self, path, skip_virtualenv_setup):
+ self.path = path
+ self.skip_virtualenv_setup = skip_virtualenv_setup
+ if not skip_virtualenv_setup:
+ self.virtualenv = find_executable("virtualenv")
+ if not self.virtualenv:
+ raise ValueError("virtualenv must be installed and on the PATH")
+ self._working_set = None
+
+ @property
+ def exists(self):
+ # We need to check also for lib_path because different python versions
+ # create different library paths.
+ return os.path.isdir(self.path) and os.path.isdir(self.lib_path)
+
+ @property
+ def broken_link(self):
+ python_link = os.path.join(self.path, ".Python")
+ return os.path.lexists(python_link) and not os.path.exists(python_link)
+
+ def create(self):
+ if os.path.exists(self.path):
+ shutil.rmtree(self.path)
+ self._working_set = None
+ call(self.virtualenv, self.path, "-p", sys.executable)
+
+ @property
+ def bin_path(self):
+ if sys.platform in ("win32", "cygwin"):
+ return os.path.join(self.path, "Scripts")
+ return os.path.join(self.path, "bin")
+
+ @property
+ def pip_path(self):
+ path = find_executable("pip3", self.bin_path)
+ if path is None:
+ raise ValueError("pip3 not found")
+ return path
+
+ @property
+ def lib_path(self):
+ base = self.path
+
+ # this block is literally taken from virtualenv 16.4.3
+ IS_PYPY = hasattr(sys, "pypy_version_info")
+ IS_JYTHON = sys.platform.startswith("java")
+ if IS_JYTHON:
+ site_packages = os.path.join(base, "Lib", "site-packages")
+ elif IS_PYPY:
+ site_packages = os.path.join(base, "site-packages")
+ else:
+ IS_WIN = sys.platform == "win32"
+ if IS_WIN:
+ site_packages = os.path.join(base, "Lib", "site-packages")
+ else:
+ site_packages = os.path.join(base, "lib", f"python{sys.version[:3]}", "site-packages")
+
+ return site_packages
+
+ @property
+ def working_set(self):
+ if not self.exists:
+ raise ValueError("trying to read working_set when venv doesn't exist")
+
+ if self._working_set is None:
+ self._working_set = get_pkg_resources().WorkingSet((self.lib_path,))
+
+ return self._working_set
+
+ def activate(self):
+ if sys.platform == 'darwin':
+ # The default Python on macOS sets a __PYVENV_LAUNCHER__ environment
+ # variable which affects invocation of python (e.g. via pip) in a
+ # virtualenv. Unset it if present to avoid this. More background:
+ # https://github.com/web-platform-tests/wpt/issues/27377
+ # https://github.com/python/cpython/pull/9516
+ os.environ.pop('__PYVENV_LAUNCHER__', None)
+ path = os.path.join(self.bin_path, "activate_this.py")
+ with open(path) as f:
+ exec(f.read(), {"__file__": path})
+
+ def start(self):
+ if not self.exists or self.broken_link:
+ self.create()
+ self.activate()
+
+ def install(self, *requirements):
+ try:
+ self.working_set.require(*requirements)
+ except Exception:
+ pass
+ else:
+ return
+
+ # `--prefer-binary` guards against race conditions when installation
+ # occurs while packages are in the process of being published.
+ call(self.pip_path, "install", "--prefer-binary", *requirements)
+
+ def install_requirements(self, requirements_path):
+ with open(requirements_path) as f:
+ try:
+ self.working_set.require(f.read())
+ except Exception:
+ pass
+ else:
+ return
+
+ # `--prefer-binary` guards against race conditions when installation
+ # occurs while packages are in the process of being published.
+ call(
+ self.pip_path, "install", "--prefer-binary", "-r", requirements_path
+ )
diff --git a/testing/web-platform/tests/tools/wpt/wpt.py b/testing/web-platform/tests/tools/wpt/wpt.py
new file mode 100644
index 0000000000..74943a52f3
--- /dev/null
+++ b/testing/web-platform/tests/tools/wpt/wpt.py
@@ -0,0 +1,240 @@
+# mypy: allow-untyped-defs
+
+import argparse
+import json
+import logging
+import multiprocessing
+import os
+import sys
+
+from tools import localpaths # noqa: F401
+
+from . import virtualenv
+
+
+here = os.path.dirname(__file__)
+wpt_root = os.path.abspath(os.path.join(here, os.pardir, os.pardir))
+
+
+def load_conditional_requirements(props, base_dir):
+ """Load conditional requirements from commands.json."""
+
+ conditional_requirements = props.get("conditional_requirements")
+ if not conditional_requirements:
+ return {}
+
+ commandline_flag_requirements = {}
+ for key, value in conditional_requirements.items():
+ if key == "commandline_flag":
+ for flag_name, requirements_paths in value.items():
+ commandline_flag_requirements[flag_name] = [
+ os.path.join(base_dir, path) for path in requirements_paths]
+ else:
+ raise KeyError(
+ f'Unsupported conditional requirement key: {key}')
+
+ return {
+ "commandline_flag": commandline_flag_requirements,
+ }
+
+
+def load_commands():
+ rv = {}
+ with open(os.path.join(here, "paths")) as f:
+ paths = [item.strip().replace("/", os.path.sep) for item in f if item.strip()]
+ for path in paths:
+ abs_path = os.path.join(wpt_root, path, "commands.json")
+ base_dir = os.path.dirname(abs_path)
+ with open(abs_path) as f:
+ data = json.load(f)
+ for command, props in data.items():
+ assert "path" in props
+ assert "script" in props
+ rv[command] = {
+ "path": os.path.join(base_dir, props["path"]),
+ "script": props["script"],
+ "parser": props.get("parser"),
+ "parse_known": props.get("parse_known", False),
+ "help": props.get("help"),
+ "virtualenv": props.get("virtualenv", True),
+ "requirements": [os.path.join(base_dir, item)
+ for item in props.get("requirements", [])]
+ }
+
+ rv[command]["conditional_requirements"] = load_conditional_requirements(
+ props, base_dir)
+
+ if rv[command]["requirements"] or rv[command]["conditional_requirements"]:
+ assert rv[command]["virtualenv"]
+ return rv
+
+
+def parse_args(argv, commands=load_commands()):
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--venv", action="store", help="Path to an existing virtualenv to use")
+ parser.add_argument("--skip-venv-setup", action="store_true",
+ dest="skip_venv_setup",
+ help="Whether to use the virtualenv as-is. Must set --venv as well")
+ parser.add_argument("--debug", action="store_true", help="Run the debugger in case of an exception")
+ subparsers = parser.add_subparsers(dest="command")
+ for command, props in commands.items():
+ subparsers.add_parser(command, help=props["help"], add_help=False)
+
+ if not argv:
+ parser.print_help()
+ return None, None
+
+ args, extra = parser.parse_known_args(argv)
+
+ return args, extra
+
+
+def import_command(prog, command, props):
+ # This currently requires the path to be a module,
+ # which probably isn't ideal but it means that relative
+ # imports inside the script work
+ rel_path = os.path.relpath(props["path"], wpt_root)
+
+ parts = os.path.splitext(rel_path)[0].split(os.path.sep)
+
+ mod_name = ".".join(parts)
+
+ mod = __import__(mod_name)
+ for part in parts[1:]:
+ mod = getattr(mod, part)
+
+ script = getattr(mod, props["script"])
+ if props["parser"] is not None:
+ parser = getattr(mod, props["parser"])()
+ parser.prog = f"{os.path.basename(prog)} {command}"
+ else:
+ parser = None
+
+ return script, parser
+
+
+def create_complete_parser():
+ """Eagerly load all subparsers. This involves more work than is required
+ for typical command-line usage. It is maintained for the purposes of
+ documentation generation as implemented in WPT's top-level `/docs`
+ directory."""
+
+ commands = load_commands()
+ parser = argparse.ArgumentParser()
+ subparsers = parser.add_subparsers()
+
+ # We should already be in a virtual environment from the top-level
+ # `wpt build-docs` command but we need to look up the environment to
+ # find out where it's located.
+ venv_path = os.environ["VIRTUAL_ENV"]
+ venv = virtualenv.Virtualenv(venv_path, True)
+
+ for command in commands:
+ props = commands[command]
+
+ for path in props.get("requirements", []):
+ venv.install_requirements(path)
+
+ subparser = import_command('wpt', command, props)[1]
+ if not subparser:
+ continue
+
+ subparsers.add_parser(command,
+ help=props["help"],
+ add_help=False,
+ parents=[subparser])
+
+ return parser
+
+
+def venv_dir():
+ return f"_venv{sys.version_info[0]}"
+
+
+def setup_virtualenv(path, skip_venv_setup, props):
+ if skip_venv_setup and path is None:
+ raise ValueError("Must set --venv when --skip-venv-setup is used")
+ should_skip_setup = path is not None and skip_venv_setup
+ if path is None:
+ path = os.path.join(wpt_root, venv_dir())
+ venv = virtualenv.Virtualenv(path, should_skip_setup)
+ if not should_skip_setup:
+ venv.start()
+ for path in props["requirements"]:
+ venv.install_requirements(path)
+ return venv
+
+
+def install_command_flag_requirements(venv, kwargs, requirements):
+ for command_flag_name, requirement_paths in requirements.items():
+ if command_flag_name in kwargs:
+ for path in requirement_paths:
+ venv.install_requirements(path)
+
+
+def main(prog=None, argv=None):
+ logging.basicConfig(level=logging.INFO)
+ # Ensure we use the spawn start method for all multiprocessing
+ try:
+ multiprocessing.set_start_method('spawn')
+ except RuntimeError as e:
+ # This can happen if we call back into wpt having already set the context
+ start_method = multiprocessing.get_start_method()
+ if start_method != "spawn":
+ logging.critical("The multiprocessing start method was set to %s by a caller", start_method)
+ raise e
+
+ if prog is None:
+ prog = sys.argv[0]
+ if argv is None:
+ argv = sys.argv[1:]
+
+ commands = load_commands()
+
+ main_args, command_args = parse_args(argv, commands)
+
+ if not main_args:
+ return
+
+ command = main_args.command
+ props = commands[command]
+ venv = None
+ if props["virtualenv"]:
+ venv = setup_virtualenv(main_args.venv, main_args.skip_venv_setup, props)
+ script, parser = import_command(prog, command, props)
+ if parser:
+ if props["parse_known"]:
+ kwargs, extras = parser.parse_known_args(command_args)
+ extras = (extras,)
+ kwargs = vars(kwargs)
+ else:
+ extras = ()
+ kwargs = vars(parser.parse_args(command_args))
+ else:
+ extras = ()
+ kwargs = {}
+
+ if venv is not None:
+ requirements = props["conditional_requirements"].get("commandline_flag")
+ if requirements is not None and not main_args.skip_venv_setup:
+ install_command_flag_requirements(venv, kwargs, requirements)
+ args = (venv,) + extras
+ else:
+ args = extras
+
+ if script:
+ try:
+ rv = script(*args, **kwargs)
+ if rv is not None:
+ sys.exit(int(rv))
+ except Exception:
+ if main_args.debug:
+ import pdb
+ pdb.post_mortem()
+ else:
+ raise
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main() # type: ignore
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)
diff --git a/testing/web-platform/tests/tools/wptserve/.coveragerc b/testing/web-platform/tests/tools/wptserve/.coveragerc
new file mode 100644
index 0000000000..0e00c079f6
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/.coveragerc
@@ -0,0 +1,11 @@
+[run]
+branch = True
+parallel = True
+omit =
+ */site-packages/*
+ */lib_pypy/*
+
+[paths]
+wptserve =
+ wptserve
+ .tox/**/site-packages/wptserve
diff --git a/testing/web-platform/tests/tools/wptserve/.gitignore b/testing/web-platform/tests/tools/wptserve/.gitignore
new file mode 100644
index 0000000000..8e87d38848
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/.gitignore
@@ -0,0 +1,40 @@
+*.py[cod]
+*~
+\#*
+
+docs/_build/
+
+# C extensions
+*.so
+
+# Packages
+*.egg
+*.egg-info
+dist
+build
+eggs
+parts
+bin
+var
+sdist
+develop-eggs
+.installed.cfg
+lib
+lib64
+
+# Installer logs
+pip-log.txt
+
+# Unit test / coverage reports
+.coverage
+.tox
+nosetests.xml
+tests/functional/html/*
+
+# Translations
+*.mo
+
+# Mr Developer
+.mr.developer.cfg
+.project
+.pydevproject
diff --git a/testing/web-platform/tests/tools/wptserve/MANIFEST.in b/testing/web-platform/tests/tools/wptserve/MANIFEST.in
new file mode 100644
index 0000000000..4bf4483522
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/MANIFEST.in
@@ -0,0 +1 @@
+include README.md \ No newline at end of file
diff --git a/testing/web-platform/tests/tools/wptserve/README.md b/testing/web-platform/tests/tools/wptserve/README.md
new file mode 100644
index 0000000000..6821dee38a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/README.md
@@ -0,0 +1,6 @@
+wptserve
+========
+
+Web server designed for use with web-platform-tests.
+
+See the docs on [web-platform-tests.org](https://web-platform-tests.org/tools/wptserve/docs/index.html).
diff --git a/testing/web-platform/tests/tools/wptserve/docs/Makefile b/testing/web-platform/tests/tools/wptserve/docs/Makefile
new file mode 100644
index 0000000000..250b6c8647
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/Makefile
@@ -0,0 +1,153 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+PAPER =
+BUILDDIR = _build
+
+# Internal variables.
+PAPEROPT_a4 = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
+
+help:
+ @echo "Please use \`make <target>' where <target> is one of"
+ @echo " html to make standalone HTML files"
+ @echo " dirhtml to make HTML files named index.html in directories"
+ @echo " singlehtml to make a single large HTML file"
+ @echo " pickle to make pickle files"
+ @echo " json to make JSON files"
+ @echo " htmlhelp to make HTML files and a HTML help project"
+ @echo " qthelp to make HTML files and a qthelp project"
+ @echo " devhelp to make HTML files and a Devhelp project"
+ @echo " epub to make an epub"
+ @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+ @echo " latexpdf to make LaTeX files and run them through pdflatex"
+ @echo " text to make text files"
+ @echo " man to make manual pages"
+ @echo " texinfo to make Texinfo files"
+ @echo " info to make Texinfo files and run them through makeinfo"
+ @echo " gettext to make PO message catalogs"
+ @echo " changes to make an overview of all changed/added/deprecated items"
+ @echo " linkcheck to check all external links for integrity"
+ @echo " doctest to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+ -rm -rf $(BUILDDIR)/*
+
+html:
+ $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+ $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+ $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+ @echo
+ @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+ $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+ @echo
+ @echo "Build finished; now you can process the pickle files."
+
+json:
+ $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+ @echo
+ @echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+ $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+ @echo
+ @echo "Build finished; now you can run HTML Help Workshop with the" \
+ ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+ $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+ @echo
+ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
+ ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+ @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/wptserve.qhcp"
+ @echo "To view the help file:"
+ @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/wptserve.qhc"
+
+devhelp:
+ $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+ @echo
+ @echo "Build finished."
+ @echo "To view the help file:"
+ @echo "# mkdir -p $$HOME/.local/share/devhelp/wptserve"
+ @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/wptserve"
+ @echo "# devhelp"
+
+epub:
+ $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+ @echo
+ @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo
+ @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+ @echo "Run \`make' in that directory to run these through (pdf)latex" \
+ "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through pdflatex..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+ $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+ @echo
+ @echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+ $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+ @echo
+ @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo
+ @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+ @echo "Run \`make' in that directory to run these through makeinfo" \
+ "(use \`make info' here to do that automatically)."
+
+info:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo "Running Texinfo files through makeinfo..."
+ make -C $(BUILDDIR)/texinfo info
+ @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+ $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+ @echo
+ @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+ $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+ @echo
+ @echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+ $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+ @echo
+ @echo "Link check complete; look for any errors in the above output " \
+ "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+ $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+ @echo "Testing of doctests in the sources finished, look at the " \
+ "results in $(BUILDDIR)/doctest/output.txt."
diff --git a/testing/web-platform/tests/tools/wptserve/docs/conf.py b/testing/web-platform/tests/tools/wptserve/docs/conf.py
new file mode 100644
index 0000000000..686eb4fc24
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/conf.py
@@ -0,0 +1,242 @@
+#
+# wptserve documentation build configuration file, created by
+# sphinx-quickstart on Wed Aug 14 17:23:24 2013.
+#
+# This file is execfile()d with the current directory set to its containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+import sys, os
+sys.path.insert(0, os.path.abspath(".."))
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#sys.path.insert(0, os.path.abspath('.'))
+
+# -- General configuration -----------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be extensions
+# coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
+extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix of source filenames.
+source_suffix = '.rst'
+
+# The encoding of source files.
+#source_encoding = 'utf-8-sig'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = 'wptserve'
+copyright = '2013, Mozilla Foundation and other wptserve contributers'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = '0.1'
+# The full version, including alpha/beta/rc tags.
+release = '0.1'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#language = None
+
+# There are two options for replacing |today|: either, you set today to some
+# non-false value, then it is used:
+#today = ''
+# Else, today_fmt is used as the format for a strftime call.
+#today_fmt = '%B %d, %Y'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+exclude_patterns = ['_build']
+
+# The reST default role (used for this markup: `text`) to use for all documents.
+#default_role = None
+
+# If true, '()' will be appended to :func: etc. cross-reference text.
+#add_function_parentheses = True
+
+# If true, the current module name will be prepended to all description
+# unit titles (such as .. function::).
+#add_module_names = True
+
+# If true, sectionauthor and moduleauthor directives will be shown in the
+# output. They are ignored by default.
+#show_authors = False
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# A list of ignored prefixes for module index sorting.
+#modindex_common_prefix = []
+
+
+# -- Options for HTML output ---------------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+html_theme = 'default'
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+#html_theme_options = {}
+
+# Add any paths that contain custom themes here, relative to this directory.
+#html_theme_path = []
+
+# The name for this set of Sphinx documents. If None, it defaults to
+# "<project> v<release> documentation".
+#html_title = None
+
+# A shorter title for the navigation bar. Default is the same as html_title.
+#html_short_title = None
+
+# The name of an image file (relative to this directory) to place at the top
+# of the sidebar.
+#html_logo = None
+
+# The name of an image file (within the static path) to use as favicon of the
+# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
+# pixels large.
+#html_favicon = None
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+# If not '', a 'Last updated on:' timestamp is inserted at every page bottom,
+# using the given strftime format.
+#html_last_updated_fmt = '%b %d, %Y'
+
+# If true, SmartyPants will be used to convert quotes and dashes to
+# typographically correct entities.
+#html_use_smartypants = True
+
+# Custom sidebar templates, maps document names to template names.
+#html_sidebars = {}
+
+# Additional templates that should be rendered to pages, maps page names to
+# template names.
+#html_additional_pages = {}
+
+# If false, no module index is generated.
+#html_domain_indices = True
+
+# If false, no index is generated.
+#html_use_index = True
+
+# If true, the index is split into individual pages for each letter.
+#html_split_index = False
+
+# If true, links to the reST sources are added to the pages.
+#html_show_sourcelink = True
+
+# If true, "Created using Sphinx" is shown in the HTML footer. Default is True.
+#html_show_sphinx = True
+
+# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True.
+#html_show_copyright = True
+
+# If true, an OpenSearch description file will be output, and all pages will
+# contain a <link> tag referring to it. The value of this option must be the
+# base URL from which the finished HTML is served.
+#html_use_opensearch = ''
+
+# This is the file name suffix for HTML files (e.g. ".xhtml").
+#html_file_suffix = None
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'wptservedoc'
+
+
+# -- Options for LaTeX output --------------------------------------------------
+
+latex_elements = {
+# The paper size ('letterpaper' or 'a4paper').
+#'papersize': 'letterpaper',
+
+# The font size ('10pt', '11pt' or '12pt').
+#'pointsize': '10pt',
+
+# Additional stuff for the LaTeX preamble.
+#'preamble': '',
+}
+
+# Grouping the document tree into LaTeX files. List of tuples
+# (source start file, target name, title, author, documentclass [howto/manual]).
+latex_documents = [
+ ('index', 'wptserve.tex', 'wptserve Documentation',
+ 'James Graham', 'manual'),
+]
+
+# The name of an image file (relative to this directory) to place at the top of
+# the title page.
+#latex_logo = None
+
+# For "manual" documents, if this is true, then toplevel headings are parts,
+# not chapters.
+#latex_use_parts = False
+
+# If true, show page references after internal links.
+#latex_show_pagerefs = False
+
+# If true, show URL addresses after external links.
+#latex_show_urls = False
+
+# Documents to append as an appendix to all manuals.
+#latex_appendices = []
+
+# If false, no module index is generated.
+#latex_domain_indices = True
+
+
+# -- Options for manual page output --------------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+ ('index', 'wptserve', 'wptserve Documentation',
+ ['James Graham'], 1)
+]
+
+# If true, show URL addresses after external links.
+#man_show_urls = False
+
+
+# -- Options for Texinfo output ------------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+# dir menu entry, description, category)
+texinfo_documents = [
+ ('index', 'wptserve', 'wptserve Documentation',
+ 'James Graham', 'wptserve', 'One line description of project.',
+ 'Miscellaneous'),
+]
+
+# Documents to append as an appendix to all manuals.
+#texinfo_appendices = []
+
+# If false, no module index is generated.
+#texinfo_domain_indices = True
+
+# How to display URL addresses: 'footnote', 'no', or 'inline'.
+#texinfo_show_urls = 'footnote'
diff --git a/testing/web-platform/tests/tools/wptserve/docs/handlers.rst b/testing/web-platform/tests/tools/wptserve/docs/handlers.rst
new file mode 100644
index 0000000000..8ecc933288
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/handlers.rst
@@ -0,0 +1,108 @@
+Handlers
+========
+
+Handlers are functions that have the general signature::
+
+ handler(request, response)
+
+It is expected that the handler will use information from
+the request (e.g. the path) either to populate the response
+object with the data to send, or to directly write to the
+output stream via the ResponseWriter instance associated with
+the request. If a handler writes to the output stream then the
+server will not attempt additional writes, i.e. the choice to write
+directly in the handler or not is all-or-nothing.
+
+A number of general-purpose handler functions are provided by default:
+
+.. _handlers.Python:
+
+Python Handlers
+---------------
+
+Python handlers are functions which provide a higher-level API over
+manually updating the response object, by causing the return value of
+the function to provide (part of) the response. There are four
+possible sets of values that may be returned::
+
+
+ ((status_code, reason), headers, content)
+ (status_code, headers, content)
+ (headers, content)
+ content
+
+Here `status_code` is an integer status code, `headers` is a list of (field
+name, value) pairs, and `content` is a string or an iterable returning strings.
+Such a function may also update the response manually. For example one may use
+`response.headers.set` to set a response header, and only return the content.
+One may even use this kind of handler, but manipulate the output socket
+directly, in which case the return value of the function, and the properties of
+the response object, will be ignored.
+
+The most common way to make a user function into a python handler is
+to use the provided `wptserve.handlers.handler` decorator::
+
+ from wptserve.handlers import handler
+
+ @handler
+ def test(request, response):
+ return [("X-Test": "PASS"), ("Content-Type", "text/plain")], "test"
+
+ #Later, assuming we have a Router object called 'router'
+
+ router.register("GET", "/test", test)
+
+JSON Handlers
+-------------
+
+This is a specialisation of the python handler type specifically
+designed to facilitate providing JSON responses. The API is largely
+the same as for a normal python handler, but the `content` part of the
+return value is JSON encoded, and a default Content-Type header of
+`application/json` is added. Again this handler is usually used as a
+decorator::
+
+ from wptserve.handlers import json_handler
+
+ @json_handler
+ def test(request, response):
+ return {"test": "PASS"}
+
+Python File Handlers
+--------------------
+
+Python file handlers are Python files which the server executes in response to
+requests made to the corresponding URL. This is hooked up to a route like
+``("*", "*.py", python_file_handler)``, meaning that any .py file will be
+treated as a handler file (note that this makes it easy to write unsafe
+handlers, particularly when running the server in a web-exposed setting).
+
+The Python files must define a single function `main` with the signature::
+
+ main(request, response)
+
+This function then behaves just like those described in
+:ref:`handlers.Python` above.
+
+asis Handlers
+-------------
+
+These are used to serve files as literal byte streams including the
+HTTP status line, headers and body. In the default configuration this
+handler is invoked for all files with a .asis extension.
+
+File Handlers
+-------------
+
+File handlers are used to serve static files. By default the content
+type of these files is set by examining the file extension. However
+this can be overridden, or additional headers supplied, by providing a
+file with the same name as the file being served but an additional
+.headers suffix, i.e. test.html has its headers set from
+test.html.headers. The format of the .headers file is plaintext, with
+each line containing::
+
+ Header-Name: header_value
+
+In addition headers can be set for a whole directory of files (but not
+subdirectories), using a file called `__dir__.headers`.
diff --git a/testing/web-platform/tests/tools/wptserve/docs/index.rst b/testing/web-platform/tests/tools/wptserve/docs/index.rst
new file mode 100644
index 0000000000..c6157b4f8c
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/index.rst
@@ -0,0 +1,27 @@
+.. wptserve documentation master file, created by
+ sphinx-quickstart on Wed Aug 14 17:23:24 2013.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+wptserve: Web Platform Test Server
+==================================
+
+A python-based HTTP server specifically targeted at being used for
+testing the web platform. This means that extreme flexibility —
+including the possibility of HTTP non-conformance — in the response is
+supported.
+
+Contents:
+
+.. toctree::
+ :maxdepth: 2
+
+ introduction
+ server
+ router
+ request
+ response
+ stash
+ handlers
+ pipes
+
diff --git a/testing/web-platform/tests/tools/wptserve/docs/introduction.rst b/testing/web-platform/tests/tools/wptserve/docs/introduction.rst
new file mode 100644
index 0000000000..b585a983a2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/introduction.rst
@@ -0,0 +1,51 @@
+Introduction
+============
+
+wptserve has been designed with the specific goal of making a server
+that is suitable for writing tests for the web platform. This means
+that it cannot use common abstractions over HTTP such as WSGI, since
+these assume that the goal is to generate a well-formed HTTP
+response. Testcases, however, often require precise control of the
+exact bytes sent over the wire and their timing. The full list of
+design goals for the server are:
+
+* Suitable to run on individual test machines and over the public internet.
+
+* Support plain TCP and SSL servers.
+
+* Serve static files with the minimum of configuration.
+
+* Allow headers to be overwritten on a per-file and per-directory
+ basis.
+
+* Full customisation of headers sent (e.g. altering or omitting
+ "mandatory" headers).
+
+* Simple per-client state.
+
+* Complex logic in tests, up to precise control over the individual
+ bytes sent and the timing of sending them.
+
+Request Handling
+----------------
+
+At the high level, the design of the server is based around similar
+concepts to those found in common web frameworks like Django, Pyramid
+or Flask. In particular the lifecycle of a typical request will be
+familiar to users of these systems. Incoming requests are parsed and a
+:doc:`Request <request>` object is constructed. This object is passed
+to a :ref:`Router <router.Interface>` instance, which is
+responsible for mapping the request method and path to a handler
+function. This handler is passed two arguments; the request object and
+a :doc:`Response <response>` object. In cases where only simple
+responses are required, the handler function may fill in the
+properties of the response object and the server will take care of
+constructing the response. However each Response also contains a
+:ref:`ResponseWriter <response.Interface>` which can be
+used to directly control the TCP socket.
+
+By default there are several built-in handler functions that provide a
+higher level API than direct manipulation of the Response
+object. These are documented in :doc:`handlers`.
+
+
diff --git a/testing/web-platform/tests/tools/wptserve/docs/make.bat b/testing/web-platform/tests/tools/wptserve/docs/make.bat
new file mode 100644
index 0000000000..40c71ff5dd
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/make.bat
@@ -0,0 +1,190 @@
+@ECHO OFF
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set BUILDDIR=_build
+set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% .
+set I18NSPHINXOPTS=%SPHINXOPTS% .
+if NOT "%PAPER%" == "" (
+ set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS%
+ set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS%
+)
+
+if "%1" == "" goto help
+
+if "%1" == "help" (
+ :help
+ echo.Please use `make ^<target^>` where ^<target^> is one of
+ echo. html to make standalone HTML files
+ echo. dirhtml to make HTML files named index.html in directories
+ echo. singlehtml to make a single large HTML file
+ echo. pickle to make pickle files
+ echo. json to make JSON files
+ echo. htmlhelp to make HTML files and a HTML help project
+ echo. qthelp to make HTML files and a qthelp project
+ echo. devhelp to make HTML files and a Devhelp project
+ echo. epub to make an epub
+ echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter
+ echo. text to make text files
+ echo. man to make manual pages
+ echo. texinfo to make Texinfo files
+ echo. gettext to make PO message catalogs
+ echo. changes to make an overview over all changed/added/deprecated items
+ echo. linkcheck to check all external links for integrity
+ echo. doctest to run all doctests embedded in the documentation if enabled
+ goto end
+)
+
+if "%1" == "clean" (
+ for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i
+ del /q /s %BUILDDIR%\*
+ goto end
+)
+
+if "%1" == "html" (
+ %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/html.
+ goto end
+)
+
+if "%1" == "dirhtml" (
+ %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml.
+ goto end
+)
+
+if "%1" == "singlehtml" (
+ %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml.
+ goto end
+)
+
+if "%1" == "pickle" (
+ %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can process the pickle files.
+ goto end
+)
+
+if "%1" == "json" (
+ %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can process the JSON files.
+ goto end
+)
+
+if "%1" == "htmlhelp" (
+ %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can run HTML Help Workshop with the ^
+.hhp project file in %BUILDDIR%/htmlhelp.
+ goto end
+)
+
+if "%1" == "qthelp" (
+ %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; now you can run "qcollectiongenerator" with the ^
+.qhcp project file in %BUILDDIR%/qthelp, like this:
+ echo.^> qcollectiongenerator %BUILDDIR%\qthelp\wptserve.qhcp
+ echo.To view the help file:
+ echo.^> assistant -collectionFile %BUILDDIR%\qthelp\wptserve.ghc
+ goto end
+)
+
+if "%1" == "devhelp" (
+ %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished.
+ goto end
+)
+
+if "%1" == "epub" (
+ %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The epub file is in %BUILDDIR%/epub.
+ goto end
+)
+
+if "%1" == "latex" (
+ %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished; the LaTeX files are in %BUILDDIR%/latex.
+ goto end
+)
+
+if "%1" == "text" (
+ %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The text files are in %BUILDDIR%/text.
+ goto end
+)
+
+if "%1" == "man" (
+ %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The manual pages are in %BUILDDIR%/man.
+ goto end
+)
+
+if "%1" == "texinfo" (
+ %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo.
+ goto end
+)
+
+if "%1" == "gettext" (
+ %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Build finished. The message catalogs are in %BUILDDIR%/locale.
+ goto end
+)
+
+if "%1" == "changes" (
+ %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.The overview file is in %BUILDDIR%/changes.
+ goto end
+)
+
+if "%1" == "linkcheck" (
+ %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Link check complete; look for any errors in the above output ^
+or in %BUILDDIR%/linkcheck/output.txt.
+ goto end
+)
+
+if "%1" == "doctest" (
+ %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest
+ if errorlevel 1 exit /b 1
+ echo.
+ echo.Testing of doctests in the sources finished, look at the ^
+results in %BUILDDIR%/doctest/output.txt.
+ goto end
+)
+
+:end
diff --git a/testing/web-platform/tests/tools/wptserve/docs/pipes.rst b/testing/web-platform/tests/tools/wptserve/docs/pipes.rst
new file mode 100644
index 0000000000..1edbd44867
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/pipes.rst
@@ -0,0 +1,8 @@
+Pipes
+======
+
+:mod:`Interface <wptserve.pipes>`
+---------------------------------
+
+.. automodule:: wptserve.pipes
+ :members:
diff --git a/testing/web-platform/tests/tools/wptserve/docs/request.rst b/testing/web-platform/tests/tools/wptserve/docs/request.rst
new file mode 100644
index 0000000000..ef5b8a0c08
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/request.rst
@@ -0,0 +1,10 @@
+Request
+=======
+
+Request object.
+
+:mod:`Interface <wptserve.request>`
+-----------------------------------
+
+.. automodule:: wptserve.request
+ :members:
diff --git a/testing/web-platform/tests/tools/wptserve/docs/response.rst b/testing/web-platform/tests/tools/wptserve/docs/response.rst
new file mode 100644
index 0000000000..0c2f45ce26
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/response.rst
@@ -0,0 +1,41 @@
+Response
+========
+
+Response object. This object is used to control the response that will
+be sent to the HTTP client. A handler function will take the response
+object and fill in various parts of the response. For example, a plain
+text response with the body 'Some example content' could be produced as::
+
+ def handler(request, response):
+ response.headers.set("Content-Type", "text/plain")
+ response.content = "Some example content"
+
+The response object also gives access to a ResponseWriter, which
+allows direct access to the response socket. For example, one could
+write a similar response but with more explicit control as follows::
+
+ import time
+
+ def handler(request, response):
+ response.add_required_headers = False # Don't implicitly add HTTP headers
+ response.writer.write_status(200)
+ response.writer.write_header("Content-Type", "text/plain")
+ response.writer.write_header("Content-Length", len("Some example content"))
+ response.writer.end_headers()
+ response.writer.write("Some ")
+ time.sleep(1)
+ response.writer.write("example content")
+
+Note that when writing the response directly like this it is always
+necessary to either set the Content-Length header or set
+`response.close_connection = True`. Without one of these, the client
+will not be able to determine where the response body ends and will
+continue to load indefinitely.
+
+.. _response.Interface:
+
+:mod:`Interface <wptserve.response>`
+------------------------------------
+
+.. automodule:: wptserve.response
+ :members:
diff --git a/testing/web-platform/tests/tools/wptserve/docs/router.rst b/testing/web-platform/tests/tools/wptserve/docs/router.rst
new file mode 100644
index 0000000000..986f581922
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/router.rst
@@ -0,0 +1,78 @@
+Router
+======
+
+The router is used to match incoming requests to request handler
+functions. Typically users don't interact with the router directly,
+but instead send a list of routes to register when starting the
+server. However it is also possible to add routes after starting the
+server by calling the `register` method on the server's `router`
+property.
+
+Routes are represented by a three item tuple::
+
+ (methods, path_match, handler)
+
+`methods` is either a string or a list of strings indicating the HTTP
+methods to match. In cases where all methods should match there is a
+special sentinel value `any_method` provided as a property of the
+`router` module that can be used.
+
+`path_match` is an expression that will be evaluated against the
+request path to decide if the handler should match. These expressions
+follow a custom syntax intended to make matching URLs straightforward
+and, in particular, to be easier to use than raw regexp for URL
+matching. There are three possible components of a match expression:
+
+* Literals. These match any character. The special characters \*, \{
+ and \} must be escaped by prefixing them with a \\.
+
+* Match groups. These match any character other than / and save the
+ result as a named group. They are delimited by curly braces; for
+ example::
+
+ {abc}
+
+ would create a match group with the name `abc`.
+
+* Stars. These are denoted with a `*` and match any character
+ including /. There can be at most one star
+ per pattern and it must follow any match groups.
+
+Path expressions always match the entire request path and a leading /
+in the expression is implied even if it is not explicitly
+provided. This means that `/foo` and `foo` are equivalent.
+
+For example, the following pattern matches all requests for resources with the
+extension `.py`::
+
+ *.py
+
+The following expression matches anything directly under `/resources`
+with a `.html` extension, and places the "filename" in the `name`
+group::
+
+ /resources/{name}.html
+
+The groups, including anything that matches a `*` are available in the
+request object through the `route_match` property. This is a
+dictionary mapping the group names, and any match for `*` to the
+matching part of the route. For example, given a route::
+
+ /api/{sub_api}/*
+
+and the request path `/api/test/html/test.html`, `route_match` would
+be::
+
+ {"sub_api": "html", "*": "html/test.html"}
+
+`handler` is a function taking a request and a response object that is
+responsible for constructing the response to the HTTP request. See
+:doc:`handlers` for more details on handler functions.
+
+.. _router.Interface:
+
+:mod:`Interface <wptserve.router>`
+----------------------------------
+
+.. automodule:: wptserve.router
+ :members:
diff --git a/testing/web-platform/tests/tools/wptserve/docs/server.rst b/testing/web-platform/tests/tools/wptserve/docs/server.rst
new file mode 100644
index 0000000000..5688a0a3bc
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/server.rst
@@ -0,0 +1,20 @@
+Server
+======
+
+Basic server classes and router.
+
+The following example creates a server that serves static files from
+the `files` subdirectory of the current directory and causes it to
+run on port 8080 until it is killed::
+
+ from wptserve import server, handlers
+
+ httpd = server.WebTestHttpd(port=8080, doc_root="./files/",
+ routes=[("GET", "*", handlers.file_handler)])
+ httpd.start(block=True)
+
+:mod:`Interface <wptserve.server>`
+----------------------------------
+
+.. automodule:: wptserve.server
+ :members:
diff --git a/testing/web-platform/tests/tools/wptserve/docs/stash.rst b/testing/web-platform/tests/tools/wptserve/docs/stash.rst
new file mode 100644
index 0000000000..6510a0f59c
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/docs/stash.rst
@@ -0,0 +1,31 @@
+Stash
+=====
+
+Object for storing cross-request state. This is unusual in that keys
+must be UUIDs, in order to prevent different clients setting the same
+key, and values are write-once, read-once to minimize the chances of
+state persisting indefinitely. The stash defines two operations;
+`put`, to add state and `take` to remove state. Furthermore, the view
+of the stash is path-specific; by default a request will only see the
+part of the stash corresponding to its own path.
+
+A typical example of using a stash to store state might be::
+
+ @handler
+ def handler(request, response):
+ # We assume this is a string representing a UUID
+ key = request.GET.first("id")
+
+ if request.method == "POST":
+ request.server.stash.put(key, "Some sample value")
+ return "Added value to stash"
+ else:
+ value = request.server.stash.take(key)
+ assert request.server.stash.take(key) is None
+ return value
+
+:mod:`Interface <wptserve.stash>`
+---------------------------------
+
+.. automodule:: wptserve.stash
+ :members:
diff --git a/testing/web-platform/tests/tools/wptserve/setup.py b/testing/web-platform/tests/tools/wptserve/setup.py
new file mode 100644
index 0000000000..36081619b6
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/setup.py
@@ -0,0 +1,23 @@
+from setuptools import setup
+
+PACKAGE_VERSION = '3.0'
+deps = ["h2>=3.0.1"]
+
+setup(name='wptserve',
+ version=PACKAGE_VERSION,
+ description="Python webserver intended for in web browser testing",
+ long_description=open("README.md").read(),
+ # Get strings from http://pypi.python.org/pypi?%3Aaction=list_classifiers
+ classifiers=["Development Status :: 5 - Production/Stable",
+ "License :: OSI Approved :: BSD License",
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers"],
+ keywords='',
+ author='James Graham',
+ author_email='james@hoppipolla.co.uk',
+ url='http://wptserve.readthedocs.org/',
+ license='BSD',
+ packages=['wptserve', 'wptserve.sslutils'],
+ include_package_data=True,
+ zip_safe=False,
+ install_requires=deps
+ )
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/__init__.py b/testing/web-platform/tests/tools/wptserve/tests/functional/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/__init__.py
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/base.py b/testing/web-platform/tests/tools/wptserve/tests/functional/base.py
new file mode 100644
index 0000000000..be5dc0d102
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/base.py
@@ -0,0 +1,148 @@
+import base64
+import logging
+import os
+import unittest
+
+from urllib.parse import urlencode, urlunsplit
+from urllib.request import Request as BaseRequest
+from urllib.request import urlopen
+
+import httpx
+import pytest
+
+from localpaths import repo_root
+
+wptserve = pytest.importorskip("wptserve")
+
+logging.basicConfig()
+
+here = os.path.dirname(__file__)
+doc_root = os.path.join(here, "docroot")
+
+
+class Request(BaseRequest):
+ def __init__(self, *args, **kwargs):
+ BaseRequest.__init__(self, *args, **kwargs)
+ self.method = "GET"
+
+ def get_method(self):
+ return self.method
+
+ def add_data(self, data):
+ if hasattr(data, "items"):
+ data = urlencode(data).encode("ascii")
+
+ assert isinstance(data, bytes)
+
+ if hasattr(BaseRequest, "add_data"):
+ BaseRequest.add_data(self, data)
+ else:
+ self.data = data
+
+ self.add_header("Content-Length", str(len(data)))
+
+
+class TestUsingServer(unittest.TestCase):
+ def setUp(self):
+ self.server = wptserve.server.WebTestHttpd(host="localhost",
+ port=0,
+ use_ssl=False,
+ certificate=None,
+ doc_root=doc_root)
+ self.server.start()
+
+ def tearDown(self):
+ self.server.stop()
+
+ def abs_url(self, path, query=None):
+ return urlunsplit(("http", "%s:%i" % (self.server.host, self.server.port), path, query, None))
+
+ def request(self, path, query=None, method="GET", headers=None, body=None, auth=None):
+ req = Request(self.abs_url(path, query))
+ req.method = method
+ if headers is None:
+ headers = {}
+
+ for name, value in headers.items():
+ req.add_header(name, value)
+
+ if body is not None:
+ req.add_data(body)
+
+ if auth is not None:
+ req.add_header("Authorization", b"Basic %s" % base64.b64encode(b"%s:%s" % auth))
+
+ return urlopen(req)
+
+ def assert_multiple_headers(self, resp, name, values):
+ assert resp.info().get_all(name) == values
+
+
+@pytest.mark.skipif(not wptserve.utils.http2_compatible(), reason="h2 server requires OpenSSL 1.0.2+")
+class TestUsingH2Server:
+ def setup_method(self, test_method):
+ self.server = wptserve.server.WebTestHttpd(host="localhost",
+ port=0,
+ use_ssl=True,
+ doc_root=doc_root,
+ key_file=os.path.join(repo_root, "tools", "certs", "web-platform.test.key"),
+ certificate=os.path.join(repo_root, "tools", "certs", "web-platform.test.pem"),
+ handler_cls=wptserve.server.Http2WebTestRequestHandler,
+ http2=True)
+ self.server.start()
+
+ self.client = httpx.Client(base_url=f'https://{self.server.host}:{self.server.port}',
+ http2=True, verify=False)
+
+ def teardown_method(self, test_method):
+ self.server.stop()
+
+
+class TestWrapperHandlerUsingServer(TestUsingServer):
+ '''For a wrapper handler, a .js dummy testing file is requried to render
+ the html file. This class extends the TestUsingServer and do some some
+ extra work: it tries to generate the dummy .js file in setUp and
+ remove it in tearDown.'''
+ dummy_files = {}
+
+ def gen_file(self, filename, empty=True, content=b''):
+ self.remove_file(filename)
+
+ with open(filename, 'wb') as fp:
+ if not empty:
+ fp.write(content)
+
+ def remove_file(self, filename):
+ if os.path.exists(filename):
+ os.remove(filename)
+
+ def setUp(self):
+ super().setUp()
+
+ for filename, content in self.dummy_files.items():
+ filepath = os.path.join(doc_root, filename)
+ if content == '':
+ self.gen_file(filepath)
+ else:
+ self.gen_file(filepath, False, content)
+
+ def run_wrapper_test(self, req_file, content_type, wrapper_handler,
+ headers=None):
+ route = ('GET', req_file, wrapper_handler())
+ self.server.router.register(*route)
+
+ resp = self.request(route[1])
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual(content_type, resp.info()['Content-Type'])
+ for key, val in headers or []:
+ self.assertEqual(val, resp.info()[key])
+
+ with open(os.path.join(doc_root, req_file), 'rb') as fp:
+ self.assertEqual(fp.read(), resp.read())
+
+ def tearDown(self):
+ super().tearDown()
+
+ for filename, _ in self.dummy_files.items():
+ filepath = os.path.join(doc_root, filename)
+ self.remove_file(filepath)
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/__init__.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/__init__.py
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/bar.any.worker.js b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/bar.any.worker.js
new file mode 100644
index 0000000000..baecd2ac54
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/bar.any.worker.js
@@ -0,0 +1,10 @@
+
+self.GLOBAL = {
+ isWindow: function() { return false; },
+ isWorker: function() { return true; },
+ isShadowRealm: function() { return false; },
+};
+importScripts("/resources/testharness.js");
+
+importScripts("/bar.any.js");
+done();
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/document.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/document.txt
new file mode 100644
index 0000000000..611dccd844
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/document.txt
@@ -0,0 +1 @@
+This is a test document
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.html b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.html
new file mode 100644
index 0000000000..8d64adc136
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.html
@@ -0,0 +1,15 @@
+<!doctype html>
+<meta charset=utf-8>
+
+<script>
+self.GLOBAL = {
+ isWindow: function() { return true; },
+ isWorker: function() { return false; },
+ isShadowRealm: function() { return false; },
+};
+</script>
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<div id=log></div>
+<script src="/foo.any.js"></script>
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.serviceworker.html b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.serviceworker.html
new file mode 100644
index 0000000000..8dcb11a376
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.serviceworker.html
@@ -0,0 +1,15 @@
+<!doctype html>
+<meta charset=utf-8>
+
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<div id=log></div>
+<script>
+(async function() {
+ const scope = 'does/not/exist';
+ let reg = await navigator.serviceWorker.getRegistration(scope);
+ if (reg) await reg.unregister();
+ reg = await navigator.serviceWorker.register("/foo.any.worker.js", {scope});
+ fetch_tests_from_worker(reg.installing);
+})();
+</script>
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.sharedworker.html b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.sharedworker.html
new file mode 100644
index 0000000000..277101697f
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.sharedworker.html
@@ -0,0 +1,9 @@
+<!doctype html>
+<meta charset=utf-8>
+
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<div id=log></div>
+<script>
+fetch_tests_from_worker(new SharedWorker("/foo.any.worker.js"));
+</script>
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.worker.html b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.worker.html
new file mode 100644
index 0000000000..f77edd971a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.any.worker.html
@@ -0,0 +1,9 @@
+<!doctype html>
+<meta charset=utf-8>
+
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<div id=log></div>
+<script>
+fetch_tests_from_worker(new Worker("/foo.any.worker.js"));
+</script>
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.window.html b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.window.html
new file mode 100644
index 0000000000..04c694ddf2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.window.html
@@ -0,0 +1,8 @@
+<!doctype html>
+<meta charset=utf-8>
+
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+
+<div id=log></div>
+<script src="/foo.window.js"></script>
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.worker.html b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.worker.html
new file mode 100644
index 0000000000..3eddf36f1c
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/foo.worker.html
@@ -0,0 +1,9 @@
+<!doctype html>
+<meta charset=utf-8>
+
+<script src="/resources/testharness.js"></script>
+<script src="/resources/testharnessreport.js"></script>
+<div id=log></div>
+<script>
+fetch_tests_from_worker(new Worker("/foo.worker.js"));
+</script>
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/invalid.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/invalid.py
new file mode 100644
index 0000000000..99f7b72cee
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/invalid.py
@@ -0,0 +1,3 @@
+# Intentional syntax error in this file
+def main(request, response:
+ return "FAIL"
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/no_main.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/no_main.py
new file mode 100644
index 0000000000..cee379fe1d
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/no_main.py
@@ -0,0 +1,3 @@
+# Oops...
+def mian(request, response):
+ return "FAIL"
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub.sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub.sub.txt
new file mode 100644
index 0000000000..4302db16a2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub.sub.txt
@@ -0,0 +1 @@
+{{host}} {{domains[]}} {{ports[http][0]}}
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub.txt
new file mode 100644
index 0000000000..4302db16a2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub.txt
@@ -0,0 +1 @@
+{{host}} {{domains[]}} {{ports[http][0]}}
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash.sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash.sub.txt
new file mode 100644
index 0000000000..369ac8ab31
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash.sub.txt
@@ -0,0 +1,6 @@
+md5: {{file_hash(md5, sub_file_hash_subject.txt)}}
+sha1: {{file_hash(sha1, sub_file_hash_subject.txt)}}
+sha224: {{file_hash(sha224, sub_file_hash_subject.txt)}}
+sha256: {{file_hash(sha256, sub_file_hash_subject.txt)}}
+sha384: {{file_hash(sha384, sub_file_hash_subject.txt)}}
+sha512: {{file_hash(sha512, sub_file_hash_subject.txt)}}
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash_subject.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash_subject.txt
new file mode 100644
index 0000000000..d567d28e8a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash_subject.txt
@@ -0,0 +1,2 @@
+This file is used to verify expected behavior of the `file_hash` "sub"
+function.
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash_unrecognized.sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash_unrecognized.sub.txt
new file mode 100644
index 0000000000..5f1281df5b
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_file_hash_unrecognized.sub.txt
@@ -0,0 +1 @@
+{{file_hash(sha007, sub_file_hash_subject.txt)}}
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_header_or_default.sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_header_or_default.sub.txt
new file mode 100644
index 0000000000..f1f941aa16
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_header_or_default.sub.txt
@@ -0,0 +1,2 @@
+{{header_or_default(X-Present, present-default)}}
+{{header_or_default(X-Absent, absent-default)}}
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_headers.sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_headers.sub.txt
new file mode 100644
index 0000000000..ee021eb863
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_headers.sub.txt
@@ -0,0 +1 @@
+{{headers[X-Test]}}
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_headers.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_headers.txt
new file mode 100644
index 0000000000..ee021eb863
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_headers.txt
@@ -0,0 +1 @@
+{{headers[X-Test]}}
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_location.sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_location.sub.txt
new file mode 100644
index 0000000000..6129abd4db
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_location.sub.txt
@@ -0,0 +1,8 @@
+host: {{location[host]}}
+hostname: {{location[hostname]}}
+path: {{location[path]}}
+pathname: {{location[pathname]}}
+port: {{location[port]}}
+query: {{location[query]}}
+scheme: {{location[scheme]}}
+server: {{location[server]}}
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_params.sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_params.sub.txt
new file mode 100644
index 0000000000..4431c21fc5
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_params.sub.txt
@@ -0,0 +1 @@
+{{GET[plus pct-20 pct-3D=]}}
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_params.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_params.txt
new file mode 100644
index 0000000000..4431c21fc5
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_params.txt
@@ -0,0 +1 @@
+{{GET[plus pct-20 pct-3D=]}}
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_url_base.sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_url_base.sub.txt
new file mode 100644
index 0000000000..889cd07fe9
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_url_base.sub.txt
@@ -0,0 +1 @@
+Before {{url_base}} After
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_uuid.sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_uuid.sub.txt
new file mode 100644
index 0000000000..fd968fecf0
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_uuid.sub.txt
@@ -0,0 +1 @@
+Before {{uuid()}} After
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_var.sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_var.sub.txt
new file mode 100644
index 0000000000..9492ec15a6
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/sub_var.sub.txt
@@ -0,0 +1 @@
+{{$first:host}} {{$second:ports[http][0]}} A {{$second}} B {{$first}} C
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/__init__.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/__init__.py
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/example_module.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/example_module.py
new file mode 100644
index 0000000000..b8e5c350ae
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/example_module.py
@@ -0,0 +1,2 @@
+def module_function():
+ return [("Content-Type", "text/plain")], "PASS"
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/file.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/file.txt
new file mode 100644
index 0000000000..06d84d30d5
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/file.txt
@@ -0,0 +1 @@
+I am here to ensure that my containing directory exists.
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/import_handler.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/import_handler.py
new file mode 100644
index 0000000000..e63395e273
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/import_handler.py
@@ -0,0 +1,5 @@
+from subdir import example_module
+
+
+def main(request, response):
+ return example_module.module_function()
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/sub_path.sub.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/sub_path.sub.txt
new file mode 100644
index 0000000000..44027f2855
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/subdir/sub_path.sub.txt
@@ -0,0 +1,3 @@
+{{fs_path(sub_path.sub.txt)}}
+{{fs_path(../sub_path.sub.txt)}}
+{{fs_path(/sub_path.sub.txt)}}
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test.asis b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test.asis
new file mode 100644
index 0000000000..b05ba7da80
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test.asis
@@ -0,0 +1,5 @@
+HTTP/1.1 202 Giraffe
+X-TEST: PASS
+Content-Length: 7
+
+Content \ No newline at end of file
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_data.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_data.py
new file mode 100644
index 0000000000..9770a5a8aa
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_data.py
@@ -0,0 +1,2 @@
+def handle_data(frame, request, response):
+ response.content.append(frame.data.swapcase())
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_headers.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_headers.py
new file mode 100644
index 0000000000..60e72d9492
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_headers.py
@@ -0,0 +1,3 @@
+def handle_headers(frame, request, response):
+ response.status = 203
+ response.headers.update([('test', 'passed')])
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_headers_data.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_headers_data.py
new file mode 100644
index 0000000000..32855093e1
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_h2_headers_data.py
@@ -0,0 +1,6 @@
+def handle_headers(frame, request, response):
+ response.status = 203
+ response.headers.update([('test', 'passed')])
+
+def handle_data(frame, request, response):
+ response.content.append(frame.data.swapcase())
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_string.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_string.py
new file mode 100644
index 0000000000..8fa605bb18
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_string.py
@@ -0,0 +1,3 @@
+def main(request, response):
+ response.headers.set("Content-Type", "text/plain")
+ return "PASS"
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_tuple_2.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_tuple_2.py
new file mode 100644
index 0000000000..fa791fbddd
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_tuple_2.py
@@ -0,0 +1,2 @@
+def main(request, response):
+ return [("Content-Type", "text/html"), ("X-Test", "PASS")], "PASS"
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_tuple_3.py b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_tuple_3.py
new file mode 100644
index 0000000000..2c2656d047
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/test_tuple_3.py
@@ -0,0 +1,2 @@
+def main(request, response):
+ return (202, "Giraffe"), [("Content-Type", "text/html"), ("X-Test", "PASS")], "PASS"
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/with_headers.txt b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/with_headers.txt
new file mode 100644
index 0000000000..45ce1a0790
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/with_headers.txt
@@ -0,0 +1 @@
+Test document with custom headers
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/with_headers.txt.sub.headers b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/with_headers.txt.sub.headers
new file mode 100644
index 0000000000..71494fccf1
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/docroot/with_headers.txt.sub.headers
@@ -0,0 +1,6 @@
+Custom-Header: PASS
+Another-Header: {{$id:uuid()}}
+Same-Value-Header: {{$id}}
+Double-Header: PA
+Double-Header: SS
+Content-Type: text/html
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/test_cookies.py b/testing/web-platform/tests/tools/wptserve/tests/functional/test_cookies.py
new file mode 100644
index 0000000000..64eab2d806
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/test_cookies.py
@@ -0,0 +1,66 @@
+import unittest
+
+import pytest
+
+wptserve = pytest.importorskip("wptserve")
+from .base import TestUsingServer
+
+
+class TestResponseSetCookie(TestUsingServer):
+ def test_name_value(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.set_cookie(b"name", b"value")
+ return "Test"
+
+ route = ("GET", "/test/name_value", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+
+ self.assertEqual(resp.info()["Set-Cookie"], "name=value; Path=/")
+
+ def test_unset(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.set_cookie(b"name", b"value")
+ response.unset_cookie(b"name")
+ return "Test"
+
+ route = ("GET", "/test/unset", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+
+ self.assertTrue("Set-Cookie" not in resp.info())
+
+ def test_delete(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.delete_cookie(b"name")
+ return "Test"
+
+ route = ("GET", "/test/delete", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+
+ parts = dict(item.split("=") for
+ item in resp.info()["Set-Cookie"].split("; ") if item)
+
+ self.assertEqual(parts["name"], "")
+ self.assertEqual(parts["Path"], "/")
+ # TODO: Should also check that expires is in the past
+
+
+class TestRequestCookies(TestUsingServer):
+ def test_set_cookie(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return request.cookies[b"name"].value
+
+ route = ("GET", "/test/set_cookie", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1], headers={"Cookie": "name=value"})
+ self.assertEqual(resp.read(), b"value")
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/test_handlers.py b/testing/web-platform/tests/tools/wptserve/tests/functional/test_handlers.py
new file mode 100644
index 0000000000..26a9f797ec
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/test_handlers.py
@@ -0,0 +1,439 @@
+import json
+import os
+import sys
+import unittest
+import uuid
+
+import pytest
+from urllib.error import HTTPError
+
+wptserve = pytest.importorskip("wptserve")
+from .base import TestUsingServer, TestUsingH2Server, doc_root
+from .base import TestWrapperHandlerUsingServer
+
+from serve import serve
+
+
+class TestFileHandler(TestUsingServer):
+ def test_GET(self):
+ resp = self.request("/document.txt")
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual("text/plain", resp.info()["Content-Type"])
+ self.assertEqual(open(os.path.join(doc_root, "document.txt"), 'rb').read(), resp.read())
+
+ def test_headers(self):
+ resp = self.request("/with_headers.txt")
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual("text/html", resp.info()["Content-Type"])
+ self.assertEqual("PASS", resp.info()["Custom-Header"])
+ # This will fail if it isn't a valid uuid
+ uuid.UUID(resp.info()["Another-Header"])
+ self.assertEqual(resp.info()["Same-Value-Header"], resp.info()["Another-Header"])
+ self.assert_multiple_headers(resp, "Double-Header", ["PA", "SS"])
+
+
+ def test_range(self):
+ resp = self.request("/document.txt", headers={"Range":"bytes=10-19"})
+ self.assertEqual(206, resp.getcode())
+ data = resp.read()
+ expected = open(os.path.join(doc_root, "document.txt"), 'rb').read()
+ self.assertEqual(10, len(data))
+ self.assertEqual("bytes 10-19/%i" % len(expected), resp.info()['Content-Range'])
+ self.assertEqual("10", resp.info()['Content-Length'])
+ self.assertEqual(expected[10:20], data)
+
+ def test_range_no_end(self):
+ resp = self.request("/document.txt", headers={"Range":"bytes=10-"})
+ self.assertEqual(206, resp.getcode())
+ data = resp.read()
+ expected = open(os.path.join(doc_root, "document.txt"), 'rb').read()
+ self.assertEqual(len(expected) - 10, len(data))
+ self.assertEqual("bytes 10-%i/%i" % (len(expected) - 1, len(expected)), resp.info()['Content-Range'])
+ self.assertEqual(expected[10:], data)
+
+ def test_range_no_start(self):
+ resp = self.request("/document.txt", headers={"Range":"bytes=-10"})
+ self.assertEqual(206, resp.getcode())
+ data = resp.read()
+ expected = open(os.path.join(doc_root, "document.txt"), 'rb').read()
+ self.assertEqual(10, len(data))
+ self.assertEqual("bytes %i-%i/%i" % (len(expected) - 10, len(expected) - 1, len(expected)),
+ resp.info()['Content-Range'])
+ self.assertEqual(expected[-10:], data)
+
+ def test_multiple_ranges(self):
+ resp = self.request("/document.txt", headers={"Range":"bytes=1-2,5-7,6-10"})
+ self.assertEqual(206, resp.getcode())
+ data = resp.read()
+ expected = open(os.path.join(doc_root, "document.txt"), 'rb').read()
+ self.assertTrue(resp.info()["Content-Type"].startswith("multipart/byteranges; boundary="))
+ boundary = resp.info()["Content-Type"].split("boundary=")[1]
+ parts = data.split(b"--" + boundary.encode("ascii"))
+ self.assertEqual(b"\r\n", parts[0])
+ self.assertEqual(b"--", parts[-1])
+ expected_parts = [(b"1-2", expected[1:3]), (b"5-10", expected[5:11])]
+ for expected_part, part in zip(expected_parts, parts[1:-1]):
+ header_string, body = part.split(b"\r\n\r\n")
+ headers = dict(item.split(b": ", 1) for item in header_string.split(b"\r\n") if item.strip())
+ self.assertEqual(headers[b"Content-Type"], b"text/plain")
+ self.assertEqual(headers[b"Content-Range"], b"bytes %s/%i" % (expected_part[0], len(expected)))
+ self.assertEqual(expected_part[1] + b"\r\n", body)
+
+ def test_range_invalid(self):
+ with self.assertRaises(HTTPError) as cm:
+ self.request("/document.txt", headers={"Range":"bytes=11-10"})
+ self.assertEqual(cm.exception.code, 416)
+
+ expected = open(os.path.join(doc_root, "document.txt"), 'rb').read()
+ with self.assertRaises(HTTPError) as cm:
+ self.request("/document.txt", headers={"Range":"bytes=%i-%i" % (len(expected), len(expected) + 10)})
+ self.assertEqual(cm.exception.code, 416)
+
+ def test_sub_config(self):
+ resp = self.request("/sub.sub.txt")
+ expected = b"localhost localhost %i" % self.server.port
+ assert resp.read().rstrip() == expected
+
+ def test_sub_headers(self):
+ resp = self.request("/sub_headers.sub.txt", headers={"X-Test": "PASS"})
+ expected = b"PASS"
+ assert resp.read().rstrip() == expected
+
+ def test_sub_params(self):
+ resp = self.request("/sub_params.txt", query="plus+pct-20%20pct-3D%3D=PLUS+PCT-20%20PCT-3D%3D&pipe=sub")
+ expected = b"PLUS PCT-20 PCT-3D="
+ assert resp.read().rstrip() == expected
+
+
+class TestFunctionHandler(TestUsingServer):
+ def test_string_rv(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return "test data"
+
+ route = ("GET", "/test/test_string_rv", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual("9", resp.info()["Content-Length"])
+ self.assertEqual(b"test data", resp.read())
+
+ def test_tuple_1_rv(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return ()
+
+ route = ("GET", "/test/test_tuple_1_rv", handler)
+ self.server.router.register(*route)
+
+ with pytest.raises(HTTPError) as cm:
+ self.request(route[1])
+
+ assert cm.value.code == 500
+
+ def test_tuple_2_rv(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return [("Content-Length", 4), ("test-header", "test-value")], "test data"
+
+ route = ("GET", "/test/test_tuple_2_rv", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual("4", resp.info()["Content-Length"])
+ self.assertEqual("test-value", resp.info()["test-header"])
+ self.assertEqual(b"test", resp.read())
+
+ def test_tuple_3_rv(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return 202, [("test-header", "test-value")], "test data"
+
+ route = ("GET", "/test/test_tuple_3_rv", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ self.assertEqual(202, resp.getcode())
+ self.assertEqual("test-value", resp.info()["test-header"])
+ self.assertEqual(b"test data", resp.read())
+
+ def test_tuple_3_rv_1(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return (202, "Some Status"), [("test-header", "test-value")], "test data"
+
+ route = ("GET", "/test/test_tuple_3_rv_1", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ self.assertEqual(202, resp.getcode())
+ self.assertEqual("Some Status", resp.msg)
+ self.assertEqual("test-value", resp.info()["test-header"])
+ self.assertEqual(b"test data", resp.read())
+
+ def test_tuple_4_rv(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return 202, [("test-header", "test-value")], "test data", "garbage"
+
+ route = ("GET", "/test/test_tuple_1_rv", handler)
+ self.server.router.register(*route)
+
+ with pytest.raises(HTTPError) as cm:
+ self.request(route[1])
+
+ assert cm.value.code == 500
+
+ def test_none_rv(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return None
+
+ route = ("GET", "/test/test_none_rv", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ assert resp.getcode() == 200
+ assert "Content-Length" not in resp.info()
+ assert resp.read() == b""
+
+
+class TestJSONHandler(TestUsingServer):
+ def test_json_0(self):
+ @wptserve.handlers.json_handler
+ def handler(request, response):
+ return {"data": "test data"}
+
+ route = ("GET", "/test/test_json_0", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual({"data": "test data"}, json.load(resp))
+
+ def test_json_tuple_2(self):
+ @wptserve.handlers.json_handler
+ def handler(request, response):
+ return [("Test-Header", "test-value")], {"data": "test data"}
+
+ route = ("GET", "/test/test_json_tuple_2", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual("test-value", resp.info()["test-header"])
+ self.assertEqual({"data": "test data"}, json.load(resp))
+
+ def test_json_tuple_3(self):
+ @wptserve.handlers.json_handler
+ def handler(request, response):
+ return (202, "Giraffe"), [("Test-Header", "test-value")], {"data": "test data"}
+
+ route = ("GET", "/test/test_json_tuple_2", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ self.assertEqual(202, resp.getcode())
+ self.assertEqual("Giraffe", resp.msg)
+ self.assertEqual("test-value", resp.info()["test-header"])
+ self.assertEqual({"data": "test data"}, json.load(resp))
+
+
+class TestPythonHandler(TestUsingServer):
+ def test_string(self):
+ resp = self.request("/test_string.py")
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual("text/plain", resp.info()["Content-Type"])
+ self.assertEqual(b"PASS", resp.read())
+
+ def test_tuple_2(self):
+ resp = self.request("/test_tuple_2.py")
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual("text/html", resp.info()["Content-Type"])
+ self.assertEqual("PASS", resp.info()["X-Test"])
+ self.assertEqual(b"PASS", resp.read())
+
+ def test_tuple_3(self):
+ resp = self.request("/test_tuple_3.py")
+ self.assertEqual(202, resp.getcode())
+ self.assertEqual("Giraffe", resp.msg)
+ self.assertEqual("text/html", resp.info()["Content-Type"])
+ self.assertEqual("PASS", resp.info()["X-Test"])
+ self.assertEqual(b"PASS", resp.read())
+
+ def test_import(self):
+ dir_name = os.path.join(doc_root, "subdir")
+ assert dir_name not in sys.path
+ assert "test_module" not in sys.modules
+ resp = self.request("/subdir/import_handler.py")
+ assert dir_name not in sys.path
+ assert "test_module" not in sys.modules
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual("text/plain", resp.info()["Content-Type"])
+ self.assertEqual(b"PASS", resp.read())
+
+ def test_no_main(self):
+ with pytest.raises(HTTPError) as cm:
+ self.request("/no_main.py")
+
+ assert cm.value.code == 500
+
+ def test_invalid(self):
+ with pytest.raises(HTTPError) as cm:
+ self.request("/invalid.py")
+
+ assert cm.value.code == 500
+
+ def test_missing(self):
+ with pytest.raises(HTTPError) as cm:
+ self.request("/missing.py")
+
+ assert cm.value.code == 404
+
+
+class TestDirectoryHandler(TestUsingServer):
+ def test_directory(self):
+ resp = self.request("/")
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual("text/html", resp.info()["Content-Type"])
+ #Add a check that the response is actually sane
+
+ def test_subdirectory_trailing_slash(self):
+ resp = self.request("/subdir/")
+ assert resp.getcode() == 200
+ assert resp.info()["Content-Type"] == "text/html"
+
+ def test_subdirectory_no_trailing_slash(self):
+ # This seems to resolve the 301 transparently, so test for 200
+ resp = self.request("/subdir")
+ assert resp.getcode() == 200
+ assert resp.info()["Content-Type"] == "text/html"
+
+
+class TestAsIsHandler(TestUsingServer):
+ def test_as_is(self):
+ resp = self.request("/test.asis")
+ self.assertEqual(202, resp.getcode())
+ self.assertEqual("Giraffe", resp.msg)
+ self.assertEqual("PASS", resp.info()["X-Test"])
+ self.assertEqual(b"Content", resp.read())
+ #Add a check that the response is actually sane
+
+
+class TestH2Handler(TestUsingH2Server):
+ def test_handle_headers(self):
+ resp = self.client.get('/test_h2_headers.py')
+
+ assert resp.status_code == 203
+ assert resp.headers['test'] == 'passed'
+ assert resp.content == b''
+
+ def test_only_main(self):
+ resp = self.client.get('/test_tuple_3.py')
+
+ assert resp.status_code == 202
+ assert resp.headers['Content-Type'] == 'text/html'
+ assert resp.headers['X-Test'] == 'PASS'
+ assert resp.content == b'PASS'
+
+ def test_handle_data(self):
+ resp = self.client.post('/test_h2_data.py', content=b'hello world!')
+
+ assert resp.status_code == 200
+ assert resp.content == b'HELLO WORLD!'
+
+ def test_handle_headers_data(self):
+ resp = self.client.post('/test_h2_headers_data.py', content=b'hello world!')
+
+ assert resp.status_code == 203
+ assert resp.headers['test'] == 'passed'
+ assert resp.content == b'HELLO WORLD!'
+
+ def test_no_main_or_handlers(self):
+ resp = self.client.get('/no_main.py')
+
+ assert resp.status_code == 500
+ assert "No main function or handlers in script " in json.loads(resp.content)["error"]["message"]
+
+ def test_not_found(self):
+ resp = self.client.get('/no_exist.py')
+
+ assert resp.status_code == 404
+
+ def test_requesting_multiple_resources(self):
+ # 1st .py resource
+ resp = self.client.get('/test_h2_headers.py')
+
+ assert resp.status_code == 203
+ assert resp.headers['test'] == 'passed'
+ assert resp.content == b''
+
+ # 2nd .py resource
+ resp = self.client.get('/test_tuple_3.py')
+
+ assert resp.status_code == 202
+ assert resp.headers['Content-Type'] == 'text/html'
+ assert resp.headers['X-Test'] == 'PASS'
+ assert resp.content == b'PASS'
+
+ # 3rd .py resource
+ resp = self.client.get('/test_h2_headers.py')
+
+ assert resp.status_code == 203
+ assert resp.headers['test'] == 'passed'
+ assert resp.content == b''
+
+
+class TestWorkersHandler(TestWrapperHandlerUsingServer):
+ dummy_files = {'foo.worker.js': b'',
+ 'foo.any.js': b''}
+
+ def test_any_worker_html(self):
+ self.run_wrapper_test('foo.any.worker.html',
+ 'text/html', serve.WorkersHandler)
+
+ def test_worker_html(self):
+ self.run_wrapper_test('foo.worker.html',
+ 'text/html', serve.WorkersHandler)
+
+
+class TestWindowHandler(TestWrapperHandlerUsingServer):
+ dummy_files = {'foo.window.js': b''}
+
+ def test_window_html(self):
+ self.run_wrapper_test('foo.window.html',
+ 'text/html', serve.WindowHandler)
+
+
+class TestAnyHtmlHandler(TestWrapperHandlerUsingServer):
+ dummy_files = {'foo.any.js': b'',
+ 'foo.any.js.headers': b'X-Foo: 1',
+ '__dir__.headers': b'X-Bar: 2'}
+
+ def test_any_html(self):
+ self.run_wrapper_test('foo.any.html',
+ 'text/html',
+ serve.AnyHtmlHandler,
+ headers=[('X-Foo', '1'), ('X-Bar', '2')])
+
+
+class TestSharedWorkersHandler(TestWrapperHandlerUsingServer):
+ dummy_files = {'foo.any.js': b'// META: global=sharedworker\n'}
+
+ def test_any_sharedworkers_html(self):
+ self.run_wrapper_test('foo.any.sharedworker.html',
+ 'text/html', serve.SharedWorkersHandler)
+
+
+class TestServiceWorkersHandler(TestWrapperHandlerUsingServer):
+ dummy_files = {'foo.any.js': b'// META: global=serviceworker\n'}
+
+ def test_serviceworker_html(self):
+ self.run_wrapper_test('foo.any.serviceworker.html',
+ 'text/html', serve.ServiceWorkersHandler)
+
+
+class TestClassicWorkerHandler(TestWrapperHandlerUsingServer):
+ dummy_files = {'bar.any.js': b''}
+
+ def test_any_work_js(self):
+ self.run_wrapper_test('bar.any.worker.js', 'text/javascript',
+ serve.ClassicWorkerHandler)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/test_input_file.py b/testing/web-platform/tests/tools/wptserve/tests/functional/test_input_file.py
new file mode 100644
index 0000000000..93db62c842
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/test_input_file.py
@@ -0,0 +1,149 @@
+from io import BytesIO
+
+import pytest
+
+from wptserve.request import InputFile
+
+bstr = b'This is a test document\nWith new lines\nSeveral in fact...'
+rfile = ''
+test_file = '' # This will be used to test the InputFile functions against
+input_file = InputFile(None, 0)
+
+
+def setup_function(function):
+ global rfile, input_file, test_file
+ rfile = BytesIO(bstr)
+ test_file = BytesIO(bstr)
+ input_file = InputFile(rfile, len(bstr))
+
+
+def teardown_function(function):
+ rfile.close()
+ test_file.close()
+
+
+def test_seek():
+ input_file.seek(2)
+ test_file.seek(2)
+ assert input_file.read(1) == test_file.read(1)
+
+ input_file.seek(4)
+ test_file.seek(4)
+ assert input_file.read(1) == test_file.read(1)
+
+
+def test_seek_backwards():
+ input_file.seek(2)
+ test_file.seek(2)
+ assert input_file.tell() == test_file.tell()
+ assert input_file.read(1) == test_file.read(1)
+ assert input_file.tell() == test_file.tell()
+
+ input_file.seek(0)
+ test_file.seek(0)
+ assert input_file.read(1) == test_file.read(1)
+
+
+def test_seek_negative_offset():
+ with pytest.raises(ValueError):
+ input_file.seek(-1)
+
+
+def test_seek_file_bigger_than_buffer():
+ old_max_buf = InputFile.max_buffer_size
+ InputFile.max_buffer_size = 10
+
+ try:
+ input_file = InputFile(rfile, len(bstr))
+
+ input_file.seek(2)
+ test_file.seek(2)
+ assert input_file.read(1) == test_file.read(1)
+
+ input_file.seek(4)
+ test_file.seek(4)
+ assert input_file.read(1) == test_file.read(1)
+ finally:
+ InputFile.max_buffer_size = old_max_buf
+
+
+def test_read():
+ assert input_file.read() == test_file.read()
+
+
+def test_read_file_bigger_than_buffer():
+ old_max_buf = InputFile.max_buffer_size
+ InputFile.max_buffer_size = 10
+
+ try:
+ input_file = InputFile(rfile, len(bstr))
+ assert input_file.read() == test_file.read()
+ finally:
+ InputFile.max_buffer_size = old_max_buf
+
+
+def test_readline():
+ assert input_file.readline() == test_file.readline()
+ assert input_file.readline() == test_file.readline()
+
+ input_file.seek(0)
+ test_file.seek(0)
+ assert input_file.readline() == test_file.readline()
+
+
+def test_readline_max_byte():
+ line = test_file.readline()
+ assert input_file.readline(max_bytes=len(line)//2) == line[:len(line)//2]
+ assert input_file.readline(max_bytes=len(line)) == line[len(line)//2:]
+
+
+def test_readline_max_byte_longer_than_file():
+ assert input_file.readline(max_bytes=1000) == test_file.readline()
+ assert input_file.readline(max_bytes=1000) == test_file.readline()
+
+
+def test_readline_file_bigger_than_buffer():
+ old_max_buf = InputFile.max_buffer_size
+ InputFile.max_buffer_size = 10
+
+ try:
+ input_file = InputFile(rfile, len(bstr))
+
+ assert input_file.readline() == test_file.readline()
+ assert input_file.readline() == test_file.readline()
+ finally:
+ InputFile.max_buffer_size = old_max_buf
+
+
+def test_readlines():
+ assert input_file.readlines() == test_file.readlines()
+
+
+def test_readlines_file_bigger_than_buffer():
+ old_max_buf = InputFile.max_buffer_size
+ InputFile.max_buffer_size = 10
+
+ try:
+ input_file = InputFile(rfile, len(bstr))
+
+ assert input_file.readlines() == test_file.readlines()
+ finally:
+ InputFile.max_buffer_size = old_max_buf
+
+
+def test_iter():
+ for a, b in zip(input_file, test_file):
+ assert a == b
+
+
+def test_iter_file_bigger_than_buffer():
+ old_max_buf = InputFile.max_buffer_size
+ InputFile.max_buffer_size = 10
+
+ try:
+ input_file = InputFile(rfile, len(bstr))
+
+ for a, b in zip(input_file, test_file):
+ assert a == b
+ finally:
+ InputFile.max_buffer_size = old_max_buf
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/test_pipes.py b/testing/web-platform/tests/tools/wptserve/tests/functional/test_pipes.py
new file mode 100644
index 0000000000..904bfb4ee4
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/test_pipes.py
@@ -0,0 +1,233 @@
+import os
+import unittest
+import time
+import json
+import urllib
+
+import pytest
+
+wptserve = pytest.importorskip("wptserve")
+from .base import TestUsingServer, doc_root
+
+
+class TestStatus(TestUsingServer):
+ def test_status(self):
+ resp = self.request("/document.txt", query="pipe=status(202)")
+ self.assertEqual(resp.getcode(), 202)
+
+class TestHeader(TestUsingServer):
+ def test_not_set(self):
+ resp = self.request("/document.txt", query="pipe=header(X-TEST,PASS)")
+ self.assertEqual(resp.info()["X-TEST"], "PASS")
+
+ def test_set(self):
+ resp = self.request("/document.txt", query="pipe=header(Content-Type,text/html)")
+ self.assertEqual(resp.info()["Content-Type"], "text/html")
+
+ def test_multiple(self):
+ resp = self.request("/document.txt", query="pipe=header(X-Test,PASS)|header(Content-Type,text/html)")
+ self.assertEqual(resp.info()["X-TEST"], "PASS")
+ self.assertEqual(resp.info()["Content-Type"], "text/html")
+
+ def test_multiple_same(self):
+ resp = self.request("/document.txt", query="pipe=header(Content-Type,FAIL)|header(Content-Type,text/html)")
+ self.assertEqual(resp.info()["Content-Type"], "text/html")
+
+ def test_multiple_append(self):
+ resp = self.request("/document.txt", query="pipe=header(X-Test,1)|header(X-Test,2,True)")
+ self.assert_multiple_headers(resp, "X-Test", ["1", "2"])
+
+ def test_semicolon(self):
+ resp = self.request("/document.txt", query="pipe=header(Refresh,3;url=http://example.com)")
+ self.assertEqual(resp.info()["Refresh"], "3;url=http://example.com")
+
+ def test_escape_comma(self):
+ resp = self.request("/document.txt", query=r"pipe=header(Expires,Thu\,%2014%20Aug%201986%2018:00:00%20GMT)")
+ self.assertEqual(resp.info()["Expires"], "Thu, 14 Aug 1986 18:00:00 GMT")
+
+ def test_escape_parenthesis(self):
+ resp = self.request("/document.txt", query=r"pipe=header(User-Agent,Mozilla/5.0%20(X11;%20Linux%20x86_64;%20rv:12.0\)")
+ self.assertEqual(resp.info()["User-Agent"], "Mozilla/5.0 (X11; Linux x86_64; rv:12.0)")
+
+class TestSlice(TestUsingServer):
+ def test_both_bounds(self):
+ resp = self.request("/document.txt", query="pipe=slice(1,10)")
+ expected = open(os.path.join(doc_root, "document.txt"), 'rb').read()
+ self.assertEqual(resp.read(), expected[1:10])
+
+ def test_no_upper(self):
+ resp = self.request("/document.txt", query="pipe=slice(1)")
+ expected = open(os.path.join(doc_root, "document.txt"), 'rb').read()
+ self.assertEqual(resp.read(), expected[1:])
+
+ def test_no_lower(self):
+ resp = self.request("/document.txt", query="pipe=slice(null,10)")
+ expected = open(os.path.join(doc_root, "document.txt"), 'rb').read()
+ self.assertEqual(resp.read(), expected[:10])
+
+class TestSub(TestUsingServer):
+ def test_sub_config(self):
+ resp = self.request("/sub.txt", query="pipe=sub")
+ expected = b"localhost localhost %i" % self.server.port
+ self.assertEqual(resp.read().rstrip(), expected)
+
+ def test_sub_file_hash(self):
+ resp = self.request("/sub_file_hash.sub.txt")
+ expected = b"""
+md5: JmI1W8fMHfSfCarYOSxJcw==
+sha1: nqpWqEw4IW8NjD6R375gtrQvtTo=
+sha224: RqQ6fMmta6n9TuA/vgTZK2EqmidqnrwBAmQLRQ==
+sha256: G6Ljg1uPejQxqFmvFOcV/loqnjPTW5GSOePOfM/u0jw=
+sha384: lkXHChh1BXHN5nT5BYhi1x67E1CyYbPKRKoF2LTm5GivuEFpVVYtvEBHtPr74N9E
+sha512: r8eLGRTc7ZznZkFjeVLyo6/FyQdra9qmlYCwKKxm3kfQAswRS9+3HsYk3thLUhcFmmWhK4dXaICzJwGFonfXwg=="""
+ self.assertEqual(resp.read().rstrip(), expected.strip())
+
+ def test_sub_file_hash_unrecognized(self):
+ with self.assertRaises(urllib.error.HTTPError):
+ self.request("/sub_file_hash_unrecognized.sub.txt")
+
+ def test_sub_headers(self):
+ resp = self.request("/sub_headers.txt", query="pipe=sub", headers={"X-Test": "PASS"})
+ expected = b"PASS"
+ self.assertEqual(resp.read().rstrip(), expected)
+
+ def test_sub_location(self):
+ resp = self.request("/sub_location.sub.txt?query_string")
+ expected = """
+host: localhost:{0}
+hostname: localhost
+path: /sub_location.sub.txt
+pathname: /sub_location.sub.txt
+port: {0}
+query: ?query_string
+scheme: http
+server: http://localhost:{0}""".format(self.server.port).encode("ascii")
+ self.assertEqual(resp.read().rstrip(), expected.strip())
+
+ def test_sub_params(self):
+ resp = self.request("/sub_params.txt", query="plus+pct-20%20pct-3D%3D=PLUS+PCT-20%20PCT-3D%3D&pipe=sub")
+ expected = b"PLUS PCT-20 PCT-3D="
+ self.assertEqual(resp.read().rstrip(), expected)
+
+ def test_sub_url_base(self):
+ resp = self.request("/sub_url_base.sub.txt")
+ self.assertEqual(resp.read().rstrip(), b"Before / After")
+
+ def test_sub_url_base_via_filename_with_query(self):
+ resp = self.request("/sub_url_base.sub.txt?pipe=slice(5,10)")
+ self.assertEqual(resp.read().rstrip(), b"e / A")
+
+ def test_sub_uuid(self):
+ resp = self.request("/sub_uuid.sub.txt")
+ self.assertRegex(resp.read().rstrip(), b"Before [a-f0-9-]+ After")
+
+ def test_sub_var(self):
+ resp = self.request("/sub_var.sub.txt")
+ port = self.server.port
+ expected = b"localhost %d A %d B localhost C" % (port, port)
+ self.assertEqual(resp.read().rstrip(), expected)
+
+ def test_sub_fs_path(self):
+ resp = self.request("/subdir/sub_path.sub.txt")
+ root = os.path.abspath(doc_root)
+ expected = """%(root)s%(sep)ssubdir%(sep)ssub_path.sub.txt
+%(root)s%(sep)ssub_path.sub.txt
+%(root)s%(sep)ssub_path.sub.txt
+""" % {"root": root, "sep": os.path.sep}
+ self.assertEqual(resp.read(), expected.encode("utf8"))
+
+ def test_sub_header_or_default(self):
+ resp = self.request("/sub_header_or_default.sub.txt", headers={"X-Present": "OK"})
+ expected = b"OK\nabsent-default"
+ self.assertEqual(resp.read().rstrip(), expected)
+
+class TestTrickle(TestUsingServer):
+ def test_trickle(self):
+ #Actually testing that the response trickles in is not that easy
+ t0 = time.time()
+ resp = self.request("/document.txt", query="pipe=trickle(1:d2:5:d1:r2)")
+ t1 = time.time()
+ expected = open(os.path.join(doc_root, "document.txt"), 'rb').read()
+ self.assertEqual(resp.read(), expected)
+ self.assertGreater(6, t1-t0)
+
+ def test_headers(self):
+ resp = self.request("/document.txt", query="pipe=trickle(d0.01)")
+ self.assertEqual(resp.info()["Cache-Control"], "no-cache, no-store, must-revalidate")
+ self.assertEqual(resp.info()["Pragma"], "no-cache")
+ self.assertEqual(resp.info()["Expires"], "0")
+
+class TestPipesWithVariousHandlers(TestUsingServer):
+ def test_with_python_file_handler(self):
+ resp = self.request("/test_string.py", query="pipe=slice(null,2)")
+ self.assertEqual(resp.read(), b"PA")
+
+ def test_with_python_func_handler(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return "PASS"
+ route = ("GET", "/test/test_pipes_1/", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1], query="pipe=slice(null,2)")
+ self.assertEqual(resp.read(), b"PA")
+
+ def test_with_python_func_handler_using_response_writer(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.writer.write_content("PASS")
+ route = ("GET", "/test/test_pipes_1/", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1], query="pipe=slice(null,2)")
+ # slice has not been applied to the response, because response.writer was used.
+ self.assertEqual(resp.read(), b"PASS")
+
+ def test_header_pipe_with_python_func_using_response_writer(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.writer.write_content("CONTENT")
+ route = ("GET", "/test/test_pipes_1/", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1], query="pipe=header(X-TEST,FAIL)")
+ # header pipe was ignored, because response.writer was used.
+ self.assertFalse(resp.info().get("X-TEST"))
+ self.assertEqual(resp.read(), b"CONTENT")
+
+ def test_with_json_handler(self):
+ @wptserve.handlers.json_handler
+ def handler(request, response):
+ return json.dumps({'data': 'PASS'})
+ route = ("GET", "/test/test_pipes_2/", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1], query="pipe=slice(null,2)")
+ self.assertEqual(resp.read(), b'"{')
+
+ def test_slice_with_as_is_handler(self):
+ resp = self.request("/test.asis", query="pipe=slice(null,2)")
+ self.assertEqual(202, resp.getcode())
+ self.assertEqual("Giraffe", resp.msg)
+ self.assertEqual("PASS", resp.info()["X-Test"])
+ # slice has not been applied to the response, because response.writer was used.
+ self.assertEqual(b"Content", resp.read())
+
+ def test_headers_with_as_is_handler(self):
+ resp = self.request("/test.asis", query="pipe=header(X-TEST,FAIL)")
+ self.assertEqual(202, resp.getcode())
+ self.assertEqual("Giraffe", resp.msg)
+ # header pipe was ignored.
+ self.assertEqual("PASS", resp.info()["X-TEST"])
+ self.assertEqual(b"Content", resp.read())
+
+ def test_trickle_with_as_is_handler(self):
+ t0 = time.time()
+ resp = self.request("/test.asis", query="pipe=trickle(1:d2:5:d1:r2)")
+ t1 = time.time()
+ self.assertTrue(b'Content' in resp.read())
+ self.assertGreater(6, t1-t0)
+
+ def test_gzip_handler(self):
+ resp = self.request("/document.txt", query="pipe=gzip")
+ self.assertEqual(resp.getcode(), 200)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/test_request.py b/testing/web-platform/tests/tools/wptserve/tests/functional/test_request.py
new file mode 100644
index 0000000000..aa492f7437
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/test_request.py
@@ -0,0 +1,183 @@
+import pytest
+
+from urllib.parse import quote_from_bytes
+
+wptserve = pytest.importorskip("wptserve")
+from .base import TestUsingServer
+from wptserve.request import InputFile
+
+
+class TestInputFile(TestUsingServer):
+ def test_seek(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ rv = []
+ f = request.raw_input
+ f.seek(5)
+ rv.append(f.read(2))
+ rv.append(b"%d" % f.tell())
+ f.seek(0)
+ rv.append(f.readline())
+ rv.append(b"%d" % f.tell())
+ rv.append(f.read(-1))
+ rv.append(b"%d" % f.tell())
+ f.seek(0)
+ rv.append(f.read())
+ f.seek(0)
+ rv.extend(f.readlines())
+
+ return b" ".join(rv)
+
+ route = ("POST", "/test/test_seek", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1], method="POST", body=b"12345ab\ncdef")
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual([b"ab", b"7", b"12345ab\n", b"8", b"cdef", b"12",
+ b"12345ab\ncdef", b"12345ab\n", b"cdef"],
+ resp.read().split(b" "))
+
+ def test_seek_input_longer_than_buffer(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ rv = []
+ f = request.raw_input
+ f.seek(5)
+ rv.append(f.read(2))
+ rv.append(b"%d" % f.tell())
+ f.seek(0)
+ rv.append(b"%d" % f.tell())
+ rv.append(b"%d" % f.tell())
+ return b" ".join(rv)
+
+ route = ("POST", "/test/test_seek", handler)
+ self.server.router.register(*route)
+
+ old_max_buf = InputFile.max_buffer_size
+ InputFile.max_buffer_size = 10
+ try:
+ resp = self.request(route[1], method="POST", body=b"1"*20)
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual([b"11", b"7", b"0", b"0"],
+ resp.read().split(b" "))
+ finally:
+ InputFile.max_buffer_size = old_max_buf
+
+ def test_iter(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ f = request.raw_input
+ return b" ".join(line for line in f)
+
+ route = ("POST", "/test/test_iter", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1], method="POST", body=b"12345\nabcdef\r\nzyxwv")
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual([b"12345\n", b"abcdef\r\n", b"zyxwv"], resp.read().split(b" "))
+
+ def test_iter_input_longer_than_buffer(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ f = request.raw_input
+ return b" ".join(line for line in f)
+
+ route = ("POST", "/test/test_iter", handler)
+ self.server.router.register(*route)
+
+ old_max_buf = InputFile.max_buffer_size
+ InputFile.max_buffer_size = 10
+ try:
+ resp = self.request(route[1], method="POST", body=b"12345\nabcdef\r\nzyxwv")
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual([b"12345\n", b"abcdef\r\n", b"zyxwv"], resp.read().split(b" "))
+ finally:
+ InputFile.max_buffer_size = old_max_buf
+
+
+class TestRequest(TestUsingServer):
+ def test_body(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ request.raw_input.seek(5)
+ return request.body
+
+ route = ("POST", "/test/test_body", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1], method="POST", body=b"12345ab\ncdef")
+ self.assertEqual(b"12345ab\ncdef", resp.read())
+
+ def test_route_match(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return request.route_match["match"] + " " + request.route_match["*"]
+
+ route = ("GET", "/test/{match}_*", handler)
+ self.server.router.register(*route)
+ resp = self.request("/test/some_route")
+ self.assertEqual(b"some route", resp.read())
+
+ def test_non_ascii_in_headers(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return request.headers[b"foo"]
+
+ route = ("GET", "/test/test_unicode_in_headers", handler)
+ self.server.router.register(*route)
+
+ # Try some non-ASCII characters and the server shouldn't crash.
+ encoded_text = "你好".encode("utf-8")
+ resp = self.request(route[1], headers={"foo": encoded_text})
+ self.assertEqual(encoded_text, resp.read())
+
+ # Try a different encoding from utf-8 to make sure the binary value is
+ # returned in verbatim.
+ encoded_text = "どうも".encode("shift-jis")
+ resp = self.request(route[1], headers={"foo": encoded_text})
+ self.assertEqual(encoded_text, resp.read())
+
+ def test_non_ascii_in_GET_params(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return request.GET[b"foo"]
+
+ route = ("GET", "/test/test_unicode_in_get", handler)
+ self.server.router.register(*route)
+
+ # We intentionally choose an encoding that's not the default UTF-8.
+ encoded_text = "どうも".encode("shift-jis")
+ quoted = quote_from_bytes(encoded_text)
+ resp = self.request(route[1], query="foo="+quoted)
+ self.assertEqual(encoded_text, resp.read())
+
+ def test_non_ascii_in_POST_params(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return request.POST[b"foo"]
+
+ route = ("POST", "/test/test_unicode_in_POST", handler)
+ self.server.router.register(*route)
+
+ # We intentionally choose an encoding that's not the default UTF-8.
+ encoded_text = "どうも".encode("shift-jis")
+ # After urlencoding, the string should only contain ASCII.
+ quoted = quote_from_bytes(encoded_text).encode("ascii")
+ resp = self.request(route[1], method="POST", body=b"foo="+quoted)
+ self.assertEqual(encoded_text, resp.read())
+
+
+class TestAuth(TestUsingServer):
+ def test_auth(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return b" ".join((request.auth.username, request.auth.password))
+
+ route = ("GET", "/test/test_auth", handler)
+ self.server.router.register(*route)
+
+ resp = self.request(route[1], auth=(b"test", b"PASS"))
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual([b"test", b"PASS"], resp.read().split(b" "))
+
+ encoded_text = "どうも".encode("shift-jis")
+ resp = self.request(route[1], auth=(encoded_text, encoded_text))
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual([encoded_text, encoded_text], resp.read().split(b" "))
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/test_response.py b/testing/web-platform/tests/tools/wptserve/tests/functional/test_response.py
new file mode 100644
index 0000000000..4a4611f60a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/test_response.py
@@ -0,0 +1,323 @@
+import os
+import unittest
+import json
+import types
+
+from http.client import BadStatusLine
+from io import BytesIO
+
+import pytest
+
+wptserve = pytest.importorskip("wptserve")
+from .base import TestUsingServer, TestUsingH2Server, doc_root
+
+def send_body_as_header(self):
+ if self._response.add_required_headers:
+ self.write_default_headers()
+
+ self.write("X-Body: ")
+ self._headers_complete = True
+
+class TestResponse(TestUsingServer):
+ def test_head_without_body(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.writer.end_headers = types.MethodType(send_body_as_header,
+ response.writer)
+ return [("X-Test", "TEST")], "body\r\n"
+
+ route = ("GET", "/test/test_head_without_body", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1], method="HEAD")
+ self.assertEqual("6", resp.info()['Content-Length'])
+ self.assertEqual("TEST", resp.info()['x-Test'])
+ self.assertEqual("", resp.info()['x-body'])
+
+ def test_head_with_body(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.send_body_for_head_request = True
+ response.writer.end_headers = types.MethodType(send_body_as_header,
+ response.writer)
+ return [("X-Test", "TEST")], "body\r\n"
+
+ route = ("GET", "/test/test_head_with_body", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1], method="HEAD")
+ self.assertEqual("6", resp.info()['Content-Length'])
+ self.assertEqual("TEST", resp.info()['x-Test'])
+ self.assertEqual("body", resp.info()['X-Body'])
+
+ def test_write_content_no_status_no_header(self):
+ resp_content = b"TEST"
+
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.writer.write_content(resp_content)
+
+ route = ("GET", "/test/test_write_content_no_status_no_header", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ assert resp.getcode() == 200
+ assert resp.read() == resp_content
+ assert resp.info()["Content-Length"] == str(len(resp_content))
+ assert "Date" in resp.info()
+ assert "Server" in resp.info()
+
+ def test_write_content_no_headers(self):
+ resp_content = b"TEST"
+
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.writer.write_status(201)
+ response.writer.write_content(resp_content)
+
+ route = ("GET", "/test/test_write_content_no_headers", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ assert resp.getcode() == 201
+ assert resp.read() == resp_content
+ assert resp.info()["Content-Length"] == str(len(resp_content))
+ assert "Date" in resp.info()
+ assert "Server" in resp.info()
+
+ def test_write_content_no_status(self):
+ resp_content = b"TEST"
+
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.writer.write_header("test-header", "test-value")
+ response.writer.write_content(resp_content)
+
+ route = ("GET", "/test/test_write_content_no_status", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ assert resp.getcode() == 200
+ assert resp.read() == resp_content
+ assert sorted(x.lower() for x in resp.info().keys()) == sorted(['test-header', 'date', 'server', 'content-length'])
+
+ def test_write_content_no_status_no_required_headers(self):
+ resp_content = b"TEST"
+
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.add_required_headers = False
+ response.writer.write_header("test-header", "test-value")
+ response.writer.write_content(resp_content)
+
+ route = ("GET", "/test/test_write_content_no_status_no_required_headers", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ assert resp.getcode() == 200
+ assert resp.read() == resp_content
+ assert resp.info().items() == [('test-header', 'test-value')]
+
+ def test_write_content_no_status_no_headers_no_required_headers(self):
+ resp_content = b"TEST"
+
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.add_required_headers = False
+ response.writer.write_content(resp_content)
+
+ route = ("GET", "/test/test_write_content_no_status_no_headers_no_required_headers", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ assert resp.getcode() == 200
+ assert resp.read() == resp_content
+ assert resp.info().items() == []
+
+ def test_write_raw_content(self):
+ resp_content = b"HTTP/1.1 202 Giraffe\n" \
+ b"X-TEST: PASS\n" \
+ b"Content-Length: 7\n\n" \
+ b"Content"
+
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.writer.write_raw_content(resp_content)
+
+ route = ("GET", "/test/test_write_raw_content", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ assert resp.getcode() == 202
+ assert resp.info()["X-TEST"] == "PASS"
+ assert resp.read() == b"Content"
+
+ def test_write_raw_content_file(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ with open(os.path.join(doc_root, "test.asis"), 'rb') as infile:
+ response.writer.write_raw_content(infile)
+
+ route = ("GET", "/test/test_write_raw_content", handler)
+ self.server.router.register(*route)
+ resp = self.request(route[1])
+ assert resp.getcode() == 202
+ assert resp.info()["X-TEST"] == "PASS"
+ assert resp.read() == b"Content"
+
+ def test_write_raw_none(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ with pytest.raises(ValueError):
+ response.writer.write_raw_content(None)
+
+ route = ("GET", "/test/test_write_raw_content", handler)
+ self.server.router.register(*route)
+ self.request(route[1])
+
+ def test_write_raw_contents_invalid_http(self):
+ resp_content = b"INVALID HTTP"
+
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.writer.write_raw_content(resp_content)
+
+ route = ("GET", "/test/test_write_raw_content", handler)
+ self.server.router.register(*route)
+
+ with pytest.raises(BadStatusLine) as e:
+ self.request(route[1])
+ assert str(e.value) == resp_content.decode('utf-8')
+
+class TestH2Response(TestUsingH2Server):
+ def test_write_without_ending_stream(self):
+ data = b"TEST"
+
+ @wptserve.handlers.handler
+ def handler(request, response):
+ headers = [
+ ('server', 'test-h2'),
+ ('test', 'PASS'),
+ ]
+ response.writer.write_headers(headers, 202)
+ response.writer.write_data_frame(data, False)
+
+ # Should detect stream isn't ended and call `writer.end_stream()`
+
+ route = ("GET", "/h2test/test", handler)
+ self.server.router.register(*route)
+ resp = self.client.get(route[1])
+
+ assert resp.status_code == 202
+ assert [x for x in resp.headers.items()] == [('server', 'test-h2'), ('test', 'PASS')]
+ assert resp.content == data
+
+ def test_set_error(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.set_error(503, message="Test error")
+
+ route = ("GET", "/h2test/test_set_error", handler)
+ self.server.router.register(*route)
+ resp = self.client.get(route[1])
+
+ assert resp.status_code == 503
+ assert json.loads(resp.content) == json.loads("{\"error\": {\"message\": \"Test error\", \"code\": 503}}")
+
+ def test_file_like_response(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ content = BytesIO(b"Hello, world!")
+ response.content = content
+
+ route = ("GET", "/h2test/test_file_like_response", handler)
+ self.server.router.register(*route)
+ resp = self.client.get(route[1])
+
+ assert resp.status_code == 200
+ assert resp.content == b"Hello, world!"
+
+ def test_list_response(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.content = ['hello', 'world']
+
+ route = ("GET", "/h2test/test_file_like_response", handler)
+ self.server.router.register(*route)
+ resp = self.client.get(route[1])
+
+ assert resp.status_code == 200
+ assert resp.content == b"helloworld"
+
+ def test_content_longer_than_frame_size(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ size = response.writer.get_max_payload_size()
+ content = "a" * (size + 5)
+ return [('payload_size', size)], content
+
+ route = ("GET", "/h2test/test_content_longer_than_frame_size", handler)
+ self.server.router.register(*route)
+ resp = self.client.get(route[1])
+
+ assert resp.status_code == 200
+ payload_size = int(resp.headers['payload_size'])
+ assert payload_size
+ assert resp.content == b"a" * (payload_size + 5)
+
+ def test_encode(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.encoding = "utf8"
+ t = response.writer.encode("hello")
+ assert t == b"hello"
+
+ with pytest.raises(ValueError):
+ response.writer.encode(None)
+
+ route = ("GET", "/h2test/test_content_longer_than_frame_size", handler)
+ self.server.router.register(*route)
+ resp = self.client.get(route[1])
+ assert resp.status_code == 200
+
+ def test_raw_header_frame(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.writer.write_raw_header_frame([
+ (':status', '204'),
+ ('server', 'TEST-H2')
+ ], end_headers=True)
+
+ route = ("GET", "/h2test/test_file_like_response", handler)
+ self.server.router.register(*route)
+ resp = self.client.get(route[1])
+
+ assert resp.status_code == 204
+ assert resp.headers['server'] == 'TEST-H2'
+ assert resp.content == b''
+
+ def test_raw_data_frame(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.write_status_headers()
+ response.writer.write_raw_data_frame(data=b'Hello world', end_stream=True)
+
+ route = ("GET", "/h2test/test_file_like_response", handler)
+ self.server.router.register(*route)
+ resp = self.client.get(route[1])
+
+ assert resp.content == b'Hello world'
+
+ def test_raw_header_continuation_frame(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ response.writer.write_raw_header_frame([
+ (':status', '204')
+ ])
+
+ response.writer.write_raw_continuation_frame([
+ ('server', 'TEST-H2')
+ ], end_headers=True)
+
+ route = ("GET", "/h2test/test_file_like_response", handler)
+ self.server.router.register(*route)
+ resp = self.client.get(route[1])
+
+ assert resp.status_code == 204
+ assert resp.headers['server'] == 'TEST-H2'
+ assert resp.content == b''
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/test_server.py b/testing/web-platform/tests/tools/wptserve/tests/functional/test_server.py
new file mode 100644
index 0000000000..939396ddee
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/test_server.py
@@ -0,0 +1,118 @@
+import unittest
+
+import pytest
+from urllib.error import HTTPError
+
+wptserve = pytest.importorskip("wptserve")
+from .base import TestUsingServer, TestUsingH2Server
+
+
+class TestFileHandler(TestUsingServer):
+ def test_not_handled(self):
+ with self.assertRaises(HTTPError) as cm:
+ self.request("/not_existing")
+
+ self.assertEqual(cm.exception.code, 404)
+
+
+class TestRewriter(TestUsingServer):
+ def test_rewrite(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return request.request_path
+
+ route = ("GET", "/test/rewritten", handler)
+ self.server.rewriter.register("GET", "/test/original", route[1])
+ self.server.router.register(*route)
+ resp = self.request("/test/original")
+ self.assertEqual(200, resp.getcode())
+ self.assertEqual(b"/test/rewritten", resp.read())
+
+
+class TestRequestHandler(TestUsingServer):
+ def test_exception(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ raise Exception
+
+ route = ("GET", "/test/raises", handler)
+ self.server.router.register(*route)
+ with self.assertRaises(HTTPError) as cm:
+ self.request("/test/raises")
+
+ self.assertEqual(cm.exception.code, 500)
+
+ def test_many_headers(self):
+ headers = {"X-Val%d" % i: str(i) for i in range(256)}
+
+ @wptserve.handlers.handler
+ def handler(request, response):
+ # Additional headers are added by urllib.request.
+ assert len(request.headers) > len(headers)
+ for k, v in headers.items():
+ assert request.headers.get(k) == \
+ wptserve.utils.isomorphic_encode(v)
+ return "OK"
+
+ route = ("GET", "/test/headers", handler)
+ self.server.router.register(*route)
+ resp = self.request("/test/headers", headers=headers)
+ self.assertEqual(200, resp.getcode())
+
+
+class TestH2Version(TestUsingH2Server):
+ # The purpose of this test is to ensure that all TestUsingH2Server tests
+ # actually end up using HTTP/2, in case there's any protocol negotiation.
+ def test_http_version(self):
+ resp = self.client.get('/')
+
+ assert resp.http_version == 'HTTP/2'
+
+
+class TestFileHandlerH2(TestUsingH2Server):
+ def test_not_handled(self):
+ resp = self.client.get("/not_existing")
+
+ assert resp.status_code == 404
+
+
+class TestRewriterH2(TestUsingH2Server):
+ def test_rewrite(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ return request.request_path
+
+ route = ("GET", "/test/rewritten", handler)
+ self.server.rewriter.register("GET", "/test/original", route[1])
+ self.server.router.register(*route)
+ resp = self.client.get("/test/original")
+ assert resp.status_code == 200
+ assert resp.content == b"/test/rewritten"
+
+
+class TestRequestHandlerH2(TestUsingH2Server):
+ def test_exception(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ raise Exception
+
+ route = ("GET", "/test/raises", handler)
+ self.server.router.register(*route)
+ resp = self.client.get("/test/raises")
+
+ assert resp.status_code == 500
+
+ def test_frame_handler_exception(self):
+ class handler_cls:
+ def frame_handler(self, request):
+ raise Exception
+
+ route = ("GET", "/test/raises", handler_cls())
+ self.server.router.register(*route)
+ resp = self.client.get("/test/raises")
+
+ assert resp.status_code == 500
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/testing/web-platform/tests/tools/wptserve/tests/functional/test_stash.py b/testing/web-platform/tests/tools/wptserve/tests/functional/test_stash.py
new file mode 100644
index 0000000000..03561bc872
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/functional/test_stash.py
@@ -0,0 +1,44 @@
+import unittest
+import uuid
+
+import pytest
+
+wptserve = pytest.importorskip("wptserve")
+from wptserve.router import any_method
+from wptserve.stash import StashServer
+from .base import TestUsingServer
+
+
+class TestResponseSetCookie(TestUsingServer):
+ def run(self, result=None):
+ with StashServer(None, authkey=str(uuid.uuid4())):
+ super().run(result)
+
+ def test_put_take(self):
+ @wptserve.handlers.handler
+ def handler(request, response):
+ if request.method == "POST":
+ request.server.stash.put(request.POST.first(b"id"), request.POST.first(b"data"))
+ data = "OK"
+ elif request.method == "GET":
+ data = request.server.stash.take(request.GET.first(b"id"))
+ if data is None:
+ return "NOT FOUND"
+ return data
+
+ id = str(uuid.uuid4())
+ route = (any_method, "/test/put_take", handler)
+ self.server.router.register(*route)
+
+ resp = self.request(route[1], method="POST", body={"id": id, "data": "Sample data"})
+ self.assertEqual(resp.read(), b"OK")
+
+ resp = self.request(route[1], query="id=" + id)
+ self.assertEqual(resp.read(), b"Sample data")
+
+ resp = self.request(route[1], query="id=" + id)
+ self.assertEqual(resp.read(), b"NOT FOUND")
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/testing/web-platform/tests/tools/wptserve/tests/test_config.py b/testing/web-platform/tests/tools/wptserve/tests/test_config.py
new file mode 100644
index 0000000000..9f84577c7f
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/test_config.py
@@ -0,0 +1,384 @@
+import json
+import logging
+import pickle
+
+from distutils.spawn import find_executable
+from logging import handlers
+
+import pytest
+
+config = pytest.importorskip("wptserve.config")
+
+logger = logging.getLogger()
+
+def test_renamed_are_renamed():
+ assert len(set(config._renamed_props.keys()) & set(config.ConfigBuilder._default.keys())) == 0
+
+
+def test_renamed_exist():
+ assert set(config._renamed_props.values()).issubset(set(config.ConfigBuilder._default.keys()))
+
+
+@pytest.mark.parametrize("base, override, expected", [
+ ({"a": 1}, {"a": 2}, {"a": 2}),
+ ({"a": 1}, {"b": 2}, {"a": 1}),
+ ({"a": {"b": 1}}, {"a": {}}, {"a": {"b": 1}}),
+ ({"a": {"b": 1}}, {"a": {"b": 2}}, {"a": {"b": 2}}),
+ ({"a": {"b": 1}}, {"a": {"b": 2, "c": 3}}, {"a": {"b": 2}}),
+ pytest.param({"a": {"b": 1}}, {"a": 2}, {"a": 1}, marks=pytest.mark.xfail),
+ pytest.param({"a": 1}, {"a": {"b": 2}}, {"a": 1}, marks=pytest.mark.xfail),
+])
+def test_merge_dict(base, override, expected):
+ assert expected == config._merge_dict(base, override)
+
+
+
+def test_as_dict():
+ with config.ConfigBuilder(logger) as c:
+ assert c.as_dict() is not None
+
+
+def test_as_dict_is_json():
+ with config.ConfigBuilder(logger) as c:
+ assert json.dumps(c.as_dict()) is not None
+
+
+def test_init_basic_prop():
+ with config.ConfigBuilder(logger, browser_host="foo.bar") as c:
+ assert c.browser_host == "foo.bar"
+
+
+def test_init_prefixed_prop():
+ with config.ConfigBuilder(logger, doc_root="/") as c:
+ assert c.doc_root == "/"
+
+
+def test_init_renamed_host():
+ logger = logging.getLogger("test_init_renamed_host")
+ logger.setLevel(logging.DEBUG)
+ handler = handlers.BufferingHandler(100)
+ logger.addHandler(handler)
+
+ with config.ConfigBuilder(logger, host="foo.bar") as c:
+ assert len(handler.buffer) == 1
+ assert "browser_host" in handler.buffer[0].getMessage() # check we give the new name in the message
+ assert not hasattr(c, "host")
+ assert c.browser_host == "foo.bar"
+
+
+def test_init_bogus():
+ with pytest.raises(TypeError) as e:
+ config.ConfigBuilder(logger, foo=1, bar=2)
+ message = e.value.args[0]
+ assert "foo" in message
+ assert "bar" in message
+
+
+def test_getitem():
+ with config.ConfigBuilder(logger, browser_host="foo.bar") as c:
+ assert c["browser_host"] == "foo.bar"
+
+
+def test_no_setitem():
+ with config.ConfigBuilder(logger) as c:
+ with pytest.raises(ValueError):
+ c["browser_host"] = "foo.bar"
+
+
+def test_iter():
+ with config.ConfigBuilder(logger) as c:
+ s = set(iter(c))
+ assert "browser_host" in s
+ assert "host" not in s
+ assert "__getitem__" not in s
+ assert "_browser_host" not in s
+
+
+def test_assignment():
+ cb = config.ConfigBuilder(logger)
+ cb.browser_host = "foo.bar"
+ with cb as c:
+ assert c.browser_host == "foo.bar"
+
+
+def test_update_basic():
+ cb = config.ConfigBuilder(logger)
+ cb.update({"browser_host": "foo.bar"})
+ with cb as c:
+ assert c.browser_host == "foo.bar"
+
+
+def test_update_prefixed():
+ cb = config.ConfigBuilder(logger)
+ cb.update({"doc_root": "/"})
+ with cb as c:
+ assert c.doc_root == "/"
+
+
+def test_update_renamed_host():
+ logger = logging.getLogger("test_update_renamed_host")
+ logger.setLevel(logging.DEBUG)
+ handler = handlers.BufferingHandler(100)
+ logger.addHandler(handler)
+
+ cb = config.ConfigBuilder(logger)
+ assert len(handler.buffer) == 0
+
+ cb.update({"host": "foo.bar"})
+
+ with cb as c:
+ assert len(handler.buffer) == 1
+ assert "browser_host" in handler.buffer[0].getMessage() # check we give the new name in the message
+ assert not hasattr(c, "host")
+ assert c.browser_host == "foo.bar"
+
+
+def test_update_bogus():
+ cb = config.ConfigBuilder(logger)
+ with pytest.raises(KeyError):
+ cb.update({"foobar": 1})
+
+
+def test_ports_auto():
+ with config.ConfigBuilder(logger,
+ ports={"http": ["auto"]},
+ ssl={"type": "none"}) as c:
+ ports = c.ports
+ assert set(ports.keys()) == {"http"}
+ assert len(ports["http"]) == 1
+ assert isinstance(ports["http"][0], int)
+
+
+def test_ports_auto_mutate():
+ cb = config.ConfigBuilder(logger,
+ ports={"http": [1001]},
+ ssl={"type": "none"})
+ cb.ports = {"http": ["auto"]}
+ with cb as c:
+ new_ports = c.ports
+ assert set(new_ports.keys()) == {"http"}
+ assert len(new_ports["http"]) == 1
+ assert isinstance(new_ports["http"][0], int)
+
+
+def test_ports_explicit():
+ with config.ConfigBuilder(logger,
+ ports={"http": [1001]},
+ ssl={"type": "none"}) as c:
+ ports = c.ports
+ assert set(ports.keys()) == {"http"}
+ assert ports["http"] == [1001]
+
+
+def test_ports_no_ssl():
+ with config.ConfigBuilder(logger,
+ ports={"http": [1001], "https": [1002], "ws": [1003], "wss": [1004]},
+ ssl={"type": "none"}) as c:
+ ports = c.ports
+ assert set(ports.keys()) == {"http", "ws"}
+ assert ports["http"] == [1001]
+ assert ports["ws"] == [1003]
+
+
+@pytest.mark.skipif(find_executable("openssl") is None,
+ reason="requires OpenSSL")
+def test_ports_openssl():
+ with config.ConfigBuilder(logger,
+ ports={"http": [1001], "https": [1002], "ws": [1003], "wss": [1004]},
+ ssl={"type": "openssl"}) as c:
+ ports = c.ports
+ assert set(ports.keys()) == {"http", "https", "ws", "wss"}
+ assert ports["http"] == [1001]
+ assert ports["https"] == [1002]
+ assert ports["ws"] == [1003]
+ assert ports["wss"] == [1004]
+
+
+def test_init_doc_root():
+ with config.ConfigBuilder(logger, doc_root="/") as c:
+ assert c.doc_root == "/"
+
+
+def test_set_doc_root():
+ cb = config.ConfigBuilder(logger)
+ cb.doc_root = "/"
+ with cb as c:
+ assert c.doc_root == "/"
+
+
+def test_server_host_from_browser_host():
+ with config.ConfigBuilder(logger, browser_host="foo.bar") as c:
+ assert c.server_host == "foo.bar"
+
+
+def test_init_server_host():
+ with config.ConfigBuilder(logger, server_host="foo.bar") as c:
+ assert c.browser_host == "localhost" # check this hasn't changed
+ assert c.server_host == "foo.bar"
+
+
+def test_set_server_host():
+ cb = config.ConfigBuilder(logger)
+ cb.server_host = "/"
+ with cb as c:
+ assert c.browser_host == "localhost" # check this hasn't changed
+ assert c.server_host == "/"
+
+
+def test_domains():
+ with config.ConfigBuilder(logger,
+ browser_host="foo.bar",
+ alternate_hosts={"alt": "foo2.bar"},
+ subdomains={"a", "b"},
+ not_subdomains={"x", "y"}) as c:
+ assert c.domains == {
+ "": {
+ "": "foo.bar",
+ "a": "a.foo.bar",
+ "b": "b.foo.bar",
+ },
+ "alt": {
+ "": "foo2.bar",
+ "a": "a.foo2.bar",
+ "b": "b.foo2.bar",
+ },
+ }
+
+
+def test_not_domains():
+ with config.ConfigBuilder(logger,
+ browser_host="foo.bar",
+ alternate_hosts={"alt": "foo2.bar"},
+ subdomains={"a", "b"},
+ not_subdomains={"x", "y"}) as c:
+ not_domains = c.not_domains
+ assert not_domains == {
+ "": {
+ "x": "x.foo.bar",
+ "y": "y.foo.bar",
+ },
+ "alt": {
+ "x": "x.foo2.bar",
+ "y": "y.foo2.bar",
+ },
+ }
+
+
+def test_domains_not_domains_intersection():
+ with config.ConfigBuilder(logger,
+ browser_host="foo.bar",
+ alternate_hosts={"alt": "foo2.bar"},
+ subdomains={"a", "b"},
+ not_subdomains={"x", "y"}) as c:
+ domains = c.domains
+ not_domains = c.not_domains
+ assert len(set(domains.keys()) ^ set(not_domains.keys())) == 0
+ for host in domains.keys():
+ host_domains = domains[host]
+ host_not_domains = not_domains[host]
+ assert len(set(host_domains.keys()) & set(host_not_domains.keys())) == 0
+ assert len(set(host_domains.values()) & set(host_not_domains.values())) == 0
+
+
+def test_all_domains():
+ with config.ConfigBuilder(logger,
+ browser_host="foo.bar",
+ alternate_hosts={"alt": "foo2.bar"},
+ subdomains={"a", "b"},
+ not_subdomains={"x", "y"}) as c:
+ all_domains = c.all_domains
+ assert all_domains == {
+ "": {
+ "": "foo.bar",
+ "a": "a.foo.bar",
+ "b": "b.foo.bar",
+ "x": "x.foo.bar",
+ "y": "y.foo.bar",
+ },
+ "alt": {
+ "": "foo2.bar",
+ "a": "a.foo2.bar",
+ "b": "b.foo2.bar",
+ "x": "x.foo2.bar",
+ "y": "y.foo2.bar",
+ },
+ }
+
+
+def test_domains_set():
+ with config.ConfigBuilder(logger,
+ browser_host="foo.bar",
+ alternate_hosts={"alt": "foo2.bar"},
+ subdomains={"a", "b"},
+ not_subdomains={"x", "y"}) as c:
+ domains_set = c.domains_set
+ assert domains_set == {
+ "foo.bar",
+ "a.foo.bar",
+ "b.foo.bar",
+ "foo2.bar",
+ "a.foo2.bar",
+ "b.foo2.bar",
+ }
+
+
+def test_not_domains_set():
+ with config.ConfigBuilder(logger,
+ browser_host="foo.bar",
+ alternate_hosts={"alt": "foo2.bar"},
+ subdomains={"a", "b"},
+ not_subdomains={"x", "y"}) as c:
+ not_domains_set = c.not_domains_set
+ assert not_domains_set == {
+ "x.foo.bar",
+ "y.foo.bar",
+ "x.foo2.bar",
+ "y.foo2.bar",
+ }
+
+
+def test_all_domains_set():
+ with config.ConfigBuilder(logger,
+ browser_host="foo.bar",
+ alternate_hosts={"alt": "foo2.bar"},
+ subdomains={"a", "b"},
+ not_subdomains={"x", "y"}) as c:
+ all_domains_set = c.all_domains_set
+ assert all_domains_set == {
+ "foo.bar",
+ "a.foo.bar",
+ "b.foo.bar",
+ "x.foo.bar",
+ "y.foo.bar",
+ "foo2.bar",
+ "a.foo2.bar",
+ "b.foo2.bar",
+ "x.foo2.bar",
+ "y.foo2.bar",
+ }
+
+
+def test_ssl_env_none():
+ with config.ConfigBuilder(logger, ssl={"type": "none"}) as c:
+ assert c.ssl_config is None
+
+
+def test_ssl_env_openssl():
+ # TODO: this currently actually tries to start OpenSSL, which isn't ideal
+ # with config.ConfigBuilder(ssl={"type": "openssl", "openssl": {"openssl_binary": "foobar"}}) as c:
+ # assert c.ssl_env is not None
+ # assert c.ssl_env.ssl_enabled is True
+ # assert c.ssl_env.binary == "foobar"
+ pass
+
+
+def test_ssl_env_bogus():
+ with pytest.raises(ValueError):
+ with config.ConfigBuilder(logger, ssl={"type": "foobar"}):
+ pass
+
+
+def test_pickle():
+ # Ensure that the config object can be pickled
+ with config.ConfigBuilder(logger) as c:
+ pickle.dumps(c)
diff --git a/testing/web-platform/tests/tools/wptserve/tests/test_replacement_tokenizer.py b/testing/web-platform/tests/tools/wptserve/tests/test_replacement_tokenizer.py
new file mode 100644
index 0000000000..6a3c563c8c
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/test_replacement_tokenizer.py
@@ -0,0 +1,38 @@
+import pytest
+
+from wptserve.pipes import ReplacementTokenizer
+
+@pytest.mark.parametrize(
+ "content,expected",
+ [
+ [b"aaa", [('ident', 'aaa')]],
+ [b"bbb()", [('ident', 'bbb'), ('arguments', [])]],
+ [b"bcd(uvw, xyz)", [('ident', 'bcd'), ('arguments', ['uvw', 'xyz'])]],
+ [b"$ccc:ddd", [('var', '$ccc'), ('ident', 'ddd')]],
+ [b"$eee", [('ident', '$eee')]],
+ [b"fff[0]", [('ident', 'fff'), ('index', 0)]],
+ [b"ggg[hhh]", [('ident', 'ggg'), ('index', 'hhh')]],
+ [b"[iii]", [('index', 'iii')]],
+ [b"jjj['kkk']", [('ident', 'jjj'), ('index', "'kkk'")]],
+ [b"lll[]", [('ident', 'lll'), ('index', "")]],
+ [b"111", [('ident', '111')]],
+ [b"$111", [('ident', '$111')]],
+ ]
+)
+def test_tokenizer(content, expected):
+ tokenizer = ReplacementTokenizer()
+ tokens = tokenizer.tokenize(content)
+ assert expected == tokens
+
+
+@pytest.mark.parametrize(
+ "content,expected",
+ [
+ [b"/", []],
+ [b"$aaa: BBB", [('var', '$aaa')]],
+ ]
+)
+def test_tokenizer_errors(content, expected):
+ tokenizer = ReplacementTokenizer()
+ tokens = tokenizer.tokenize(content)
+ assert expected == tokens
diff --git a/testing/web-platform/tests/tools/wptserve/tests/test_request.py b/testing/web-platform/tests/tools/wptserve/tests/test_request.py
new file mode 100644
index 0000000000..a2161e9646
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/test_request.py
@@ -0,0 +1,104 @@
+from unittest import mock
+
+from wptserve.request import Request, RequestHeaders, MultiDict
+
+
+class MockHTTPMessage(dict):
+ """A minimum (and not completely correctly) mock of HTTPMessage for testing.
+
+ Constructing HTTPMessage is annoying and different in Python 2 and 3. This
+ only implements the parts used by RequestHeaders.
+
+ Requirements for construction:
+ * Keys are header names and MUST be lower-case.
+ * Values are lists of header values (even if there's only one).
+ * Keys and values should be native strings to match stdlib's behaviours.
+ """
+ def __getitem__(self, key):
+ assert isinstance(key, str)
+ values = dict.__getitem__(self, key.lower())
+ assert isinstance(values, list)
+ return values[0]
+
+ def get(self, key, default=None):
+ try:
+ return self[key]
+ except KeyError:
+ return default
+
+ def getallmatchingheaders(self, key):
+ values = dict.__getitem__(self, key.lower())
+ return [f"{key}: {v}\n" for v in values]
+
+
+def test_request_headers_get():
+ raw_headers = MockHTTPMessage({
+ 'x-foo': ['foo'],
+ 'x-bar': ['bar1', 'bar2'],
+ })
+ headers = RequestHeaders(raw_headers)
+ assert headers['x-foo'] == b'foo'
+ assert headers['X-Bar'] == b'bar1, bar2'
+ assert headers.get('x-bar') == b'bar1, bar2'
+
+
+def test_request_headers_encoding():
+ raw_headers = MockHTTPMessage({
+ 'x-foo': ['foo'],
+ 'x-bar': ['bar1', 'bar2'],
+ })
+ headers = RequestHeaders(raw_headers)
+ assert isinstance(headers['x-foo'], bytes)
+ assert isinstance(headers['x-bar'], bytes)
+ assert isinstance(headers.get_list('x-bar')[0], bytes)
+
+
+def test_request_url_from_server_address():
+ request_handler = mock.Mock()
+ request_handler.server.scheme = 'http'
+ request_handler.server.server_address = ('localhost', '8000')
+ request_handler.path = '/demo'
+ request_handler.headers = MockHTTPMessage()
+
+ request = Request(request_handler)
+ assert request.url == 'http://localhost:8000/demo'
+ assert isinstance(request.url, str)
+
+
+def test_request_url_from_host_header():
+ request_handler = mock.Mock()
+ request_handler.server.scheme = 'http'
+ request_handler.server.server_address = ('localhost', '8000')
+ request_handler.path = '/demo'
+ request_handler.headers = MockHTTPMessage({'host': ['web-platform.test:8001']})
+
+ request = Request(request_handler)
+ assert request.url == 'http://web-platform.test:8001/demo'
+ assert isinstance(request.url, str)
+
+
+def test_multidict():
+ m = MultiDict()
+ m["foo"] = "bar"
+ m["bar"] = "baz"
+ m.add("foo", "baz")
+ m.add("baz", "qux")
+
+ assert m["foo"] == "bar"
+ assert m.get("foo") == "bar"
+ assert m["bar"] == "baz"
+ assert m.get("bar") == "baz"
+ assert m["baz"] == "qux"
+ assert m.get("baz") == "qux"
+
+ assert m.first("foo") == "bar"
+ assert m.last("foo") == "baz"
+ assert m.get_list("foo") == ["bar", "baz"]
+ assert m.get_list("non_existent") == []
+
+ assert m.get("non_existent") is None
+ try:
+ m["non_existent"]
+ assert False, "An exception should be raised"
+ except KeyError:
+ pass
diff --git a/testing/web-platform/tests/tools/wptserve/tests/test_response.py b/testing/web-platform/tests/tools/wptserve/tests/test_response.py
new file mode 100644
index 0000000000..d10554b4df
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/test_response.py
@@ -0,0 +1,32 @@
+from io import BytesIO
+from unittest import mock
+
+from wptserve.response import Response
+
+
+def test_response_status():
+ cases = [200, (200, b'OK'), (200, 'OK'), ('200', 'OK')]
+
+ for case in cases:
+ handler = mock.Mock()
+ handler.wfile = BytesIO()
+ request = mock.Mock()
+ request.protocol_version = 'HTTP/1.1'
+ response = Response(handler, request)
+
+ response.status = case
+ expected = case if isinstance(case, tuple) else (case, None)
+ if expected[0] == '200':
+ expected = (200, expected[1])
+ assert response.status == expected
+ response.writer.write_status(*response.status)
+ assert handler.wfile.getvalue() == b'HTTP/1.1 200 OK\r\n'
+
+
+def test_response_status_not_string():
+ # This behaviour is not documented but kept for backward compatibility.
+ handler = mock.Mock()
+ request = mock.Mock()
+ response = Response(handler, request)
+ response.status = (200, 100)
+ assert response.status == (200, '100')
diff --git a/testing/web-platform/tests/tools/wptserve/tests/test_stash.py b/testing/web-platform/tests/tools/wptserve/tests/test_stash.py
new file mode 100644
index 0000000000..4157db5726
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/tests/test_stash.py
@@ -0,0 +1,146 @@
+import multiprocessing
+import threading
+import sys
+
+from multiprocessing.managers import BaseManager
+
+import pytest
+
+Stash = pytest.importorskip("wptserve.stash").Stash
+
+@pytest.fixture()
+def add_cleanup():
+ fns = []
+
+ def add(fn):
+ fns.append(fn)
+
+ yield add
+
+ for fn in fns:
+ fn()
+
+
+def run(process_queue, request_lock, response_lock):
+ """Create two Stash instances in parallel threads. Use the provided locks
+ to ensure the first thread is actively establishing an interprocess
+ communication channel at the moment the second thread executes."""
+
+ def target(thread_queue):
+ stash = Stash("/", ("localhost", 4543), b"some key")
+
+ # The `lock` property of the Stash instance should always be set
+ # immediately following initialization. These values are asserted in
+ # the active test.
+ thread_queue.put(stash.lock is None)
+
+ thread_queue = multiprocessing.Queue()
+ first = threading.Thread(target=target, args=(thread_queue,))
+ second = threading.Thread(target=target, args=(thread_queue,))
+
+ request_lock.acquire()
+ response_lock.acquire()
+ first.start()
+
+ request_lock.acquire()
+
+ # At this moment, the `first` thread is waiting for a proxied object.
+ # Create a second thread in order to inspect the behavior of the Stash
+ # constructor at this moment.
+
+ second.start()
+
+ # Allow the `first` thread to proceed
+
+ response_lock.release()
+
+ # Wait for both threads to complete and report their stateto the test
+ process_queue.put(thread_queue.get())
+ process_queue.put(thread_queue.get())
+
+
+class SlowLock(BaseManager):
+ # This can only be used in test_delayed_lock since that test modifies the
+ # class body, but it has to be a global for multiprocessing
+ pass
+
+
+@pytest.mark.xfail(sys.platform == "win32" or
+ multiprocessing.get_start_method() == "spawn",
+ reason="https://github.com/web-platform-tests/wpt/issues/16938")
+def test_delayed_lock(add_cleanup):
+ """Ensure that delays in proxied Lock retrieval do not interfere with
+ initialization in parallel threads."""
+
+ request_lock = multiprocessing.Lock()
+ response_lock = multiprocessing.Lock()
+
+ queue = multiprocessing.Queue()
+
+ def mutex_lock_request():
+ """This request handler allows the caller to delay execution of a
+ thread which has requested a proxied representation of the `lock`
+ property, simulating a "slow" interprocess communication channel."""
+
+ request_lock.release()
+ response_lock.acquire()
+ return threading.Lock()
+
+ SlowLock.register("get_dict", callable=lambda: {})
+ SlowLock.register("Lock", callable=mutex_lock_request)
+
+ slowlock = SlowLock(("localhost", 4543), b"some key")
+ slowlock.start()
+ add_cleanup(lambda: slowlock.shutdown())
+
+ parallel = multiprocessing.Process(target=run,
+ args=(queue, request_lock, response_lock))
+ parallel.start()
+ add_cleanup(lambda: parallel.terminate())
+
+ assert [queue.get(), queue.get()] == [False, False], (
+ "both instances had valid locks")
+
+
+class SlowDict(BaseManager):
+ # This can only be used in test_delayed_dict since that test modifies the
+ # class body, but it has to be a global for multiprocessing
+ pass
+
+
+@pytest.mark.xfail(sys.platform == "win32" or
+ multiprocessing.get_start_method() == "spawn",
+ reason="https://github.com/web-platform-tests/wpt/issues/16938")
+def test_delayed_dict(add_cleanup):
+ """Ensure that delays in proxied `dict` retrieval do not interfere with
+ initialization in parallel threads."""
+
+ request_lock = multiprocessing.Lock()
+ response_lock = multiprocessing.Lock()
+
+ queue = multiprocessing.Queue()
+
+ # This request handler allows the caller to delay execution of a thread
+ # which has requested a proxied representation of the "get_dict" property.
+ def mutex_dict_request():
+ """This request handler allows the caller to delay execution of a
+ thread which has requested a proxied representation of the `get_dict`
+ property, simulating a "slow" interprocess communication channel."""
+ request_lock.release()
+ response_lock.acquire()
+ return {}
+
+ SlowDict.register("get_dict", callable=mutex_dict_request)
+ SlowDict.register("Lock", callable=lambda: threading.Lock())
+
+ slowdict = SlowDict(("localhost", 4543), b"some key")
+ slowdict.start()
+ add_cleanup(lambda: slowdict.shutdown())
+
+ parallel = multiprocessing.Process(target=run,
+ args=(queue, request_lock, response_lock))
+ parallel.start()
+ add_cleanup(lambda: parallel.terminate())
+
+ assert [queue.get(), queue.get()] == [False, False], (
+ "both instances had valid locks")
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/__init__.py b/testing/web-platform/tests/tools/wptserve/wptserve/__init__.py
new file mode 100644
index 0000000000..a286bfe0b3
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/__init__.py
@@ -0,0 +1,3 @@
+from .server import WebTestHttpd, WebTestServer, Router # noqa: F401
+from .request import Request # noqa: F401
+from .response import Response # noqa: F401
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/config.py b/testing/web-platform/tests/tools/wptserve/wptserve/config.py
new file mode 100644
index 0000000000..b87795430f
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/config.py
@@ -0,0 +1,328 @@
+# mypy: allow-untyped-defs
+
+import copy
+import os
+from collections import defaultdict
+from typing import Any, Mapping
+
+from . import sslutils
+from .utils import get_port
+
+
+_renamed_props = {
+ "host": "browser_host",
+ "bind_hostname": "bind_address",
+ "external_host": "server_host",
+ "host_ip": "server_host",
+}
+
+
+def _merge_dict(base_dict, override_dict):
+ rv = base_dict.copy()
+ for key, value in base_dict.items():
+ if key in override_dict:
+ if isinstance(value, dict):
+ rv[key] = _merge_dict(value, override_dict[key])
+ else:
+ rv[key] = override_dict[key]
+ return rv
+
+
+class Config(Mapping[str, Any]):
+ """wptserve configuration data
+
+ Immutable configuration that's safe to be passed between processes.
+
+ Inherits from Mapping for backwards compatibility with the old dict-based config
+
+ :param data: - Extra configuration data
+ """
+ def __init__(self, data):
+ for name in data.keys():
+ if name.startswith("_"):
+ raise ValueError("Invalid configuration key %s" % name)
+ self.__dict__.update(data)
+
+ def __str__(self):
+ return str(self.__dict__)
+
+ def __setattr__(self, key, value):
+ raise ValueError("Config is immutable")
+
+ def __setitem__(self, key, value):
+ raise ValueError("Config is immutable")
+
+ def __getitem__(self, key):
+ try:
+ return getattr(self, key)
+ except AttributeError:
+ raise ValueError
+
+ def __contains__(self, key):
+ return key in self.__dict__
+
+ def __iter__(self):
+ return (x for x in self.__dict__ if not x.startswith("_"))
+
+ def __len__(self):
+ return len([item for item in self])
+
+ def as_dict(self):
+ return json_types(self.__dict__, skip={"_logger"})
+
+
+def json_types(obj, skip=None):
+ if skip is None:
+ skip = set()
+ if isinstance(obj, dict):
+ return {key: json_types(value) for key, value in obj.items() if key not in skip}
+ if (isinstance(obj, str) or
+ isinstance(obj, int) or
+ isinstance(obj, float) or
+ isinstance(obj, bool) or
+ obj is None):
+ return obj
+ if isinstance(obj, list) or hasattr(obj, "__iter__"):
+ return [json_types(value) for value in obj]
+ raise ValueError
+
+
+class ConfigBuilder:
+ """Builder object for setting the wptserve config.
+
+ Configuration can be passed in as a dictionary to the constructor, or
+ set via attributes after construction. Configuration options must match
+ the keys on the _default class property.
+
+ The generated configuration is obtained by using the builder
+ object as a context manager; this returns a Config object
+ containing immutable configuration that may be shared between
+ threads and processes. In general the configuration is only valid
+ for the context used to obtain it.
+
+ with ConfigBuilder() as config:
+ # Use the configuration
+ print config.browser_host
+
+ The properties on the final configuration include those explicitly
+ supplied and computed properties. The computed properties are
+ defined by the computed_properties attribute on the class. This
+ is a list of property names, each corresponding to a _get_<name>
+ method on the class. These methods are called in the order defined
+ in computed_properties and are passed a single argument, a
+ dictionary containing the current set of properties. Thus computed
+ properties later in the list may depend on the value of earlier
+ ones.
+
+
+ :param logger: - A logger object. This is used for logging during
+ the creation of the configuration, but isn't
+ part of the configuration
+ :param subdomains: - A set of valid subdomains to include in the
+ configuration.
+ :param not_subdomains: - A set of invalid subdomains to include in
+ the configuration.
+ :param config_cls: - A class to use for the configuration. Defaults
+ to default_config_cls
+ """
+
+ _default = {
+ "browser_host": "localhost",
+ "alternate_hosts": {},
+ "doc_root": os.path.dirname("__file__"),
+ "server_host": None,
+ "ports": {"http": [8000]},
+ "check_subdomains": True,
+ "log_level": "debug",
+ "bind_address": True,
+ "ssl": {
+ "type": "none",
+ "encrypt_after_connect": False,
+ "none": {},
+ "openssl": {
+ "openssl_binary": "openssl",
+ "base_path": "_certs",
+ "password": "web-platform-tests",
+ "force_regenerate": False,
+ "duration": 30,
+ "base_conf_path": None
+ },
+ "pregenerated": {
+ "host_key_path": None,
+ "host_cert_path": None,
+ },
+ },
+ "aliases": []
+ }
+ default_config_cls = Config
+
+ # Configuration properties that are computed. Each corresponds to a method
+ # _get_foo, which is called with the current data dictionary. The properties
+ # are computed in the order specified in the list.
+ computed_properties = ["log_level",
+ "paths",
+ "server_host",
+ "ports",
+ "domains",
+ "not_domains",
+ "all_domains",
+ "domains_set",
+ "not_domains_set",
+ "all_domains_set",
+ "ssl_config"]
+
+ def __init__(self,
+ logger,
+ subdomains=set(),
+ not_subdomains=set(),
+ config_cls=None,
+ **kwargs):
+
+ self._logger = logger
+ self._data = self._default.copy()
+ self._ssl_env = None
+
+ self._config_cls = config_cls or self.default_config_cls
+
+ for k, v in self._default.items():
+ self._data[k] = kwargs.pop(k, v)
+
+ self._data["subdomains"] = subdomains
+ self._data["not_subdomains"] = not_subdomains
+
+ for k, new_k in _renamed_props.items():
+ if k in kwargs:
+ logger.warning(
+ "%s in config is deprecated; use %s instead" % (
+ k,
+ new_k
+ )
+ )
+ self._data[new_k] = kwargs.pop(k)
+
+ if kwargs:
+ raise TypeError("__init__() got unexpected keyword arguments %r" % (tuple(kwargs),))
+
+ def __setattr__(self, key, value):
+ if not key[0] == "_":
+ self._data[key] = value
+ else:
+ self.__dict__[key] = value
+
+ def update(self, override):
+ """Load an overrides dict to override config values"""
+ override = override.copy()
+
+ for k in self._default:
+ if k in override:
+ self._set_override(k, override.pop(k))
+
+ for k, new_k in _renamed_props.items():
+ if k in override:
+ self._logger.warning(
+ "%s in config is deprecated; use %s instead" % (
+ k,
+ new_k
+ )
+ )
+ self._set_override(new_k, override.pop(k))
+
+ if override:
+ k = next(iter(override))
+ raise KeyError("unknown config override '%s'" % k)
+
+ def _set_override(self, k, v):
+ old_v = self._data[k]
+ if isinstance(old_v, dict):
+ self._data[k] = _merge_dict(old_v, v)
+ else:
+ self._data[k] = v
+
+ def __enter__(self):
+ if self._ssl_env is not None:
+ raise ValueError("Tried to re-enter configuration")
+ data = self._data.copy()
+ prefix = "_get_"
+ for key in self.computed_properties:
+ data[key] = getattr(self, prefix + key)(data)
+ return self._config_cls(data)
+
+ def __exit__(self, *args):
+ self._ssl_env.__exit__(*args)
+ self._ssl_env = None
+
+ def _get_log_level(self, data):
+ return data["log_level"].upper()
+
+ def _get_paths(self, data):
+ return {"doc_root": data["doc_root"]}
+
+ def _get_server_host(self, data):
+ return data["server_host"] if data.get("server_host") is not None else data["browser_host"]
+
+ def _get_ports(self, data):
+ new_ports = defaultdict(list)
+ for scheme, ports in data["ports"].items():
+ if scheme in ["wss", "https"] and not sslutils.get_cls(data["ssl"]["type"]).ssl_enabled:
+ continue
+ for i, port in enumerate(ports):
+ real_port = get_port("") if port == "auto" else port
+ new_ports[scheme].append(real_port)
+ return new_ports
+
+ def _get_domains(self, data):
+ hosts = data["alternate_hosts"].copy()
+ assert "" not in hosts
+ hosts[""] = data["browser_host"]
+
+ rv = {}
+ for name, host in hosts.items():
+ rv[name] = {subdomain: (subdomain.encode("idna").decode("ascii") + "." + host)
+ for subdomain in data["subdomains"]}
+ rv[name][""] = host
+ return rv
+
+ def _get_not_domains(self, data):
+ hosts = data["alternate_hosts"].copy()
+ assert "" not in hosts
+ hosts[""] = data["browser_host"]
+
+ rv = {}
+ for name, host in hosts.items():
+ rv[name] = {subdomain: (subdomain.encode("idna").decode("ascii") + "." + host)
+ for subdomain in data["not_subdomains"]}
+ return rv
+
+ def _get_all_domains(self, data):
+ rv = copy.deepcopy(data["domains"])
+ nd = data["not_domains"]
+ for host in rv:
+ rv[host].update(nd[host])
+ return rv
+
+ def _get_domains_set(self, data):
+ return {domain
+ for per_host_domains in data["domains"].values()
+ for domain in per_host_domains.values()}
+
+ def _get_not_domains_set(self, data):
+ return {domain
+ for per_host_domains in data["not_domains"].values()
+ for domain in per_host_domains.values()}
+
+ def _get_all_domains_set(self, data):
+ return data["domains_set"] | data["not_domains_set"]
+
+ def _get_ssl_config(self, data):
+ ssl_type = data["ssl"]["type"]
+ ssl_cls = sslutils.get_cls(ssl_type)
+ kwargs = data["ssl"].get(ssl_type, {})
+ self._ssl_env = ssl_cls(self._logger, **kwargs)
+ self._ssl_env.__enter__()
+ if self._ssl_env.ssl_enabled:
+ key_path, cert_path = self._ssl_env.host_cert_path(data["domains_set"])
+ ca_cert_path = self._ssl_env.ca_cert_path(data["domains_set"])
+ return {"key_path": key_path,
+ "ca_cert_path": ca_cert_path,
+ "cert_path": cert_path,
+ "encrypt_after_connect": data["ssl"].get("encrypt_after_connect", False)}
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/constants.py b/testing/web-platform/tests/tools/wptserve/wptserve/constants.py
new file mode 100644
index 0000000000..584f2cc1c7
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/constants.py
@@ -0,0 +1,98 @@
+from . import utils
+
+content_types = utils.invert_dict({
+ "application/json": ["json"],
+ "application/wasm": ["wasm"],
+ "application/xhtml+xml": ["xht", "xhtm", "xhtml"],
+ "application/xml": ["xml"],
+ "application/x-xpinstall": ["xpi"],
+ "audio/mp4": ["m4a"],
+ "audio/mpeg": ["mp3"],
+ "audio/ogg": ["oga"],
+ "audio/webm": ["weba"],
+ "audio/x-wav": ["wav"],
+ "image/avif": ["avif"],
+ "image/bmp": ["bmp"],
+ "image/gif": ["gif"],
+ "image/jpeg": ["jpg", "jpeg"],
+ "image/png": ["png"],
+ "image/svg+xml": ["svg"],
+ "text/cache-manifest": ["manifest"],
+ "text/css": ["css"],
+ "text/event-stream": ["event_stream"],
+ "text/html": ["htm", "html"],
+ "text/javascript": ["js", "mjs"],
+ "text/plain": ["txt", "md"],
+ "text/vtt": ["vtt"],
+ "video/mp4": ["mp4", "m4v"],
+ "video/ogg": ["ogg", "ogv"],
+ "video/webm": ["webm"],
+})
+
+response_codes = {
+ 100: ('Continue', 'Request received, please continue'),
+ 101: ('Switching Protocols',
+ 'Switching to new protocol; obey Upgrade header'),
+
+ 200: ('OK', 'Request fulfilled, document follows'),
+ 201: ('Created', 'Document created, URL follows'),
+ 202: ('Accepted',
+ 'Request accepted, processing continues off-line'),
+ 203: ('Non-Authoritative Information', 'Request fulfilled from cache'),
+ 204: ('No Content', 'Request fulfilled, nothing follows'),
+ 205: ('Reset Content', 'Clear input form for further input.'),
+ 206: ('Partial Content', 'Partial content follows.'),
+
+ 300: ('Multiple Choices',
+ 'Object has several resources -- see URI list'),
+ 301: ('Moved Permanently', 'Object moved permanently -- see URI list'),
+ 302: ('Found', 'Object moved temporarily -- see URI list'),
+ 303: ('See Other', 'Object moved -- see Method and URL list'),
+ 304: ('Not Modified',
+ 'Document has not changed since given time'),
+ 305: ('Use Proxy',
+ 'You must use proxy specified in Location to access this '
+ 'resource.'),
+ 307: ('Temporary Redirect',
+ 'Object moved temporarily -- see URI list'),
+
+ 400: ('Bad Request',
+ 'Bad request syntax or unsupported method'),
+ 401: ('Unauthorized',
+ 'No permission -- see authorization schemes'),
+ 402: ('Payment Required',
+ 'No payment -- see charging schemes'),
+ 403: ('Forbidden',
+ 'Request forbidden -- authorization will not help'),
+ 404: ('Not Found', 'Nothing matches the given URI'),
+ 405: ('Method Not Allowed',
+ 'Specified method is invalid for this resource.'),
+ 406: ('Not Acceptable', 'URI not available in preferred format.'),
+ 407: ('Proxy Authentication Required', 'You must authenticate with '
+ 'this proxy before proceeding.'),
+ 408: ('Request Timeout', 'Request timed out; try again later.'),
+ 409: ('Conflict', 'Request conflict.'),
+ 410: ('Gone',
+ 'URI no longer exists and has been permanently removed.'),
+ 411: ('Length Required', 'Client must specify Content-Length.'),
+ 412: ('Precondition Failed', 'Precondition in headers is false.'),
+ 413: ('Request Entity Too Large', 'Entity is too large.'),
+ 414: ('Request-URI Too Long', 'URI is too long.'),
+ 415: ('Unsupported Media Type', 'Entity body in unsupported format.'),
+ 416: ('Requested Range Not Satisfiable',
+ 'Cannot satisfy request range.'),
+ 417: ('Expectation Failed',
+ 'Expect condition could not be satisfied.'),
+
+ 500: ('Internal Server Error', 'Server got itself in trouble'),
+ 501: ('Not Implemented',
+ 'Server does not support this operation'),
+ 502: ('Bad Gateway', 'Invalid responses from another server/proxy.'),
+ 503: ('Service Unavailable',
+ 'The server cannot process the request due to a high load'),
+ 504: ('Gateway Timeout',
+ 'The gateway server did not receive a timely response'),
+ 505: ('HTTP Version Not Supported', 'Cannot fulfill request.'),
+}
+
+h2_headers = ['method', 'scheme', 'host', 'path', 'authority', 'status']
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/handlers.py b/testing/web-platform/tests/tools/wptserve/wptserve/handlers.py
new file mode 100644
index 0000000000..bae6a8f137
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/handlers.py
@@ -0,0 +1,512 @@
+# mypy: allow-untyped-defs
+
+import json
+import os
+import traceback
+from collections import defaultdict
+
+from urllib.parse import quote, unquote, urljoin
+
+from .constants import content_types
+from .pipes import Pipeline, template
+from .ranges import RangeParser
+from .request import Authentication
+from .response import MultipartContent
+from .utils import HTTPException
+
+from html import escape
+
+__all__ = ["file_handler", "python_script_handler",
+ "FunctionHandler", "handler", "json_handler",
+ "as_is_handler", "ErrorHandler", "BasicAuthHandler"]
+
+
+def guess_content_type(path):
+ ext = os.path.splitext(path)[1].lstrip(".")
+ if ext in content_types:
+ return content_types[ext]
+
+ return "application/octet-stream"
+
+
+def filesystem_path(base_path, request, url_base="/"):
+ if base_path is None:
+ base_path = request.doc_root
+
+ path = unquote(request.url_parts.path)
+
+ if path.startswith(url_base):
+ path = path[len(url_base):]
+
+ if ".." in path:
+ raise HTTPException(404)
+
+ new_path = os.path.join(base_path, path)
+
+ # Otherwise setting path to / allows access outside the root directory
+ if not new_path.startswith(base_path):
+ raise HTTPException(404)
+
+ return new_path
+
+
+class DirectoryHandler:
+ def __init__(self, base_path=None, url_base="/"):
+ self.base_path = base_path
+ self.url_base = url_base
+
+ def __repr__(self):
+ return "<%s base_path:%s url_base:%s>" % (self.__class__.__name__, self.base_path, self.url_base)
+
+ def __call__(self, request, response):
+ url_path = request.url_parts.path
+
+ if not url_path.endswith("/"):
+ response.status = 301
+ response.headers = [("Location", "%s/" % request.url)]
+ return
+
+ path = filesystem_path(self.base_path, request, self.url_base)
+
+ assert os.path.isdir(path)
+
+ response.headers = [("Content-Type", "text/html")]
+ response.content = """<!doctype html>
+<meta name="viewport" content="width=device-width">
+<title>Directory listing for %(path)s</title>
+<h1>Directory listing for %(path)s</h1>
+<ul>
+%(items)s
+</ul>
+""" % {"path": escape(url_path),
+ "items": "\n".join(self.list_items(url_path, path))} # noqa: E122
+
+ def list_items(self, base_path, path):
+ assert base_path.endswith("/")
+
+ # TODO: this won't actually list all routes, only the
+ # ones that correspond to a real filesystem path. It's
+ # not possible to list every route that will match
+ # something, but it should be possible to at least list the
+ # statically defined ones
+
+ if base_path != "/":
+ link = urljoin(base_path, "..")
+ yield ("""<li class="dir"><a href="%(link)s">%(name)s</a></li>""" %
+ {"link": link, "name": ".."})
+ items = []
+ prev_item = None
+ # This ensures that .headers always sorts after the file it provides the headers for. E.g.,
+ # if we have x, x-y, and x.headers, the order will be x, x.headers, and then x-y.
+ for item in sorted(os.listdir(path), key=lambda x: (x[:-len(".headers")], x) if x.endswith(".headers") else (x, x)):
+ if prev_item and prev_item + ".headers" == item:
+ items[-1][1] = item
+ prev_item = None
+ continue
+ items.append([item, None])
+ prev_item = item
+ for item, dot_headers in items:
+ link = escape(quote(item))
+ dot_headers_markup = ""
+ if dot_headers is not None:
+ dot_headers_markup = (""" (<a href="%(link)s">.headers</a>)""" %
+ {"link": escape(quote(dot_headers))})
+ if os.path.isdir(os.path.join(path, item)):
+ link += "/"
+ class_ = "dir"
+ else:
+ class_ = "file"
+ yield ("""<li class="%(class)s"><a href="%(link)s">%(name)s</a>%(headers)s</li>""" %
+ {"link": link, "name": escape(item), "class": class_,
+ "headers": dot_headers_markup})
+
+
+def parse_qs(qs):
+ """Parse a query string given as a string argument (data of type
+ application/x-www-form-urlencoded). Data are returned as a dictionary. The
+ dictionary keys are the unique query variable names and the values are
+ lists of values for each name.
+
+ This implementation is used instead of Python's built-in `parse_qs` method
+ in order to support the semicolon character (which the built-in method
+ interprets as a parameter delimiter)."""
+ pairs = [item.split("=", 1) for item in qs.split('&') if item]
+ rv = defaultdict(list)
+ for pair in pairs:
+ if len(pair) == 1 or len(pair[1]) == 0:
+ continue
+ name = unquote(pair[0].replace('+', ' '))
+ value = unquote(pair[1].replace('+', ' '))
+ rv[name].append(value)
+ return dict(rv)
+
+
+def wrap_pipeline(path, request, response):
+ """Applies pipelines to a response.
+
+ Pipelines are specified in the filename (.sub.) or the query param (?pipe).
+ """
+ query = parse_qs(request.url_parts.query)
+ pipe_string = ""
+
+ if ".sub." in path:
+ ml_extensions = {".html", ".htm", ".xht", ".xhtml", ".xml", ".svg"}
+ escape_type = "html" if os.path.splitext(path)[1] in ml_extensions else "none"
+ pipe_string = "sub(%s)" % escape_type
+
+ if "pipe" in query:
+ if pipe_string:
+ pipe_string += "|"
+
+ pipe_string += query["pipe"][-1]
+
+ if pipe_string:
+ response = Pipeline(pipe_string)(request, response)
+
+ return response
+
+
+def load_headers(request, path):
+ """Loads headers from files for a given path.
+
+ Attempts to load both the neighbouring __dir__{.sub}.headers and
+ PATH{.sub}.headers (applying template substitution if needed); results are
+ concatenated in that order.
+ """
+ def _load(request, path):
+ headers_path = path + ".sub.headers"
+ if os.path.exists(headers_path):
+ use_sub = True
+ else:
+ headers_path = path + ".headers"
+ use_sub = False
+
+ try:
+ with open(headers_path, "rb") as headers_file:
+ data = headers_file.read()
+ except OSError:
+ return []
+ else:
+ if use_sub:
+ data = template(request, data, escape_type="none")
+ return [tuple(item.strip() for item in line.split(b":", 1))
+ for line in data.splitlines() if line]
+
+ return (_load(request, os.path.join(os.path.dirname(path), "__dir__")) +
+ _load(request, path))
+
+
+class FileHandler:
+ def __init__(self, base_path=None, url_base="/"):
+ self.base_path = base_path
+ self.url_base = url_base
+ self.directory_handler = DirectoryHandler(self.base_path, self.url_base)
+
+ def __repr__(self):
+ return "<%s base_path:%s url_base:%s>" % (self.__class__.__name__, self.base_path, self.url_base)
+
+ def __call__(self, request, response):
+ path = filesystem_path(self.base_path, request, self.url_base)
+
+ if os.path.isdir(path):
+ return self.directory_handler(request, response)
+ try:
+ #This is probably racy with some other process trying to change the file
+ file_size = os.stat(path).st_size
+ response.headers.update(self.get_headers(request, path))
+ if "Range" in request.headers:
+ try:
+ byte_ranges = RangeParser()(request.headers['Range'], file_size)
+ except HTTPException as e:
+ if e.code == 416:
+ response.headers.set("Content-Range", "bytes */%i" % file_size)
+ raise
+ else:
+ byte_ranges = None
+ data = self.get_data(response, path, byte_ranges)
+ response.content = data
+ response = wrap_pipeline(path, request, response)
+ return response
+
+ except OSError:
+ raise HTTPException(404)
+
+ def get_headers(self, request, path):
+ rv = load_headers(request, path)
+
+ if not any(key.lower() == b"content-type" for (key, _) in rv):
+ rv.insert(0, (b"Content-Type", guess_content_type(path).encode("ascii")))
+
+ return rv
+
+ def get_data(self, response, path, byte_ranges):
+ """Return either the handle to a file, or a string containing
+ the content of a chunk of the file, if we have a range request."""
+ if byte_ranges is None:
+ return open(path, 'rb')
+ else:
+ with open(path, 'rb') as f:
+ response.status = 206
+ if len(byte_ranges) > 1:
+ parts_content_type, content = self.set_response_multipart(response,
+ byte_ranges,
+ f)
+ for byte_range in byte_ranges:
+ content.append_part(self.get_range_data(f, byte_range),
+ parts_content_type,
+ [("Content-Range", byte_range.header_value())])
+ return content
+ else:
+ response.headers.set("Content-Range", byte_ranges[0].header_value())
+ return self.get_range_data(f, byte_ranges[0])
+
+ def set_response_multipart(self, response, ranges, f):
+ parts_content_type = response.headers.get("Content-Type")
+ if parts_content_type:
+ parts_content_type = parts_content_type[-1]
+ else:
+ parts_content_type = None
+ content = MultipartContent()
+ response.headers.set("Content-Type", "multipart/byteranges; boundary=%s" % content.boundary)
+ return parts_content_type, content
+
+ def get_range_data(self, f, byte_range):
+ f.seek(byte_range.lower)
+ return f.read(byte_range.upper - byte_range.lower)
+
+
+file_handler = FileHandler() # type: ignore
+
+
+class PythonScriptHandler:
+ def __init__(self, base_path=None, url_base="/"):
+ self.base_path = base_path
+ self.url_base = url_base
+
+ def __repr__(self):
+ return "<%s base_path:%s url_base:%s>" % (self.__class__.__name__, self.base_path, self.url_base)
+
+ def _load_file(self, request, response, func):
+ """
+ This loads the requested python file as an environ variable.
+
+ Once the environ is loaded, the passed `func` is run with this loaded environ.
+
+ :param request: The request object
+ :param response: The response object
+ :param func: The function to be run with the loaded environ with the modified filepath. Signature: (request, response, environ, path)
+ :return: The return of func
+ """
+ path = filesystem_path(self.base_path, request, self.url_base)
+
+ try:
+ environ = {"__file__": path}
+ with open(path, 'rb') as f:
+ exec(compile(f.read(), path, 'exec'), environ, environ)
+
+ if func is not None:
+ return func(request, response, environ, path)
+
+ except OSError:
+ raise HTTPException(404)
+
+ def __call__(self, request, response):
+ def func(request, response, environ, path):
+ if "main" in environ:
+ handler = FunctionHandler(environ["main"])
+ handler(request, response)
+ wrap_pipeline(path, request, response)
+ else:
+ raise HTTPException(500, "No main function in script %s" % path)
+
+ self._load_file(request, response, func)
+
+ def frame_handler(self, request):
+ """
+ This creates a FunctionHandler with one or more of the handling functions.
+
+ Used by the H2 server.
+
+ :param request: The request object used to generate the handler.
+ :return: A FunctionHandler object with one or more of these functions: `handle_headers`, `handle_data` or `main`
+ """
+ def func(request, response, environ, path):
+ def _main(req, resp):
+ pass
+
+ handler = FunctionHandler(_main)
+ if "main" in environ:
+ handler.func = environ["main"]
+ if "handle_headers" in environ:
+ handler.handle_headers = environ["handle_headers"]
+ if "handle_data" in environ:
+ handler.handle_data = environ["handle_data"]
+
+ if handler.func is _main and not hasattr(handler, "handle_headers") and not hasattr(handler, "handle_data"):
+ raise HTTPException(500, "No main function or handlers in script %s" % path)
+
+ return handler
+ return self._load_file(request, None, func)
+
+
+python_script_handler = PythonScriptHandler() # type: ignore
+
+
+class FunctionHandler:
+ def __init__(self, func):
+ self.func = func
+
+ def __call__(self, request, response):
+ try:
+ rv = self.func(request, response)
+ except HTTPException:
+ raise
+ except Exception:
+ msg = traceback.format_exc()
+ raise HTTPException(500, message=msg)
+ if rv is not None:
+ if isinstance(rv, tuple):
+ if len(rv) == 3:
+ status, headers, content = rv
+ response.status = status
+ elif len(rv) == 2:
+ headers, content = rv
+ else:
+ raise HTTPException(500)
+ response.headers.update(headers)
+ else:
+ content = rv
+ response.content = content
+ wrap_pipeline('', request, response)
+
+
+# The generic name here is so that this can be used as a decorator
+def handler(func):
+ return FunctionHandler(func)
+
+
+class JsonHandler:
+ def __init__(self, func):
+ self.func = func
+
+ def __call__(self, request, response):
+ return FunctionHandler(self.handle_request)(request, response)
+
+ def handle_request(self, request, response):
+ rv = self.func(request, response)
+ response.headers.set("Content-Type", "application/json")
+ enc = json.dumps
+ if isinstance(rv, tuple):
+ rv = list(rv)
+ value = tuple(rv[:-1] + [enc(rv[-1])])
+ length = len(value[-1])
+ else:
+ value = enc(rv)
+ length = len(value)
+ response.headers.set("Content-Length", length)
+ return value
+
+
+def json_handler(func):
+ return JsonHandler(func)
+
+
+class AsIsHandler:
+ def __init__(self, base_path=None, url_base="/"):
+ self.base_path = base_path
+ self.url_base = url_base
+
+ def __call__(self, request, response):
+ path = filesystem_path(self.base_path, request, self.url_base)
+
+ try:
+ with open(path, 'rb') as f:
+ response.writer.write_raw_content(f.read())
+ wrap_pipeline(path, request, response)
+ response.close_connection = True
+ except OSError:
+ raise HTTPException(404)
+
+
+as_is_handler = AsIsHandler() # type: ignore
+
+
+class BasicAuthHandler:
+ def __init__(self, handler, user, password):
+ """
+ A Basic Auth handler
+
+ :Args:
+ - handler: a secondary handler for the request after authentication is successful (example file_handler)
+ - user: string of the valid user name or None if any / all credentials are allowed
+ - password: string of the password required
+ """
+ self.user = user
+ self.password = password
+ self.handler = handler
+
+ def __call__(self, request, response):
+ if "authorization" not in request.headers:
+ response.status = 401
+ response.headers.set("WWW-Authenticate", "Basic")
+ return response
+ else:
+ auth = Authentication(request.headers)
+ if self.user is not None and (self.user != auth.username or self.password != auth.password):
+ response.set_error(403, "Invalid username or password")
+ return response
+ return self.handler(request, response)
+
+
+basic_auth_handler = BasicAuthHandler(file_handler, None, None) # type: ignore
+
+
+class ErrorHandler:
+ def __init__(self, status):
+ self.status = status
+
+ def __call__(self, request, response):
+ response.set_error(self.status)
+
+
+class StringHandler:
+ def __init__(self, data, content_type, **headers):
+ """Handler that returns a fixed data string and headers
+
+ :param data: String to use
+ :param content_type: Content type header to server the response with
+ :param headers: List of headers to send with responses"""
+
+ self.data = data
+
+ self.resp_headers = [("Content-Type", content_type)]
+ for k, v in headers.items():
+ self.resp_headers.append((k.replace("_", "-"), v))
+
+ self.handler = handler(self.handle_request)
+
+ def handle_request(self, request, response):
+ return self.resp_headers, self.data
+
+ def __call__(self, request, response):
+ rv = self.handler(request, response)
+ return rv
+
+
+class StaticHandler(StringHandler):
+ def __init__(self, path, format_args, content_type, **headers):
+ """Handler that reads a file from a path and substitutes some fixed data
+
+ Note that *.headers files have no effect in this handler.
+
+ :param path: Path to the template file to use
+ :param format_args: Dictionary of values to substitute into the template file
+ :param content_type: Content type header to server the response with
+ :param headers: List of headers to send with responses"""
+
+ with open(path) as f:
+ data = f.read()
+ if format_args:
+ data = data % format_args
+
+ return super().__init__(data, content_type, **headers)
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/logger.py b/testing/web-platform/tests/tools/wptserve/wptserve/logger.py
new file mode 100644
index 0000000000..8eff146a01
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/logger.py
@@ -0,0 +1,5 @@
+import logging
+
+def get_logger() -> logging.Logger:
+ # Use the root logger
+ return logging.getLogger()
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/pipes.py b/testing/web-platform/tests/tools/wptserve/wptserve/pipes.py
new file mode 100644
index 0000000000..a9a85a136b
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/pipes.py
@@ -0,0 +1,561 @@
+# mypy: allow-untyped-defs
+
+from collections import deque
+import base64
+import gzip as gzip_module
+import hashlib
+import os
+import re
+import time
+import uuid
+
+from html import escape
+from io import BytesIO
+from typing import Any, Callable, ClassVar, Dict, Optional, TypeVar
+
+T = TypeVar('T')
+
+
+def resolve_content(response):
+ return b"".join(item for item in response.iter_content(read_file=True))
+
+
+class Pipeline:
+ pipes = {} # type: ClassVar[Dict[str, Callable[..., Any]]]
+
+ def __init__(self, pipe_string):
+ self.pipe_functions = self.parse(pipe_string)
+
+ def parse(self, pipe_string):
+ functions = []
+ for item in PipeTokenizer().tokenize(pipe_string):
+ if not item:
+ break
+ if item[0] == "function":
+ functions.append((self.pipes[item[1]], []))
+ elif item[0] == "argument":
+ functions[-1][1].append(item[1])
+ return functions
+
+ def __call__(self, request, response):
+ for func, args in self.pipe_functions:
+ response = func(request, response, *args)
+ return response
+
+
+class PipeTokenizer:
+ def __init__(self):
+ #This whole class can likely be replaced by some regexps
+ self.state = None
+
+ def tokenize(self, string):
+ self.string = string
+ self.state = self.func_name_state
+ self._index = 0
+ while self.state:
+ yield self.state()
+ yield None
+
+ def get_char(self):
+ if self._index >= len(self.string):
+ return None
+ rv = self.string[self._index]
+ self._index += 1
+ return rv
+
+ def func_name_state(self):
+ rv = ""
+ while True:
+ char = self.get_char()
+ if char is None:
+ self.state = None
+ if rv:
+ return ("function", rv)
+ else:
+ return None
+ elif char == "(":
+ self.state = self.argument_state
+ return ("function", rv)
+ elif char == "|":
+ if rv:
+ return ("function", rv)
+ else:
+ rv += char
+
+ def argument_state(self):
+ rv = ""
+ while True:
+ char = self.get_char()
+ if char is None:
+ self.state = None
+ return ("argument", rv)
+ elif char == "\\":
+ rv += self.get_escape()
+ if rv is None:
+ #This should perhaps be an error instead
+ return ("argument", rv)
+ elif char == ",":
+ return ("argument", rv)
+ elif char == ")":
+ self.state = self.func_name_state
+ return ("argument", rv)
+ else:
+ rv += char
+
+ def get_escape(self):
+ char = self.get_char()
+ escapes = {"n": "\n",
+ "r": "\r",
+ "t": "\t"}
+ return escapes.get(char, char)
+
+
+class pipe:
+ def __init__(self, *arg_converters: Callable[[str], Any]):
+ self.arg_converters = arg_converters
+ self.max_args = len(self.arg_converters)
+ self.min_args = 0
+ opt_seen = False
+ for item in self.arg_converters:
+ if not opt_seen:
+ if isinstance(item, opt):
+ opt_seen = True
+ else:
+ self.min_args += 1
+ else:
+ if not isinstance(item, opt):
+ raise ValueError("Non-optional argument cannot follow optional argument")
+
+ def __call__(self, f):
+ def inner(request, response, *args):
+ if not (self.min_args <= len(args) <= self.max_args):
+ raise ValueError("Expected between %d and %d args, got %d" %
+ (self.min_args, self.max_args, len(args)))
+ arg_values = tuple(f(x) for f, x in zip(self.arg_converters, args))
+ return f(request, response, *arg_values)
+ Pipeline.pipes[f.__name__] = inner
+ #We actually want the undecorated function in the main namespace
+ return f
+
+
+class opt:
+ def __init__(self, f: Callable[[str], Any]):
+ self.f = f
+
+ def __call__(self, arg: str) -> Any:
+ return self.f(arg)
+
+
+def nullable(func: Callable[[str], T]) -> Callable[[str], Optional[T]]:
+ def inner(arg: str) -> Optional[T]:
+ if arg.lower() == "null":
+ return None
+ else:
+ return func(arg)
+ return inner
+
+
+def boolean(arg: str) -> bool:
+ if arg.lower() in ("true", "1"):
+ return True
+ elif arg.lower() in ("false", "0"):
+ return False
+ raise ValueError
+
+
+@pipe(int)
+def status(request, response, code):
+ """Alter the status code.
+
+ :param code: Status code to use for the response."""
+ response.status = code
+ return response
+
+
+@pipe(str, str, opt(boolean))
+def header(request, response, name, value, append=False):
+ """Set a HTTP header.
+
+ Replaces any existing HTTP header of the same name unless
+ append is set, in which case the header is appended without
+ replacement.
+
+ :param name: Name of the header to set.
+ :param value: Value to use for the header.
+ :param append: True if existing headers should not be replaced
+ """
+ if not append:
+ response.headers.set(name, value)
+ else:
+ response.headers.append(name, value)
+ return response
+
+
+@pipe(str)
+def trickle(request, response, delays):
+ """Send the response in parts, with time delays.
+
+ :param delays: A string of delays and amounts, in bytes, of the
+ response to send. Each component is separated by
+ a colon. Amounts in bytes are plain integers, whilst
+ delays are floats prefixed with a single d e.g.
+ d1:100:d2
+ Would cause a 1 second delay, would then send 100 bytes
+ of the file, and then cause a 2 second delay, before sending
+ the remainder of the file.
+
+ If the last token is of the form rN, instead of sending the
+ remainder of the file, the previous N instructions will be
+ repeated until the whole file has been sent e.g.
+ d1:100:d2:r2
+ Causes a delay of 1s, then 100 bytes to be sent, then a 2s delay
+ and then a further 100 bytes followed by a two second delay
+ until the response has been fully sent.
+ """
+ def parse_delays():
+ parts = delays.split(":")
+ rv = []
+ for item in parts:
+ if item.startswith("d"):
+ item_type = "delay"
+ item = item[1:]
+ value = float(item)
+ elif item.startswith("r"):
+ item_type = "repeat"
+ value = int(item[1:])
+ if not value % 2 == 0:
+ raise ValueError
+ else:
+ item_type = "bytes"
+ value = int(item)
+ if len(rv) and rv[-1][0] == item_type:
+ rv[-1][1] += value
+ else:
+ rv.append((item_type, value))
+ return rv
+
+ delays = parse_delays()
+ if not delays:
+ return response
+ content = resolve_content(response)
+ offset = [0]
+
+ if not ("Cache-Control" in response.headers or
+ "Pragma" in response.headers or
+ "Expires" in response.headers):
+ response.headers.set("Cache-Control", "no-cache, no-store, must-revalidate")
+ response.headers.set("Pragma", "no-cache")
+ response.headers.set("Expires", "0")
+
+ def add_content(delays, repeat=False):
+ for i, (item_type, value) in enumerate(delays):
+ if item_type == "bytes":
+ yield content[offset[0]:offset[0] + value]
+ offset[0] += value
+ elif item_type == "delay":
+ time.sleep(value)
+ elif item_type == "repeat":
+ if i != len(delays) - 1:
+ continue
+ while offset[0] < len(content):
+ yield from add_content(delays[-(value + 1):-1], True)
+
+ if not repeat and offset[0] < len(content):
+ yield content[offset[0]:]
+
+ response.content = add_content(delays)
+ return response
+
+
+@pipe(nullable(int), opt(nullable(int)))
+def slice(request, response, start, end=None):
+ """Send a byte range of the response body
+
+ :param start: The starting offset. Follows python semantics including
+ negative numbers.
+
+ :param end: The ending offset, again with python semantics and None
+ (spelled "null" in a query string) to indicate the end of
+ the file.
+ """
+ content = resolve_content(response)[start:end]
+ response.content = content
+ response.headers.set("Content-Length", len(content))
+ return response
+
+
+class ReplacementTokenizer:
+ def arguments(self, token):
+ unwrapped = token[1:-1].decode('utf8')
+ return ("arguments", re.split(r",\s*", unwrapped) if unwrapped else [])
+
+ def ident(self, token):
+ return ("ident", token.decode('utf8'))
+
+ def index(self, token):
+ token = token[1:-1].decode('utf8')
+ try:
+ index = int(token)
+ except ValueError:
+ index = token
+ return ("index", index)
+
+ def var(self, token):
+ token = token[:-1].decode('utf8')
+ return ("var", token)
+
+ def tokenize(self, string):
+ assert isinstance(string, bytes)
+ return self.scanner.scan(string)[0]
+
+ # re.Scanner is missing from typeshed:
+ # https://github.com/python/typeshed/pull/3071
+ scanner = re.Scanner([(br"\$\w+:", var), # type: ignore
+ (br"\$?\w+", ident),
+ (br"\[[^\]]*\]", index),
+ (br"\([^)]*\)", arguments)])
+
+
+class FirstWrapper:
+ def __init__(self, params):
+ self.params = params
+
+ def __getitem__(self, key):
+ try:
+ if isinstance(key, str):
+ key = key.encode('iso-8859-1')
+ return self.params.first(key)
+ except KeyError:
+ return ""
+
+
+@pipe(opt(nullable(str)))
+def sub(request, response, escape_type="html"):
+ """Substitute environment information about the server and request into the script.
+
+ :param escape_type: String detailing the type of escaping to use. Known values are
+ "html" and "none", with "html" the default for historic reasons.
+
+ The format is a very limited template language. Substitutions are
+ enclosed by {{ and }}. There are several available substitutions:
+
+ host
+ A simple string value and represents the primary host from which the
+ tests are being run.
+ domains
+ A dictionary of available domains indexed by subdomain name.
+ ports
+ A dictionary of lists of ports indexed by protocol.
+ location
+ A dictionary of parts of the request URL. Valid keys are
+ 'server, 'scheme', 'host', 'hostname', 'port', 'path' and 'query'.
+ 'server' is scheme://host:port, 'host' is hostname:port, and query
+ includes the leading '?', but other delimiters are omitted.
+ headers
+ A dictionary of HTTP headers in the request.
+ header_or_default(header, default)
+ The value of an HTTP header, or a default value if it is absent.
+ For example::
+
+ {{header_or_default(X-Test, test-header-absent)}}
+
+ GET
+ A dictionary of query parameters supplied with the request.
+ uuid()
+ A pesudo-random UUID suitable for usage with stash
+ file_hash(algorithm, filepath)
+ The cryptographic hash of a file. Supported algorithms: md5, sha1,
+ sha224, sha256, sha384, and sha512. For example::
+
+ {{file_hash(md5, dom/interfaces.html)}}
+
+ fs_path(filepath)
+ The absolute path to a file inside the wpt document root
+
+ So for example in a setup running on localhost with a www
+ subdomain and a http server on ports 80 and 81::
+
+ {{host}} => localhost
+ {{domains[www]}} => www.localhost
+ {{ports[http][1]}} => 81
+
+ It is also possible to assign a value to a variable name, which must start
+ with the $ character, using the ":" syntax e.g.::
+
+ {{$id:uuid()}}
+
+ Later substitutions in the same file may then refer to the variable
+ by name e.g.::
+
+ {{$id}}
+ """
+ content = resolve_content(response)
+
+ new_content = template(request, content, escape_type=escape_type)
+
+ response.content = new_content
+ return response
+
+class SubFunctions:
+ @staticmethod
+ def uuid(request):
+ return str(uuid.uuid4())
+
+ # Maintain a list of supported algorithms, restricted to those that are
+ # available on all platforms [1]. This ensures that test authors do not
+ # unknowingly introduce platform-specific tests.
+ #
+ # [1] https://docs.python.org/2/library/hashlib.html
+ supported_algorithms = ("md5", "sha1", "sha224", "sha256", "sha384", "sha512")
+
+ @staticmethod
+ def file_hash(request, algorithm, path):
+ assert isinstance(algorithm, str)
+ if algorithm not in SubFunctions.supported_algorithms:
+ raise ValueError("Unsupported encryption algorithm: '%s'" % algorithm)
+
+ hash_obj = getattr(hashlib, algorithm)()
+ absolute_path = os.path.join(request.doc_root, path)
+
+ try:
+ with open(absolute_path, "rb") as f:
+ hash_obj.update(f.read())
+ except OSError:
+ # In this context, an unhandled IOError will be interpreted by the
+ # server as an indication that the template file is non-existent.
+ # Although the generic "Exception" is less precise, it avoids
+ # triggering a potentially-confusing HTTP 404 error in cases where
+ # the path to the file to be hashed is invalid.
+ raise Exception('Cannot open file for hash computation: "%s"' % absolute_path)
+
+ return base64.b64encode(hash_obj.digest()).strip()
+
+ @staticmethod
+ def fs_path(request, path):
+ if not path.startswith("/"):
+ subdir = request.request_path[len(request.url_base):]
+ if "/" in subdir:
+ subdir = subdir.rsplit("/", 1)[0]
+ root_rel_path = subdir + "/" + path
+ else:
+ root_rel_path = path[1:]
+ root_rel_path = root_rel_path.replace("/", os.path.sep)
+ absolute_path = os.path.abspath(os.path.join(request.doc_root, root_rel_path))
+ if ".." in os.path.relpath(absolute_path, request.doc_root):
+ raise ValueError("Path outside wpt root")
+ return absolute_path
+
+ @staticmethod
+ def header_or_default(request, name, default):
+ return request.headers.get(name, default)
+
+def template(request, content, escape_type="html"):
+ #TODO: There basically isn't any error handling here
+ tokenizer = ReplacementTokenizer()
+
+ variables = {}
+
+ def config_replacement(match):
+ content, = match.groups()
+
+ tokens = tokenizer.tokenize(content)
+ tokens = deque(tokens)
+
+ token_type, field = tokens.popleft()
+ assert isinstance(field, str)
+
+ if token_type == "var":
+ variable = field
+ token_type, field = tokens.popleft()
+ assert isinstance(field, str)
+ else:
+ variable = None
+
+ if token_type != "ident":
+ raise Exception("unexpected token type %s (token '%r'), expected ident" % (token_type, field))
+
+ if field in variables:
+ value = variables[field]
+ elif hasattr(SubFunctions, field):
+ value = getattr(SubFunctions, field)
+ elif field == "headers":
+ value = request.headers
+ elif field == "GET":
+ value = FirstWrapper(request.GET)
+ elif field == "hosts":
+ value = request.server.config.all_domains
+ elif field == "domains":
+ value = request.server.config.all_domains[""]
+ elif field == "host":
+ value = request.server.config["browser_host"]
+ elif field in request.server.config:
+ value = request.server.config[field]
+ elif field == "location":
+ value = {"server": "%s://%s:%s" % (request.url_parts.scheme,
+ request.url_parts.hostname,
+ request.url_parts.port),
+ "scheme": request.url_parts.scheme,
+ "host": "%s:%s" % (request.url_parts.hostname,
+ request.url_parts.port),
+ "hostname": request.url_parts.hostname,
+ "port": request.url_parts.port,
+ "path": request.url_parts.path,
+ "pathname": request.url_parts.path,
+ "query": "?%s" % request.url_parts.query}
+ elif field == "url_base":
+ value = request.url_base
+ else:
+ raise Exception("Undefined template variable %s" % field)
+
+ while tokens:
+ ttype, field = tokens.popleft()
+ if ttype == "index":
+ value = value[field]
+ elif ttype == "arguments":
+ value = value(request, *field)
+ else:
+ raise Exception(
+ "unexpected token type %s (token '%r'), expected ident or arguments" % (ttype, field)
+ )
+
+ assert isinstance(value, (int, (bytes, str))), tokens
+
+ if variable is not None:
+ variables[variable] = value
+
+ escape_func = {"html": lambda x:escape(x, quote=True),
+ "none": lambda x:x}[escape_type]
+
+ # Should possibly support escaping for other contexts e.g. script
+ # TODO: read the encoding of the response
+ # cgi.escape() only takes text strings in Python 3.
+ if isinstance(value, bytes):
+ value = value.decode("utf-8")
+ elif isinstance(value, int):
+ value = str(value)
+ return escape_func(value).encode("utf-8")
+
+ template_regexp = re.compile(br"{{([^}]*)}}")
+ new_content = template_regexp.sub(config_replacement, content)
+
+ return new_content
+
+@pipe()
+def gzip(request, response):
+ """This pipe gzip-encodes response data.
+
+ It sets (or overwrites) these HTTP headers:
+ Content-Encoding is set to gzip
+ Content-Length is set to the length of the compressed content
+ """
+ content = resolve_content(response)
+ response.headers.set("Content-Encoding", "gzip")
+
+ out = BytesIO()
+ with gzip_module.GzipFile(fileobj=out, mode="w") as f:
+ f.write(content)
+ response.content = out.getvalue()
+
+ response.headers.set("Content-Length", len(response.content))
+
+ return response
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/ranges.py b/testing/web-platform/tests/tools/wptserve/wptserve/ranges.py
new file mode 100644
index 0000000000..622b807002
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/ranges.py
@@ -0,0 +1,96 @@
+# mypy: allow-untyped-defs
+
+from .utils import HTTPException
+
+
+class RangeParser:
+ def __call__(self, header, file_size):
+ try:
+ header = header.decode("ascii")
+ except UnicodeDecodeError:
+ raise HTTPException(400, "Non-ASCII range header value")
+ prefix = "bytes="
+ if not header.startswith(prefix):
+ raise HTTPException(416, message=f"Unrecognised range type {header}")
+
+ parts = header[len(prefix):].split(",")
+ ranges = []
+ for item in parts:
+ components = item.split("-")
+ if len(components) != 2:
+ raise HTTPException(416, "Bad range specifier %s" % (item))
+ data = []
+ for component in components:
+ if component == "":
+ data.append(None)
+ else:
+ try:
+ data.append(int(component))
+ except ValueError:
+ raise HTTPException(416, "Bad range specifier %s" % (item))
+ try:
+ ranges.append(Range(data[0], data[1], file_size))
+ except ValueError:
+ raise HTTPException(416, "Bad range specifier %s" % (item))
+
+ return self.coalesce_ranges(ranges, file_size)
+
+ def coalesce_ranges(self, ranges, file_size):
+ rv = []
+ target = None
+ for current in reversed(sorted(ranges)):
+ if target is None:
+ target = current
+ else:
+ new = target.coalesce(current)
+ target = new[0]
+ if len(new) > 1:
+ rv.append(new[1])
+ rv.append(target)
+
+ return rv[::-1]
+
+
+class Range:
+ def __init__(self, lower, upper, file_size):
+ self.file_size = file_size
+ self.lower, self.upper = self._abs(lower, upper)
+ if self.lower >= self.upper or self.lower >= self.file_size:
+ raise ValueError
+
+ def __repr__(self):
+ return f"<Range {self.lower}-{self.upper}>"
+
+ def __lt__(self, other):
+ return self.lower < other.lower
+
+ def __gt__(self, other):
+ return self.lower > other.lower
+
+ def __eq__(self, other):
+ return self.lower == other.lower and self.upper == other.upper
+
+ def _abs(self, lower, upper):
+ if lower is None and upper is None:
+ lower, upper = 0, self.file_size
+ elif lower is None:
+ lower, upper = max(0, self.file_size - upper), self.file_size
+ elif upper is None:
+ lower, upper = lower, self.file_size
+ else:
+ lower, upper = lower, min(self.file_size, upper + 1)
+
+ return lower, upper
+
+ def coalesce(self, other):
+ assert self.file_size == other.file_size
+
+ if (self.upper < other.lower or self.lower > other.upper):
+ return sorted([self, other])
+ else:
+ return [Range(min(self.lower, other.lower),
+ max(self.upper, other.upper) - 1,
+ self.file_size)]
+
+ def header_value(self):
+ return "bytes %i-%i/%i" % (self.lower, self.upper - 1, self.file_size)
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/request.py b/testing/web-platform/tests/tools/wptserve/wptserve/request.py
new file mode 100644
index 0000000000..bfbbae3c28
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/request.py
@@ -0,0 +1,690 @@
+# mypy: allow-untyped-defs
+
+import base64
+import cgi
+import tempfile
+
+from http.cookies import BaseCookie
+from io import BytesIO
+from typing import Dict, List, TypeVar
+from urllib.parse import parse_qsl, urlsplit
+
+from . import stash
+from .utils import HTTPException, isomorphic_encode, isomorphic_decode
+
+KT = TypeVar('KT')
+VT = TypeVar('VT')
+
+missing = object()
+
+
+class Server:
+ """Data about the server environment
+
+ .. attribute:: config
+
+ Environment configuration information with information about the
+ various servers running, their hostnames and ports.
+
+ .. attribute:: stash
+
+ Stash object holding state stored on the server between requests.
+
+ """
+ config = None
+
+ def __init__(self, request):
+ self._stash = None
+ self._request = request
+
+ @property
+ def stash(self):
+ if self._stash is None:
+ address, authkey = stash.load_env_config()
+ self._stash = stash.Stash(self._request.url_parts.path, address, authkey)
+ return self._stash
+
+
+class InputFile:
+ max_buffer_size = 1024*1024
+
+ def __init__(self, rfile, length):
+ """File-like object used to provide a seekable view of request body data"""
+ self._file = rfile
+ self.length = length
+
+ self._file_position = 0
+
+ if length > self.max_buffer_size:
+ self._buf = tempfile.TemporaryFile()
+ else:
+ self._buf = BytesIO()
+
+ @property
+ def _buf_position(self):
+ rv = self._buf.tell()
+ assert rv <= self._file_position
+ return rv
+
+ def read(self, bytes=-1):
+ assert self._buf_position <= self._file_position
+
+ if bytes < 0:
+ bytes = self.length - self._buf_position
+ bytes_remaining = min(bytes, self.length - self._buf_position)
+
+ if bytes_remaining == 0:
+ return b""
+
+ if self._buf_position != self._file_position:
+ buf_bytes = min(bytes_remaining, self._file_position - self._buf_position)
+ old_data = self._buf.read(buf_bytes)
+ bytes_remaining -= buf_bytes
+ else:
+ old_data = b""
+
+ assert bytes_remaining == 0 or self._buf_position == self._file_position, (
+ "Before reading buffer position (%i) didn't match file position (%i)" %
+ (self._buf_position, self._file_position))
+ new_data = self._file.read(bytes_remaining)
+ self._buf.write(new_data)
+ self._file_position += bytes_remaining
+ assert bytes_remaining == 0 or self._buf_position == self._file_position, (
+ "After reading buffer position (%i) didn't match file position (%i)" %
+ (self._buf_position, self._file_position))
+
+ return old_data + new_data
+
+ def tell(self):
+ return self._buf_position
+
+ def seek(self, offset):
+ if offset > self.length or offset < 0:
+ raise ValueError
+ if offset <= self._file_position:
+ self._buf.seek(offset)
+ else:
+ self.read(offset - self._file_position)
+
+ def readline(self, max_bytes=None):
+ if max_bytes is None:
+ max_bytes = self.length - self._buf_position
+
+ if self._buf_position < self._file_position:
+ data = self._buf.readline(max_bytes)
+ if data.endswith(b"\n") or len(data) == max_bytes:
+ return data
+ else:
+ data = b""
+
+ assert self._buf_position == self._file_position
+
+ initial_position = self._file_position
+ found = False
+ buf = []
+ max_bytes -= len(data)
+ while not found:
+ readahead = self.read(min(2, max_bytes))
+ max_bytes -= len(readahead)
+ for i, c in enumerate(readahead):
+ if c == b"\n"[0]:
+ buf.append(readahead[:i+1])
+ found = True
+ break
+ if not found:
+ buf.append(readahead)
+ if not readahead or not max_bytes:
+ break
+ new_data = b"".join(buf)
+ data += new_data
+ self.seek(initial_position + len(new_data))
+ return data
+
+ def readlines(self):
+ rv = []
+ while True:
+ data = self.readline()
+ if data:
+ rv.append(data)
+ else:
+ break
+ return rv
+
+ def __next__(self):
+ data = self.readline()
+ if data:
+ return data
+ else:
+ raise StopIteration
+
+ next = __next__
+
+ def __iter__(self):
+ return self
+
+
+class Request:
+ """Object representing a HTTP request.
+
+ .. attribute:: doc_root
+
+ The local directory to use as a base when resolving paths
+
+ .. attribute:: route_match
+
+ Regexp match object from matching the request path to the route
+ selected for the request.
+
+ .. attribute:: client_address
+
+ Contains a tuple of the form (host, port) representing the client's address.
+
+ .. attribute:: protocol_version
+
+ HTTP version specified in the request.
+
+ .. attribute:: method
+
+ HTTP method in the request.
+
+ .. attribute:: request_path
+
+ Request path as it appears in the HTTP request.
+
+ .. attribute:: url_base
+
+ The prefix part of the path; typically / unless the handler has a url_base set
+
+ .. attribute:: url
+
+ Absolute URL for the request.
+
+ .. attribute:: url_parts
+
+ Parts of the requested URL as obtained by urlparse.urlsplit(path)
+
+ .. attribute:: request_line
+
+ Raw request line
+
+ .. attribute:: headers
+
+ RequestHeaders object providing a dictionary-like representation of
+ the request headers.
+
+ .. attribute:: raw_headers.
+
+ Dictionary of non-normalized request headers.
+
+ .. attribute:: body
+
+ Request body as a string
+
+ .. attribute:: raw_input
+
+ File-like object representing the body of the request.
+
+ .. attribute:: GET
+
+ MultiDict representing the parameters supplied with the request.
+ Note that these may be present on non-GET requests; the name is
+ chosen to be familiar to users of other systems such as PHP.
+ Both keys and values are binary strings.
+
+ .. attribute:: POST
+
+ MultiDict representing the request body parameters. Most parameters
+ are present as string values, but file uploads have file-like
+ values. All string values (including keys) have binary type.
+
+ .. attribute:: cookies
+
+ A Cookies object representing cookies sent with the request with a
+ dictionary-like interface.
+
+ .. attribute:: auth
+
+ An instance of Authentication with username and password properties
+ representing any credentials supplied using HTTP authentication.
+
+ .. attribute:: server
+
+ Server object containing information about the server environment.
+ """
+
+ def __init__(self, request_handler):
+ self.doc_root = request_handler.server.router.doc_root
+ self.route_match = None # Set by the router
+ self.client_address = request_handler.client_address
+
+ self.protocol_version = request_handler.protocol_version
+ self.method = request_handler.command
+
+ # Keys and values in raw headers are native strings.
+ self._headers = None
+ self.raw_headers = request_handler.headers
+
+ scheme = request_handler.server.scheme
+ host = self.raw_headers.get("Host")
+ port = request_handler.server.server_address[1]
+
+ if host is None:
+ host = request_handler.server.server_address[0]
+ else:
+ if ":" in host:
+ host, port = host.split(":", 1)
+
+ self.request_path = request_handler.path
+ self.url_base = "/"
+
+ if self.request_path.startswith(scheme + "://"):
+ self.url = self.request_path
+ else:
+ # TODO(#23362): Stop using native strings for URLs.
+ self.url = "%s://%s:%s%s" % (
+ scheme, host, port, self.request_path)
+ self.url_parts = urlsplit(self.url)
+
+ self.request_line = request_handler.raw_requestline
+
+ self.raw_input = InputFile(request_handler.rfile,
+ int(self.raw_headers.get("Content-Length", 0)))
+
+ self._body = None
+
+ self._GET = None
+ self._POST = None
+ self._cookies = None
+ self._auth = None
+
+ self.server = Server(self)
+
+ def __repr__(self):
+ return "<Request %s %s>" % (self.method, self.url)
+
+ @property
+ def GET(self):
+ if self._GET is None:
+ kwargs = {
+ "keep_blank_values": True,
+ "encoding": "iso-8859-1",
+ }
+ params = parse_qsl(self.url_parts.query, **kwargs)
+ self._GET = MultiDict()
+ for key, value in params:
+ self._GET.add(isomorphic_encode(key), isomorphic_encode(value))
+ return self._GET
+
+ @property
+ def POST(self):
+ if self._POST is None:
+ # Work out the post parameters
+ pos = self.raw_input.tell()
+ self.raw_input.seek(0)
+ kwargs = {
+ "fp": self.raw_input,
+ "environ": {"REQUEST_METHOD": self.method},
+ "headers": self.raw_headers,
+ "keep_blank_values": True,
+ "encoding": "iso-8859-1",
+ }
+ fs = cgi.FieldStorage(**kwargs)
+ self._POST = MultiDict.from_field_storage(fs)
+ self.raw_input.seek(pos)
+ return self._POST
+
+ @property
+ def cookies(self):
+ if self._cookies is None:
+ parser = BinaryCookieParser()
+ cookie_headers = self.headers.get("cookie", b"")
+ parser.load(cookie_headers)
+ cookies = Cookies()
+ for key, value in parser.items():
+ cookies[isomorphic_encode(key)] = CookieValue(value)
+ self._cookies = cookies
+ return self._cookies
+
+ @property
+ def headers(self):
+ if self._headers is None:
+ self._headers = RequestHeaders(self.raw_headers)
+ return self._headers
+
+ @property
+ def body(self):
+ if self._body is None:
+ pos = self.raw_input.tell()
+ self.raw_input.seek(0)
+ self._body = self.raw_input.read()
+ self.raw_input.seek(pos)
+ return self._body
+
+ @property
+ def auth(self):
+ if self._auth is None:
+ self._auth = Authentication(self.headers)
+ return self._auth
+
+
+class H2Request(Request):
+ def __init__(self, request_handler):
+ self.h2_stream_id = request_handler.h2_stream_id
+ self.frames = []
+ super().__init__(request_handler)
+
+
+class RequestHeaders(Dict[bytes, List[bytes]]):
+ """Read-only dictionary-like API for accessing request headers.
+
+ Unlike BaseHTTPRequestHandler.headers, this class always returns all
+ headers with the same name (separated by commas). And it ensures all keys
+ (i.e. names of headers) and values have binary type.
+ """
+ def __init__(self, items):
+ for header in items.keys():
+ key = isomorphic_encode(header).lower()
+ # get all headers with the same name
+ values = items.getallmatchingheaders(header)
+ if len(values) > 1:
+ # collect the multiple variations of the current header
+ multiples = []
+ # loop through the values from getallmatchingheaders
+ for value in values:
+ # getallmatchingheaders returns raw header lines, so
+ # split to get name, value
+ multiples.append(isomorphic_encode(value).split(b':', 1)[1].strip())
+ headers = multiples
+ else:
+ headers = [isomorphic_encode(items[header])]
+ dict.__setitem__(self, key, headers)
+
+ def __getitem__(self, key):
+ """Get all headers of a certain (case-insensitive) name. If there is
+ more than one, the values are returned comma separated"""
+ key = isomorphic_encode(key)
+ values = dict.__getitem__(self, key.lower())
+ if len(values) == 1:
+ return values[0]
+ else:
+ return b", ".join(values)
+
+ def __setitem__(self, name, value):
+ raise Exception
+
+ def get(self, key, default=None):
+ """Get a string representing all headers with a particular value,
+ with multiple headers separated by a comma. If no header is found
+ return a default value
+
+ :param key: The header name to look up (case-insensitive)
+ :param default: The value to return in the case of no match
+ """
+ try:
+ return self[key]
+ except KeyError:
+ return default
+
+ def get_list(self, key, default=missing):
+ """Get all the header values for a particular field name as
+ a list"""
+ key = isomorphic_encode(key)
+ try:
+ return dict.__getitem__(self, key.lower())
+ except KeyError:
+ if default is not missing:
+ return default
+ else:
+ raise
+
+ def __contains__(self, key):
+ key = isomorphic_encode(key)
+ return dict.__contains__(self, key.lower())
+
+ def iteritems(self):
+ for item in self:
+ yield item, self[item]
+
+ def itervalues(self):
+ for item in self:
+ yield self[item]
+
+
+class CookieValue:
+ """Representation of cookies.
+
+ Note that cookies are considered read-only and the string value
+ of the cookie will not change if you update the field values.
+ However this is not enforced.
+
+ .. attribute:: key
+
+ The name of the cookie.
+
+ .. attribute:: value
+
+ The value of the cookie
+
+ .. attribute:: expires
+
+ The expiry date of the cookie
+
+ .. attribute:: path
+
+ The path of the cookie
+
+ .. attribute:: comment
+
+ The comment of the cookie.
+
+ .. attribute:: domain
+
+ The domain with which the cookie is associated
+
+ .. attribute:: max_age
+
+ The max-age value of the cookie.
+
+ .. attribute:: secure
+
+ Whether the cookie is marked as secure
+
+ .. attribute:: httponly
+
+ Whether the cookie is marked as httponly
+
+ """
+ def __init__(self, morsel):
+ self.key = morsel.key
+ self.value = morsel.value
+
+ for attr in ["expires", "path",
+ "comment", "domain", "max-age",
+ "secure", "version", "httponly"]:
+ setattr(self, attr.replace("-", "_"), morsel[attr])
+
+ self._str = morsel.OutputString()
+
+ def __str__(self):
+ return self._str
+
+ def __repr__(self):
+ return self._str
+
+ def __eq__(self, other):
+ """Equality comparison for cookies. Compares to other cookies
+ based on value alone and on non-cookies based on the equality
+ of self.value with the other object so that a cookie with value
+ "ham" compares equal to the string "ham"
+ """
+ if hasattr(other, "value"):
+ return self.value == other.value
+ return self.value == other
+
+
+class MultiDict(Dict[KT, VT]):
+ """Dictionary type that holds multiple values for each key"""
+ # TODO: this should perhaps also order the keys
+ def __init__(self):
+ pass
+
+ def __setitem__(self, name, value):
+ dict.__setitem__(self, name, [value])
+
+ def add(self, name, value):
+ if name in self:
+ dict.__getitem__(self, name).append(value)
+ else:
+ dict.__setitem__(self, name, [value])
+
+ def __getitem__(self, key):
+ """Get the first value with a given key"""
+ return self.first(key)
+
+ def first(self, key, default=missing):
+ """Get the first value with a given key
+
+ :param key: The key to lookup
+ :param default: The default to return if key is
+ not found (throws if nothing is
+ specified)
+ """
+ if key in self and dict.__getitem__(self, key):
+ return dict.__getitem__(self, key)[0]
+ elif default is not missing:
+ return default
+ raise KeyError(key)
+
+ def last(self, key, default=missing):
+ """Get the last value with a given key
+
+ :param key: The key to lookup
+ :param default: The default to return if key is
+ not found (throws if nothing is
+ specified)
+ """
+ if key in self and dict.__getitem__(self, key):
+ return dict.__getitem__(self, key)[-1]
+ elif default is not missing:
+ return default
+ raise KeyError(key)
+
+ # We need to explicitly override dict.get; otherwise, it won't call
+ # __getitem__ and would return a list instead.
+ def get(self, key, default=None):
+ """Get the first value with a given key
+
+ :param key: The key to lookup
+ :param default: The default to return if key is
+ not found (None by default)
+ """
+ return self.first(key, default)
+
+ def get_list(self, key):
+ """Get all values with a given key as a list
+
+ :param key: The key to lookup
+ """
+ if key in self:
+ return dict.__getitem__(self, key)
+ else:
+ return []
+
+ @classmethod
+ def from_field_storage(cls, fs):
+ """Construct a MultiDict from a cgi.FieldStorage
+
+ Note that all keys and values are binary strings.
+ """
+ self = cls()
+ if fs.list is None:
+ return self
+ for key in fs:
+ values = fs[key]
+ if not isinstance(values, list):
+ values = [values]
+
+ for value in values:
+ if not value.filename:
+ value = isomorphic_encode(value.value)
+ else:
+ assert isinstance(value, cgi.FieldStorage)
+ self.add(isomorphic_encode(key), value)
+ return self
+
+
+class BinaryCookieParser(BaseCookie): # type: ignore
+ """A subclass of BaseCookie that returns values in binary strings
+
+ This is not intended to store the cookies; use Cookies instead.
+ """
+ def value_decode(self, val):
+ """Decode value from network to (real_value, coded_value).
+
+ Override BaseCookie.value_decode.
+ """
+ return isomorphic_encode(val), val
+
+ def value_encode(self, val):
+ raise NotImplementedError('BinaryCookieParser is not for setting cookies')
+
+ def load(self, rawdata):
+ """Load cookies from a binary string.
+
+ This overrides and calls BaseCookie.load. Unlike BaseCookie.load, it
+ does not accept dictionaries.
+ """
+ assert isinstance(rawdata, bytes)
+ # BaseCookie.load expects a native string
+ super().load(isomorphic_decode(rawdata))
+
+
+class Cookies(MultiDict[bytes, CookieValue]):
+ """MultiDict specialised for Cookie values
+
+ Keys are binary strings and values are CookieValue objects.
+ """
+ def __init__(self):
+ pass
+
+ def __getitem__(self, key):
+ return self.last(key)
+
+
+class Authentication:
+ """Object for dealing with HTTP Authentication
+
+ .. attribute:: username
+
+ The username supplied in the HTTP Authorization
+ header, or None
+
+ .. attribute:: password
+
+ The password supplied in the HTTP Authorization
+ header, or None
+
+ Both attributes are binary strings (`str` in Py2, `bytes` in Py3), since
+ RFC7617 Section 2.1 does not specify the encoding for username & password
+ (as long it's compatible with ASCII). UTF-8 should be a relatively safe
+ choice if callers need to decode them as most browsers use it.
+ """
+ def __init__(self, headers):
+ self.username = None
+ self.password = None
+
+ auth_schemes = {b"Basic": self.decode_basic}
+
+ if "authorization" in headers:
+ header = headers.get("authorization")
+ assert isinstance(header, bytes)
+ auth_type, data = header.split(b" ", 1)
+ if auth_type in auth_schemes:
+ self.username, self.password = auth_schemes[auth_type](data)
+ else:
+ raise HTTPException(400, "Unsupported authentication scheme %s" % auth_type)
+
+ def decode_basic(self, data):
+ assert isinstance(data, bytes)
+ decoded_data = base64.b64decode(data)
+ return decoded_data.split(b":", 1)
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/response.py b/testing/web-platform/tests/tools/wptserve/wptserve/response.py
new file mode 100644
index 0000000000..5c0ea7dd8d
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/response.py
@@ -0,0 +1,818 @@
+# mypy: allow-untyped-defs
+
+from collections import OrderedDict
+from datetime import datetime, timedelta
+from io import BytesIO
+import json
+import uuid
+
+from hpack.struct import HeaderTuple
+from http.cookies import BaseCookie, Morsel
+from hyperframe.frame import HeadersFrame, DataFrame, ContinuationFrame
+
+from .constants import response_codes, h2_headers
+from .logger import get_logger
+from .utils import isomorphic_decode, isomorphic_encode
+
+missing = object()
+
+
+class Response:
+ """Object representing the response to a HTTP request
+
+ :param handler: RequestHandler being used for this response
+ :param request: Request that this is the response for
+
+ .. attribute:: request
+
+ Request associated with this Response.
+
+ .. attribute:: encoding
+
+ The encoding to use when converting unicode to strings for output.
+
+ .. attribute:: add_required_headers
+
+ Boolean indicating whether mandatory headers should be added to the
+ response.
+
+ .. attribute:: send_body_for_head_request
+
+ Boolean, default False, indicating whether the body content should be
+ sent when the request method is HEAD.
+
+ .. attribute:: writer
+
+ The ResponseWriter for this response
+
+ .. attribute:: status
+
+ Status tuple (code, message). Can be set to an integer in which case the
+ message part is filled in automatically, or a tuple (code, message) in
+ which case code is an int and message is a text or binary string.
+
+ .. attribute:: headers
+
+ List of HTTP headers to send with the response. Each item in the list is a
+ tuple of (name, value).
+
+ .. attribute:: content
+
+ The body of the response. This can either be a string or a iterable of response
+ parts. If it is an iterable, any item may be a string or a function of zero
+ parameters which, when called, returns a string."""
+
+ def __init__(self, handler, request, response_writer_cls=None):
+ self.request = request
+ self.encoding = "utf8"
+
+ self.add_required_headers = True
+ self.send_body_for_head_request = False
+ self.close_connection = False
+
+ self.logger = get_logger()
+ self.writer = response_writer_cls(handler, self) if response_writer_cls else ResponseWriter(handler, self)
+
+ self._status = (200, None)
+ self.headers = ResponseHeaders()
+ self.content = []
+
+ @property
+ def status(self):
+ return self._status
+
+ @status.setter
+ def status(self, value):
+ if hasattr(value, "__len__"):
+ if len(value) != 2:
+ raise ValueError
+ else:
+ code = int(value[0])
+ message = value[1]
+ # Only call str() if message is not a string type, so that we
+ # don't get `str(b"foo") == "b'foo'"` in Python 3.
+ if not isinstance(message, (bytes, str)):
+ message = str(message)
+ self._status = (code, message)
+ else:
+ self._status = (int(value), None)
+
+ def set_cookie(self, name, value, path="/", domain=None, max_age=None,
+ expires=None, samesite=None, secure=False, httponly=False,
+ comment=None):
+ """Set a cookie to be sent with a Set-Cookie header in the
+ response
+
+ :param name: name of the cookie (a binary string)
+ :param value: value of the cookie (a binary string, or None)
+ :param max_age: datetime.timedelta int representing the time (in seconds)
+ until the cookie expires
+ :param path: String path to which the cookie applies
+ :param domain: String domain to which the cookie applies
+ :param samesit: String indicating whether the cookie should be
+ restricted to same site context
+ :param secure: Boolean indicating whether the cookie is marked as secure
+ :param httponly: Boolean indicating whether the cookie is marked as
+ HTTP Only
+ :param comment: String comment
+ :param expires: datetime.datetime or datetime.timedelta indicating a
+ time or interval from now when the cookie expires
+
+ """
+ # TODO(Python 3): Convert other parameters (e.g. path) to bytes, too.
+ if value is None:
+ value = b''
+ max_age = 0
+ expires = timedelta(days=-1)
+
+ name = isomorphic_decode(name)
+ value = isomorphic_decode(value)
+
+ days = {i+1: name for i, name in enumerate(["jan", "feb", "mar",
+ "apr", "may", "jun",
+ "jul", "aug", "sep",
+ "oct", "nov", "dec"])}
+
+ if isinstance(expires, timedelta):
+ expires = datetime.utcnow() + expires
+
+ if expires is not None:
+ expires_str = expires.strftime("%d %%s %Y %H:%M:%S GMT")
+ expires_str = expires_str % days[expires.month]
+ expires = expires_str
+
+ if max_age is not None:
+ if hasattr(max_age, "total_seconds"):
+ max_age = int(max_age.total_seconds())
+ max_age = "%.0d" % max_age
+
+ m = Morsel()
+
+ def maybe_set(key, value):
+ if value is not None and value is not False:
+ m[key] = value
+
+ m.set(name, value, value)
+ maybe_set("path", path)
+ maybe_set("domain", domain)
+ maybe_set("comment", comment)
+ maybe_set("expires", expires)
+ maybe_set("max-age", max_age)
+ maybe_set("secure", secure)
+ maybe_set("httponly", httponly)
+ maybe_set("samesite", samesite)
+
+ self.headers.append("Set-Cookie", m.OutputString())
+
+ def unset_cookie(self, name):
+ """Remove a cookie from those that are being sent with the response"""
+ name = isomorphic_decode(name)
+ cookies = self.headers.get("Set-Cookie")
+ parser = BaseCookie()
+ for cookie in cookies:
+ parser.load(isomorphic_decode(cookie))
+
+ if name in parser.keys():
+ del self.headers["Set-Cookie"]
+ for m in parser.values():
+ if m.key != name:
+ self.headers.append(("Set-Cookie", m.OutputString()))
+
+ def delete_cookie(self, name, path="/", domain=None):
+ """Delete a cookie on the client by setting it to the empty string
+ and to expire in the past"""
+ self.set_cookie(name, None, path=path, domain=domain, max_age=0,
+ expires=timedelta(days=-1))
+
+ def iter_content(self, read_file=False):
+ """Iterator returning chunks of response body content.
+
+ If any part of the content is a function, this will be called
+ and the resulting value (if any) returned.
+
+ :param read_file: boolean controlling the behaviour when content is a
+ file handle. When set to False the handle will be
+ returned directly allowing the file to be passed to
+ the output in small chunks. When set to True, the
+ entire content of the file will be returned as a
+ string facilitating non-streaming operations like
+ template substitution.
+ """
+ if isinstance(self.content, bytes):
+ yield self.content
+ elif isinstance(self.content, str):
+ yield self.content.encode(self.encoding)
+ elif hasattr(self.content, "read"):
+ if read_file:
+ yield self.content.read()
+ else:
+ yield self.content
+ else:
+ for item in self.content:
+ if hasattr(item, "__call__"):
+ value = item()
+ else:
+ value = item
+ if value:
+ yield value
+
+ def write_status_headers(self):
+ """Write out the status line and headers for the response"""
+ self.writer.write_status(*self.status)
+ for item in self.headers:
+ self.writer.write_header(*item)
+ self.writer.end_headers()
+
+ def write_content(self):
+ """Write out the response content"""
+ if self.request.method != "HEAD" or self.send_body_for_head_request:
+ for item in self.iter_content():
+ self.writer.write_content(item)
+
+ def write(self):
+ """Write the whole response"""
+ self.write_status_headers()
+ self.write_content()
+
+ def set_error(self, code, message=""):
+ """Set the response status headers and return a JSON error object:
+
+ {"error": {"code": code, "message": message}}
+ code is an int (HTTP status code), and message is a text string.
+ """
+ err = {"code": code,
+ "message": message}
+ data = json.dumps({"error": err})
+ self.status = code
+ self.headers = [("Content-Type", "application/json"),
+ ("Content-Length", len(data))]
+ self.content = data
+ if code == 500:
+ if isinstance(message, str) and message:
+ first_line = message.splitlines()[0]
+ else:
+ first_line = "<no message given>"
+ self.logger.error("Exception loading %s: %s" % (self.request.url,
+ first_line))
+ self.logger.info(message)
+
+
+class MultipartContent:
+ def __init__(self, boundary=None, default_content_type=None):
+ self.items = []
+ if boundary is None:
+ boundary = str(uuid.uuid4())
+ self.boundary = boundary
+ self.default_content_type = default_content_type
+
+ def __call__(self):
+ boundary = b"--" + self.boundary.encode("ascii")
+ rv = [b"", boundary]
+ for item in self.items:
+ rv.append(item.to_bytes())
+ rv.append(boundary)
+ rv[-1] += b"--"
+ return b"\r\n".join(rv)
+
+ def append_part(self, data, content_type=None, headers=None):
+ if content_type is None:
+ content_type = self.default_content_type
+ self.items.append(MultipartPart(data, content_type, headers))
+
+ def __iter__(self):
+ #This is hackish; when writing the response we need an iterable
+ #or a string. For a multipart/byterange response we want an
+ #iterable that contains a single callable; the MultipartContent
+ #object itself
+ yield self
+
+
+class MultipartPart:
+ def __init__(self, data, content_type=None, headers=None):
+ assert isinstance(data, bytes), data
+ self.headers = ResponseHeaders()
+
+ if content_type is not None:
+ self.headers.set("Content-Type", content_type)
+
+ if headers is not None:
+ for name, value in headers:
+ if name.lower() == b"content-type":
+ func = self.headers.set
+ else:
+ func = self.headers.append
+ func(name, value)
+
+ self.data = data
+
+ def to_bytes(self):
+ rv = []
+ for key, value in self.headers:
+ assert isinstance(key, bytes)
+ assert isinstance(value, bytes)
+ rv.append(b"%s: %s" % (key, value))
+ rv.append(b"")
+ rv.append(self.data)
+ return b"\r\n".join(rv)
+
+
+def _maybe_encode(s):
+ """Encode a string or an int into binary data using isomorphic_encode()."""
+ if isinstance(s, int):
+ return b"%i" % (s,)
+ return isomorphic_encode(s)
+
+
+class ResponseHeaders:
+ """Dictionary-like object holding the headers for the response"""
+ def __init__(self):
+ self.data = OrderedDict()
+
+ def set(self, key, value):
+ """Set a header to a specific value, overwriting any previous header
+ with the same name
+
+ :param key: Name of the header to set
+ :param value: Value to set the header to
+ """
+ key = _maybe_encode(key)
+ value = _maybe_encode(value)
+ self.data[key.lower()] = (key, [value])
+
+ def append(self, key, value):
+ """Add a new header with a given name, not overwriting any existing
+ headers with the same name
+
+ :param key: Name of the header to add
+ :param value: Value to set for the header
+ """
+ key = _maybe_encode(key)
+ value = _maybe_encode(value)
+ if key.lower() in self.data:
+ self.data[key.lower()][1].append(value)
+ else:
+ self.set(key, value)
+
+ def get(self, key, default=missing):
+ """Get the set values for a particular header."""
+ key = _maybe_encode(key)
+ try:
+ return self[key]
+ except KeyError:
+ if default is missing:
+ return []
+ return default
+
+ def __getitem__(self, key):
+ """Get a list of values for a particular header
+
+ """
+ key = _maybe_encode(key)
+ return self.data[key.lower()][1]
+
+ def __delitem__(self, key):
+ key = _maybe_encode(key)
+ del self.data[key.lower()]
+
+ def __contains__(self, key):
+ key = _maybe_encode(key)
+ return key.lower() in self.data
+
+ def __setitem__(self, key, value):
+ self.set(key, value)
+
+ def __iter__(self):
+ for key, values in self.data.values():
+ for value in values:
+ yield key, value
+
+ def items(self):
+ return list(self)
+
+ def update(self, items_iter):
+ for name, value in items_iter:
+ self.append(name, value)
+
+ def __repr__(self):
+ return repr(self.data)
+
+
+class H2Response(Response):
+
+ def __init__(self, handler, request):
+ super().__init__(handler, request, response_writer_cls=H2ResponseWriter)
+
+ def write_status_headers(self):
+ self.writer.write_headers(self.headers, *self.status)
+
+ # Hacky way of detecting last item in generator
+ def write_content(self):
+ """Write out the response content"""
+ if self.request.method != "HEAD" or self.send_body_for_head_request:
+ item = None
+ item_iter = self.iter_content()
+ try:
+ item = next(item_iter)
+ while True:
+ check_last = next(item_iter)
+ self.writer.write_data(item, last=False)
+ item = check_last
+ except StopIteration:
+ if item:
+ self.writer.write_data(item, last=True)
+
+
+class H2ResponseWriter:
+
+ def __init__(self, handler, response):
+ self.socket = handler.request
+ self.h2conn = handler.conn
+ self._response = response
+ self._handler = handler
+ self.stream_ended = False
+ self.content_written = False
+ self.request = response.request
+ self.logger = response.logger
+
+ def write_headers(self, headers, status_code, status_message=None, stream_id=None, last=False):
+ """
+ Send a HEADER frame that is tracked by the local state machine.
+
+ Write a HEADER frame using the H2 Connection object, will only work if the stream is in a state to send
+ HEADER frames.
+
+ :param headers: List of (header, value) tuples
+ :param status_code: The HTTP status code of the response
+ :param stream_id: Id of stream to send frame on. Will use the request stream ID if None
+ :param last: Flag to signal if this is the last frame in stream.
+ """
+ formatted_headers = []
+ secondary_headers = [] # Non ':' prefixed headers are to be added afterwards
+
+ for header, value in headers:
+ # h2_headers are native strings
+ # header field names are strings of ASCII
+ if isinstance(header, bytes):
+ header = header.decode('ascii')
+ # value in headers can be either string or integer
+ if isinstance(value, bytes):
+ value = self.decode(value)
+ if header in h2_headers:
+ header = ':' + header
+ formatted_headers.append((header, str(value)))
+ else:
+ secondary_headers.append((header, str(value)))
+
+ formatted_headers.append((':status', str(status_code)))
+ formatted_headers.extend(secondary_headers)
+
+ with self.h2conn as connection:
+ connection.send_headers(
+ stream_id=self.request.h2_stream_id if stream_id is None else stream_id,
+ headers=formatted_headers,
+ end_stream=last or self.request.method == "HEAD"
+ )
+
+ self.write(connection)
+
+ def write_data(self, item, last=False, stream_id=None):
+ """
+ Send a DATA frame that is tracked by the local state machine.
+
+ Write a DATA frame using the H2 Connection object, will only work if the stream is in a state to send
+ DATA frames. Uses flow control to split data into multiple data frames if it exceeds the size that can
+ be in a single frame.
+
+ :param item: The content of the DATA frame
+ :param last: Flag to signal if this is the last frame in stream.
+ :param stream_id: Id of stream to send frame on. Will use the request stream ID if None
+ """
+ if isinstance(item, (str, bytes)):
+ data = BytesIO(self.encode(item))
+ else:
+ data = item
+
+ # Find the length of the data
+ data.seek(0, 2)
+ data_len = data.tell()
+ data.seek(0)
+
+ # If the data is longer than max payload size, need to write it in chunks
+ payload_size = self.get_max_payload_size()
+ while data_len > payload_size:
+ self.write_data_frame(data.read(payload_size), False, stream_id)
+ data_len -= payload_size
+ payload_size = self.get_max_payload_size()
+
+ self.write_data_frame(data.read(), last, stream_id)
+
+ def write_data_frame(self, data, last, stream_id=None):
+ with self.h2conn as connection:
+ connection.send_data(
+ stream_id=self.request.h2_stream_id if stream_id is None else stream_id,
+ data=data,
+ end_stream=last,
+ )
+ self.write(connection)
+ self.stream_ended = last
+
+ def write_push(self, promise_headers, push_stream_id=None, status=None, response_headers=None, response_data=None):
+ """Write a push promise, and optionally write the push content.
+
+ This will write a push promise to the request stream. If you do not provide headers and data for the response,
+ then no response will be pushed, and you should push them yourself using the ID returned from this function
+
+ :param promise_headers: A list of header tuples that matches what the client would use to
+ request the pushed response
+ :param push_stream_id: The ID of the stream the response should be pushed to. If none given, will
+ use the next available id.
+ :param status: The status code of the response, REQUIRED if response_headers given
+ :param response_headers: The headers of the response
+ :param response_data: The response data.
+ :return: The ID of the push stream
+ """
+ with self.h2conn as connection:
+ push_stream_id = push_stream_id if push_stream_id is not None else connection.get_next_available_stream_id()
+ connection.push_stream(self.request.h2_stream_id, push_stream_id, promise_headers)
+ self.write(connection)
+
+ has_data = response_data is not None
+ if response_headers is not None:
+ assert status is not None
+ self.write_headers(response_headers, status, stream_id=push_stream_id, last=not has_data)
+
+ if has_data:
+ self.write_data(response_data, last=True, stream_id=push_stream_id)
+
+ return push_stream_id
+
+ def end_stream(self, stream_id=None):
+ """Ends the stream with the given ID, or the one that request was made on if no ID given."""
+ with self.h2conn as connection:
+ connection.end_stream(stream_id if stream_id is not None else self.request.h2_stream_id)
+ self.write(connection)
+ self.stream_ended = True
+
+ def write_raw_header_frame(self, headers, stream_id=None, end_stream=False, end_headers=False, frame_cls=HeadersFrame):
+ """
+ Ignores the statemachine of the stream and sends a HEADER frame regardless.
+
+ Unlike `write_headers`, this does not check to see if a stream is in the correct state to have HEADER frames
+ sent through to it. It will build a HEADER frame and send it without using the H2 Connection object other than
+ to HPACK encode the headers.
+
+ :param headers: List of (header, value) tuples
+ :param stream_id: Id of stream to send frame on. Will use the request stream ID if None
+ :param end_stream: Set to True to add END_STREAM flag to frame
+ :param end_headers: Set to True to add END_HEADERS flag to frame
+ """
+ if not stream_id:
+ stream_id = self.request.h2_stream_id
+
+ header_t = []
+ for header, value in headers:
+ header_t.append(HeaderTuple(header, value))
+
+ with self.h2conn as connection:
+ frame = frame_cls(stream_id, data=connection.encoder.encode(header_t))
+
+ if end_stream:
+ self.stream_ended = True
+ frame.flags.add('END_STREAM')
+ if end_headers:
+ frame.flags.add('END_HEADERS')
+
+ data = frame.serialize()
+ self.write_raw(data)
+
+ def write_raw_data_frame(self, data, stream_id=None, end_stream=False):
+ """
+ Ignores the statemachine of the stream and sends a DATA frame regardless.
+
+ Unlike `write_data`, this does not check to see if a stream is in the correct state to have DATA frames
+ sent through to it. It will build a DATA frame and send it without using the H2 Connection object. It will
+ not perform any flow control checks.
+
+ :param data: The data to be sent in the frame
+ :param stream_id: Id of stream to send frame on. Will use the request stream ID if None
+ :param end_stream: Set to True to add END_STREAM flag to frame
+ """
+ if not stream_id:
+ stream_id = self.request.h2_stream_id
+
+ frame = DataFrame(stream_id, data=data)
+
+ if end_stream:
+ self.stream_ended = True
+ frame.flags.add('END_STREAM')
+
+ data = frame.serialize()
+ self.write_raw(data)
+
+ def write_raw_continuation_frame(self, headers, stream_id=None, end_headers=False):
+ """
+ Ignores the statemachine of the stream and sends a CONTINUATION frame regardless.
+
+ This provides the ability to create and write a CONTINUATION frame to the stream, which is not exposed by
+ `write_headers` as the h2 library handles the split between HEADER and CONTINUATION internally. Will perform
+ HPACK encoding on the headers.
+
+ :param headers: List of (header, value) tuples
+ :param stream_id: Id of stream to send frame on. Will use the request stream ID if None
+ :param end_headers: Set to True to add END_HEADERS flag to frame
+ """
+ self.write_raw_header_frame(headers, stream_id=stream_id, end_headers=end_headers, frame_cls=ContinuationFrame)
+
+
+ def get_max_payload_size(self, stream_id=None):
+ """Returns the maximum size of a payload for the given stream."""
+ stream_id = stream_id if stream_id is not None else self.request.h2_stream_id
+ with self.h2conn as connection:
+ return min(connection.remote_settings.max_frame_size, connection.local_flow_control_window(stream_id)) - 9
+
+ def write(self, connection):
+ self.content_written = True
+ data = connection.data_to_send()
+ self.socket.sendall(data)
+
+ def write_raw(self, raw_data):
+ """Used for sending raw bytes/data through the socket"""
+
+ self.content_written = True
+ self.socket.sendall(raw_data)
+
+ def decode(self, data):
+ """Convert bytes to unicode according to response.encoding."""
+ if isinstance(data, bytes):
+ return data.decode(self._response.encoding)
+ elif isinstance(data, str):
+ return data
+ else:
+ raise ValueError(type(data))
+
+ def encode(self, data):
+ """Convert unicode to bytes according to response.encoding."""
+ if isinstance(data, bytes):
+ return data
+ elif isinstance(data, str):
+ return data.encode(self._response.encoding)
+ else:
+ raise ValueError
+
+
+class ResponseWriter:
+ """Object providing an API to write out a HTTP response.
+
+ :param handler: The RequestHandler being used.
+ :param response: The Response associated with this writer."""
+ def __init__(self, handler, response):
+ self._wfile = handler.wfile
+ self._response = response
+ self._handler = handler
+ self._status_written = False
+ self._headers_seen = set()
+ self._headers_complete = False
+ self.content_written = False
+ self.request = response.request
+ self.file_chunk_size = 32 * 1024
+ self.default_status = 200
+
+ def _seen_header(self, name):
+ return self.encode(name.lower()) in self._headers_seen
+
+ def write_status(self, code, message=None):
+ """Write out the status line of a response.
+
+ :param code: The integer status code of the response.
+ :param message: The message of the response. Defaults to the message commonly used
+ with the status code."""
+ if message is None:
+ if code in response_codes:
+ message = response_codes[code][0]
+ else:
+ message = ''
+ self.write(b"%s %d %s\r\n" %
+ (isomorphic_encode(self._response.request.protocol_version), code, isomorphic_encode(message)))
+ self._status_written = True
+
+ def write_header(self, name, value):
+ """Write out a single header for the response.
+
+ If a status has not been written, a default status will be written (currently 200)
+
+ :param name: Name of the header field
+ :param value: Value of the header field
+ :return: A boolean indicating whether the write succeeds
+ """
+ if not self._status_written:
+ self.write_status(self.default_status)
+ self._headers_seen.add(self.encode(name.lower()))
+ if not self.write(name):
+ return False
+ if not self.write(b": "):
+ return False
+ if isinstance(value, int):
+ if not self.write(str(value)):
+ return False
+ elif not self.write(value):
+ return False
+ return self.write(b"\r\n")
+
+ def write_default_headers(self):
+ for name, f in [("Server", self._handler.version_string),
+ ("Date", self._handler.date_time_string)]:
+ if not self._seen_header(name):
+ if not self.write_header(name, f()):
+ return False
+
+ if (isinstance(self._response.content, (bytes, str)) and
+ not self._seen_header("content-length")):
+ #Would be nice to avoid double-encoding here
+ if not self.write_header("Content-Length", len(self.encode(self._response.content))):
+ return False
+
+ return True
+
+ def end_headers(self):
+ """Finish writing headers and write the separator.
+
+ Unless add_required_headers on the response is False,
+ this will also add HTTP-mandated headers that have not yet been supplied
+ to the response headers.
+ :return: A boolean indicating whether the write succeeds
+ """
+
+ if self._response.add_required_headers:
+ if not self.write_default_headers():
+ return False
+
+ if not self.write("\r\n"):
+ return False
+ if not self._seen_header("content-length"):
+ self._response.close_connection = True
+ self._headers_complete = True
+
+ return True
+
+ def write_content(self, data):
+ """Write the body of the response.
+
+ HTTP-mandated headers will be automatically added with status default to 200 if they have
+ not been explicitly set.
+ :return: A boolean indicating whether the write succeeds
+ """
+ if not self._status_written:
+ self.write_status(self.default_status)
+ if not self._headers_complete:
+ self._response.content = data
+ self.end_headers()
+ return self.write_raw_content(data)
+
+ def write_raw_content(self, data):
+ """Writes the data 'as is'"""
+ if data is None:
+ raise ValueError('data cannot be None')
+ if isinstance(data, (str, bytes)):
+ # Deliberately allows both text and binary types. See `self.encode`.
+ return self.write(data)
+ else:
+ return self.write_content_file(data)
+
+ def write(self, data):
+ """Write directly to the response, converting unicode to bytes
+ according to response.encoding.
+ :return: A boolean indicating whether the write succeeds
+ """
+ self.content_written = True
+ try:
+ self._wfile.write(self.encode(data))
+ return True
+ except OSError:
+ # This can happen if the socket got closed by the remote end
+ return False
+
+ def write_content_file(self, data):
+ """Write a file-like object directly to the response in chunks."""
+ self.content_written = True
+ success = True
+ while True:
+ buf = data.read(self.file_chunk_size)
+ if not buf:
+ success = False
+ break
+ try:
+ self._wfile.write(buf)
+ except OSError:
+ success = False
+ break
+ data.close()
+ return success
+
+ def encode(self, data):
+ """Convert unicode to bytes according to response.encoding."""
+ if isinstance(data, bytes):
+ return data
+ elif isinstance(data, str):
+ return data.encode(self._response.encoding)
+ else:
+ raise ValueError("data %r should be text or binary, but is %s" % (data, type(data)))
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/router.py b/testing/web-platform/tests/tools/wptserve/wptserve/router.py
new file mode 100644
index 0000000000..92c1b04a46
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/router.py
@@ -0,0 +1,180 @@
+# mypy: allow-untyped-defs
+
+import itertools
+import re
+import sys
+
+from .logger import get_logger
+
+any_method = object()
+
+class RouteTokenizer:
+ def literal(self, scanner, token):
+ return ("literal", token)
+
+ def slash(self, scanner, token):
+ return ("slash", None)
+
+ def group(self, scanner, token):
+ return ("group", token[1:-1])
+
+ def star(self, scanner, token):
+ return ("star", token[1:-3])
+
+ def scan(self, input_str):
+ scanner = re.Scanner([(r"/", self.slash),
+ (r"{\w*}", self.group),
+ (r"\*", self.star),
+ (r"(?:\\.|[^{\*/])*", self.literal),])
+ return scanner.scan(input_str)
+
+class RouteCompiler:
+ def __init__(self):
+ self.reset()
+
+ def reset(self):
+ self.star_seen = False
+
+ def compile(self, tokens):
+ self.reset()
+
+ func_map = {"slash":self.process_slash,
+ "literal":self.process_literal,
+ "group":self.process_group,
+ "star":self.process_star}
+
+ re_parts = ["^"]
+
+ if not tokens or tokens[0][0] != "slash":
+ tokens = itertools.chain([("slash", None)], tokens)
+
+ for token in tokens:
+ re_parts.append(func_map[token[0]](token))
+
+ if self.star_seen:
+ re_parts.append(")")
+ re_parts.append("$")
+
+ return re.compile("".join(re_parts))
+
+ def process_literal(self, token):
+ return re.escape(token[1])
+
+ def process_slash(self, token):
+ return "/"
+
+ def process_group(self, token):
+ if self.star_seen:
+ raise ValueError("Group seen after star in regexp")
+ return "(?P<%s>[^/]+)" % token[1]
+
+ def process_star(self, token):
+ if self.star_seen:
+ raise ValueError("Star seen after star in regexp")
+ self.star_seen = True
+ return "(.*"
+
+def compile_path_match(route_pattern):
+ """tokens: / or literal or match or *"""
+
+ tokenizer = RouteTokenizer()
+ tokens, unmatched = tokenizer.scan(route_pattern)
+
+ assert unmatched == "", unmatched
+
+ compiler = RouteCompiler()
+
+ return compiler.compile(tokens)
+
+class Router:
+ """Object for matching handler functions to requests.
+
+ :param doc_root: Absolute path of the filesystem location from
+ which to serve tests
+ :param routes: Initial routes to add; a list of three item tuples
+ (method, path_pattern, handler_function), defined
+ as for register()
+ """
+
+ def __init__(self, doc_root, routes):
+ self.doc_root = doc_root
+ self.routes = []
+ self.logger = get_logger()
+
+ # Add the doc_root to the Python path, so that any Python handler can
+ # correctly locate helper scripts (see RFC_TO_BE_LINKED).
+ #
+ # TODO: In a perfect world, Router would not need to know about this
+ # and the handler itself would take care of it. Currently, however, we
+ # treat handlers like functions and so there's no easy way to do that.
+ if self.doc_root not in sys.path:
+ sys.path.insert(0, self.doc_root)
+
+ for route in reversed(routes):
+ self.register(*route)
+
+ def register(self, methods, path, handler):
+ r"""Register a handler for a set of paths.
+
+ :param methods: Set of methods this should match. "*" is a
+ special value indicating that all methods should
+ be matched.
+
+ :param path_pattern: Match pattern that will be used to determine if
+ a request path matches this route. Match patterns
+ consist of either literal text, match groups,
+ denoted {name}, which match any character except /,
+ and, at most one \*, which matches and character and
+ creates a match group to the end of the string.
+ If there is no leading "/" on the pattern, this is
+ automatically implied. For example::
+
+ api/{resource}/*.json
+
+ Would match `/api/test/data.json` or
+ `/api/test/test2/data.json`, but not `/api/test/data.py`.
+
+ The match groups are made available in the request object
+ as a dictionary through the route_match property. For
+ example, given the route pattern above and the path
+ `/api/test/data.json`, the route_match property would
+ contain::
+
+ {"resource": "test", "*": "data.json"}
+
+ :param handler: Function that will be called to process matching
+ requests. This must take two parameters, the request
+ object and the response object.
+
+ """
+ if isinstance(methods, (bytes, str)) or methods is any_method:
+ methods = [methods]
+ for method in methods:
+ self.routes.append((method, compile_path_match(path), handler))
+ self.logger.debug("Route pattern: %s" % self.routes[-1][1].pattern)
+
+ def get_handler(self, request):
+ """Get a handler for a request or None if there is no handler.
+
+ :param request: Request to get a handler for.
+ :rtype: Callable or None
+ """
+ for method, regexp, handler in reversed(self.routes):
+ if (request.method == method or
+ method in (any_method, "*") or
+ (request.method == "HEAD" and method == "GET")):
+ m = regexp.match(request.url_parts.path)
+ if m:
+ if not hasattr(handler, "__class__"):
+ name = handler.__name__
+ else:
+ name = handler.__class__.__name__
+ self.logger.debug("Found handler %s" % name)
+
+ match_parts = m.groupdict().copy()
+ if len(match_parts) < len(m.groups()):
+ match_parts["*"] = m.groups()[-1]
+ request.route_match = match_parts
+
+ return handler
+ return None
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/routes.py b/testing/web-platform/tests/tools/wptserve/wptserve/routes.py
new file mode 100644
index 0000000000..b6e3800018
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/routes.py
@@ -0,0 +1,6 @@
+from . import handlers
+from .router import any_method
+routes = [(any_method, "*.py", handlers.python_script_handler),
+ ("GET", "*.asis", handlers.as_is_handler),
+ ("GET", "*", handlers.file_handler),
+ ]
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/server.py b/testing/web-platform/tests/tools/wptserve/wptserve/server.py
new file mode 100644
index 0000000000..8038a78df8
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/server.py
@@ -0,0 +1,927 @@
+# mypy: allow-untyped-defs
+
+import errno
+import http.server
+import os
+import socket
+from socketserver import ThreadingMixIn
+import ssl
+import sys
+import threading
+import time
+import traceback
+import uuid
+from collections import OrderedDict
+from queue import Empty, Queue
+from typing import Dict
+
+from h2.config import H2Configuration
+from h2.connection import H2Connection
+from h2.events import RequestReceived, ConnectionTerminated, DataReceived, StreamReset, StreamEnded
+from h2.exceptions import StreamClosedError, ProtocolError
+from h2.settings import SettingCodes
+from h2.utilities import extract_method_header
+
+from urllib.parse import urlsplit, urlunsplit
+
+from mod_pywebsocket import dispatch
+from mod_pywebsocket.handshake import HandshakeException, AbortedByUserException
+
+from . import routes as default_routes
+from .config import ConfigBuilder
+from .logger import get_logger
+from .request import Server, Request, H2Request
+from .response import Response, H2Response
+from .router import Router
+from .utils import HTTPException, isomorphic_decode, isomorphic_encode
+from .constants import h2_headers
+from .ws_h2_handshake import WsH2Handshaker
+
+# We need to stress test that browsers can send/receive many headers (there is
+# no specified limit), but the Python stdlib has an arbitrary limit of 100
+# headers. Hitting the limit leads to HTTP 431, so we monkey patch it higher.
+# https://bugs.python.org/issue26586
+# https://github.com/web-platform-tests/wpt/pull/24451
+import http.client
+assert isinstance(getattr(http.client, '_MAXHEADERS'), int)
+setattr(http.client, '_MAXHEADERS', 512)
+
+"""
+HTTP server designed for testing purposes.
+
+The server is designed to provide flexibility in the way that
+requests are handled, and to provide control both of exactly
+what bytes are put on the wire for the response, and in the
+timing of sending those bytes.
+
+The server is based on the stdlib HTTPServer, but with some
+notable differences in the way that requests are processed.
+Overall processing is handled by a WebTestRequestHandler,
+which is a subclass of BaseHTTPRequestHandler. This is responsible
+for parsing the incoming request. A RequestRewriter is then
+applied and may change the request data if it matches a
+supplied rule.
+
+Once the request data had been finalised, Request and Response
+objects are constructed. These are used by the other parts of the
+system to read information about the request and manipulate the
+response.
+
+Each request is handled by a particular handler function. The
+mapping between Request and the appropriate handler is determined
+by a Router. By default handlers are installed to interpret files
+under the document root with .py extensions as executable python
+files (see handlers.py for the api for such files), .asis files as
+bytestreams to be sent literally and all other files to be served
+statically.
+
+The handler functions are responsible for either populating the
+fields of the response object, which will then be written when the
+handler returns, or for directly writing to the output stream.
+"""
+
+
+class RequestRewriter:
+ def __init__(self, rules):
+ """Object for rewriting the request path.
+
+ :param rules: Initial rules to add; a list of three item tuples
+ (method, input_path, output_path), defined as for
+ register()
+ """
+ self.rules = {}
+ for rule in reversed(rules):
+ self.register(*rule)
+ self.logger = get_logger()
+
+ def register(self, methods, input_path, output_path):
+ """Register a rewrite rule.
+
+ :param methods: Set of methods this should match. "*" is a
+ special value indicating that all methods should
+ be matched.
+
+ :param input_path: Path to match for the initial request.
+
+ :param output_path: Path to replace the input path with in
+ the request.
+ """
+ if isinstance(methods, (bytes, str)):
+ methods = [methods]
+ self.rules[input_path] = (methods, output_path)
+
+ def rewrite(self, request_handler):
+ """Rewrite the path in a BaseHTTPRequestHandler instance, if
+ it matches a rule.
+
+ :param request_handler: BaseHTTPRequestHandler for which to
+ rewrite the request.
+ """
+ split_url = urlsplit(request_handler.path)
+ if split_url.path in self.rules:
+ methods, destination = self.rules[split_url.path]
+ if "*" in methods or request_handler.command in methods:
+ self.logger.debug("Rewriting request path %s to %s" %
+ (request_handler.path, destination))
+ new_url = list(split_url)
+ new_url[2] = destination
+ new_url = urlunsplit(new_url)
+ request_handler.path = new_url
+
+
+class WebTestServer(ThreadingMixIn, http.server.HTTPServer):
+ allow_reuse_address = True
+ acceptable_errors = (errno.EPIPE, errno.ECONNABORTED)
+ request_queue_size = 2000
+
+ # Ensure that we don't hang on shutdown waiting for requests
+ daemon_threads = True
+
+ def __init__(self, server_address, request_handler_cls,
+ router, rewriter, bind_address, ws_doc_root=None,
+ config=None, use_ssl=False, key_file=None, certificate=None,
+ encrypt_after_connect=False, latency=None, http2=False, **kwargs):
+ """Server for HTTP(s) Requests
+
+ :param server_address: tuple of (server_name, port)
+
+ :param request_handler_cls: BaseHTTPRequestHandler-like class to use for
+ handling requests.
+
+ :param router: Router instance to use for matching requests to handler
+ functions
+
+ :param rewriter: RequestRewriter-like instance to use for preprocessing
+ requests before they are routed
+
+ :param config: Dictionary holding environment configuration settings for
+ handlers to read, or None to use the default values.
+
+ :param use_ssl: Boolean indicating whether the server should use SSL
+
+ :param key_file: Path to key file to use if SSL is enabled.
+
+ :param certificate: Path to certificate to use if SSL is enabled.
+
+ :param ws_doc_root: Document root for websockets
+
+ :param encrypt_after_connect: For each connection, don't start encryption
+ until a CONNECT message has been received.
+ This enables the server to act as a
+ self-proxy.
+
+ :param bind_address True to bind the server to both the IP address and
+ port specified in the server_address parameter.
+ False to bind the server only to the port in the
+ server_address parameter, but not to the address.
+ :param latency: Delay in ms to wait before serving each response, or
+ callable that returns a delay in ms
+ """
+ self.router = router
+ self.rewriter = rewriter
+
+ self.scheme = "http2" if http2 else "https" if use_ssl else "http"
+ self.logger = get_logger()
+
+ self.latency = latency
+
+ if bind_address:
+ hostname_port = server_address
+ else:
+ hostname_port = ("",server_address[1])
+
+ http.server.HTTPServer.__init__(self, hostname_port, request_handler_cls, **kwargs)
+
+ if config is not None:
+ Server.config = config
+ else:
+ self.logger.debug("Using default configuration")
+ with ConfigBuilder(self.logger,
+ browser_host=server_address[0],
+ ports={"http": [self.server_address[1]]}) as config:
+ assert config["ssl_config"] is None
+ Server.config = config
+
+
+
+ self.ws_doc_root = ws_doc_root
+ self.key_file = key_file
+ self.certificate = certificate
+ self.encrypt_after_connect = use_ssl and encrypt_after_connect
+
+ if use_ssl and not encrypt_after_connect:
+ if http2:
+ ssl_context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
+ ssl_context.load_cert_chain(keyfile=self.key_file, certfile=self.certificate)
+ ssl_context.set_alpn_protocols(['h2'])
+ self.socket = ssl_context.wrap_socket(self.socket,
+ server_side=True)
+
+ else:
+ self.socket = ssl.wrap_socket(self.socket,
+ keyfile=self.key_file,
+ certfile=self.certificate,
+ server_side=True)
+
+ def handle_error(self, request, client_address):
+ error = sys.exc_info()[1]
+
+ if ((isinstance(error, OSError) and
+ isinstance(error.args, tuple) and
+ error.args[0] in self.acceptable_errors) or
+ (isinstance(error, IOError) and
+ error.errno in self.acceptable_errors)):
+ pass # remote hang up before the result is sent
+ else:
+ msg = traceback.format_exc()
+ self.logger.error(f"{type(error)} {error}")
+ self.logger.info(msg)
+
+
+class BaseWebTestRequestHandler(http.server.BaseHTTPRequestHandler):
+ """RequestHandler for WebTestHttpd"""
+
+ def __init__(self, *args, **kwargs):
+ self.logger = get_logger()
+ http.server.BaseHTTPRequestHandler.__init__(self, *args, **kwargs)
+
+ def finish_handling_h1(self, request_line_is_valid):
+
+ self.server.rewriter.rewrite(self)
+
+ request = Request(self)
+ response = Response(self, request)
+
+ if request.method == "CONNECT":
+ self.handle_connect(response)
+ return
+
+ if not request_line_is_valid:
+ response.set_error(414)
+ response.write()
+ return
+
+ self.logger.debug(f"{request.method} {request.request_path}")
+ handler = self.server.router.get_handler(request)
+ self.finish_handling(request, response, handler)
+
+ def finish_handling(self, request, response, handler):
+ # If the handler we used for the request had a non-default base path
+ # set update the doc_root of the request to reflect this
+ if hasattr(handler, "base_path") and handler.base_path:
+ request.doc_root = handler.base_path
+ if hasattr(handler, "url_base") and handler.url_base != "/":
+ request.url_base = handler.url_base
+
+ if self.server.latency is not None:
+ if callable(self.server.latency):
+ latency = self.server.latency()
+ else:
+ latency = self.server.latency
+ self.logger.warning("Latency enabled. Sleeping %i ms" % latency)
+ time.sleep(latency / 1000.)
+
+ if handler is None:
+ self.logger.debug("No Handler found!")
+ response.set_error(404)
+ else:
+ try:
+ handler(request, response)
+ except HTTPException as e:
+ if 500 <= e.code < 600:
+ self.logger.warning("HTTPException in handler: %s" % e)
+ self.logger.warning(traceback.format_exc())
+ response.set_error(e.code, str(e))
+ except Exception as e:
+ self.respond_with_error(response, e)
+ self.logger.debug("%i %s %s (%s) %i" % (response.status[0],
+ request.method,
+ request.request_path,
+ request.headers.get('Referer'),
+ request.raw_input.length))
+
+ if not response.writer.content_written:
+ response.write()
+
+ # If a python handler has been used, the old ones won't send a END_STR data frame, so this
+ # allows for backwards compatibility by accounting for these handlers that don't close streams
+ if isinstance(response, H2Response) and not response.writer.stream_ended:
+ response.writer.end_stream()
+
+ # If we want to remove this in the future, a solution is needed for
+ # scripts that produce a non-string iterable of content, since these
+ # can't set a Content-Length header. A notable example of this kind of
+ # problem is with the trickle pipe i.e. foo.js?pipe=trickle(d1)
+ if response.close_connection:
+ self.close_connection = True
+
+ if not self.close_connection:
+ # Ensure that the whole request has been read from the socket
+ request.raw_input.read()
+
+ def handle_connect(self, response):
+ self.logger.debug("Got CONNECT")
+ response.status = 200
+ response.write()
+ if self.server.encrypt_after_connect:
+ self.logger.debug("Enabling SSL for connection")
+ self.request = ssl.wrap_socket(self.connection,
+ keyfile=self.server.key_file,
+ certfile=self.server.certificate,
+ server_side=True)
+ self.setup()
+ return
+
+ def respond_with_error(self, response, e):
+ message = str(e)
+ if message:
+ err = [message]
+ else:
+ err = []
+ err.append(traceback.format_exc())
+ response.set_error(500, "\n".join(err))
+
+
+class Http2WebTestRequestHandler(BaseWebTestRequestHandler):
+ protocol_version = "HTTP/2.0"
+
+ def handle_one_request(self):
+ """
+ This is the main HTTP/2.0 Handler.
+
+ When a browser opens a connection to the server
+ on the HTTP/2.0 port, the server enters this which will initiate the h2 connection
+ and keep running throughout the duration of the interaction, and will read/write directly
+ from the socket.
+
+ Because there can be multiple H2 connections active at the same
+ time, a UUID is created for each so that it is easier to tell them apart in the logs.
+ """
+
+ config = H2Configuration(client_side=False)
+ self.conn = H2ConnectionGuard(H2Connection(config=config))
+ self.close_connection = False
+
+ # Generate a UUID to make it easier to distinguish different H2 connection debug messages
+ self.uid = str(uuid.uuid4())[:8]
+
+ self.logger.debug('(%s) Initiating h2 Connection' % self.uid)
+
+ with self.conn as connection:
+ # Bootstrapping WebSockets with HTTP/2 specification requires
+ # ENABLE_CONNECT_PROTOCOL to be set in order to enable WebSocket
+ # over HTTP/2
+ new_settings = dict(connection.local_settings)
+ new_settings[SettingCodes.ENABLE_CONNECT_PROTOCOL] = 1
+ connection.local_settings.update(new_settings)
+ connection.local_settings.acknowledge()
+
+ connection.initiate_connection()
+ data = connection.data_to_send()
+ window_size = connection.remote_settings.initial_window_size
+
+ self.request.sendall(data)
+
+ # Dict of { stream_id: (thread, queue) }
+ stream_queues = {}
+
+ try:
+ while not self.close_connection:
+ data = self.request.recv(window_size)
+ if data == '':
+ self.logger.debug('(%s) Socket Closed' % self.uid)
+ self.close_connection = True
+ continue
+
+ with self.conn as connection:
+ frames = connection.receive_data(data)
+ window_size = connection.remote_settings.initial_window_size
+
+ self.logger.debug('(%s) Frames Received: ' % self.uid + str(frames))
+
+ for frame in frames:
+ if isinstance(frame, ConnectionTerminated):
+ self.logger.debug('(%s) Connection terminated by remote peer ' % self.uid)
+ self.close_connection = True
+
+ # Flood all the streams with connection terminated, this will cause them to stop
+ for stream_id, (thread, queue) in stream_queues.items():
+ queue.put(frame)
+
+ elif hasattr(frame, 'stream_id'):
+ if frame.stream_id not in stream_queues:
+ queue = Queue()
+ stream_queues[frame.stream_id] = (self.start_stream_thread(frame, queue), queue)
+ stream_queues[frame.stream_id][1].put(frame)
+
+ if isinstance(frame, StreamEnded) or (hasattr(frame, "stream_ended") and frame.stream_ended):
+ del stream_queues[frame.stream_id]
+
+ except OSError as e:
+ self.logger.error(f'({self.uid}) Closing Connection - \n{str(e)}')
+ if not self.close_connection:
+ self.close_connection = True
+ except Exception as e:
+ self.logger.error(f'({self.uid}) Unexpected Error - \n{str(e)}')
+ finally:
+ for stream_id, (thread, queue) in stream_queues.items():
+ queue.put(None)
+ thread.join()
+
+ def _is_extended_connect_frame(self, frame):
+ if not isinstance(frame, RequestReceived):
+ return False
+
+ method = extract_method_header(frame.headers)
+ if method != b"CONNECT":
+ return False
+
+ protocol = ""
+ for key, value in frame.headers:
+ if key in (b':protocol', ':protocol'):
+ protocol = isomorphic_encode(value)
+ break
+ if protocol != b"websocket":
+ raise ProtocolError(f"Invalid protocol {protocol} with CONNECT METHOD")
+
+ return True
+
+ def start_stream_thread(self, frame, queue):
+ """
+ This starts a new thread to handle frames for a specific stream.
+ :param frame: The first frame on the stream
+ :param queue: A queue object that the thread will use to check for new frames
+ :return: The thread object that has already been started
+ """
+ if self._is_extended_connect_frame(frame):
+ target = Http2WebTestRequestHandler._stream_ws_thread
+ else:
+ target = Http2WebTestRequestHandler._stream_thread
+ t = threading.Thread(
+ target=target,
+ args=(self, frame.stream_id, queue)
+ )
+ t.start()
+ return t
+
+ def _stream_ws_thread(self, stream_id, queue):
+ frame = queue.get(True, None)
+
+ if frame is None:
+ return
+
+ rfile, wfile = os.pipe()
+ rfile, wfile = os.fdopen(rfile, 'rb'), os.fdopen(wfile, 'wb', 0) # needs to be unbuffer for websockets
+ stream_handler = H2HandlerCopy(self, frame, rfile)
+
+ h2request = H2Request(stream_handler)
+ h2response = H2Response(stream_handler, h2request)
+
+ dispatcher = dispatch.Dispatcher(self.server.ws_doc_root, None, False)
+ if not dispatcher.get_handler_suite(stream_handler.path):
+ h2response.set_error(404)
+ h2response.write()
+ return
+
+ request_wrapper = _WebSocketRequest(stream_handler, h2response)
+
+ handshaker = WsH2Handshaker(request_wrapper, dispatcher)
+ try:
+ handshaker.do_handshake()
+ except HandshakeException as e:
+ self.logger.info('Handshake failed for error: %s' % e)
+ h2response.set_error(e.status)
+ h2response.write()
+ return
+ except AbortedByUserException:
+ h2response.write()
+ return
+
+ # h2 Handshaker prepares the headers but does not send them down the
+ # wire. Flush the headers here.
+ try:
+ h2response.write_status_headers()
+ except StreamClosedError:
+ # work around https://github.com/web-platform-tests/wpt/issues/27786
+ # The stream was already closed.
+ return
+
+ request_wrapper._dispatcher = dispatcher
+
+ # we need two threads:
+ # - one to handle the frame queue
+ # - one to handle the request (dispatcher.transfer_data is blocking)
+ # the alternative is to have only one (blocking) thread. That thread
+ # will call transfer_data. That would require a special case in
+ # handle_one_request, to bypass the queue and write data to wfile
+ # directly.
+ t = threading.Thread(
+ target=Http2WebTestRequestHandler._stream_ws_sub_thread,
+ args=(self, request_wrapper, stream_handler, queue)
+ )
+ t.start()
+
+ while not self.close_connection:
+ try:
+ frame = queue.get(True, 1)
+ except Empty:
+ continue
+
+ if isinstance(frame, DataReceived):
+ wfile.write(frame.data)
+ if frame.stream_ended:
+ raise NotImplementedError("frame.stream_ended")
+ wfile.close()
+ elif frame is None or isinstance(frame, (StreamReset, StreamEnded, ConnectionTerminated)):
+ self.logger.debug(f'({self.uid} - {stream_id}) Stream Reset, Thread Closing')
+ break
+
+ t.join()
+
+ def _stream_ws_sub_thread(self, request, stream_handler, queue):
+ dispatcher = request._dispatcher
+ try:
+ dispatcher.transfer_data(request)
+ except StreamClosedError:
+ # work around https://github.com/web-platform-tests/wpt/issues/27786
+ # The stream was already closed.
+ queue.put(None)
+ return
+
+ stream_id = stream_handler.h2_stream_id
+ with stream_handler.conn as connection:
+ try:
+ connection.end_stream(stream_id)
+ data = connection.data_to_send()
+ stream_handler.request.sendall(data)
+ except StreamClosedError: # maybe the stream has already been closed
+ pass
+ queue.put(None)
+
+ def _stream_thread(self, stream_id, queue):
+ """
+ This thread processes frames for a specific stream. It waits for frames to be placed
+ in the queue, and processes them. When it receives a request frame, it will start processing
+ immediately, even if there are data frames to follow. One of the reasons for this is that it
+ can detect invalid requests before needing to read the rest of the frames.
+ """
+
+ # The file-like pipe object that will be used to share data to request object if data is received
+ wfile = None
+ request = None
+ response = None
+ req_handler = None
+ while not self.close_connection:
+ try:
+ frame = queue.get(True, 1)
+ except Empty:
+ # Restart to check for close_connection
+ continue
+
+ self.logger.debug(f'({self.uid} - {stream_id}) {str(frame)}')
+
+ if isinstance(frame, RequestReceived):
+ rfile, wfile = os.pipe()
+ rfile, wfile = os.fdopen(rfile, 'rb'), os.fdopen(wfile, 'wb')
+
+ stream_handler = H2HandlerCopy(self, frame, rfile)
+
+ stream_handler.server.rewriter.rewrite(stream_handler)
+ request = H2Request(stream_handler)
+ response = H2Response(stream_handler, request)
+
+ req_handler = stream_handler.server.router.get_handler(request)
+
+ if hasattr(req_handler, "frame_handler"):
+ # Convert this to a handler that will utilise H2 specific functionality, such as handling individual frames
+ req_handler = self.frame_handler(request, response, req_handler)
+
+ if hasattr(req_handler, 'handle_headers'):
+ req_handler.handle_headers(frame, request, response)
+
+ elif isinstance(frame, DataReceived):
+ wfile.write(frame.data)
+
+ if hasattr(req_handler, 'handle_data'):
+ req_handler.handle_data(frame, request, response)
+
+ if frame.stream_ended:
+ wfile.close()
+ elif frame is None or isinstance(frame, (StreamReset, StreamEnded, ConnectionTerminated)):
+ self.logger.debug(f'({self.uid} - {stream_id}) Stream Reset, Thread Closing')
+ break
+
+ if request is not None:
+ request.frames.append(frame)
+
+ if hasattr(frame, "stream_ended") and frame.stream_ended:
+ try:
+ self.finish_handling(request, response, req_handler)
+ except StreamClosedError:
+ self.logger.debug('(%s - %s) Unable to write response; stream closed' %
+ (self.uid, stream_id))
+ break
+
+ def frame_handler(self, request, response, handler):
+ try:
+ return handler.frame_handler(request)
+ except HTTPException as e:
+ response.set_error(e.code, str(e))
+ response.write()
+ except Exception as e:
+ self.respond_with_error(response, e)
+ response.write()
+
+
+class H2ConnectionGuard:
+ """H2Connection objects are not threadsafe, so this keeps thread safety"""
+ lock = threading.Lock()
+
+ def __init__(self, obj):
+ assert isinstance(obj, H2Connection)
+ self.obj = obj
+
+ def __enter__(self):
+ self.lock.acquire()
+ return self.obj
+
+ def __exit__(self, exception_type, exception_value, traceback):
+ self.lock.release()
+
+
+class H2Headers(Dict[bytes, bytes]):
+ def __init__(self, headers):
+ self.raw_headers = OrderedDict()
+ for key, val in headers:
+ key = isomorphic_decode(key)
+ val = isomorphic_decode(val)
+ self.raw_headers[key] = val
+ dict.__setitem__(self, self._convert_h2_header_to_h1(key), val)
+
+ def _convert_h2_header_to_h1(self, header_key):
+ if header_key[1:] in h2_headers and header_key[0] == ':':
+ return header_key[1:]
+ else:
+ return header_key
+
+ # TODO This does not seem relevant for H2 headers, so using a dummy function for now
+ def getallmatchingheaders(self, header):
+ return ['dummy function']
+
+
+class H2HandlerCopy:
+ def __init__(self, handler, req_frame, rfile):
+ self.headers = H2Headers(req_frame.headers)
+ self.command = self.headers['method']
+ self.path = self.headers['path']
+ self.h2_stream_id = req_frame.stream_id
+ self.server = handler.server
+ self.protocol_version = handler.protocol_version
+ self.client_address = handler.client_address
+ self.raw_requestline = ''
+ self.rfile = rfile
+ self.request = handler.request
+ self.conn = handler.conn
+
+class Http1WebTestRequestHandler(BaseWebTestRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def handle_one_request(self):
+ response = None
+
+ try:
+ self.close_connection = False
+
+ request_line_is_valid = self.get_request_line()
+
+ if self.close_connection:
+ return
+
+ request_is_valid = self.parse_request()
+ if not request_is_valid:
+ #parse_request() actually sends its own error responses
+ return
+
+ self.finish_handling_h1(request_line_is_valid)
+
+ except socket.timeout as e:
+ self.log_error("Request timed out: %r", e)
+ self.close_connection = True
+ return
+
+ except Exception:
+ err = traceback.format_exc()
+ if response:
+ response.set_error(500, err)
+ response.write()
+
+ def get_request_line(self):
+ try:
+ self.raw_requestline = self.rfile.readline(65537)
+ except OSError:
+ self.close_connection = True
+ return False
+ if len(self.raw_requestline) > 65536:
+ self.requestline = ''
+ self.request_version = ''
+ self.command = ''
+ return False
+ if not self.raw_requestline:
+ self.close_connection = True
+ return True
+
+class WebTestHttpd:
+ """
+ :param host: Host from which to serve (default: 127.0.0.1)
+ :param port: Port from which to serve (default: 8000)
+ :param server_cls: Class to use for the server (default depends on ssl vs non-ssl)
+ :param handler_cls: Class to use for the RequestHandler
+ :param use_ssl: Use a SSL server if no explicit server_cls is supplied
+ :param key_file: Path to key file to use if ssl is enabled
+ :param certificate: Path to certificate file to use if ssl is enabled
+ :param encrypt_after_connect: For each connection, don't start encryption
+ until a CONNECT message has been received.
+ This enables the server to act as a
+ self-proxy.
+ :param router_cls: Router class to use when matching URLs to handlers
+ :param doc_root: Document root for serving files
+ :param ws_doc_root: Document root for websockets
+ :param routes: List of routes with which to initialize the router
+ :param rewriter_cls: Class to use for request rewriter
+ :param rewrites: List of rewrites with which to initialize the rewriter_cls
+ :param config: Dictionary holding environment configuration settings for
+ handlers to read, or None to use the default values.
+ :param bind_address: Boolean indicating whether to bind server to IP address.
+ :param latency: Delay in ms to wait before serving each response, or
+ callable that returns a delay in ms
+
+ HTTP server designed for testing scenarios.
+
+ Takes a router class which provides one method get_handler which takes a Request
+ and returns a handler function.
+
+ .. attribute:: host
+
+ The host name or ip address of the server
+
+ .. attribute:: port
+
+ The port on which the server is running
+
+ .. attribute:: router
+
+ The Router object used to associate requests with resources for this server
+
+ .. attribute:: rewriter
+
+ The Rewriter object used for URL rewriting
+
+ .. attribute:: use_ssl
+
+ Boolean indicating whether the server is using ssl
+
+ .. attribute:: started
+
+ Boolean indicating whether the server is running
+
+ """
+ def __init__(self, host="127.0.0.1", port=8000,
+ server_cls=None, handler_cls=Http1WebTestRequestHandler,
+ use_ssl=False, key_file=None, certificate=None, encrypt_after_connect=False,
+ router_cls=Router, doc_root=os.curdir, ws_doc_root=None, routes=None,
+ rewriter_cls=RequestRewriter, bind_address=True, rewrites=None,
+ latency=None, config=None, http2=False):
+
+ if routes is None:
+ routes = default_routes.routes
+
+ self.host = host
+
+ self.router = router_cls(doc_root, routes)
+ self.rewriter = rewriter_cls(rewrites if rewrites is not None else [])
+
+ self.use_ssl = use_ssl
+ self.http2 = http2
+ self.logger = get_logger()
+
+ if server_cls is None:
+ server_cls = WebTestServer
+
+ if use_ssl:
+ if not os.path.exists(key_file):
+ raise ValueError(f"SSL certificate not found: {key_file}")
+ if not os.path.exists(certificate):
+ raise ValueError(f"SSL key not found: {certificate}")
+
+ try:
+ self.httpd = server_cls((host, port),
+ handler_cls,
+ self.router,
+ self.rewriter,
+ config=config,
+ bind_address=bind_address,
+ ws_doc_root=ws_doc_root,
+ use_ssl=use_ssl,
+ key_file=key_file,
+ certificate=certificate,
+ encrypt_after_connect=encrypt_after_connect,
+ latency=latency,
+ http2=http2)
+ self.started = False
+
+ _host, self.port = self.httpd.socket.getsockname()
+ except Exception:
+ self.logger.critical("Failed to start HTTP server on port %s; "
+ "is something already using that port?" % port)
+ raise
+
+ def start(self):
+ """Start the server.
+
+ :param block: True to run the server on the current thread, blocking,
+ False to run on a separate thread."""
+ http_type = "http2" if self.http2 else "https" if self.use_ssl else "http"
+ http_scheme = "https" if self.use_ssl else "http"
+ self.logger.info(f"Starting {http_type} server on {http_scheme}://{self.host}:{self.port}")
+ self.started = True
+ self.server_thread = threading.Thread(target=self.httpd.serve_forever)
+ self.server_thread.setDaemon(True) # don't hang on exit
+ self.server_thread.start()
+
+ def stop(self):
+ """
+ Stops the server.
+
+ If the server is not running, this method has no effect.
+ """
+ if self.started:
+ try:
+ self.httpd.shutdown()
+ self.httpd.server_close()
+ self.server_thread.join()
+ self.server_thread = None
+ self.logger.info(f"Stopped http server on {self.host}:{self.port}")
+ except AttributeError:
+ pass
+ self.started = False
+ self.httpd = None
+
+ def get_url(self, path="/", query=None, fragment=None):
+ if not self.started:
+ return None
+
+ return urlunsplit(("http" if not self.use_ssl else "https",
+ f"{self.host}:{self.port}",
+ path, query, fragment))
+
+
+class _WebSocketConnection:
+ def __init__(self, request_handler, response):
+ """Mimic mod_python mp_conn.
+
+ :param request_handler: A H2HandlerCopy instance.
+
+ :param response: A H2Response instance.
+ """
+
+ self._request_handler = request_handler
+ self._response = response
+
+ self.remote_addr = self._request_handler.client_address
+
+ def write(self, data):
+ self._response.writer.write_data(data, False)
+
+ def read(self, length):
+ return self._request_handler.rfile.read(length)
+
+
+class _WebSocketRequest:
+ def __init__(self, request_handler, response):
+ """Mimic mod_python request.
+
+ :param request_handler: A H2HandlerCopy instance.
+
+ :param response: A H2Response instance.
+ """
+
+ self.connection = _WebSocketConnection(request_handler, response)
+ self.protocol = "HTTP/2"
+ self._response = response
+
+ self.uri = request_handler.path
+ self.unparsed_uri = request_handler.path
+ self.method = request_handler.command
+ # read headers from request_handler
+ self.headers_in = request_handler.headers
+ # write headers directly into H2Response
+ self.headers_out = response.headers
+
+ # proxies status to H2Response
+ @property
+ def status(self):
+ return self._response.status
+
+ @status.setter
+ def status(self, status):
+ self._response.status = status
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/__init__.py b/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/__init__.py
new file mode 100644
index 0000000000..244faeadda
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/__init__.py
@@ -0,0 +1,16 @@
+# mypy: allow-untyped-defs
+
+from .base import NoSSLEnvironment
+from .openssl import OpenSSLEnvironment
+from .pregenerated import PregeneratedSSLEnvironment
+
+environments = {"none": NoSSLEnvironment,
+ "openssl": OpenSSLEnvironment,
+ "pregenerated": PregeneratedSSLEnvironment}
+
+
+def get_cls(name):
+ try:
+ return environments[name]
+ except KeyError:
+ raise ValueError("%s is not a valid SSL type." % name)
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/base.py b/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/base.py
new file mode 100644
index 0000000000..d5f913735a
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/base.py
@@ -0,0 +1,19 @@
+# mypy: allow-untyped-defs
+
+class NoSSLEnvironment:
+ ssl_enabled = False
+
+ def __init__(self, *args, **kwargs):
+ pass
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args, **kwargs):
+ pass
+
+ def host_cert_path(self, hosts):
+ return None, None
+
+ def ca_cert_path(self, hosts):
+ return None
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/openssl.py b/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/openssl.py
new file mode 100644
index 0000000000..5a16097e37
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/openssl.py
@@ -0,0 +1,424 @@
+# mypy: allow-untyped-defs
+
+import functools
+import os
+import random
+import shutil
+import subprocess
+import tempfile
+from datetime import datetime, timedelta
+
+# Amount of time beyond the present to consider certificates "expired." This
+# allows certificates to be proactively re-generated in the "buffer" period
+# prior to their exact expiration time.
+CERT_EXPIRY_BUFFER = dict(hours=6)
+
+
+class OpenSSL:
+ def __init__(self, logger, binary, base_path, conf_path, hosts, duration,
+ base_conf_path=None):
+ """Context manager for interacting with OpenSSL.
+ Creates a config file for the duration of the context.
+
+ :param logger: stdlib logger or python structured logger
+ :param binary: path to openssl binary
+ :param base_path: path to directory for storing certificates
+ :param conf_path: path for configuration file storing configuration data
+ :param hosts: list of hosts to include in configuration (or None if not
+ generating host certificates)
+ :param duration: Certificate duration in days"""
+
+ self.base_path = base_path
+ self.binary = binary
+ self.conf_path = conf_path
+ self.base_conf_path = base_conf_path
+ self.logger = logger
+ self.proc = None
+ self.cmd = []
+ self.hosts = hosts
+ self.duration = duration
+
+ def __enter__(self):
+ with open(self.conf_path, "w") as f:
+ f.write(get_config(self.base_path, self.hosts, self.duration))
+ return self
+
+ def __exit__(self, *args, **kwargs):
+ os.unlink(self.conf_path)
+
+ def log(self, line):
+ if hasattr(self.logger, "process_output"):
+ self.logger.process_output(self.proc.pid if self.proc is not None else None,
+ line.decode("utf8", "replace"),
+ command=" ".join(self.cmd))
+ else:
+ self.logger.debug(line)
+
+ def __call__(self, cmd, *args, **kwargs):
+ """Run a command using OpenSSL in the current context.
+
+ :param cmd: The openssl subcommand to run
+ :param *args: Additional arguments to pass to the command
+ """
+ self.cmd = [self.binary, cmd]
+ if cmd != "x509":
+ self.cmd += ["-config", self.conf_path]
+ self.cmd += list(args)
+
+ # Copy the environment and add OPENSSL_CONF if available.
+ env = os.environ.copy()
+ if self.base_conf_path is not None:
+ env["OPENSSL_CONF"] = self.base_conf_path
+
+ self.proc = subprocess.Popen(self.cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ env=env)
+ stdout, stderr = self.proc.communicate()
+ self.log(stdout)
+ if self.proc.returncode != 0:
+ raise subprocess.CalledProcessError(self.proc.returncode, self.cmd,
+ output=stdout)
+
+ self.cmd = []
+ self.proc = None
+ return stdout
+
+
+def make_subject(common_name,
+ country=None,
+ state=None,
+ locality=None,
+ organization=None,
+ organization_unit=None):
+ args = [("country", "C"),
+ ("state", "ST"),
+ ("locality", "L"),
+ ("organization", "O"),
+ ("organization_unit", "OU"),
+ ("common_name", "CN")]
+
+ rv = []
+
+ for var, key in args:
+ value = locals()[var]
+ if value is not None:
+ rv.append("/%s=%s" % (key, value.replace("/", "\\/")))
+
+ return "".join(rv)
+
+def make_alt_names(hosts):
+ return ",".join("DNS:%s" % host for host in hosts)
+
+def make_name_constraints(hosts):
+ return ",".join("permitted;DNS:%s" % host for host in hosts)
+
+def get_config(root_dir, hosts, duration=30):
+ if hosts is None:
+ san_line = ""
+ constraints_line = ""
+ else:
+ san_line = "subjectAltName = %s" % make_alt_names(hosts)
+ constraints_line = "nameConstraints = " + make_name_constraints(hosts)
+
+ if os.path.sep == "\\":
+ # This seems to be needed for the Shining Light OpenSSL on
+ # Windows, at least.
+ root_dir = root_dir.replace("\\", "\\\\")
+
+ rv = """[ ca ]
+default_ca = CA_default
+
+[ CA_default ]
+dir = %(root_dir)s
+certs = $dir
+new_certs_dir = $certs
+crl_dir = $dir%(sep)scrl
+database = $dir%(sep)sindex.txt
+private_key = $dir%(sep)scacert.key
+certificate = $dir%(sep)scacert.pem
+serial = $dir%(sep)sserial
+crldir = $dir%(sep)scrl
+crlnumber = $dir%(sep)scrlnumber
+crl = $crldir%(sep)scrl.pem
+RANDFILE = $dir%(sep)sprivate%(sep)s.rand
+x509_extensions = usr_cert
+name_opt = ca_default
+cert_opt = ca_default
+default_days = %(duration)d
+default_crl_days = %(duration)d
+default_md = sha256
+preserve = no
+policy = policy_anything
+copy_extensions = copy
+
+[ policy_anything ]
+countryName = optional
+stateOrProvinceName = optional
+localityName = optional
+organizationName = optional
+organizationalUnitName = optional
+commonName = supplied
+emailAddress = optional
+
+[ req ]
+default_bits = 2048
+default_keyfile = privkey.pem
+distinguished_name = req_distinguished_name
+attributes = req_attributes
+x509_extensions = v3_ca
+
+# Passwords for private keys if not present they will be prompted for
+# input_password = secret
+# output_password = secret
+string_mask = utf8only
+req_extensions = v3_req
+
+[ req_distinguished_name ]
+countryName = Country Name (2 letter code)
+countryName_default = AU
+countryName_min = 2
+countryName_max = 2
+stateOrProvinceName = State or Province Name (full name)
+stateOrProvinceName_default =
+localityName = Locality Name (eg, city)
+0.organizationName = Organization Name
+0.organizationName_default = Web Platform Tests
+organizationalUnitName = Organizational Unit Name (eg, section)
+#organizationalUnitName_default =
+commonName = Common Name (e.g. server FQDN or YOUR name)
+commonName_max = 64
+emailAddress = Email Address
+emailAddress_max = 64
+
+[ req_attributes ]
+
+[ usr_cert ]
+basicConstraints=CA:false
+subjectKeyIdentifier=hash
+authorityKeyIdentifier=keyid,issuer
+
+[ v3_req ]
+basicConstraints = CA:FALSE
+keyUsage = nonRepudiation, digitalSignature, keyEncipherment
+extendedKeyUsage = serverAuth
+%(san_line)s
+
+[ v3_ca ]
+basicConstraints = CA:true
+subjectKeyIdentifier=hash
+authorityKeyIdentifier=keyid:always,issuer:always
+keyUsage = keyCertSign
+%(constraints_line)s
+""" % {"root_dir": root_dir,
+ "san_line": san_line,
+ "duration": duration,
+ "constraints_line": constraints_line,
+ "sep": os.path.sep.replace("\\", "\\\\")}
+
+ return rv
+
+class OpenSSLEnvironment:
+ ssl_enabled = True
+
+ def __init__(self, logger, openssl_binary="openssl", base_path=None,
+ password="web-platform-tests", force_regenerate=False,
+ duration=30, base_conf_path=None):
+ """SSL environment that creates a local CA and host certificate using OpenSSL.
+
+ By default this will look in base_path for existing certificates that are still
+ valid and only create new certificates if there aren't any. This behaviour can
+ be adjusted using the force_regenerate option.
+
+ :param logger: a stdlib logging compatible logger or mozlog structured logger
+ :param openssl_binary: Path to the OpenSSL binary
+ :param base_path: Path in which certificates will be stored. If None, a temporary
+ directory will be used and removed when the server shuts down
+ :param password: Password to use
+ :param force_regenerate: Always create a new certificate even if one already exists.
+ """
+ self.logger = logger
+
+ self.temporary = False
+ if base_path is None:
+ base_path = tempfile.mkdtemp()
+ self.temporary = True
+
+ self.base_path = os.path.abspath(base_path)
+ self.password = password
+ self.force_regenerate = force_regenerate
+ self.duration = duration
+ self.base_conf_path = base_conf_path
+
+ self.path = None
+ self.binary = openssl_binary
+ self.openssl = None
+
+ self._ca_cert_path = None
+ self._ca_key_path = None
+ self.host_certificates = {}
+
+ def __enter__(self):
+ if not os.path.exists(self.base_path):
+ os.makedirs(self.base_path)
+
+ path = functools.partial(os.path.join, self.base_path)
+
+ with open(path("index.txt"), "w"):
+ pass
+ with open(path("serial"), "w") as f:
+ serial = "%x" % random.randint(0, 1000000)
+ if len(serial) % 2:
+ serial = "0" + serial
+ f.write(serial)
+
+ self.path = path
+
+ return self
+
+ def __exit__(self, *args, **kwargs):
+ if self.temporary:
+ shutil.rmtree(self.base_path)
+
+ def _config_openssl(self, hosts):
+ conf_path = self.path("openssl.cfg")
+ return OpenSSL(self.logger, self.binary, self.base_path, conf_path, hosts,
+ self.duration, self.base_conf_path)
+
+ def ca_cert_path(self, hosts):
+ """Get the path to the CA certificate file, generating a
+ new one if needed"""
+ if self._ca_cert_path is None and not self.force_regenerate:
+ self._load_ca_cert()
+ if self._ca_cert_path is None:
+ self._generate_ca(hosts)
+ return self._ca_cert_path
+
+ def _load_ca_cert(self):
+ key_path = self.path("cacert.key")
+ cert_path = self.path("cacert.pem")
+
+ if self.check_key_cert(key_path, cert_path, None):
+ self.logger.info("Using existing CA cert")
+ self._ca_key_path, self._ca_cert_path = key_path, cert_path
+
+ def check_key_cert(self, key_path, cert_path, hosts):
+ """Check that a key and cert file exist and are valid"""
+ if not os.path.exists(key_path) or not os.path.exists(cert_path):
+ return False
+
+ with self._config_openssl(hosts) as openssl:
+ end_date_str = openssl("x509",
+ "-noout",
+ "-enddate",
+ "-in", cert_path).decode("utf8").split("=", 1)[1].strip()
+ # Not sure if this works in other locales
+ end_date = datetime.strptime(end_date_str, "%b %d %H:%M:%S %Y %Z")
+ time_buffer = timedelta(**CERT_EXPIRY_BUFFER)
+ # Because `strptime` does not account for time zone offsets, it is
+ # always in terms of UTC, so the current time should be calculated
+ # accordingly.
+ if end_date < datetime.utcnow() + time_buffer:
+ return False
+
+ #TODO: check the key actually signed the cert.
+ return True
+
+ def _generate_ca(self, hosts):
+ path = self.path
+ self.logger.info("Generating new CA in %s" % self.base_path)
+
+ key_path = path("cacert.key")
+ req_path = path("careq.pem")
+ cert_path = path("cacert.pem")
+
+ with self._config_openssl(hosts) as openssl:
+ openssl("req",
+ "-batch",
+ "-new",
+ "-newkey", "rsa:2048",
+ "-keyout", key_path,
+ "-out", req_path,
+ "-subj", make_subject("web-platform-tests"),
+ "-passout", "pass:%s" % self.password)
+
+ openssl("ca",
+ "-batch",
+ "-create_serial",
+ "-keyfile", key_path,
+ "-passin", "pass:%s" % self.password,
+ "-selfsign",
+ "-extensions", "v3_ca",
+ "-notext",
+ "-in", req_path,
+ "-out", cert_path)
+
+ os.unlink(req_path)
+
+ self._ca_key_path, self._ca_cert_path = key_path, cert_path
+
+ def host_cert_path(self, hosts):
+ """Get a tuple of (private key path, certificate path) for a host,
+ generating new ones if necessary.
+
+ hosts must be a list of all hosts to appear on the certificate, with
+ the primary hostname first."""
+ hosts = tuple(sorted(hosts, key=lambda x:len(x)))
+ if hosts not in self.host_certificates:
+ if not self.force_regenerate:
+ key_cert = self._load_host_cert(hosts)
+ else:
+ key_cert = None
+ if key_cert is None:
+ key, cert = self._generate_host_cert(hosts)
+ else:
+ key, cert = key_cert
+ self.host_certificates[hosts] = key, cert
+
+ return self.host_certificates[hosts]
+
+ def _load_host_cert(self, hosts):
+ host = hosts[0]
+ key_path = self.path("%s.key" % host)
+ cert_path = self.path("%s.pem" % host)
+
+ # TODO: check that this cert was signed by the CA cert
+ if self.check_key_cert(key_path, cert_path, hosts):
+ self.logger.info("Using existing host cert")
+ return key_path, cert_path
+
+ def _generate_host_cert(self, hosts):
+ host = hosts[0]
+ if not self.force_regenerate:
+ self._load_ca_cert()
+ if self._ca_key_path is None:
+ self._generate_ca(hosts)
+ ca_key_path = self._ca_key_path
+
+ assert os.path.exists(ca_key_path)
+
+ path = self.path
+
+ req_path = path("wpt.req")
+ cert_path = path("%s.pem" % host)
+ key_path = path("%s.key" % host)
+
+ self.logger.info("Generating new host cert")
+
+ with self._config_openssl(hosts) as openssl:
+ openssl("req",
+ "-batch",
+ "-newkey", "rsa:2048",
+ "-keyout", key_path,
+ "-in", ca_key_path,
+ "-nodes",
+ "-out", req_path)
+
+ openssl("ca",
+ "-batch",
+ "-in", req_path,
+ "-passin", "pass:%s" % self.password,
+ "-subj", make_subject(host),
+ "-out", cert_path)
+
+ os.unlink(req_path)
+
+ return key_path, cert_path
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/pregenerated.py b/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/pregenerated.py
new file mode 100644
index 0000000000..5e9b1181a4
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/sslutils/pregenerated.py
@@ -0,0 +1,28 @@
+# mypy: allow-untyped-defs
+
+class PregeneratedSSLEnvironment:
+ """SSL environment to use with existing key/certificate files
+ e.g. when running on a server with a public domain name
+ """
+ ssl_enabled = True
+
+ def __init__(self, logger, host_key_path, host_cert_path,
+ ca_cert_path=None):
+ self._ca_cert_path = ca_cert_path
+ self._host_key_path = host_key_path
+ self._host_cert_path = host_cert_path
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *args, **kwargs):
+ pass
+
+ def host_cert_path(self, hosts):
+ """Return the key and certificate paths for the host"""
+ return self._host_key_path, self._host_cert_path
+
+ def ca_cert_path(self, hosts):
+ """Return the certificate path of the CA that signed the
+ host certificates, or None if that isn't known"""
+ return self._ca_cert_path
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/stash.py b/testing/web-platform/tests/tools/wptserve/wptserve/stash.py
new file mode 100644
index 0000000000..90c3078ff8
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/stash.py
@@ -0,0 +1,235 @@
+# mypy: allow-untyped-defs
+
+import base64
+import json
+import os
+import threading
+import queue
+import uuid
+
+from multiprocessing.managers import BaseManager, BaseProxy
+# We also depend on some undocumented parts of multiprocessing.managers which
+# don't have any type annotations.
+from multiprocessing.managers import AcquirerProxy, DictProxy, public_methods # type: ignore
+from typing import Dict
+
+from .utils import isomorphic_encode
+
+
+class StashManager(BaseManager):
+ shared_data: Dict[str, object] = {}
+ lock = threading.Lock()
+
+
+def _get_shared():
+ return StashManager.shared_data
+
+
+def _get_lock():
+ return StashManager.lock
+
+StashManager.register("get_dict",
+ callable=_get_shared,
+ proxytype=DictProxy)
+StashManager.register('Lock',
+ callable=_get_lock,
+ proxytype=AcquirerProxy)
+
+
+# We have to create an explicit class here because the built-in
+# AutoProxy has a bug with nested managers, and the MakeProxy
+# method doesn't work with spawn-based multiprocessing, since the
+# generated class can't be pickled for use in child processes.
+class QueueProxy(BaseProxy):
+ _exposed_ = public_methods(queue.Queue)
+
+
+for method in QueueProxy._exposed_:
+
+ def impl_fn(method):
+ def _impl(self, *args, **kwargs):
+ return self._callmethod(method, args, kwargs)
+ _impl.__name__ = method
+ return _impl
+
+ setattr(QueueProxy, method, impl_fn(method)) # type: ignore
+
+
+StashManager.register("Queue",
+ callable=queue.Queue,
+ proxytype=QueueProxy)
+
+
+class StashServer:
+ def __init__(self, address=None, authkey=None, mp_context=None):
+ self.address = address
+ self.authkey = authkey
+ self.manager = None
+ self.mp_context = mp_context
+
+ def __enter__(self):
+ self.manager, self.address, self.authkey = start_server(self.address,
+ self.authkey,
+ self.mp_context)
+ store_env_config(self.address, self.authkey)
+
+ def __exit__(self, *args, **kwargs):
+ if self.manager is not None:
+ self.manager.shutdown()
+
+
+def load_env_config():
+ address, authkey = json.loads(os.environ["WPT_STASH_CONFIG"])
+ if isinstance(address, list):
+ address = tuple(address)
+ else:
+ address = str(address)
+ authkey = base64.b64decode(authkey)
+ return address, authkey
+
+
+def store_env_config(address, authkey):
+ authkey = base64.b64encode(authkey)
+ os.environ["WPT_STASH_CONFIG"] = json.dumps((address, authkey.decode("ascii")))
+
+
+def start_server(address=None, authkey=None, mp_context=None):
+ if isinstance(authkey, str):
+ authkey = authkey.encode("ascii")
+ kwargs = {}
+ if mp_context is not None:
+ kwargs["ctx"] = mp_context
+ manager = StashManager(address, authkey, **kwargs)
+ manager.start()
+
+ address = manager._address
+ if isinstance(address, bytes):
+ address = address.decode("ascii")
+ return (manager, address, manager._authkey)
+
+
+class LockWrapper:
+ def __init__(self, lock):
+ self.lock = lock
+
+ def acquire(self):
+ self.lock.acquire()
+
+ def release(self):
+ self.lock.release()
+
+ def __enter__(self):
+ self.acquire()
+
+ def __exit__(self, *args, **kwargs):
+ self.release()
+
+
+#TODO: Consider expiring values after some fixed time for long-running
+#servers
+
+class Stash:
+ """Key-value store for persisting data across HTTP/S and WS/S requests.
+
+ This data store is specifically designed for persisting data across server
+ requests. The synchronization is achieved by using the BaseManager from
+ the multiprocessing module so different processes can acccess the same data.
+
+ Stash can be used interchangeably between HTTP, HTTPS, WS and WSS servers.
+ A thing to note about WS/S servers is that they require additional steps in
+ the handlers for accessing the same underlying shared data in the Stash.
+ This can usually be achieved by using load_env_config(). When using Stash
+ interchangeably between HTTP/S and WS/S request, the path part of the key
+ should be expliclitly specified if accessing the same key/value subset.
+
+ The store has several unusual properties. Keys are of the form (path,
+ uuid), where path is, by default, the path in the HTTP request and
+ uuid is a unique id. In addition, the store is write-once, read-once,
+ i.e. the value associated with a particular key cannot be changed once
+ written and the read operation (called "take") is destructive. Taken together,
+ these properties make it difficult for data to accidentally leak
+ between different resources or different requests for the same
+ resource.
+ """
+
+ _proxy = None
+ lock = None
+ manager = None
+ _initializing = threading.Lock()
+
+ def __init__(self, default_path, address=None, authkey=None):
+ self.default_path = default_path
+ self._get_proxy(address, authkey)
+ self.data = Stash._proxy
+
+ def _get_proxy(self, address=None, authkey=None):
+ if address is None and authkey is None:
+ Stash._proxy = {}
+ Stash.lock = threading.Lock()
+
+ # Initializing the proxy involves connecting to the remote process and
+ # retrieving two proxied objects. This process is not inherently
+ # atomic, so a lock must be used to make it so. Atomicity ensures that
+ # only one thread attempts to initialize the connection and that any
+ # threads running in parallel correctly wait for initialization to be
+ # fully complete.
+ with Stash._initializing:
+ if Stash.lock:
+ return
+
+ Stash.manager = StashManager(address, authkey)
+ Stash.manager.connect()
+ Stash._proxy = self.manager.get_dict()
+ Stash.lock = LockWrapper(self.manager.Lock())
+
+ def get_queue(self):
+ return self.manager.Queue()
+
+ def _wrap_key(self, key, path):
+ if path is None:
+ path = self.default_path
+ # This key format is required to support using the path. Since the data
+ # passed into the stash can be a DictProxy which wouldn't detect
+ # changes when writing to a subdict.
+ if isinstance(key, bytes):
+ # UUIDs are within the ASCII charset.
+ key = key.decode('ascii')
+ return (isomorphic_encode(path), uuid.UUID(key).bytes)
+
+ def put(self, key, value, path=None):
+ """Place a value in the shared stash.
+
+ :param key: A UUID to use as the data's key.
+ :param value: The data to store. This can be any python object.
+ :param path: The path that has access to read the data (by default
+ the current request path)"""
+ if value is None:
+ raise ValueError("SharedStash value may not be set to None")
+ internal_key = self._wrap_key(key, path)
+ if internal_key in self.data:
+ raise StashError("Tried to overwrite existing shared stash value "
+ "for key %s (old value was %s, new value is %s)" %
+ (internal_key, self.data[internal_key], value))
+ else:
+ self.data[internal_key] = value
+
+ def take(self, key, path=None):
+ """Remove a value from the shared stash and return it.
+
+ :param key: A UUID to use as the data's key.
+ :param path: The path that has access to read the data (by default
+ the current request path)"""
+ internal_key = self._wrap_key(key, path)
+ value = self.data.get(internal_key, None)
+ if value is not None:
+ try:
+ self.data.pop(internal_key)
+ except KeyError:
+ # Silently continue when pop error occurs.
+ pass
+
+ return value
+
+
+class StashError(Exception):
+ pass
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/utils.py b/testing/web-platform/tests/tools/wptserve/wptserve/utils.py
new file mode 100644
index 0000000000..a592e41637
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/utils.py
@@ -0,0 +1,195 @@
+import socket
+from typing import AnyStr, Dict, List, TypeVar
+
+from .logger import get_logger
+
+KT = TypeVar('KT')
+VT = TypeVar('VT')
+
+
+def isomorphic_decode(s: AnyStr) -> str:
+ """Decodes a binary string into a text string using iso-8859-1.
+
+ Returns `str`. The function is a no-op if the argument already has a text
+ type. iso-8859-1 is chosen because it is an 8-bit encoding whose code
+ points range from 0x0 to 0xFF and the values are the same as the binary
+ representations, so any binary string can be decoded into and encoded from
+ iso-8859-1 without any errors or data loss. Python 3 also uses iso-8859-1
+ (or latin-1) extensively in http:
+ https://github.com/python/cpython/blob/273fc220b25933e443c82af6888eb1871d032fb8/Lib/http/client.py#L213
+ """
+ if isinstance(s, str):
+ return s
+
+ if isinstance(s, bytes):
+ return s.decode("iso-8859-1")
+
+ raise TypeError("Unexpected value (expecting string-like): %r" % s)
+
+
+def isomorphic_encode(s: AnyStr) -> bytes:
+ """Encodes a text-type string into binary data using iso-8859-1.
+
+ Returns `bytes`. The function is a no-op if the argument already has a
+ binary type. This is the counterpart of isomorphic_decode.
+ """
+ if isinstance(s, bytes):
+ return s
+
+ if isinstance(s, str):
+ return s.encode("iso-8859-1")
+
+ raise TypeError("Unexpected value (expecting string-like): %r" % s)
+
+
+def invert_dict(dict: Dict[KT, List[VT]]) -> Dict[VT, KT]:
+ rv = {}
+ for key, values in dict.items():
+ for value in values:
+ if value in rv:
+ raise ValueError
+ rv[value] = key
+ return rv
+
+
+class HTTPException(Exception):
+ def __init__(self, code: int, message: str = ""):
+ self.code = code
+ self.message = message
+
+
+def _open_socket(host: str, port: int) -> socket.socket:
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ if port != 0:
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ sock.bind((host, port))
+ sock.listen(5)
+ return sock
+
+
+def is_bad_port(port: int) -> bool:
+ """
+ Bad port as per https://fetch.spec.whatwg.org/#port-blocking
+ """
+ return port in [
+ 1, # tcpmux
+ 7, # echo
+ 9, # discard
+ 11, # systat
+ 13, # daytime
+ 15, # netstat
+ 17, # qotd
+ 19, # chargen
+ 20, # ftp-data
+ 21, # ftp
+ 22, # ssh
+ 23, # telnet
+ 25, # smtp
+ 37, # time
+ 42, # name
+ 43, # nicname
+ 53, # domain
+ 69, # tftp
+ 77, # priv-rjs
+ 79, # finger
+ 87, # ttylink
+ 95, # supdup
+ 101, # hostriame
+ 102, # iso-tsap
+ 103, # gppitnp
+ 104, # acr-nema
+ 109, # pop2
+ 110, # pop3
+ 111, # sunrpc
+ 113, # auth
+ 115, # sftp
+ 117, # uucp-path
+ 119, # nntp
+ 123, # ntp
+ 135, # loc-srv / epmap
+ 137, # netbios-ns
+ 139, # netbios-ssn
+ 143, # imap2
+ 161, # snmp
+ 179, # bgp
+ 389, # ldap
+ 427, # afp (alternate)
+ 465, # smtp (alternate)
+ 512, # print / exec
+ 513, # login
+ 514, # shell
+ 515, # printer
+ 526, # tempo
+ 530, # courier
+ 531, # chat
+ 532, # netnews
+ 540, # uucp
+ 548, # afp
+ 554, # rtsp
+ 556, # remotefs
+ 563, # nntp+ssl
+ 587, # smtp (outgoing)
+ 601, # syslog-conn
+ 636, # ldap+ssl
+ 989, # ftps-data
+ 999, # ftps
+ 993, # ldap+ssl
+ 995, # pop3+ssl
+ 1719, # h323gatestat
+ 1720, # h323hostcall
+ 1723, # pptp
+ 2049, # nfs
+ 3659, # apple-sasl
+ 4045, # lockd
+ 5060, # sip
+ 5061, # sips
+ 6000, # x11
+ 6566, # sane-port
+ 6665, # irc (alternate)
+ 6666, # irc (alternate)
+ 6667, # irc (default)
+ 6668, # irc (alternate)
+ 6669, # irc (alternate)
+ 6697, # irc+tls
+ 10080, # amanda
+ ]
+
+
+def get_port(host: str = '') -> int:
+ host = host or '127.0.0.1'
+ port = 0
+ while True:
+ free_socket = _open_socket(host, 0)
+ port = free_socket.getsockname()[1]
+ free_socket.close()
+ if not is_bad_port(port):
+ break
+ return port
+
+def http2_compatible() -> bool:
+ # The HTTP/2.0 server requires OpenSSL 1.0.2+.
+ #
+ # For systems using other SSL libraries (e.g. LibreSSL), we assume they
+ # have the necessary support.
+ import ssl
+ if not ssl.OPENSSL_VERSION.startswith("OpenSSL"):
+ logger = get_logger()
+ logger.warning(
+ 'Skipping HTTP/2.0 compatibility check as system is not using '
+ 'OpenSSL (found: %s)' % ssl.OPENSSL_VERSION)
+ return True
+
+ # Note that OpenSSL's versioning scheme differs between 1.1.1 and
+ # earlier and 3.0.0. ssl.OPENSSL_VERSION_INFO returns a
+ # (major, minor, 0, patch, 0)
+ # tuple with OpenSSL 3.0.0 and later, and a
+ # (major, minor, fix, patch, status)
+ # tuple for older releases.
+ # Semantically, "patch" in 3.0.0+ is similar to "fix" in previous versions.
+ #
+ # What we do in the check below is allow OpenSSL 3.x.y+, 1.1.x+ and 1.0.2+.
+ ssl_v = ssl.OPENSSL_VERSION_INFO
+ return (ssl_v[0] > 1 or
+ (ssl_v[0] == 1 and
+ (ssl_v[1] == 1 or
+ (ssl_v[1] == 0 and ssl_v[2] >= 2))))
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/wptserve.py b/testing/web-platform/tests/tools/wptserve/wptserve/wptserve.py
new file mode 100755
index 0000000000..1eaa934936
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/wptserve.py
@@ -0,0 +1,35 @@
+#!/usr/bin/env python3
+# mypy: allow-untyped-defs
+
+import argparse
+import os
+
+from .server import WebTestHttpd
+
+def abs_path(path):
+ return os.path.abspath(path)
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description="HTTP server designed for extreme flexibility "
+ "required in testing situations.")
+ parser.add_argument("document_root", action="store", type=abs_path,
+ help="Root directory to serve files from")
+ parser.add_argument("--port", "-p", dest="port", action="store",
+ type=int, default=8000,
+ help="Port number to run server on")
+ parser.add_argument("--host", "-H", dest="host", action="store",
+ type=str, default="127.0.0.1",
+ help="Host to run server on")
+ return parser.parse_args()
+
+
+def main():
+ args = parse_args()
+ httpd = WebTestHttpd(host=args.host, port=args.port,
+ use_ssl=False, certificate=None,
+ doc_root=args.document_root)
+ httpd.start()
+
+if __name__ == "__main__":
+ main() # type: ignore
diff --git a/testing/web-platform/tests/tools/wptserve/wptserve/ws_h2_handshake.py b/testing/web-platform/tests/tools/wptserve/wptserve/ws_h2_handshake.py
new file mode 100644
index 0000000000..af668dd558
--- /dev/null
+++ b/testing/web-platform/tests/tools/wptserve/wptserve/ws_h2_handshake.py
@@ -0,0 +1,72 @@
+# mypy: allow-untyped-defs
+
+"""This file provides the opening handshake processor for the Bootstrapping
+WebSockets with HTTP/2 protocol (RFC 8441).
+
+Specification:
+https://tools.ietf.org/html/rfc8441
+"""
+
+from mod_pywebsocket import common
+
+from mod_pywebsocket.handshake.base import get_mandatory_header
+from mod_pywebsocket.handshake.base import HandshakeException
+from mod_pywebsocket.handshake.base import validate_mandatory_header
+from mod_pywebsocket.handshake.base import HandshakerBase
+
+
+def check_connect_method(request):
+ if request.method != 'CONNECT':
+ raise HandshakeException('Method is not CONNECT: %r' % request.method)
+
+
+class WsH2Handshaker(HandshakerBase): # type: ignore
+ def __init__(self, request, dispatcher):
+ """Bootstrapping handshake processor for the WebSocket protocol with HTTP/2 (RFC 8441).
+
+ :param request: mod_python request.
+
+ :param dispatcher: Dispatcher (dispatch.Dispatcher).
+
+ WsH2Handshaker will add attributes such as ws_resource during handshake.
+ """
+
+ super().__init__(request, dispatcher)
+
+ def _transform_header(self, header):
+ return header.lower()
+
+ def _protocol_rfc(self):
+ return 'RFC 8441'
+
+ def _validate_request(self):
+ check_connect_method(self._request)
+ validate_mandatory_header(self._request, ':protocol', 'websocket')
+ get_mandatory_header(self._request, 'authority')
+
+ def _set_accept(self):
+ # irrelevant for HTTP/2 handshake
+ pass
+
+ def _send_handshake(self):
+ # We are not actually sending the handshake, but just preparing it. It
+ # will be flushed by the caller.
+ self._request.status = 200
+
+ self._request.headers_out['upgrade'] = common.WEBSOCKET_UPGRADE_TYPE
+ self._request.headers_out[
+ 'connection'] = common.UPGRADE_CONNECTION_TYPE
+
+ if self._request.ws_protocol is not None:
+ self._request.headers_out[
+ 'sec-websocket-protocol'] = self._request.ws_protocol
+
+ if (self._request.ws_extensions is not None and
+ len(self._request.ws_extensions) != 0):
+ self._request.headers_out[
+ 'sec-websocket-extensions'] = common.format_extensions(
+ self._request.ws_extensions)
+
+ # Headers not specific for WebSocket
+ for name, value in self._request.extra_headers:
+ self._request.headers_out[name] = value